Memory management Symfony forms and Doctrine - php

When building a form using Symfony the build of the form is terribly slow and the memory spikes.
The form is build using some subforms and uses some one-to-many relations. When the data of the form becomes larger (more entities in the many side) the form is slower and memory is usage is getting larger this seem okey though the amount of time and memory usage don't seem to.
Example when having about 71 enities in the many side the memory usage is about 116 MB and takes 14 seconds to load.
I already deduced the number of queries done (from 75 to 4) though the memory spike still happens the moment the form is created
$form = $this->createForm(new TapsAndAppliancesType(), $taps);
Any tips and tricks to speed this up?

I assume you use type entity in your form. They are quite heavy, since first all entities are fetched as objects and then reduced to some id => label style.
So you could write your own entityChoice type, which works with an id => label -array (so nothing is fetched as an object in the frist place) and add a DataTransformer to this type:
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use MyNamespace\EntityToIdTransformer;
class EntityChoiceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new EntityToIdTransformer($options['repository']));
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'empty_value' => false,
'empty_data' => null,
));
$resolver->setRequired(array(
'repository'
));
}
public function getParent()
{
return 'choice';
}
public function getName()
{
return 'entityChoice';
}
}
And as DataTransformer:
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class EntityToIdTransformer implements DataTransformerInterface
{
private $entityRepository;
public function __construct(EntityRepository $entityRepository)
{
$this->entityRepository = $entityRepository;
}
/**
* #param object|array $entity
* #return int|int[]
*
* #throws TransformationFailedException
*/
public function transform($entity)
{
if ($entity === null) {
return null;
}
elseif (is_array($entity) || $entity instanceof \Doctrine\ORM\PersistentCollection) {
$ids = array();
foreach ($entity as $subEntity) {
$ids[] = $subEntity->getId();
}
return $ids;
}
elseif (is_object($entity)) {
return $entity->getId();
}
throw new TransformationFailedException((is_object($entity)? get_class($entity) : '').'('.gettype($entity).') is not a valid class for EntityToIdTransformer');
}
/**
* #param int|array $id
* #return object|object[]
*
* #throws TransformationFailedException
*/
public function reverseTransform($id)
{
if ($id === null) {
return null;
}
elseif (is_numeric($id)) {
$entity = $this->entityRepository->findOneBy(array('id' => $id));
if ($entity === null) {
throw new TransformationFailedException('A '.$this->entityRepository->getClassName().' with id #'.$id.' does not exist!');
}
return $entity;
}
elseif (is_array($id)) {
if (empty($id)) {
return array();
}
$entities = $this->entityRepository->findBy(array('id' => $id)); // its array('id' => array(...)), resulting in many entities!!
if (count($id) != count($entities)) {
throw new TransformationFailedException('Some '.$this->entityRepository->getClassName().' with ids #'.implode(', ', $id).' do not exist!');
}
return $entities;
}
throw new TransformationFailedException(gettype($id).' is not a valid type for EntityToIdTransformer');
}
}
And finally register the FormType as new Type in service.yml
services:
myNamespace.form.type.entityChoice:
class: MyNamespace\EntityChoiceType
tags:
- { name: form.type, alias: entityChoice }
You can then use it in your form as
$formBuilder->add('appliance', 'entityChoice', array(
'label' => 'My Label',
'repository' => $repository,
'choices' => $repository->getLabelsById(),
'multiple' => false,
'required' => false,
'empty_value' => '(none)',
))
with $repository as an instance of your desired repository and 'choices' as an array with id => label

Related

Symfony reusable AJAX select / ChoiceType

