Doctrine入門、アソシエーションと多対一

毎回DBを作成したり削除したりするのが大変なのでシェルスクリプトを作ります。
名前は適当でいいですが、db-initialize.shとしました。

vendor/bin/doctrine orm:schema-tool:drop --force
vendor/bin/doctrine orm:schema-tool:create --dump-sql
vendor/bin/doctrine orm:schema-tool:create
php doc-init.php

テーブルを削除して、作成するテーブルのSQLを出力して、テーブルを作成して、DBの初期化をする作業をまとめています。
db-initialize.shを実行可能にしてコマンドで叩きます。

./db-initialize.sh

doc-init.phpではテーブルに追加したり削除したりなど初期化するコードを書きます。

さらにそれとは別で、取得系の操作を行うexample.phpを作成し、こちらをデバッグすることで効率を上げます。

多対一 一方向

単純なサンプルで親のCategoryと子のArticleという一対多を定義します。
データベースではArticleテーブルにCategoryへの外部キーが設定されます。
子は親を持ちますが、親は子を参照しない一方向の定義を試します。

Category.php

namespace App\Entities;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Category
{
    /**
     * @ORM\Id
     * @ORM\Column
     * @ORM\GeneratedValue
     */
    private int $id;

    public function getId(){ return $this->id; }
    public function setId(int $id){ $this->id = $id; }

    /**
     * @ORM\Column
     */
    private string $name;

    public function getName(){ return $this->name; }
    public function setName(string $name){ $this->name = $name; }

    public function __construct(string $name = "")
    {
        $this->name = $name;
    }

}

Article.php

namespace App\Entities;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Article
{

    /**
     * @ORM\Id
     * @ORM\Column
     * @ORM\GeneratedValue
     */
    private int $id;

    public function getId(){ return $this->id; }
    public function setId(int $id){ $this->id = $id; }

    /**
     * @ORM\Column
     */
    private string $title;

    public function getTitle(){ return $this->title; }
    public function setTitle(string $title){ $this->title = $title; }

    /**
     * @ORM\ManyToOne(targetEntity="Category")
     */
    private Category $category;

    public function __construct(string $title = "")
    {
        $this->title = $title;
    }

    public function setCategory(Category $category)
    {
        $this->category = $category;
    }

    public function getCategory()
    {
        return $this->category ?? null;
    }

}

doc-init.php

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$c2 = new Category('Fruits');

$a1 = new Article('Chikin cooking');
$a2 = new Article('Pork is Buhi!');
$a3 = new Article('Banana monkey');
$a4 = new Article('Apple is red');

// BananaとAppleの親を設定
$a3->setCategory($c2);
$a4->setCategory($c2);

$manager->persist($c1);
$manager->persist($c2);

$manager->persist($a1);
$manager->persist($a2);
$manager->persist($a3);
$manager->persist($a4);

$manager->flush();

コマンドを実行

./db-initialize.sh

これでテーブルの再作成と、初期化用のPHPファイル(doc-init.php)の内容が反映されるはずです。

出力されたSQL

コマンドを実行すると次のSQLが出力されました。

