🎻

[Symfony] nelmio/aliceのフィクスチャでエンティティのidなどprivateプロパティも指定したい!

2022/10/20に公開

やりたいこと

Symfonyプロジェクトでは liip/test-fixtures-bundle などのバンドルを使って nelmio/alice でテストフィクスチャを作ることが多いと思います。

nelmio/aliceがオブジェクトのプロパティにアクセスする手段として、デフォルトでは Nelmio\Alice\PropertyAccess\StdPropertyAccessor というクラスが使われます。

このクラスはオブジェクトのprivateプロパティにはアクセスできないため、エンティティの $id などの(通常は)セッターを提供しないprivateプロパティについては、フィクスチャで値を指定することはできません。

App\Entity\Foo:
  foo1:
    id: 1
    bar: baz

このフィクスチャをロードしようとすると、

Nelmio\Alice\Throwable\Exception\Generator\DebugUnexpectedValueException: An error occurred while generating the fixture "foo1" (App\Entity\Foo): Could not hydrate the property "id" of the object "foo1" (class: App\Entity\Foo).

このようなエラーになります。

しかし、場合によっては $id などprivateプロパティの値を指定してフィクスチャを作成したいこともあるでしょう。

例えば

例えば、Single Table Inheritance(STI) を使った以下のようなエンティティがあるとします。

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn('type')]
#[ORM\DiscriminatorMap([
    'shop' => Shop::class,
    'customer' => Customer::class,
    'admin' => Admin::class,
])]
abstract class User
#[ORM\Entity(repositoryClass: ShopRepository::class)]
class Shop extends User
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer extends User
#[ORM\Entity(repositoryClass: AdminRepository::class)]
class Admin extends User

これらのクラスのインスタンスがテスト時にそれぞれ必要で、以下のようなフィクスチャを書いたとしましょう。

App\Entity\Shop:
  shop{1..2}:
    # ...

App\Entity\Customer:
  customer{1..2}:
    # ...

App\Entity\Admin:
  admin1:
    # ...

この場合、振られるidはYAMLに定義した順番で

フィクスチャ id
shop1 1
shop2 2
customer1 3
customer2 4
admin1 5

となってほしいところですが、実はそうなりません😓(場合によっては)

詳細は調べられていませんが、DBレイヤーで同じテーブルを共有するエンティティの場合には、YAMLの書き順とINSERT文の発行順が必ずしも一致しないようです。(詳しい方いたら詳細コメントいただけると嬉しいです🙏)

このような場合、実際に一度実行してみて、結果的にどのフィクスチャがidいくつになるのかを調べて、それに合わせてテストコードを書く、ということをすればとりあえずは問題なくテストできるのですが、そのidの採番順が常に同一であることがどこかで保証されているのかどうなのか分からないのでとても不安です。

そこで、こんなふうにidを指定したくなります。

App\Entity\Shop:
  shop{1..2}:
    id: <current()>
    # ...

App\Entity\Customer:
  customer{1..2}:
    id: <identity($current+2)>
    # ...

App\Entity\Admin:
  admin1:
    id: 5
    # ...

やり方

前置きが長くなりましたが、解決策はとても簡単で、デフォルトの Nelmio\Alice\PropertyAccess\StdPropertyAccessor の代わりに Nelmio\Alice\PropertyAccess\ReflectionPropertyAccessor というクラスを使えばよいだけです。

具体的には、config/services_test.yaml に以下の設定を追記するだけでOKです。

services:
  nelmio_alice.property_accessor:
    class: Nelmio\Alice\PropertyAccess\ReflectionPropertyAccessor
    arguments: ['@property_accessor']

注意事項

手元の環境だと、Symfony 5.4のプロジェクトでは上記の arguments: ['@property_accessor'] は省略してももとの設定が引き継がれて問題なく動作したのですが、Symfony 6.4のプロジェクトだと arguments: ['@property_accessor'] を省略すると以下のエラーになりました。

ArgumentCountError: Too few arguments to function Nelmio\Alice\PropertyAccess\ReflectionPropertyAccessor::__construct(), 0 passed in /path/to/project/var/cache/test/ContainerZKu8osY/getNelmioAlice_Generator_Resolver_Value_Chainable_FixturePropertyReferenceResolverService.php on line 29 and exactly 1 expected

