Symfony: 如何在嵌套表单中持久化具有ManyToMany关系的新实体

huangapple go评论61阅读模式
英文:

Symfony: How to persist new entities with ManytoMany relation in embedded form

问题

以下是您要翻译的内容:

用例

TLDR;

我有两个具有ManyToMany关系的实体。我想要同时使用一个单一表单持久化两个新对象。为此,我创建了两个FromTypes,其中一个嵌套了另一个。

稍微详细一点...

目标是为用户提供一个用于查询事件的表单。事件实体包含像starttime、endtime等属性,这些属性是Event的简单属性,以及位置(Location实体与OneToMany关系,一个事件有一个位置,一个位置可以有多个事件)和联系人(Contact实体与ManyToMany关系,一个事件可以有多个联系人,一个联系人可以有多个事件)。对于所讨论的特定表单,用户只需提供一个联系人就足够了,这是一个故意的选择,足够了。

为了构建可重用的表单,有两个简单的表单,LocationFormType和ContactFormType,以及一个更复杂的EventFormType。EventFormType更复杂,因为它嵌套了LocationFormType和ContactFormType,以便一次创建一个事件实体,所谓的“一次完成”。
当我使用选项A构建EventFormType时(请参阅下面的代码),表单呈现正确,按照预期的方式。一切都看起来很好,直到提交表单。然后问题开始...

问题

在$form->handleRequest()上,FormSystem引发错误,因为嵌入的表单未为相关对象提供可遍历的对象。

类“App\Entity\Event”中的属性“contact”可以使用方法“addContact()”、“removeContact()”来定义,但新值必须是数组或\Traversable的实例。

显然,嵌入的FormType提供了一个单一对象,而关系的属性需要一个Collection。当我使用CollectionType进行嵌入(选项B,请参阅下面的代码),表单不再呈现,因为CollectionType似乎需要已经存在的实体。但是我想创建一个新的实体,所以没有可以传递的对象。

我的代码

#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    ...

    #[ORM\ManyToMany(targetEntity: Contact::class, inversedBy: 'events')]
    ...
    private Collection $contact;
    
    ...

    public function __construct()
    {
        $this->contact = new ArrayCollection();
    }
    
    ...

    /**
     * @return Collection<int, Contact>
     */
    public function getContact(): Collection
    {
        return $this->contact;
    }

    public function addContact(Contact $contact): self
    {
        if (!$this->contact->contains($contact)) {
            $this->contact->add($contact);
        }

        return $this;
    }

    public function removeContact(Contact $contact): self
    {
        $this->contact->removeElement($contact);

        return $this;
    }
    
    ...
}
#[ORM\Entity(repositoryClass: ContactRepository::class)]
class Contact
{
    ...

    #[ORM\ManyToMany(targetEntity: Event::class, mappedBy: 'contact')]
    private Collection $events;

    public function __construct()
    {
        $this->events = new ArrayCollection();
    }
    
    ...

    /**
     * @return Collection<int, Event>
     */
    public function getEvents(): Collection
    {
        return $this->events;
    }

    public function addEvent(Event $event): self
    {
        if (!$this->events->contains($event)) {
            $this->events->add($event);
            $event->addContact($this);
        }

        return $this;
    }

    public function removeEvent(Event $event): self
    {
        if ($this->events->removeElement($event)) {
            $event->removeContact($this);
        }

        return $this;
    }
}
class EventFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ...
            // 选项A:直接嵌入相关的FormType
            ->add('contact', ContactFormType::class, [
                ...
            ])
            // 选项B:使用CollectionType嵌入
            ->add('contact', CollectionType::class, [
                'entry_type' => ContactFormType::class
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Event::class,
        ]);
    }
}
class ContactFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add(
            ... // 这里只添加Contact实体的字段,没有特殊配置
            )
        ;
    }

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

失败的解决方案

使用原型设置“allow_add” => true

我发现有解决方案建议在CollectionType上设置“allow_add” => true,并在Twig中使用

..vars.prototype来呈现表单。在我的情况下,这是一个不太正规的解决方案。我不想添加多个表单。而且如果不设置“allow_add”,CollectionType中就没有原型,因此缺少呈现表单所需的数据。

向CollectionType提供空对象

为了避免设置“allow_add” => true,但仍然有一个对象来正确呈现表单,我尝试在我的控制器中传递Contact的空实例

$eventForm = $this->createForm(EventFormType::class);
if(!$eventForm->get('contact')) $eventForm->get('contact')->setData(array(new Contact()));

这在初始加载时有效,但在提交表单时会出现问题。也许我可以让它起作用,但是我的直觉再次让我觉得这是一个不太正规的解决方案。

结论

实际上

英文:

Use case

TLDR;

I have two entities with a ManytoMany relation. I want to persist two new objects at the same time with one single form. To do so, I created two FromTypes with one embedding the other.

A bit more...

The goal is to provide users with a form to make an inquiry for an event. The Event entity consists of properties like starttime, endtime e.g. that are simple properties of Event aswell as a location (Location entity with a OneToMany relation, one Event has one Location, one Location can have many Events) and a contactperson (Contact entity with a ManyToMany relation, one Event can have multiple Contacts, one Contact can have multiple Events). For the particular form in question it is enough (and a deliberate choice) for the user to provide only one Contact as that is the bare minimum needed and enough for a start.

