Doctrine入門、UnitOfWorkとトラッキング状態

DoctrineはUnit of Workパターンに対応したライブラリです。

Unit of Workパターンって何?

って話だけど、

複雑な処理では一度にたくさんの更新系(追加、削除、編集)があり、
その都度DBに問い合わせするのはちょっとね、
いったんそれらの処理をメモリ上に記録しておいて、後でまとめてDBに更新しちゃおう!
って感じに思ってる。

間違ってる可能性大なので詳しくはこちらをご覧ください。

https://bliki-ja.github.io/pofeaa/UnitofWork/

まずDoctrineのちょっと不思議な仕組みを覗いていきます。

use App\Entities\Item;
use Doctrine\ORM\EntityManager;

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

/** @var Item $item */
$item = $manager->find(Item::class, 3);

// アイテムjを更新
$item->setPower(12345);

// セーブ(ここで初めて変更がDBに反映される)
$manager->flush();

foreach($manager->getRepository(Item::class)->findAll() as $item)
{
    echo "{$item->getId()} - {$item->getName()} :  {$item->getPower()}\n";
}
1 - F :  8
2 - A :  3
3 - C :  12345
4 - D :  5
5 - G :  7
6 - E :  2
7 - B :  4

まず、id3のアイテムを取得します。

$item = $manager->find(Item::class, 3);

そのアイテムを変更しました。

$item->setPower(12345);

次にflush()で実行するわけですが、

$manager->flush();

これで実際にデータベースへ反映されます。

・・・!?

でもなんでこれで変更が可能になってるんでしょう!?

よくあるORMの典型的な方法なら$item->save()みたいな感じで保存されます。
その手のエンティティは特定のORM実装のクラスを実装しているので理解できます。
しかし今回のエンティティ(Item)自体は単純なクラス(POPO)です。

もうすこし調べていきます。

っと、その前にデータベースのデータを元に戻しておきます。
そのうえで、

エンティティの一意性

その前に同一のIDを持つエンティティは同じインスタンスとして扱われることを知る必要があります。

use App\Entities\Item;
use Doctrine\ORM\EntityManager;

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

$repository = $manager->getRepository(Item::class);

$item1 = $manager->find(Item::class, 3);
$item2 = $repository->findOneBy(['name' => 'C']);
$item3 = $manager->createQuery(
    'SELECT i FROM App\Entities\Item i WHERE i.power = 6'
)->getSingleResult();

if($item1 === $item2 && $item2 === $item3)
{
    echo "全部一緒だぞ!\n";
    echo "{$item1->getId()} - {$item1->getName()} :  {$item1->getPower()}\n";
}

これ、どうなるかわかりますか?

全部一緒だぞ!
3 - C :  6

実は、

$manager->find(Item::class, 3);

これも

$repository->findOneBy(['name' => 'C']);

これも

SELECT i FROM App\Entities\Item i WHERE i.power = 6

いずれもid3のエンティティを取得しています。
今回の$repository->findOneBy()は条件から検索結果を一つだけ取得するメソッドです。

そしてそのエンティティのインスタンスは全て同じです。

一度取得したエンティティはEntityManagerに保持されますが、すでに同じIDのエンティティがある場合は無視されるようです。
つまり同じIDのエンティティは同じ空間に2つといらないゼ!という。

同じIDのインスタンスは常に一つ!

ためしにSQL Serverのプロファイラで監視しましたが、3回分のデータベースへの問い合わせをしているようです。

エンティティのトラッキング状態を知る

エンティティの状態を知るにはEntityManagerからgetUnitOfWork()UnitOfWorkインスタンスを取得します。
そのgetEntityState()でエンティティの状態を調べることが出来ます。

エンティティの状態はUnitOfWork::STATE_MANAGED, UnitOfWork::STATE_NEW, UnitOfWork::STATE_DETACHED, UnitOfWork::STATE_REMOVEDがあります。

use App\Entities\Item;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\UnitOfWork;

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

$work = $manager->getUnitOfWork();

/** @var array<Item> $items */
$items = $manager->getRepository(Item::class)->findAll();

// id = 2 を削除
$manager->remove($items[1]);

// id = 5 を外す
$manager->detach($items[4]);