Symfonyのどこかのバージョンから、既存のサービス定義の一部のフィールドだけを上書きするということができなくなっているようです(軽くググった限りソースを見つけられなかったので要出典です🙏)

これで、

App\Entity\Foo:
  foo1:
    id: 1
    bar: baz

このようなフィクスチャがエラーなくロードできるようになります。

注意点

$id についてこの方法を使う場合、注意点があります。

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

この記事で紹介したような方法でPostgreSQLのidカラムのデフォルト値に nextval() が設定されている場合、$id に値を指定してINSERTすると、nextval() が実行されない ため、フィクスチャをロードしたあと、テストコードから普通にエンティティを新規作成しようとすると id=1をINSERTしようとしてしまい、DBレイヤーでエラーになります。

なので、テストコード内で新規作成する必要のあるエンティティの $id は値を指定することを諦めるしかありません。(要出典)

このような場合は、PropertyAccessor を独自に拡張して、$id に値を指定してINSERTしたときにも nextval() を実行するように してしまうことで解決できます。(DoctrineによるDBの抽象化は台無しになりますが)

具体的には、以下のようなクラスを実装します。

// tests/Util/Alice/IdSequenceIncrementPropertyAccessor.php

namespace App\Tests\Util\Alice;

use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface;

final readonly class IdSequenceIncrementPropertyAccessor implements PropertyAccessorInterface
{
    public function __construct(
        private PropertyAccessorInterface $decorated,
        private EntityManagerInterface $em,
        private Connection $connection,
    ) {
    }

    public function setValue(object|array &$objectOrArray, PropertyPathInterface|string $propertyPath, mixed $value): void
    {
        $this->decorated->setValue($objectOrArray, $propertyPath, $value);

        if (is_object($objectOrArray) && 'id' === $propertyPath) {
            $tableName = $this->em->getClassMetadata($objectOrArray::class)->getTableName();
            $this->connection->executeStatement(sprintf('SELECT NEXTVAL(\'%s_id_seq\')', $tableName));
        }
    }

    public function getValue(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): mixed
    {
        return $this->decorated->getValue($objectOrArray, $propertyPath);
    }

    public function isWritable(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): bool
    {
        return $this->decorated->isWritable($objectOrArray, $propertyPath);
    }

    public function isReadable(object|array $objectOrArray, PropertyPathInterface|string $propertyPath): bool
    {
        return $this->decorated->isReadable($objectOrArray, $propertyPath);
    }
}

既存の PropertyAccessor をデコレートして、setValue() メソッドの実行後に nextval() を実行する処理を追加しているだけです。

次に、config/services_test.yaml にてこのクラスを nelmio_alice.property_accessor サービスのデコレータとして登録します。(Symfonyのサービスデコレート機能については 公式ドキュメント をご参照ください)

services:
  nelmio_alice.property_accessor:
    class: Nelmio\Alice\PropertyAccess\ReflectionPropertyAccessor
    arguments: ['@property_accessor']

  # これを追記
  App\Tests\Util\Alice\IdSequenceIncrementPropertyAccessor:
    decorates: nelmio_alice.property_accessor

これで、フィクスチャで $id に値を指定した場合にも nextval() が実行されるため、その後テストコード内でそのエンティティを新規作成しても正しく最新の連番が発行されるようになります🙆‍♂️

ちなみに、<current()> の戻り値は string 型なので、もし $id プロパティの型が int である場合は、id: <(intval($current))> のように int 型にキャストしてから渡す必要があるので要注意です。

参考:nelmio/aliceでcurrentの値をintegerとして扱う方法について

毎回それを書くのが面倒であれば、上記の PropertyAccessor クラスを以下のようにしておいてもいいかもしれません。

public function setValue(object|array &$objectOrArray, PropertyPathInterface|string $propertyPath, mixed $value): void
{
    if (is_object($objectOrArray) && 'id' === $propertyPath) {
        $value = intval($value);
    }

    $this->decorated->setValue($objectOrArray, $propertyPath, $value);

    if (is_object($objectOrArray) && 'id' === $propertyPath) {
        $tableName = $this->em->getClassMetadata($objectOrArray::class)->getTableName();
        $this->connection->executeStatement(sprintf('SELECT NEXTVAL(\'%s_id_seq\')', $tableName));
    }
}
GitHubで編集を提案

Discussion