To build reusable forms, there are two simple forms with LocationFormType and ContactFormType and a more complex EventFormType. More complex as it embedds both LocationFormType and ContactFormType to create an Event entity "in one go" so to speak.
When I build the EventFormType with option A (see code below), the form renders correct and the way it is intended. Everything looks fine until the form is submitted. Then the problem starts...

Problem

On $form->handleRequest() the FormSystem throws an error because the embedded form is not providing a Traversable for the related object.

The property "contact" in class "App\Entity\Event" can be defined with the methods "addContact()", "removeContact()" but the new value must be an array or an instance of \Traversable.

Obviously the embedded FormType is providing a single object, while the property for the relation needs a Collection. When I use CollectionType for embedding (option B, see code below), the form is not rendering anymore as CollectionType seemingly expects entities to be present already. But I want to create a new one. So there is no object I could pass.

My Code

#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    ...

    #[ORM\ManyToMany(targetEntity: Contact::class, inversedBy: 'events')]
    ...
    private Collection $contact;
    
    ...

    public function __construct()
    {
        $this->contact = new ArrayCollection();
    }
    
    ...

    /**
     * @return Collection<int, Contact>
     */
    public function getContact(): Collection
    {
        return $this->contact;
    }

    public function addContact(Contact $contact): self
    {
        if (!$this->contact->contains($contact)) {
            $this->contact->add($contact);
        }

        return $this;
    }

    public function removeContact(Contact $contact): self
    {
        $this->contact->removeElement($contact);

        return $this;
    }
    
    ...
}
#[ORM\Entity(repositoryClass: ContactRepository::class)]
class Contact
{
    ...

    #[ORM\ManyToMany(targetEntity: Event::class, mappedBy: 'contact')]
    private Collection $events;

    public function __construct()
    {
        $this->events = new ArrayCollection();
    }
    
    ...

    /**
     * @return Collection<int, Event>
     */
    public function getEvents(): Collection
    {
        return $this->events;
    }

    public function addEvent(Event $event): self
    {
        if (!$this->events->contains($event)) {
            $this->events->add($event);
            $event->addContact($this);
        }

        return $this;
    }

    public function removeEvent(Event $event): self
    {
        if ($this->events->removeElement($event)) {
            $event->removeContact($this);
        }

        return $this;
    }
}
class EventFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ...
            // option A: embedding related FormType directly
            ->add('contact', ContactFormType::class, [
                ...
            ])
            // option B: embedding with CollectionType
            ->add('contact', CollectionType::class, [
                'entry_type' => ContactFormType::class
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Event::class,
        ]);
    }
}
class ContactFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add(
            ... // here I only add the fields for Contact entity, no special config
            )
        ;
    }

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

Failed solutions

'allow_add' => true with prototype

I found solutions suggesting to set 'allow_add' => true on the CollectionType and render the form in Twig with <form>.<related object>.vars.prototype
Thats a hacky solution (so I think) in my use case. I don't want to add multiple forms. And without 'allow_add' there is no prototype in CollectionType, so the data to render the form is missing.

provide empty object to CollectionType

To omit 'allow_add' => true but have an object to render the form correctly, I tried passing an empty instance of Contact in my controller

$eventForm = $this-&gt;createForm(EventFormType::class);
if(!$eventForm-&gt;get(&#39;contact&#39;)) $eventForm-&gt;get(&#39;contact&#39;)-&gt;setData(array(new Contact()));

That works on initial load, but creates issues when the form is submitted. Maybe I could make it work, but my gut gives me 'hacky vibes' once again.

Conclusion

Actually I think I'm missing some basic point here as I think my use case is nothing edgy or in any way unusual. Can anyone give me a hint as where I'm going wrong with my approach?

P.S.: I'm unsure wether my issue was discussed (without a solution) over on Github.

答案1

得分: 0

好的,我已解决了这个问题。在这种情况下,需要使用数据映射器

可以通过使用'getter'和'setter'选项键(文档)来映射单个表单字段。在这种特殊情况下,只需使用setter选项:

->add('contact', ContactFormType::class, [
    ...
    'setter' => function (Event &$event, Contact $contact, FormInterface $form) {
        $event->addContact($contact);
    }
])

当创建ManyToMany关系时,Symfony CLI提供了addContact()方法,但也可以手动添加(文档SymfonyCast)。

英文:

Okay, so I solved the problem. For this scenario one has to make use of Data Mappers.

It is possible to map single form fields by using the 'getter' and 'setter' option keys (Docs). In this particular case the setter-option is enough:

        -&gt;add(&#39;contact&#39;, ContactFormType::class, [
            ...
            &#39;setter&#39; =&gt; function (Event &amp;$event, Contact $contact, FormInterface $form) {
                $event-&gt;addContact($contact);
            }
        ])

The addContact()-method is provided by Symfonys CLI when creating ManyToMany relations, but can be added manually aswell (Docs, SymfonyCast).

huangapple
  • 本文由 发表于 2023年1月9日 18:30:21
  • 转载请务必保留本文链接:https://go.coder-hub.com/75055956.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定