Chapter 13

エンティティを作成

たつきち
たつきち
2022.07.29に更新

この章に対応するコミット

デモアプリは日本語と英語に対応するためすべての文字列リテラルを翻訳しているので、コミットの内容は本文の解説と若干異なります。

エンティティを作成

すでにユーザー周りの機能を一通り作ってテストを書くところまでやってきましたが、ここから先の流れは基本的にそれと同じです。

エンティティを作って、CRUDを作って、機能テストを書く、というのが基本の流れになります。

ユーザーエンティティとそれ以外の一般的なデータのエンティティでは若干要件が異なるところもあるので、ここから改めて、一般的なエンティティについての開発の流れを説明していきます。

まずはエンティティの作成です。

make:entity コマンドで雛形を作成する

ここではまず 顧客Customer)エンティティを作成してみましょう。

エンティティを作るときは、 make:entity コマンドを活用すると楽です。

Doctrine ORMのアノテーションの書き方とか、正直僕はあんまり覚えてなくて、 make:entity で自動で記載してくれるものをベースにコピペ&修正で開発しています。

$ bin/console make:entity Customer

 created: src/Entity/Customer.php
 created: src/Repository/CustomerRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > state

 Field type (enter ? to see all types) [string]:
 > 

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 > 

 updated: src/Entity/Customer.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > name

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/Customer.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >

  Success!

 Next: When you're ready, create a migration with php bin/console make:migration

こんな感じで、 bin/console make:entity {エンティティクラス名} を実行して、あとは対話式でプロパティ名や型などを入力(デフォルト値のままでよければ何も入力せずEnter)していくだけでOKです。便利ですね!

こうして自動生成されたエンティティクラスとリポジトリクラスを以下のように軽く整形して、必要に応じてプロパティにバリデーションを設定すればエンティティは完成です。

  <?php
  
+ declare(strict_types=1);
+ 
  namespace App\Entity;
  
  use App\Repository\CustomerRepository;
  use Doctrine\ORM\Mapping as ORM;
+ use Gedmo\Timestampable\Traits\TimestampableEntity;
+ use Symfony\Component\Validator\Constraints as Assert;
  
  /**
   * @ORM\Entity(repositoryClass=CustomerRepository::class)
   */
  class Customer
  {
+     use TimestampableEntity;
+ 
      /**
       * @ORM\Id
       * @ORM\GeneratedValue
       * @ORM\Column(type="integer")
       */
      private $id;
  
      /**
       * @ORM\Column(type="string", length=255)
+      *
+      * @Assert\NotBlank()
       */
-     private $state;
+     public ?string $state = null;
 
      /**
       * @ORM\Column(type="string", length=255)
+      *
+      * @Assert\NotBlank()
       */
-     private $name;
+     public ?string $name = null;
  
      public function getId(): ?int
      {
          return $this->id;
      }
- 
-     public function getState(): ?string
-     {
-         return $this->state;
-     }
- 
-     public function setState(string $state): self
-     {
-         $this->state = $state;
- 
-         return $this;
-     }
- 
-     public function getName(): ?string
-     {
-         return $this->name;
-     }
- 
-     public function setName(string $name): self
-     {
-         $this->name = $name;
- 
-         return $this;
-     }
+ 
+     public function __toString(): string
+     {
+         return $this->name;
+     }
+ 
  }
  <?php
  
+ declare(strict_types=1);
+ 
  namespace App\Repository;
  
  use App\Entity\Customer;
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
  use Doctrine\Persistence\ManagerRegistry;
  
  /**
   * @method Customer|null find($id, $lockMode = null, $lockVersion = null)
   * @method Customer|null findOneBy(array $criteria, array $orderBy = null)
   * @method Customer[]    findAll()
   * @method Customer[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
   */
  class CustomerRepository extends ServiceEntityRepository
  {
      public function __construct(ManagerRegistry $registry)
      {
          parent::__construct($registry, Customer::class);
      }
- 
-     // /**
-     //  * @return Customer[] Returns an array of Customer objects
-     //  */
-     /*
-     public function findByExampleField($value)
-     {
-         return $this->createQueryBuilder('c')
-             ->andWhere('c.exampleField = :val')
-             ->setParameter('val', $value)
-             ->orderBy('c.id', 'ASC')
-             ->setMaxResults(10)
-             ->getQuery()
-             ->getResult()
-         ;
-     }
-     */
- 
-     /*
-     public function findOneBySomeField($value): ?Customer
-     {
-         return $this->createQueryBuilder('c')
-             ->andWhere('c.exampleField = :val')
-             ->setParameter('val', $value)
-             ->getQuery()
-             ->getOneOrNullResult()
-         ;
-     }
-     */
  }