CREATE TABLE Article (
    id INT AUTO_INCREMENT NOT NULL,
    category_id INT DEFAULT NULL,
    title VARCHAR(255) NOT NULL,
    INDEX IDX_CD8737FA12469DE2 (category_id),
    PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;

CREATE TABLE Category (
    id INT AUTO_INCREMENT NOT NULL,
    name VARCHAR(255) NOT NULL,
    PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;

ALTER TABLE Article ADD CONSTRAINT FK_CD8737FA12469DE2 FOREIGN KEY (category_id)
    REFERENCES Category (id);

MySQLを覗いてみる

MySQLでSQLを投げてみた結果。

mysql> select * from Article;
+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |        NULL | Chikin cooking |
|  2 |        NULL | Pork is Buhi!  |
|  3 |           2 | Banana monkey  |
|  4 |           2 | Apple is red   |
+----+-------------+----------------+
4 rows in set (0.00 sec)

mysql> select * from Category;
+----+--------+
| id | name   |
+----+--------+
|  1 | Meats  |
|  2 | Fruits |
+----+--------+
2 rows in set (0.00 sec)

Articleの解説

Articleクラスの$categoryプロパティで親エンティティを設定。

/**
 * @ORM\ManyToOne(targetEntity="Category")
 */
private Category $category;

ManyToOneは自分のエンティティ(Article)がManyで、相手のエンティティ(Category)がOneの意味。

// BananaとAppleの親を設定
$a3->setCategory($c2);
$a4->setCategory($c2);

Articleは親エンティティ(Category)を設定しています。

$manager->persist($c1);
$manager->persist($c2);

$manager->persist($a1);
$manager->persist($a2);
$manager->persist($a3);
$manager->persist($a4);

この例ではすべてpersist()する必要があります。

取得系(example.php)

取得系のコードexample.phpを作成し、デバッグします。

foreach($articles as $article)
{
    echo "{$article->getTitle()}: ";
    echo $article->getCategory()?->getName() ?: 'NULL';
    echo "\n";
}

結果。

Chikin cooking: NULL
Pork is Buhi!: NULL
Banana monkey: Fruits
Apple is red: Fruits

Article一覧を取得、それぞれ親のCategoryも同時に取得してます。

DQLで書く時は

$items = $manager
    ->createQuery('SELECT c.name, a.title FROM App\Entities\Article a JOIN a.category c')
    ->getResult();

foreach($items as $item)
{
    echo "{$item['title']} - {$item['name']} \n";
}
Banana monkey - Fruits 
Apple is red - Fruits 

ちょっとDQLに慣れるまで時間かかりそうですが、SQLと違ってa.categoryとあくまでエンティティに対してのクエリになってます。
NULLは無視されてます。

次は双方向についてです。

多対一 双方向

今度は親(Category)からも子(Article)の参照が出来るようにします。
双方で参照しあう場合、inversedBymappingByを設定します。

また、双方向な関連では所有者側(Owning Side)逆側(Inverse Side)の概念があるようです。
今回の例では所有者側がArticle(外部キー持ってる方)で、逆側がCategoryです。

Category.php

namespace App\Entities;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Category
{
    /**
     * @ORM\Id
     * @ORM\Column
     * @ORM\GeneratedValue
     */
    private int $id;

    public function getId(){ return $this->id; }
    public function setId(int $id){ $this->id = $id; }

    /**
     * @ORM\Column
     */
    private string $name;

    public function getName(){ return $this->name; }
    public function setName(string $name){ $this->name = $name; }

    /**
     * @ORM\OneToMany(targetEntity="Article", mappedBy="category")
     */
    private $articles;

    public function getArticles()
    {
        return $this->articles;
    }

    public function __construct(string $name = "")
    {
        $this->name = $name;
        $this->articles = new ArrayCollection();
    }

}

Article.php

namespace App\Entities;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Article
{

    /**
     * @ORM\Id
     * @ORM\Column
     * @ORM\GeneratedValue
     */
    private int $id;

    public function getId(){ return $this->id; }
    public function setId(int $id){ $this->id = $id; }

    /**
     * @ORM\Column
     */
    private string $title;

    public function getTitle(){ return $this->title; }
    public function setTitle(string $title){ $this->title = $title; }

    /**
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles")
     */
    private Category $category;

    public function setCategory(Category $category)
    {
        $this->category = $category;
    }

    public function getCategory()
    {
        return $this->category ?? null;
    }

    public function __construct(string $title = "")
    {
        $this->title = $title;
    }

}

ArticleとCategoryの解説

まずArticleではManyToOneでinversedByを設定します。

/**
 * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles")
 */
private Category $category;

Articleが所有者側(子)でMany、相手の逆側(親)がCategoryでOne、Categoryのarticlesから参照されるという意味の設定です。

Articleが所有者側で、逆側がCategory

次にCategoryです。
OneToMany()はCategoryがOne(1)で、
targetEntityで指定したArticleがMany(多)、
mappedByがArticleのcategoryプロパティから参照されているという意味の設定です。

/**
 * @ORM\OneToMany(targetEntity="Article", mappedBy="category")
 */
private $articles;

所有者側(子)がinversedBy、逆側(親)がmappedByを設定してます。

doc-init.php

前回と同じです。

コマンドを実行

./db-initialize.sh

データベースを再作成します。

example.php

Categoryから子(Article)一覧を取得できます。

use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$categories = $manager
    ->createQuery('SELECT c FROM App\Entities\Category c')
    ->getResult();

foreach($categories as $category)
{
    $articles = $category->getArticles();
    foreach($articles as $article)
    {
        echo sprintf("%s : %s\n",
            $category->getName(),
            $article->getTitle(),
        );
    }
}
Fruits : Banana monkey
Fruits : Apple is red

今後、データベースの再作成は省略しますので自分の判断で行ってください。

cascadeでpersistの設定。

cascadeの意味が分かりづらかったので細かく試していきました。
先にdoc-init.phpをものすごく小規模なものにしました。

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$a1 = new Article('Chikin cooking');

// 親のCategoryを追加
$a1->setCategory($c1);

$manager->persist($c1);
$manager->persist($a1);
$manager->flush();

SQLの実行結果はこうなります。

mysql> select * from Category;
+----+-------+
| id | name  |
+----+-------+
|  1 | Meats |
+----+-------+
1 row in set (0.00 sec)

mysql> select * from Article;
+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |           1 | Chikin cooking |
+----+-------------+----------------+
1 row in set (0.00 sec)

さてここで、

$manager->persist($c1);
$manager->persist($a1);
$manager->flush();

Category, Article, 両方をpersist()しないといけません。

//$manager->persist($c1);
$manager->persist($a1);
$manager->flush();

もしCategoryをpersist()しなかったらエラーになります。

何が言いたいかといいますと、

$a1->setCategory($c1);

ArticleにCategoryを追加してんだから、自動的にCategoryのほうもpersist()して欲しいですよね。
そこで、

/**
 * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles", cascade={"persist"})
 */
private Category $category;

cascadeでpersistを設定すると、Articleのpersist()がCategoryに伝番します。

//$manager->persist($c1);
$manager->persist($a1);
$manager->flush();

もう一度実行すると今度はエラーになりません。
逆にCategoryの方もこれを設定します。

-- Category.php --

namespace App\Entities;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Category
{
    /**
     * @ORM\Id
     * @ORM\Column
     * @ORM\GeneratedValue
     */
    private int $id;

    public function getId(){ return $this->id; }
    public function setId(int $id){ $this->id = $id; }

    /**
     * @ORM\Column
     */
    private string $name;

    public function getName(){ return $this->name; }
    public function setName(string $name){ $this->name = $name; }

    /**
     * @ORM\OneToMany(targetEntity="Article", mappedBy="category", cascade={"persist"})
     */
    private $articles;

    public function getArticles()
    {
        return $this->articles;
    }

    public function addArticle(Article $article)
    {
        $this->articles[] = $article;
    }

    public function __construct(string $name = "")
    {
        $this->name = $name;
        $this->articles = new ArrayCollection();
    }

}

CategoryにaddArticle()メソッドを追加しました。
またOneToMany()にcascadeも追加しました。

-- doc-init.php --

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$a1 = new Article('Chikin cooking');

$c1->addArticle($a1);

$manager->persist($c1);
$manager->flush();
mysql> select * from Category;
+----+-------+
| id | name  |
+----+-------+
|  1 | Meats |
+----+-------+
1 row in set (0.00 sec)

mysql> select * from Article;
+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |        NULL | Chikin cooking |
+----+-------------+----------------+
1 row in set (0.00 sec)

今度はCategoryにArticleを追加しました。

$c1->addArticle($a1);

persist()は伝番されるので

$manager->persist($c1);

Categoryの方だけpersist()しておけばいいです。

ただ、

おきづきだろうか!

+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |        NULL | Chikin cooking |
+----+-------------+----------------+

ArticleはCategoryの外部キーが設定されていません。
persist()は伝番してくれるけど、結局Articleには親のCategoryを追加しないといけません。

-- doc-init.php --

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$a1 = new Article('Chikin cooking');

$c1->addArticle($a1);
$a1->setCategory($c1); // ←ほら、ここ重要

$manager->persist($c1);
$manager->flush();
mysql> select * from Category;
+----+-------+
| id | name  |
+----+-------+
|  1 | Meats |
+----+-------+
1 row in set (0.01 sec)

mysql> select * from Article;
+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |           1 | Chikin cooking |
+----+-------------+----------------+
1 row in set (0.00 sec)

外部キーは設定されていました。

ただ、実際には子に親を設定する処理($a1->setCategory($c1);)は隠ぺいし、子を追加する時に行った方がいいでしょう。
addArticle()メソッド内に移します。

public function addArticle(Article $article)
{
    $article->setCategory($this);
    $this->articles[] = $article;
}

これで呼び出す側はスッキリします。

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$a1 = new Article('Chikin cooking');

$c1->addArticle($a1); // ← この中に隠ぺいされてる。

$manager->persist($c1);
$manager->flush();

cascadeでremoveの設定

まずdoc-init.phpを変更します。

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$c2 = new Category('Fruits');

$a1 = new Article('Chikin cooking');
$a2 = new Article('Pork is Buhi!');
$a3 = new Article('Banana monkey');
$a4 = new Article('Apple is red');

$c1->addArticle($a1);
$c1->addArticle($a2);
$c2->addArticle($a3);
$c2->addArticle($a4);

$manager->persist($c1);
$manager->persist($c2);
$manager->flush();
mysql> select * from Category;
+----+--------+
| id | name   |
+----+--------+
|  1 | Meats  |
|  2 | Fruits |
+----+--------+
2 rows in set (0.00 sec)

mysql> select * from Article;
+----+-------------+----------------+
| id | category_id | title          |
+----+-------------+----------------+
|  1 |           1 | Chikin cooking |
|  2 |           1 | Pork is Buhi!  |
|  3 |           2 | Banana monkey  |
|  4 |           2 | Apple is red   |
+----+-------------+----------------+
4 rows in set (0.00 sec)

今度は削除を見ていきます。

doc-init.phpを編集します。

use App\Entities\Article;
use App\Entities\Category;
use Doctrine\ORM\EntityManager;

/** @var EntityManager $manager */
$manager = require 'createManager.php';

$c1 = new Category('Meats');
$c2 = new Category('Fruits');

$a1 = new Article('Chikin cooking');
$a2 = new Article('Pork is Buhi!');
$a3 = new Article('Banana monkey');
$a4 = new Article('Apple is red');

$c1->addArticle($a1);
$c1->addArticle($a2);
$c2->addArticle($a3);
$c2->addArticle($a4);

$manager->persist($c1);
$manager->persist($c2);
$manager->flush();

// クリア
$manager->clear();

$c1 = $manager->find(Category::class, 1);
$manager->remove($c1);

foreach($c1->getArticles() as $article)
{
    $manager->remove($article);
}

$manager->flush();

IDが1のCategoryを削除、
さらに子のArticleを2つ削除します。

mysql> select * from Category;
+----+--------+
| id | name   |
+----+--------+
|  2 | Fruits |
+----+--------+
1 row in set (0.00 sec)

mysql> select * from Article;
+----+-------------+---------------+
| id | category_id | title         |
+----+-------------+---------------+
|  3 |           2 | Banana monkey |
|  4 |           2 | Apple is red  |
+----+-------------+---------------+
2 rows in set (0.00 sec)

思った通り親子もろとも削除出来ました。
でも、Categoryを削除($manager->remove($c1);)した時点でその子も削除したいです。
もし親を削除する時、その子をすべて削除しないとエラーになります。

$c1 = $manager->find(Category::class, 1);
$manager->remove($c1);

foreach($c1->getArticles() as $article)
{
    $manager->remove($article);
    break; // ←最初の1個しか子を削除しないように抜ける。
}

$manager->flush();

↑ エラーになる。

親を削除すれば子も削除するように伝番させるにはCategoryのcascadeでremoveを設定します。

/**
 * @ORM\OneToMany(targetEntity="Article", mappedBy="category", cascade={"persist", "remove"})
 */
private $articles;

すると親を削除すると子もろとも削除します。

$c1 = $manager->find(Category::class, 1);
$manager->remove($c1);

$manager->flush();

今度は子の削除を見ていきます。

-- doc-init.php --

$a1 = $manager->find(Article::class, 1);
$manager->remove($a1);

$manager->flush();
mysql> select * from Category;
+----+--------+
| id | name   |
+----+--------+
|  1 | Meats  |
|  2 | Fruits |
+----+--------+
2 rows in set (0.00 sec)

mysql> select * from Article;
+----+-------------+---------------+
| id | category_id | title         |
+----+-------------+---------------+
|  2 |           1 | Pork is Buhi! |
|  3 |           2 | Banana monkey |
|  4 |           2 | Apple is red  |
+----+-------------+---------------+
3 rows in set (0.00 sec)

ここまでは予想通りですが。

子側にもcascade: removeを設定すると予想外なことが起きました。

子にcascade: removeを設定したときの混乱。

仕様がいまいちわからず、試行錯誤していく中大体の見当がついた、みたいな内容です。
間違いがある可能性大ですのでご了承ください。

/**
 * Articleの設定
 * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles", cascade={"persist", "remove"})
 */
private Category $category;

/**
 * Categoryの設定
 * @ORM\OneToMany(targetEntity="Article", mappedBy="category", cascade={"persist", "remove"})
 */
private $articles;
mysql> select * from Category;
+----+--------+
| id | name   |
+----+--------+
|  2 | Fruits |
+----+--------+
1 row in set (0.00 sec)

mysql> select * from Article;
+----+-------------+---------------+
| id | category_id | title         |
+----+-------------+---------------+
|  3 |           2 | Banana monkey |
|  4 |           2 | Apple is red  |
+----+-------------+---------------+
2 rows in set (0.00 sec)

Article(id=1)を削除すると、
Article(id=2)も削除されている。
それどころか親のCategory(id=1)まで削除されている。

多分子Article(id=1)の削除が親に伝番、さらに親Category(id=1)の削除が子にも伝番、みたいな?

逆に、親にはcascade: removeを設定せず、子にだけ設定すると、

/**
 * Articleの設定
 * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles", cascade={"persist", "remove"})
 */
private Category $category;

/**
 * Categoryの設定
 * @ORM\OneToMany(targetEntity="Article", mappedBy="category", cascade={"persist"})
 */
private $articles;

Article(id=1)だけを削除するとエラーになります。
Article(id=1)とArticle(id=2)の両方を削除すると、親も削除されます。

$a1 = $manager->find(Article::class, 1);
$a2 = $manager->find(Article::class, 2);
$manager->remove($a1);
$manager->remove($a2);

$manager->flush();

つまり子にcascade: removeを付けると、子すべて削除しないとエラーになる、子をすべて削除すると親も削除される
みたいな感じになってました。

ちょっと複雑になりすぎました。

削除系ではcascadeの他にorphanRemoval=trueonDelete="CASCADE"などがありますが大変なので省略します。

https://tech.quartetcom.co.jp/2016/12/22/doctrine-cascade-remove/

この辺りが参考になります。

certificate Docker Gutenberg Hyper-V openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク