🎻

Doctrine+PostgreSQLでは、#[ORM\GeneratedValue]デフォルトだとpersistしただけでidが進む

2022/10/20に公開約4,100字

どういうこと?

Symfony + Doctrineのプロジェクトで、DBがPostgreSQLな場合においては、エンティティの $id を普通に

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

のように定義していると、persist() しただけでflush() しなくても)DBレイヤーで SELECT NEXTVAL('{テーブル名}_id_seq') が発行されて idが進んでしまう、ということを今日知りました😳

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

flush() しない限りidが進まないようにするには?

正常系で persist() だけして flush() しないという処理を書くことはそれほど多くないかもしれませんが、ゼロではないと思います。

僕の場合は、API Platform を使っていて、POSTオペレーションの結果をプレビューする(実際に永続化はせずに)APIを作る際にそのような処理を書きました。

この場合、フロントエンドで確認画面を表示→戻って修正→確認画面を表示→・・・を繰り返す度に、DBレイヤーでidがインクリメントされてしまい、データ管理上あまり嬉しくありません。

flush() しない限りidが進まないようにするには、エンティティの $id の定義において、以下のように GeneratedValuestrategy'IDENTITY' を指定すればよいです。

  #[ORM\Id]
- #[ORM\GeneratedValue]
+ #[ORM\GeneratedValue(strategy: 'IDENTITY')]
  #[ORM\Column(type: 'integer')]
  private ?int $id = null;

参考:php - Stop Doctrine querying for nextval before inserting data - Stack Overflow #answer-53090519

こう書き換えて bin/console doctrine:migrations:diff を実行すると、

$this->addSql('CREATE SEQUENCE {テーブル名}_id_seq');
$this->addSql('SELECT setval(\'{テーブル名}_id_seq\', (SELECT MAX(id) FROM {テーブル名}))');
$this->addSql('ALTER TABLE {テーブル名} ALTER id SET DEFAULT nextval(\'{テーブル名}_id_seq\')');

このようなマイグレーションスクリプトが生成されます。

今回のように途中で設定を変更した場合、DB上にシーケンスは既に存在していて適切にインクリメントされてきているはずなので、1-2行目は不要です。

というわけで

// $this->addSql('CREATE SEQUENCE {テーブル名}_id_seq');
// $this->addSql('SELECT setval(\'{テーブル名}_id_seq\', (SELECT MAX(id) FROM {テーブル名}))');
$this->addSql('ALTER TABLE {テーブル名} ALTER id SET DEFAULT nextval(\'{テーブル名}_id_seq\')');

この1行だけ残してマイグレーションすればOKです。

これで、

  • #[ORM\GeneratedValue(strategy: 'IDENTITY')] になっているため、persist() 時にDcotrineが SELECT NEXTVAL('{テーブル名}_id_seq') を発行しなくなる
  • DBレイヤーでidカラムのデフォルト値が nextval('{テーブル名}_id_seq') に設定されているため、INSERT時に自動でidが採番される

という挙動になります。

おまけ:この設定の場合に、API Platformで flush() せずにPOSTオペレーションの結果を得るには?

今回の僕のケースでは、API Platform

  • フロントからPOSTされたデータをhydrationして作成したエンティティを
  • EntityListenerの prePersist() などを実行させる目的で persist() だけして
  • flush() はせずにエンティティをフロントに返す

というのがやりたいことでした。

デフォルトの設定にしていたときは、(意図せず)persist()しただけでインクリメント後のidが取得されてエンティティにセットされていたので、そのまま返すだけでよかったのですが、今回の設定に変更したことで、副作用として flush() するまでidが確定しなくなったため、そのまま返そうとすると $idnull なために、IRIと呼ばれるリソースの識別子を生成できずエラーが発生するようになりました。

そこで、(しょうがなく、)リフレクションを使って無理やりエンティティに $id を設定できるようにしたのでその方法もついでに紹介しておきます。

まずこんな感じのサービスクラスを作ります。

namespace App\Doctrine;

use Doctrine\ORM\EntityManagerInterface;

class IdSetter
{
    public function __construct(private EntityManagerInterface $em)
    {
    }

    public function setId(object $entity, ?int $id = null): void
    {
        if (!$id) {
            $lastEntity = $this->em->getRepository(get_class($entity))->findOneBy([], ['id' => 'desc']);
            if (!$lastEntity) {
                $id = 1;
            } elseif (method_exists($lastEntity, 'getId')) {
                $id = $lastEntity->getId() + 1;
            } else {
                throw new \LogicException('エンティティに "getId()" メソッドがありません。');
            }
        }

        $reflectionProperty = new \ReflectionProperty($entity, 'id');
        $reflectionProperty->setAccessible(true);
        $reflectionProperty->setValue($entity, $id);
    }
}

その上で、DataPersisterpersist() メソッドを以下のような実装にします。

※ この例では、API Platformのバージョンは2系です。

public function persist($data, array $context = []): Foo
{
    $this->em->persist($data);

    if (/* プレビューモードの場合 */) {
        if ($context['collection_operation_name'] ?? false) { // postの場合のみidを採番
            $this->idSetter->setId($data);
        }

        return $data;
    }

    $this->em->flush();
    $this->em->refresh($data);

    return $data;
}

これで、POSTオペレーションをプレビューモードで実行している場合にのみ、実際に採番されるであろうidを擬似的にエンティティにセットした上で、flush() はせずに返す、という処理が実現できます。

GitHubで編集を提案

Discussion

ログインするとコメントできます