I want to create a reusable AJAX-based select (select2) using Symfony form types and I've spent quite some time on it but can't get it to work like I want.
As far as I know you cannot override options of form fields after they have been added, so you have to re-add them with the new config. The Symfony docs also provide some examples on how to dynamically add or modify forms using events. https://symfony.com/doc/current/form/dynamic_form_modification.html
I've managed to create my AJAX based elements in the form and it's working but not completely reusable yet:
Form field extends Doctrine EntityType to have full support of data mappers etc
Form field is initialized with 'choices' => [], so Doctrine does not load any entities from db
Existing choices on edit is added during FormEvents::PRE_SET_DATA
Posted choices are added during FormEvents::PRE_SUBMIT
The current setup works but only in the parent form. I want to have my AjaxNodeType completely reusable so the parent form does not need to care about data handling and events.
Following the 2 examples, parent and child form. Here with event listeners in both, but of course they should only be in one.
Is it simply not possible in single element types to replace "yourself" in the parent form or am I doing something wrong? Is there any other way to dynamically change the choices?
Parent form
This works!
class MyResultElementType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'fixedNode',
AjaxNodeType::class,
[
'label' => 'Fixed node',
'required' => false,
]
);
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData()->getFixedNode();
//this works here but not in child???
if ($fixedNode) {
$name = 'fixedNode';
$parentForm = $event->getForm();
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$fixedNode];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
$data = $event->getData()['fixedNode'];
if ($data) {
$name = 'fixedNode';
$parentForm = $event->getForm();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'data_class' => MyResultElement::class,
'method' => 'POST',
]
);
}
}
Child form / single select This does NOT work. On POST the fixedNode field is not set to the form data-entity.
class AjaxNodeType extends AbstractType
{
/** #var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
//this does not work here but in parent???
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) {
if ($event->getData()) {
$fixedNode = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
$newChoices = [$fixedNode];
// check if the choices already match, otherwise we'll end up in an endless loop ???
if ($options['choices'] !== $newChoices) {
$options['choices'] = $newChoices;
$parentForm->add($name, AjaxNodeType::class, $options);
}
}
},
1000
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (PreSubmitEvent $event) {
if ($event->getData()) {
$name = $event->getForm()->getName();
$data = $event->getData();
$parentForm = $event->getForm()->getParent();
// we have to add the POST-ed data/node here to the choice list
// otherwise the submitted value is not valid
$node = $this->entityManager->find(Node::class, $data);
$options = $parentForm->get($name)->getConfig()->getOptions();
$options['choices'] = [$node];
$parentForm->add($name, self::class, $options);
}
},
1000
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
// prevent doctrine from loading ALL nodes
'choices' => [],
]
);
}
public function getParent(): string
{
return EntityType::class;
}
}
Again, answering my own Questions :)
After spending some more time on it I got a working solution. And maybe it will be helpful for someone.
I've switched from EntityType to ChoiceType as it only made things more complicated and is not actually needed
multiple and single selects need different settings/workarrounds (like by_reference), see the line comments below
re-adding yourself to the parent form works, I don't know why it did not before...
beware of endless-loops when re-adding / re-submitting values
The main re-usable AjaxEntityType that does all the logic without reference to any specific entity:
<?php
namespace Tool\Form\SingleInputs;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AjaxEntityType extends AbstractType
{
/** #var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
// shamelessly copied from DoctrineType - this is needed to support Doctrine Collections in multi-selects
if ($options['multiple'] && interface_exists(Collection::class)) {
$builder
->addEventSubscriber(new MergeDoctrineCollectionListener())
->addViewTransformer(new CollectionToArrayTransformer(), true);
}
// PRE_SET_DATA is the entrypoint on form creation where we need to populate existing choices
// we process current data and set it as choices so it will be rendered correctly
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (PreSetDataEvent $event) use($options) {
$data = $event->getData();
$hasData = ($options['multiple'] && count($data) > 0) || (!$options['multiple'] && $data !== null);
if ($hasData) {
$entityOrList = $event->getData();
$name = $event->getForm()->getName();
$parentForm = $event->getForm()->getParent();
$options = $parentForm->get($name)->getConfig()->getOptions();
// ONLY do this if the choices are empty, otherwise readding PRE_SUBMIT will not work because this is called again!
if(empty($options['choices'])) {
if($options['multiple']) {
$newChoices = [];
foreach ($entityOrList as $item) {
$newChoices[$item->getId()] = $item;
}
} else {
$newChoices = [$entityOrList->getId() => $entityOrList];
}
$options['choices'] = $newChoices;
$parentForm->add($name, self::class, $options);
}
}
},
1000
);
// PRE_SUBMIT is the entrypoint where we need to process the submitted values
// we have to add the POST-ed choices, otherwise this field won't be valid
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use($options) {
$entityIdOrList = $event->getData();
$entityClass = $options['class'];
// new choices constructed from POST
$newChoices = [];
if ($options['multiple']) {
foreach ($entityIdOrList as $id) {
if ($id) {
$newChoices[$id] = $this->entityManager->find($entityClass, $id);
}
}
} elseif ($entityIdOrList) {
$newChoices = [$entityIdOrList => $this->entityManager->find($entityClass, $entityIdOrList)];
}
$name = $event->getForm()->getName();
$parentform = $event->getForm()->getParent();
$currentChoices = $event->getForm()->getConfig()->getOptions()['choices'];
// if the user selected/posted new choices that have not been in the existing list, add them all
if ($newChoices && count(array_diff($newChoices, $currentChoices)) > 0) {
$options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
$options['choices'] = $newChoices;
// re-add ourselves to the parent form with updated / POST-ed options
$parentform->add($name, self::class, $options);
if(!$parentform->get($name)->isSubmitted()) {
// after re-adding we also need to re-submit ourselves
$parentform->get($name)->submit($entityIdOrList);
}
}
}, 1000);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'mapped' => true,
'choice_value' => 'id',
'choice_label' => 'selectLabel',
'choices' => [],
'attr' => [
'class' => 'select2-ajax',
],
'ajax_url' => null,
]
);
// AJAX endpoint that is select2 compatible
// https://select2.org/data-sources/ajax
$resolver->setRequired('ajax_url');
$resolver->setAllowedTypes('ajax_url', ['string']);
// entity class to process
$resolver->setRequired('class');
// by_reference needs to be true for single-selects, otherwise our entities will be cloned!
// by_reference needs to be false for multi-selects, otherwise the setters wont be called for doctrine collections!
$resolver->setDefault('by_reference', function (Options $options) {
return !$options['multiple'];
});
// adds the ajax_url as attribute
$resolver->setNormalizer('attr', function (Options $options, $value) {
$value['data-custom-ajax-url'] = $options['ajax_url'];
return $value;
});
}
public function getParent(): string
{
return ChoiceType::class;
}
}
Actual usage with specific entity and ajax endpoint:
<?php
namespace Tool\Form\SingleInputs;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tool\Entities\User\Node;
class AjaxNodeType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
[
'class' => Node::class,
'ajax_url' => 's/ajax-select/nodes',
]
);
}
public function getParent(): string
{
return AjaxEntityType::class;
}
}

Save EntityType choice as string - Symfony 4

I have entity1 and entity2.
In the entity1's form, I am displaying a choice list where the options are comming from entity2.
I want to save the selected choice as string inside a column in entity1's table, but I dont want to create any relations between the tables.
How Should I do that?
class Entity1 {
/**
* #ORM\Column(type="string")
*/
private $historico;
}
class Entity2 {
/**
* #ORM\Column(type="string")
*/
private $description;
}
Entity1FormType.php
$builder->add('historico', EntityType::class, [
'class' => Entity2::class,
'choice_label' => 'description',
'choice_value' => 'description',
'placeholder' => ''
]);
The choices display fine, but when I submit I get the following error:
Expected argument of type "string", "App\Entity\Entity2" given.
If I use 'mapped' => false, the input submit as null.
How do I convert the entity object to string?
Help a symfony noob :)
If you use mapped => false you have to fetch the data manually in your controller after the form is submitted.
so you will have something like this:
public function postYourFormAction(Request $request)
{
$entity1 = new Entity1();
$form = $this->createForm(Entity1Type::class $entity1);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
$entity1 = $form->getData;
$historico = $form->get('historico')->getData();
$entity1->setHistorico($historico);
$em->persist($entity1);
$em->flush();
}
}
This can be done with data transformers so you would not have to unmap fields.
Your form could be as below. Note the choice value getting the string
class OrderType extends AbstractType
{
public function __construct(private ItemToStringTransformer $transformer)
{
$this->transformer = $transformer;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('itemCode', EntityType::class, [
'class' => Item::class,
'autocomplete' => true,
'required' => true,
'choice_value' => function (?Item $entity) {
return $entity ? $entity->getCode() : '';
},
// validation message if the data transformer fails
'invalid_message' => 'That is not a valid Item Code',
]);
$builder->get('accountCode')->addModelTransformer($this->transformer);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// Configure your form options here
]);
}
}
And then the data transformer can be as below
class ItemToStringTransformer implements DataTransformerInterface
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
//transforming item object to string
public function reverseTransform($item): ?string
{
if (null === $item) {
return null;
}
return $item->getCode();
}
// transforming string to item object
public function transform($itemCode): ?Item
{
if (!$itemCode) {
return null;
}
$item = $this->entityManager
->getRepository(Item::class)
// query for the glCode with this id
->findOneBy(['code' => $itemCode])
;
if (null === $item) {
// causes a validation error
// this message is not shown to the user
// see the invalid_message option
throw new TransformationFailedException(sprintf('Item code "%s" does not exist!', $itemCode));
}
return $item;
}
}
You can read further in the symfony documentation https://symfony.com/doc/current/form/data_transformers.html#example-2-transforming-an-issue-number-into-an-issue-entity

