[Symfony] nelmio/aliceのフィクスチャでエンティティのidなどprivateプロパティも指定したい!
やりたいこと
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
型にキャストしてから渡す必要があるので要注意です。
毎回それを書くのが面倒であれば、上記の 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));
}
}
Discussion