Doctrine ORMでPostgreSQLのforeign keyをDEFERRABLE INITIALLY DEFERREDにする

Doctrine ORMでリレーションを定義すると、PostgreSQL用のforeign key（外部キー制約）は `NOT DEFERRABLE INITIALY IMMEDIATE` となります。

Doctrine ORMを使っているPHPアプリケーションのみで使うデータベースを作っている場合は特に問題にはならないでしょう。すべてのデータベースアクセスはDoctrine ORMを使って行うので、foreign keyのオプションを気にする必要はないからです。（PostgreSQLの便利機能は使わない前提）

では、他のシステムとデータベース全体や一部テーブルを共有する場合など、データベース設計としてdeferrableなforeign keyがほしいときはどうすればいいか？

## Doctrine ORMのDDL管理の仕組み

(ORM) マッピング定義（annotation,attribute,yaml,php設定ファイル等でPHPのクラスにマッピング定義をつける）
↓
(ORM) MappingDriverが各形式のマッピング定義を読み込み、連想配列化
↓
(ORM/DBAL) SchemaToolが連想配列化されたマッピング定義を使って、Schemaを作る
↓
(DBAL) 各データベースごと（MySQL,PostgreSQL,SQLite等）のPlatformがSchemaをSQLに変換

Schemaの段階では、テーブルやカラム・リレーションの情報はDoctrine DBALによって抽象化されており、どのデータベースプラットフォームにも使えるような設定しか持っていません。
一部、オプションだけが各データベースプラットフォーム固有の情報を保持できます。大抵のPHPアプリケーションではマッピング定義の時点で利用するプラットフォームが決まっているため、データベースプラットフォーム固有情報はoptionsやcolumnDefinitionsのようなオプション項目に開発者が埋め込んだものです。

開発者が便利な `doctrine:schema:create` や `doctrine:schema:update` 、あるいは `doctrine:migrations:diff` コマンドを呼び出したとき、Doctrine ORMは内部でこの仕組を使ってSQLを生成したり、実行したりしています。

## DoctrineでNOT DEFERRABLEなforeign key制約を作っているのはどこか

`DEFERRABLE` にしても `NOT DEFERRABLE` にしてもPostgreSQLの概念なのでPostgreSQLのPlatformクラスにあります。
https://github.com/doctrine/dbal/blob/22de295f10edbe00df74f517612f1fbd711131e2/src/Platforms/PostgreSQLPlatform.php#L488-L514

Platformのコードを見ると `deferrable` `deffered` というオプションをForeignKeyに持たせれば良さそうです。

が、SchemaToolのほうを見るとSchemaToolで作られるForeignKeyにはオプションを渡す記述がありません。（渡されるオプションは `['onDelete' => '...']` のみ）
https://github.com/doctrine/orm/blob/5283e1441cc020d526d4842c6bfa21c1a500886d/lib/Doctrine/ORM/Tools/SchemaTool.php#L291-L299


## DoctrineのEventシステムを使ってSchemaのオプションを更新する

そこで、DoctrineのEventSubscriberを使って、オプションを更新します。

使えるイベントは2つあります。1つのテーブルだけを更新したいのかすべてのテーブルに適用したいのかによって使い分けると良いでしょう。

イベント名 | タイミング
--- | ---
Doctrine\ORM\Tool\ToolEvents::postGenerateSchemaTable　| 個別のテーブル用のスキーマを組み立て終わった後、テーブル1つについて発火
Doctrine\ORM\Tools\ToolEvents::postGenerateSchema | 全テーブルのスキーマを組み立て終わった後、スキーマ全体について1回だけ発火

このイベントに対するDoctrineイベントサブスクライバを書き、ForeignKeyConstraintの `deferrable` と `deferred` をtrueにセットすることで、 `DEFERRABLE INITIALLY DEFERRED` としてDoctrineに認識させることができます（schema:updateやmigrations:diffで無駄なdiffが出ません）。下記に全テーブルに適用するサブスクライバの例を示します。

```php
<?php

declare(strict_types=1);

namespace App\Service\Doctrine;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Doctrine\ORM\Tools\ToolEvents;

/**
 * foreign keyをdefferable initially deferredにする
 */
class ForeignKeyDeferrableSchemaSubscriber implements EventSubscriber
{
    /**
     * @var array|bool[]
     */
    private array $myForeignKeyOptions = [
        'deferrable' => true,
        'deferred' => true,
    ];

    public function getSubscribedEvents(): iterable
    {
        return [
            ToolEvents::postGenerateSchema,
        ];
    }

    public function postGenerateSchema(GenerateSchemaEventArgs $args): void
    {
        $schema = $args->getSchema();
        foreach ($schema->getTables() as $table) {
            foreach ($table->getForeignKeys() as $foreignKey) {
                $table->removeForeignKey($foreignKey->getName());
                $table->addForeignKeyConstraint(
                    $schema->getTable($foreignKey->getForeignTableName()),
                    $foreignKey->getLocalColumns(),
                    $foreignKey->getForeignColumns(),
                    array_merge($foreignKey->getOptions(), $this->myForeignKeyOptions),
                );
            }
        }
    }
}

```

## 感想

DoctrineはリアルワールドのデータベースであってもPHPエンジニアがORMで扱えるような機能が整っているなぁと再認識しました。
データベースをきちんと管理したいDBAさんがいるようなプロダクトにもピッタリのORMだと思います！