Keep good data in form after failed validation on symfony form collection

I have a form with a dynamic form collection. The point is that I don't want to allow the user to remove specific entries (that are modified in another part of the app). So I added a specific validation constraint that works: the form is not valid if I remove an element that is not "deletable".
The problem is that, as the element was removed in what the user submitted, the element is not in the form anymore, and after submit form data is locked.
Here is an example to show the problem:
class AppointmentController extends Controller
{
public function editAppointment(Request $request, Appointment $appointment)
{
// Here
// count($appointment->getSlot()) === 3
$form = $this->createForm('appointment', $appointment, [
'questionnaire' => $questionnaire
]);
$form->handleRequest($request);
if ($form->isValid()) {
// Persisting
}
// Here on failing validation, there is
// count($appointment->getSlot()) === 2
// Because we removed one slot from "dynamically" in the form, but the user can't do that,
// so we need to reset the slots but it's not possible because form data is locked after "submit".
return $this->render('App:Appointment:edit.html.twig', ['form' => $form->createView()]);
}
}
class AppointmentType extends AbstractTYpe
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('slots', 'collection', [
'type' => new SlotType(),
'allow_add' => true,
'prototype' => true,
'allow_delete' => true,
'error_bubbling' => false,
'by_reference' => false,
'constraints' => [
new WrongSlotRemoval($builder->getData()->getSlots())
]
])
;
}
}
class WrongSlotRemoval extends Constraint
{
public $message = 'Impossible to delete slog.';
/**
* #var null|\App\Entity\AppointmentSlot[]
*/
private $slots;
/**
* #param \App\Entity\AppointmentSlot[]|null $slots
*/
public function __construct($slots = null)
{
// Clone the collection because it can be modified by reference
// in order to add or delete items.
if ($slots !== null) {
$this->slots = clone $slots;
}
}
/**
* #return \App\Entity\AppointmentSlot[]
*/
public function getSlots()
{
return $this->slots;
}
/**
* #param \App\Entity\AppointmentSlot[] $slots
* #return self
*/
public function setSlots($slots)
{
$this->slots = $slots;
return $this;
}
}
class WrongSlotRemovalValidator extends ConstraintValidator
{
/**
* #param \App\Entity\AppointmentSlot[] $object
* #param WrongSlotRemoval $constraint
*/
public function validate($object, Constraint $constraint)
{
foreach($constraint->getSlots() as $slot) {
if (!$object->contains($slot) && !$slot->isDeletable()) {
$this->context
->buildViolation($constraint->message)
->addViolation()
;
return;
}
}
}
}
Any idea about how to modify form data after submit ?
Here is a screen of the problem: http://file.nekland.fr/dev/pb_form_collection.jpeg
This is a Symfony limitation: https://github.com/symfony/symfony/issues/5480
A workaround exists: re-creating the form and copy errors of each node of the form to the new form (that contains good data).
My solution will be to throw an error in the validator because the user is not able to remove an item (the cross is disabled), so he hacked the system. Showing an error is not a problem.

