🎻

[Symfony][Doctrine] UniqueEntity制約はpersistしただけの既存エンティティとの重複は検出してくれない

2021/12/03に公開

Symfony Advent Calendar 2021 の3日目の記事です!🎄🌙

ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲

昨日も僕の記事で、[Symfony][Doctrine] 論理削除と変更履歴とDBのビューを駆使して複雑な集計ロジックをシンプルなコードで実装した例 でした✨

はじめに

UniqueEntity 制約 はSymfony + Doctrineで開発していればとても頻繁に使う機能ですが、今更になって以下の事実に気づきました。

https://twitter.com/ttskch/status/1462572609476853760

これについて事実関係を確認したので、対応策も含めてシェアしたいと思います。

起こったこと

  • あるエンティティのCSVインポート機能を実装していた
  • そのエンティティにはアプリレイヤーで UniqueEntity 制約が、 DBレイヤーでユニーク制約がそれぞれ設定されていた
  • CSVインポート処理においては、1行ずつflushするのではなく、一旦persistだけしてメモリ上に溜めておいて、一定数ごとにまとめてflushするようにしていた(参考
  • すでにDBに永続化されているデータとCSVの行データが重複した場合はバリデーションエラーになるが、persistだけしてflushしていないデータと重複していてもバリデーションエラーにならなかった
    • 具体的には、CSVファイル内(の、ある程度近い行)に重複した行がある場合に、一方の行データがpersistされたあと、他方の行データをバリデーションしてもエラーにならなかった
  • バリデーションエラーにならないため、flush時にDBレイヤーでエラーになった

調べて分かったこと

マジで?と思い、自分の使い方がおかしいか、何か方法があるのを見落としているのではとググってみると、以下のポストが見つかりました。

symfony - Trigger UniqueEntity after persisting and before flushing data - Stack Overflow

どうやら本当にそういう仕様っぽいという雰囲気を感じます。

というわけで、Symfonyのコードを読んでみます。

この部分 で、以下のようにして重複する既存エンティティを取得しています。

$result = $repository->{$constraint->repositoryMethod}($criteria);

$constraint->repositoryMethod はデフォルトでは findBy なので、リポジトリの findBy() メソッドで取得されないエンティティとは重複していても無視されるという動作になります。

そして、実際に簡単なコードを書いて実験してみるとすぐに分かりますが、findBy() メソッドではpersistしただけのエンティティは取得されません。(ちなみに findBy() メソッドの実装は こんな感じ になっています)

というわけで、UniqueEntity 制約はそのまま使うとpersistしただけの既存エンティティとの重複は検出してくれないということが分かりました🤦‍♂️

解決した方法

解決する方法は

  • 簡単 CSVデータを1行ずつflushするようにする
  • 簡単 諦めてDBレイヤーのエラー( Doctrine\DBAL\Exception\UniqueConstraintViolationException )をcatchしてエラー処理する
  • 面倒臭い persistしただけの既存エンティティとの重複も検出してくれるよう改造する

あたりになるかなと思いますが、今回は3つ目の方法で対処しました。

具体的には、まず当該エンティティのリポジトリクラスに、以下のようなメソッドを追加します。

class FooRepository extends ServiceEntityRepository
{
    // ...

    public function findLogicallyBy(array $criteria): array
    {
        $result = $this->findBy($criteria);

        foreach ($this->_em->getUnitOfWork()->getScheduledEntityInsertions() as $entity) {
            foreach ($criteria as $property => $value) {
                $getter = 'get' . ucfirst($property);
                if ($entity->$getter() != $value) { // プロパティの値がオブジェクトの場合に内容だけを比較したいので !== ではなく != で比較
                    continue 2;
                }
            }
            $result[] = $entity;
        }

        return $result;
    }
}

通常の findBy() の取得結果に、persist済みのエンティティのうち $criteria に一致するエンティティを加えた結果セットを返す実装になっています。

処理の性質上どうしても二重ループになってしまうので、flushせずにメモリ上に溜めるデータの数が多くなると計算量が爆発してしまうので要注意です。今回は要件的に十分許容できたのでこの対応にしました。

次に、UniqueEntity 制約のバリデーション時にこのメソッドが呼ばれるように設定を変更します。

/**
 * @ORM\Entity(repositoryClass=FooRepository::class)
 * @UniqueEntity(fields={"bar"}, repositoryMethod="findLogicallyBy")
 */
class Foo
{
    // ...
}

repositoryMethod="findLogicallyBy" により、重複エンティティの検出にデフォルトの findBy() ではなく先ほど実装した findLogicallyBy() を使うように指定しています。

これで、persistしただけの既存エンティティとの重複もバリデーションで弾いてくれるようになりました👌

おわりに

というわけで、Symfony + Doctrineで、UniqueEntity 制約がpersistしただけの既存エンティティとの重複は検出してくれないことを確認し、その対処方法をご紹介しました。

パフォーマンスと相談しつつ活用してみていただければと思います。

Symfony Advent Calendar 2021、明日は @77web さんです!お楽しみに!

GitHubで編集を提案

Discussion