// Add(StateはManagedになるがIdentiferMapには登録されない)
$addItem = new Item('XXX', 123);
$manager->persist($addItem);

// New(インスタンスを作っただけ)
$newItem = new Item('YYY', 456);

$msg = [
    UnitOfWork::STATE_MANAGED => 'Managed',
    UnitOfWork::STATE_NEW => 'New',
    UnitOfWork::STATE_DETACHED => 'Detached',
    UnitOfWork::STATE_REMOVED => 'Removed'
];

foreach([...$items, $addItem, $newItem] as $item)
{
    $state = $work->getEntityState($item);
    $s = isset($msg[$state]) ? $msg[$state] : '???';
    echo "--- [$s] ---\n";
    print_r($item);
}

$manager->flush();
--- [Managed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 1
    [name:App\Entities\Item:private] => F
    [power:App\Entities\Item:private] => 8
)
--- [Removed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 2
    [name:App\Entities\Item:private] => A
    [power:App\Entities\Item:private] => 3
)
--- [Managed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 3
    [name:App\Entities\Item:private] => C
    [power:App\Entities\Item:private] => 6
)
--- [Managed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 4
    [name:App\Entities\Item:private] => D
    [power:App\Entities\Item:private] => 5
)
--- [Detached] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 5
    [name:App\Entities\Item:private] => G
    [power:App\Entities\Item:private] => 7
)
--- [Managed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 6
    [name:App\Entities\Item:private] => E
    [power:App\Entities\Item:private] => 2
)
--- [Managed] ---
App\Entities\Item Object
(
    [id:App\Entities\Item:private] => 7
    [name:App\Entities\Item:private] => B
    [power:App\Entities\Item:private] => 4
)
--- [Managed] ---
App\Entities\Item Object
(
    [name:App\Entities\Item:private] => XXX
    [power:App\Entities\Item:private] => 123
)
--- [New] ---
App\Entities\Item Object
(
    [name:App\Entities\Item:private] => YYY
    [power:App\Entities\Item:private] => 456
)

UnitOfWorkのインスタンスをEntityManagerから取得します。

$work = $manager->getUnitOfWork();

エンティティのトラッキング状態はgetEntityState()から取得出来ます。

$state = $work->getEntityState($item);

まず、いったん全アイテムを取得します。
その後何もなければそのアイテムの状態はUnitOfWork::STATE_MANAGEDになります。

$items = $manager->getRepository(Item::class)->findAll();

次にアイテムを削除します。
するとアイテムの状態はUnitOfWork::STATE_REMOVEDになります。
flush()した時にデータベースのテーブルから削除されます。

$manager->remove($items[1]);

次にアイテムをデタッチ(外すとかそういう意味)させます。
するとアイテムの状態はUnitOfWork::STATE_DETACHEDになります。
flush()してもデータベースのテーブルから削除はされません、これはなんにもしないということです。

$manager->detach($items[4]);

さらに新規作成したアイテムをpersist()します。
その時のアイテムの状態はUnitOfWork::STATE_MANAGEDのままです。
これまで通り、データベースのテーブルに追加されます。

$addItem = new Item('XXX', 123);
$manager->persist($addItem);

最後にアイテムを作成だけします。
作成しただけなのでそのアイテムはUnitOfWork::STATE_NEWになります。

$newItem = new Item('YYY', 456);

データベースのデータはこうなります。

1 - F :  8
3 - C :  6
4 - D :  5
5 - G :  7
6 - E :  2
7 - B :  4
8 - XXX :  123

ID=2が削除、ID=5は何もなってない、ID=8が新たに作成。

このように、EntityManagerは内部で取得したエンティティを追跡していて、
その後flush()時に変更、削除、追加されたエンティティをまとめてデータベースに反映する仕組みになってます。

UnitOfWorkのgetIdentityMap()の動作

ところでUnitOfWorkにはgetIdentityMap()があってエンティティのマップを取得出来るようになってます。

use App\Entities\Item;
use Doctrine\ORM\EntityManager;

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

$manager->find(Item::class, 2);
$manager->find(Item::class, 5);
$manager->find(Item::class, 7);

// UnitOfWork
$work = $manager->getUnitOfWork();