子エンティティを作ってみる

次に、 顧客Customer)エンティティのみにOneToManyで属する 担当者Person)エンティティを作ってみます。

先ほどと同様に make:entity コマンドを活用します。

$ bin/console make:entity Customer\\Person

 created: src/Entity/Customer/Person.php
 created: src/Repository/Customer/PersonRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > customer

 Field type (enter ? to see all types) [string]:
 > relation

 What class should this entity be related to?:
 > Customer

What type of relationship is this?
 ------------ ----------------------------------------------------------------------
  Type         Description
 ------------ ----------------------------------------------------------------------
  ManyToOne    Each Person relates to (has) one Customer.
               Each Customer can relate to (can have) many Person objects

  OneToMany    Each Person can relate to (can have) many Customer objects.
               Each Customer relates to (has) one Person

  ManyToMany   Each Person can relate to (can have) many Customer objects.
               Each Customer can also relate to (can also have) many Person objects

  OneToOne     Each Person relates to (has) exactly one Customer.
               Each Customer also relates to (has) exactly one Person.
 ------------ ----------------------------------------------------------------------

 Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
 > ManyToOne

 Is the Person.customer property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to add a new property to Customer so that you can access/update Person objects from it - e.g. $customer->getPeople()? (yes/no) [yes]:
 >

 A new property will also be added to the Customer class so that you can access the related Person objects from it.

 New field name inside Customer [people]:
 >

 Do you want to activate orphanRemoval on your relationship?
 A Person is "orphaned" when it is removed from its related Customer.
 e.g. $customer->removePerson($person)

 NOTE: If a Person may *change* from one Customer to another, answer "no".

 Do you want to automatically delete orphaned App\Entity\Customer\Person objects (orphanRemoval)? (yes/no) [no]:
 > yes

 updated: src/Entity/Customer/Person.php
 updated: src/Entity/Customer.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > fullName

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/Customer/Person.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > email

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Customer/Person.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > tel

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Customer/Person.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > address

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Customer/Person.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >

  Success!

 Next: When you're ready, create a migration with php bin/console make:migration

やっていることは

  1. bin/console make:entity Customer\\Person を実行(bin/console make:entity を実行したあと Customer\Person を入力するのと同じ)
  2. customer プロパティを relation 型で ManyToOne を指定して作成
  3. fullName email tel address プロパティを通常のカラムとして作成

という感じです。

こうして自動生成された PersonPersonRepository も、多少整形して不要なコメントを削除しておきます。 Person の中身は以下のようになります。

<?php

declare(strict_types=1);

namespace App\Entity\Customer;

use App\Entity\Customer;
use App\Repository\Customer\PersonRepository;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=PersonRepository::class)
 */
class Person
{
    use TimestampableEntity;

    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Customer::class, inversedBy="people")
     * @ORM\JoinColumn(nullable=false)
     */
    public ?Customer $customer = null;

    /**
     * @ORM\Column(type="string", length=255)
     *
     * @Assert\NotBlank()
     */
    public ?string $fullName = null;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     *
     * @Assert\Email()
     */
    public ?string $email = null;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    public ?string $tel = null;

    /**
     * @ORM\Column(type="text", nullable=true)
     */
    public ?string $address = null;

    public function getId(): ?int
    {
        return $this->id;
    }
}

