🎻

[Symfony] 循環参照しているエンティティのフォームで自分自身を選択できないようにする

2020/04/28に公開

要件

以下のような要件を考えます。

  • 「ユーザー」というエンティティがある
  • ユーザーは0人または1人の「上司」を持つことができる
  • 逆に言えばユーザーは0人〜複数人の「部下」を持つことができる

この場合、エンティティのコードは以下のような感じになるでしょう。

class User
{
    // ...
    
    /**
     * @ManyToOne(targetEntity="User", inversedBy="subordinates")
     * @JoinColumn(nullable=true)
     */
    private $boss;

    /**
     * @OneToMany(targetEntity="User", mappedBy="boss")
     */
    private $subordinates;

    // ...
}

さて、このエンティティの作成・編集フォームを考えます。

特に何も考えずに以下のようなFormTypeを作ったとしましょう。

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('displayName', TextType::class, [
                'required' => false,
                'label' => '表示名',
            ])
            ->add('boss', EntityType::class, [
                'class' => User::class,
                'required' => false,
                'label' => '上司ユーザー',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => User::class,
        ]);
    }
}

これをレンダリングすると、以下のように「上司ユーザー」の選択欄に自分自身も表示されてしまいます。

DBの構造的には自分自身を選択することもできてしまいますが、せめてフォームでは自分自身を除外して選択肢を提示できるようにしたいですよね。

その方法について解説します。

結論: EntityTypeの query_builder オプションを使う

実はEntityTypeには query_builder というオプションが用意されていて、これを使って自由に選択肢を加工することができます👍

具体的には、FormTypeのコードを以下のようにを変更すれば、「上司ユーザー」欄から特定のユーザーを除外したフォームを作成できるようになります。

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $user = $options['user'];
        
        $builder
            ->add('displayName', TextType::class, [
                'required' => false,
                'label' => '表示名',
            ])
            ->add('boss', EntityType::class, [
                'class' => User::class,
                'required' => false,
                'label' => '上司ユーザー',
                'query_builder' => function(UserRepository $repository) use ($user) {
                    $qb = $repository->createQueryBuilder('u');
                    if ($user) {
                        $qb
                            ->where('u != :user')
                            ->setParameter('user', $user)
                        ;
                    }
                    return $qb;
                },
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver
            ->setDefaults([
                'data_class' => User::class,
                'user' => null,
            ])
            ->setAllowedTypes('user', [User::class, 'null'])
        ;
    }
}

解説

若干やっていることが多いので詳しく説明します。

configureOptions() でやっていること

まずは下半分、 configureOptions() メソッドの実装に注目してください。

public function configureOptions(OptionsResolver $resolver)
{
    $resolver
        ->setDefaults([
            'data_class' => User::class,
            'user' => null,
        ])
        ->setAllowedTypes('user', [User::class, 'null'])
    ;
}

何やら setAllowedTypes('user', [User::class, 'null']) なる操作をしていますね。

これは、FormTypeがオプションとして受け取れるパラメータを追加で定義しているのです。これにより、コントローラ側で

$user = $this->getUser();

$form = $this->createForm(UserType::class, $user, [
    'user' => $user,
]);

といった具合に 'user' というオプションを渡せるようになります。

->setDefaults([
    'data_class' => User::class,
    'user' => null,
])

でデフォルト値を設定しているので、 'user' オプションが渡されなかった場合はnullがセットされます。

なお、 setAllowedTypes('user', [User::class, 'null']) ということなので、 'user' オプションに渡せる値の型は User クラスのエンティティか null のどちらかしか許可されません。

試しに

$form = $this->createForm(UserType::class, $user, [
    'user' => 'foo',
]);

のように文字列を渡してみると、

こんな感じでエラーになります。

また、 setAllowedTypes('user', [User::class, 'null']) をよく見ると null ではなく 'null' と文字列になっていることに気がつくと思います。

これは間違いではなくて、型の種類を指定する情報として OptionsResolver が解釈できる文字列を渡しているのです。ここで間違えて null を渡すと、ここの型宣言 に引っかかってエラーになります。

buildForm() でやっていること

さて、上記のとおり 'user' オプションで User クラスのエンティティを受け取れるようになっているので、あとはこの受け取った Userboss フィールドの選択肢から除外してあげればよいだけです。

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $user = $options['user'];
    
    $builder
        // ...
        ->add('boss', EntityType::class, [
            // ...
            'query_builder' => function(UserRepository $repository) use ($user) {
                $qb = $repository->createQueryBuilder('u');
                if ($user) {
                    $qb
                        ->where('u != :user')
                        ->setParameter('user', $user)
                    ;
                }
                return $qb;
            },
        ])
    ;
}

ここは普通にQueryBuilderを組み立てているだけなので特に難しくないですね👍

ポイントとしては、 'user' が渡されなかったときは特に絞り込まずにすべてのユーザーを返せるようにしてあるというところでしょうか。

動かしてみる

以上の実装で実際に動かしてみると、以下のようにちゃんと自分自身が除かれて表示されました🙌

余談

今回は自分自身を除いてフォームを出力するための方法を示しましたが、より万全を期するならこれに加えて boss プロパティに自分自身をセットできないようにしておくべきでしょう。

丁寧にやるならカスタムバリデーションを書くのがよさそうですが、今回のようにフォームで対応することでイレギュラーな入力はほぼ遮断できている前提なら、単純にsetterでチェックするだけでも十分だと思います。

public function setBoss(?User $boss): self
{
    if ($boss === $this) {
        throw new \RuntimeException('Cannot set oneself as the boss');
    }

    $this->boss = $boss;

    return $this;
}

まとめ

  • Symfonyで循環参照しているエンティティのフォームで自分自身を選択できないようにするには、EntityTypeの query_builder オプションを使えばいい
GitHubで編集を提案

Discussion