// トラッキング中のエンティティ
$map = $work->getIdentityMap();

print_r($map);
Array
(
    [App\Entities\Item] => Array
        (
            [2] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 2
                    [name:App\Entities\Item:private] => A
                    [power:App\Entities\Item:private] => 3
                )

            [5] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 5
                    [name:App\Entities\Item:private] => G
                    [power:App\Entities\Item:private] => 7
                )

            [7] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 7
                    [name:App\Entities\Item:private] => B
                    [power:App\Entities\Item:private] => 4
                )

        )
)

まず適当に参照系でエンティティを取得します。

$manager->find(Item::class, 2);
$manager->find(Item::class, 5);
$manager->find(Item::class, 7);

UnitOfWorkからgetIdentityMap()でトラッキング中のエンティティを取得出来ます。

$map = $work->getIdentityMap();

ただ勘違いしていて、新規にエンティティを作成してpersist()してもここに追加はされてません。
追加されるのはflush()した後だったので、メソッド名から見てもIDが設定されてないものは対象外なのかも。

何時どのタイミングでトラッキングされるかをちょっと気になったので調べてみました。
把握している範囲ですが、

  • 取得系で取得する時(エンティティ化でUnitOfWork->createEntity()が実行されるとき。)
  • parsist()したアイテムがflush()をえた時(コミットされUnitOfWork->executeInserts()が実行される時)

でした。まだあるかもしれませんが。

またremove()detach()されたエンティティはマップから除外されます。
その辺りもっと詳しく見ていきます。

マップの動作チェック

今回の例で、UnitOfWork->getIdentityMap()から得られるエンティティを調べます。
さらに結果をflush()する前と後で比較していきます。

use App\Entities\Item;
use Doctrine\ORM\EntityManager;

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

$work = $manager->getUnitOfWork();

/** @var array<Item> $items */
$items = $manager->getRepository(Item::class)->findAll();

// id = 2 を削除
$manager->remove($items[1]);

// id = 5 を外す
$manager->detach($items[4]);

// Add(StateはManagedになるがIdentiferMapには登録されない)
$addItem = new Item('XXX', 123);
$manager->persist($addItem);

// New(インスタンスを作っただけ)
$newItem = new Item('YYY', 456);

print_r($work->getIdentityMap());

// コミット
$manager->flush();

print_r($work->getIdentityMap());
Array
(
    [App\Entities\Item] => Array
        (
            [1] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 1
                    [name:App\Entities\Item:private] => F
                    [power:App\Entities\Item:private] => 8
                )

            [3] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 3
                    [name:App\Entities\Item:private] => C
                    [power:App\Entities\Item:private] => 6
                )

            [4] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 4
                    [name:App\Entities\Item:private] => D
                    [power:App\Entities\Item:private] => 5
                )

            [6] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 6
                    [name:App\Entities\Item:private] => E
                    [power:App\Entities\Item:private] => 2
                )

            [7] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 7
                    [name:App\Entities\Item:private] => B
                    [power:App\Entities\Item:private] => 4
                )

        )

)
Array
(
    [App\Entities\Item] => Array
        (
            [1] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 1
                    [name:App\Entities\Item:private] => F
                    [power:App\Entities\Item:private] => 8
                )

            [3] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 3
                    [name:App\Entities\Item:private] => C
                    [power:App\Entities\Item:private] => 6
                )

            [4] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 4
                    [name:App\Entities\Item:private] => D
                    [power:App\Entities\Item:private] => 5
                )

            [6] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 6
                    [name:App\Entities\Item:private] => E
                    [power:App\Entities\Item:private] => 2
                )

            [7] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 7
                    [name:App\Entities\Item:private] => B
                    [power:App\Entities\Item:private] => 4
                )

            [8] => App\Entities\Item Object
                (
                    [id:App\Entities\Item:private] => 8
                    [name:App\Entities\Item:private] => XXX
                    [power:App\Entities\Item:private] => 123
                )

        )
)

前後で変わりが無いのが削除及びデタッチしたエンティティでともにマップから除外されています。
後の方にはflush()後に追加したアイテムが反映されてます。

今回は追加、削除でしたが次回は変更についてです。

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