また、make:entity でリレーションを設定した Customer のほうにも自動で修正が加わっています。これも以下のように少しだけ手直しします。

  /**
   * @ORM\Entity(repositoryClass=CustomerRepository::class)
   */
  class Customer
  {
      use TimestampableEntity;
  
      /**
       * @ORM\Id
       * @ORM\GeneratedValue
       * @ORM\Column(type="integer")
       */
      private $id;
  
      /**
       * @ORM\Column(type="string", length=255)
       *
       * @Assert\NotBlank()
       */
      public ?string $state = null;
  
      /**
       * @ORM\Column(type="string", length=255)
       *
       * @Assert\NotBlank()
       */
      public ?string $name = null;
  
      /**
+      * @var Collection|Person[]
+      *
-      * @ORM\OneToMany(targetEntity=Person::class, mappedBy="customer", orphanRemoval=true)
+      * @ORM\OneToMany(targetEntity=Person::class, mappedBy="customer", cascade={"persist", "remove"}, orphanRemoval=true)
+      *
+      * @Assert\Valid()
       */
-     private $people;
+     public Collection $people;
  
      public function __construct()
      {
          $this->people = new ArrayCollection();
      }
  
      public function getId(): ?int
      {
          return $this->id;
      }
  
-     /**
-      * @return Collection|Person[]
-      */
-     public function getPeople(): Collection
-     {
-         return $this->people;
-     }
- 
      public function addPerson(Person $person): self
      {
          if (!$this->people->contains($person)) {
              $this->people[] = $person;
-             $person->setCustomer($this);
+             $person->customer = $this;
          }
  
          return $this;
      }
  
      public function removePerson(Person $person): self
      {
          if ($this->people->removeElement($person)) {
-             // set the owning side to null (unless already changed)
-             if ($person->getCustomer() === $this) {
+             if ($person->customer === $this) {
-                 $person->setCustomer(null);
+                 $person->customer = null;
              }
          }
  
          return $this;
      }
  
      public function __toString(): string
      {
          return $this->name;
      }
  }

エンティティ固有の定数を別クラスに定義する

ここで、Customer エンティティの $state プロパティは顧客の状態を保持することを想定しているのですが、顧客の状態はフリーテキストではなく

  • 未対応
  • 対応中
  • 受注
  • 失注

のうちのいずれかの文字列に制限したいとしましょう。

このように、プロパティを選択式にしたい(特定の選択肢のうちのいずれかの文字列だけしか入らないようにしたい)という要件はとても一般的です。

こういう場合、エンティティクラス内に定数の定義を書いていると、選択式の項目が多いほどエンティティのコードが定数まみれになっていってしまうので、コードの見通しを保つために、定数定義だけを別クラスに分けておくのがおすすめです👌

今回の例では以下のようなイメージです。

// src/EntityConstant/CustomerConstant.php

namespace App\EntityConstant;

final class CustomerConstant
{
    const STATE_INITIAL = '未対応';
    const STATE_WIP = '対応中';
    const STATE_COMPLETE = '受注';
    const STATE_FAILED = '失注';

    public static function getValidStates(): array
    {
        return [
            self::STATE_INITIAL,
            self::STATE_WIP,
            self::STATE_COMPLETE,
            self::STATE_FAILED,
        ];
    }
}

この上で、エンティティのプロパティには Choice制約の callback オプション を使って選択肢以外の値をバリデーションによってエラーにするように設定しておきます。

  /**
   * @ORM\Column(type="string", length=255)
   *
   * @Assert\NotBlank()
+  * @Assert\Choice(callback={CustomerConstant::class, "getValidStates"})
   */
  public ?string $state = null;

これで、 $state プロパティを選択式にすることができました👍

ちなみに、DoctrineEnumBundle というバンドルを使うと、DBAL Typeとして独自のEnum型を定義する ことでこれと同様の結果を得ることができます。

が、実質的にやっていることは上記とまったく同じなので、あえてこのバンドルに依存する必然性はあまりないかなと思います。

マイグレーション

さて、ひとまず Customer エンティティと Customer\Person エンティティが出来上がったので、マイグレーションスクリプトを自動生成して、(ちゃんと内容を確認した上で)マイグレーションを実行しておきましょう。

$ bin/console doctrine:migrations:diff
$ bin/console doctrine:migrations:migrate