Symfony 2 Transformer on entity form type

I'm trying to create a new form type in Symfony 2. It is based on entity type, it uses select2 on frontend and I need the user to be able to select existing entity or create the new one.
My idea was to send entity's id and let it to be converted by the default entity type if user select existing entity or send something like "_new:entered text" if user enter new value. Then this string should be converted to the new form entity by my own model transformer, which should look something like this:
<?php
namespace Acme\MainBundle\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
class EmptyEntityTransformer
implements DataTransformerInterface
{
private $entityName;
public function __construct($entityName)
{
$this->entityName = $entityName;
}
public function transform($val)
{
return $val;
}
public function reverseTransform($val)
{
$ret = $val;
if (substr($val, 0, 5) == '_new:') {
$param = substr($val, 5);
$ret = new $this->entityName($param);
}
return $ret;
}
}
Unfortunately, the transformer is only called when existing entity is selected. When I enter a new value, the string is sent in the request but transformer's reverseTransform method is not called at all.
I'm new to Symfony so I don't even know if this approach is correct. Do you have any Idea how to solve this?
edit:
My form type code is:
<?php
namespace Acme\MainBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Acme\MainBundle\Form\DataTransformer\EmptyEntityTransformer;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
class Select2EntityType
extends AbstractType
{
protected $router;
public function __construct(Router $router)
{
$this->router = $router;
}
/**
* {#inheritdoc}
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
parent::setDefaultOptions($resolver);
$resolver->setDefaults(array(
'placeholder' => null,
'path' => false,
'pathParams' => null,
'allowNew' => false,
'newClass' => false,
));
}
public function getParent()
{
return 'entity';
}
public function getName()
{
return 's2_entity';
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
if ($options['newClass']) {
$transformer = new EmptyEntityTransformer($options['newClass']);
$builder->addModelTransformer($transformer);
}
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$field = $view->vars['name'];
$parentData = $form->getParent()->getData();
$opts = array();
if (null !== $parentData) {
$accessor = PropertyAccess::createPropertyAccessor();
$val = $accessor->getValue($parentData, $field);
if (is_object($val)) {
$getter = 'get' . ucfirst($options['property']);
$opts['selectedLabel'] = $val->$getter();
}
elseif ($choices = $options['choices']) {
if (is_array($choices) && array_key_exists($val, $choices)) {
$opts['selectedLabel'] = $choices[$val];
}
}
}
$jsOpts = array('placeholder');
foreach ($jsOpts as $jsOpt) {
if (!empty($options[$jsOpt])) {
$opts[$jsOpt] = $options[$jsOpt];
}
}
$view->vars['allowNew'] = !empty($options['allowNew']);
$opts['allowClear'] = !$options['required'];
if ($options['path']) {
$ajax = array();
if (!$options['path']) {
throw new \RuntimeException('You must define path option to use ajax');
}
$ajax['url'] = $this->router->generate($options['path'], array_merge($options['pathParams'], array(
'fieldName' => $options['property'],
)));
$ajax['quietMillis'] = 250;
$opts['ajax'] = $ajax;
}
$view->vars['options'] = $opts;
}
}
and then I create this form type:
class EditType
extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('masterProject', 's2_entity', array(
'label' => 'Label',
'class' => 'MyBundle:MyEntity',
'property' => 'name',
'path' => 'my_route',
'pathParams' => array('entityName' => 'name'),
'allowNew' => true,
'newClass' => '\\...\\MyEntity',
))
...
Thanks for your suggestions
I think I found an answer however I'm not really sure if this is the correct solution. When I tried to understand how EntityType works I noticed that it uses EntityChoiceList to retrive list of available options and in this class there is a getChoicesForValues method which is called when ids are transformed to entities. So I implemented my own ChoiceList which adds my own class to the end of the returned array:
<?php
namespace Acme\MainBundle\Form\ChoiceList;
use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityChoiceList;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
class EmptyEntityChoiceList
extends EntityChoiceList
{
private $newClassName = null;
public function __construct(ObjectManager $manager, $class, $labelPath = null, EntityLoaderInterface $entityLoader = null, $entities = null, array $preferredEntities = array(), $groupPath = null, PropertyAccessorInterface $propertyAccessor = null, $newClassName = null)
{
parent::__construct($manager, $class, $labelPath, $entityLoader, $entities, $preferredEntities, $groupPath, $propertyAccessor);
$this->newClassName = $newClassName;
}
public function getChoicesForValues(array $values)
{
$ret = parent::getChoicesForValues($values);
foreach ($values as $value) {
if (is_string($value) && substr($value, 0, 5) == '_new:') {
$val = substr($value, 5);
if ($this->newClassName) {
$val = new $this->newClassName($val);
}
$ret[] = $val;
}
}
return $ret;
}
}
Registering this ChoiceList to the form type is a bit complicated because the class name of original choice list is hardcoded in the DoctrineType which EntityType extends but it is not difficult to understand how to do it if you have a look into this class.
The reason why DataTransformer is not called probably is that EntityType is capable to return array of results and transform is applied to every item of this collection. If the result array is empty, there is obviously no item to call transformer on.
I had exactly the same question as yours, I chose to use an FormEvent with still a DataTransformer
The idea is to switch the field type (the entity one) just before the submit.
public function preSubmit(FormEvent $event)
{
$data = $event->getData();
$form = $event->getForm();
if (substr($data['project'], 0, 5) == '_new:') {
$form->add('project', ProjectCreateByNameType::class, $options);
}
}
This will replace the project field with a new custom one before the submit if needed.
ProjectCreateByNameType can extend a TextField and have to add the DataTransformer.

Symfony2 DataTransformers - How do you transform back without ID or unique values?

I have a collection of PhoneNumber entities for each Contact (Name, Email) entity. PhoneNumber is broken down into area code, exchange, suffix, extension, and then a type selector (Work, Home, Mobile). I want to use a DataTransformer so that the number can display in a single form field instead of 4 different text boxes. The phonenumbers are not unique within the database.
UPDATED: How do I access the the full entity when transforming from the string version back to the Entity? I previously ran into this problem and ended up putting the entity's ID inside of brackets within the text field and then regexed them out in the transform so that I could do a query to get the entity.
The phonenumber_combined is a custom form type service that references my PhoneNumberCombinedType class.
My add method for the collection of Phones within Contact:
->add('phones', 'collection', array(
'label' => 'Phones',
'type' => new PhoneNumberType(),
'allow_add' => true,
'allow_delete' => true
))
;
PhoneNumberType buildForm function:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('phone', 'phonenumber_combined')
->add('type', 'entity',
array('class' => 'Test\Bundle\SystemBundle\Entity\Type',
'property' => 'name',
'query_builder' => function(EntityRepository $er){
return $er->createQueryBuilder('type')
->where('type.type = :t')
->orderBy('type.name', 'ASC')
->setParameter('t', 'PhoneNumber');
}))
;
}
The PhoneNumberCombinedType service:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$transformer = new PhoneNumberToStringTransformer($this->om);
$builder->addViewTransformer($transformer);
}
PhoneNumberToStringTransformer:
public function transform($phonenumber)
{
if (null === $phonenumber) {
return null;
}
return $phonenumber->__toString();
}
public function reverseTransform($phonenumber)
{
if (!$phonenumber) {
return null;
}
// PHONE NUMBER IS JUST A STRING AT THIS POINT, HOW DO I GET THE ENTITY?
}
You would have to pass an object manager via constructor injection then operate on that like you normally would.
class PhoneNumberTransformer implements DataTransformerInterface
{
/**
* #var ObjectManager
*/
private $om;
/**
* #param ObjectManager $om
*/
public function __construct(ObjectManager $om)
{
$this->om = $om;
}
public function transform($phonenumber)
{
if ($phonenumber === null) {
return null;
}
return $phonenumber->__toString();
}
public function reverseTransform($phonenumber)
{
if (!$this->phonenumber) {
return null;
}
$phoneEntity = $this->om
->getRepository('{REFERENCE_TO_THE_ENTITY}')
->findOneBy(array('phonenumber' => $phonenumber));
if ($phoneEntity === null) {
throw new TransformationFailedException();
}
return $phoneEntity;
}
}
Sorry for the lack of updates but I have found a decent solution to this problem. I added a setPhone method to the PhoneNumber entity that seems to be called automatically after the reverse transform. In my transformer, I added a regex to split my phone number into its respective sections and then added each section to an array. This array is returned by the transformer which is then used by setPhone to update the PhoneNumber object.
My setPhone method in the PhoneNumber entity:
/**
* Used by PhoneNumberToStringTransformer to set Entity data
* #param array $phone_data
*/
public function setPhone($phone_data)
{
if($phone_data == null) {
$this->setAreaCode(null);
$this->setExchange(null);
$this->setSuffix(null);
$this->setExtension(null);
return $this;
}
$this->setAreaCode($phone_data['AreaCode']);
$this->setExchange($phone_data['Exchange']);
$this->setSuffix($phone_data['Suffix']);
$this->setExtension($phone_data['Extension']);
return $this;
}
The reverseTransform in my PhoneNumberToStringTransformer:
/**
* Transforms a phonenumber string to an array that will be passed to setPhone
* which will handle setting each entity field.
*
* #param string $string Phone number string
* #return (array) areaCode, exchange, suffix and extension
*/
public function reverseTransform($string)
{
if (!$string || strlen($string) == 0) {
return null;
}
// Split phone number into array for areaCode, exchange, suffix, and extension
$pattern = '/(\d{3})?-?(\d{3})?-?(\d{4})?( x(\d+))?/';
preg_match_all($pattern, $string, $matches, PREG_PATTERN_ORDER);
$phone_data = array('AreaCode'=>null, 'Exchange'=>null, 'Suffix'=>null, 'Extension'=>null);
$counter = 1;
foreach($phone_data as $key => $value) {
if($counter == 4)
$counter = 5;
if(isset($matches[$counter][0]) && $matches[$counter][0] !== '') {
$phone_data[$key] = $matches[$counter][0];
} else {
$phone_data[$key] = null;
}
$counter++;
}
return $phone_data;
}

Categories