We're running into a small code-design smell with symfony and our forms. It is not a problem per se, but makes me wonder if we could attain our goals any other way.
For the sake of simplicity, let me briefly explain a setup: let "Product" be an entity that represents a product in a database, meant to be sold in an online store. Since the online store is designed to have several languages in it, every single bit of information that could be related to a language is in the entity "Product_descriptions" that is related in a manyToOne fashion to the "Product". Finally we have designed a "Language" entity, representing every single language the user can see the store in.
As you can imagine, the code is pretty standard stuff:
class Language
{
private $language_id;
private $language_name;
private $language_code;
//Some other stuff.
};
class Product
{
private $product_id;
private $product_reference;
private $product_weight;
private $product_descriptions; //As an arrayCollection of "Product_description" objects.
//Some other stuff.
};
class Product_description
{
private $product_description_id;
private $product_name;
private $product_long_description;
private $product_short_description;
private $product; //A reference to the Product itself.
private $language; //A reference to the language this is meant to be seen in.
};
Okay, now for the problem itself. The setup, as expected, works wonderfully. It is in the backend where the smell resides.
To create new products we have designed a symfony form Type. In the same form we would like to be able to set all the product information as well as the information for every possible language. The smell comes in when we need to feed all possible "Language"s to the form type, check if a "Product_description" exists for a "Language" and "Product", show the empty text field (in case it does not exist) or the filled field... Our solution requests that a repository for all languages is injected into the form . Let me show you how it goes (please, take into consideration that this is not the real code... something may be missing):
class ProductType extends AbstractType
{
private $language_repo;
public function __construct($r)
{
$this->language_repo=$r;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('product_name', 'text')
->add('product_code', 'text');
$product=$builder->getData();
//We retrieve all languages here, to check if an entry for that
//language exists and show its data.
$languages=$this->language_repo->findAll();
foreach($languages as $key => &$lan)
{
//Here we look for existing data... This will return null if there's none.
$product_description=$product->get_description_for_language($lan);
$default_name=$product_description ? $product_description->getProductName() : '';
$default_long=$product_description ? $product_description->getProductLongDescription() : '';
$default_short=$product_description ? $product_description->getProductShortDescription() : '';
//Here we manually create the name_#language_id# form data... That we will retrieve later.
$builder->add('name_'.$lan->getLanguageId(), 'text', array(
'label' => 'Name for '.$lan->getName(),
'mapped' => false,
'data' => $default_name))
->add('long_'.$lan->getLanguageId(), 'text', array(
'label' => 'Name for '.$lan->getName(),
'mapped' => false,
'data' => $default_long))
->add('short_'.$lan->getLanguageId(), 'text', array(
'label' => 'Name for '.$lan->getName(),
'mapped' => false,
'data' => $default_short));
}
$builder->add('save', 'submit', array('label' => 'Save data'));
}
//And some other stuff here.
}
As you can see, we are manually setting some data keys that we need to retrieve later in the controller. The setup works, of course. Any new language will yield an empty form field. Any existing language shows the related information.
Now for the controller, this gets messier even... When we're submitting the form we go like this:
private function process_form_data(Form &$f, Product &$item, Request &$request)
{
//Find all languages...
$em=$this->getDoctrine()->getManager();
$languages=$em->getRepository("MyBundle:Language")->findAll();
//Get submitted data for that language..
foreach($languages as $key => &$lan)
{
$name_language=$f->get('name_'.$lan->getLanguageId())->getData();
$long_language=$f->get('long_'.$lan->getLanguageId())->getData();
$short_language=$f->get('short_'.$lan->getLanguageId())->getData();
//Check if the language entry exists... Create it, if it doesn't. Feed the data.
$product_description=$product->get_description_for_language($lan);
if(!$product_description)
{
$product_description=new Product_description();
$product_description->setLanguage($lan);
$product_description->setProduct($product);
}
$product_description->setName($name_language);
$product_description->setLongDescription($long_language);
$product_description->setShortDescription($short_language);
$em->persist($product_description);
}
//Do the product stuff, persist, flush, generate a redirect...Not shown.
}
It works, but seems to me that is not the "symfony" way of doing things. How would you do this?. Have you found a more elegant approach?.
Thanks a lot.
I think you should revisit the way you translate the entities...
An existing way is to use the DoctrineExtensionBundle, translatable to be precise...
You'll find more info here :
https://github.com/Atlantic18/DoctrineExtensions/blob/master/doc/translatable.md
Here is an extract to see how it can work :
<?php
// first load the article
$article = $em->find('Entity\Article', 1 /*article id*/);
$article->setTitle('my title in de');
$article->setContent('my content in de');
$article->setTranslatableLocale('de_de'); // change locale
$em->persist($article);
$em->flush();
( now the article has a german translation )
Related
I am writing a symfony app where some entity (let's say a car) has a relationship to another entity (let's say a paint color), but the relationship can also be null (a car without any color documented).
I'd like to make a form to search for cars in my database where you can either :
search a car with a specific color (yellow for example)
search for a car without any color documented
not search anything in that field (for example, I don't care for the car's color, all I want to search is the amount of doors the car has)
I can easily do 1 and 3, but 2 is more difficult.
Best I could do was to load all colors from the database, hack inside an unpersisted color entity (which would have a null id) and put it in a choicetype.
However this never felt "clean" and I now have a problem with that.
Here's my form so far :
class Search extends AbstractType
{
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
private function getColorRepository(): ColorRepository
{
return $this->entityManager->getRepository(Color::class);
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$colors = $this->getColorRepository->findAll();
$unidentifiedColor = new Color();
$unidentifiedColor->setName("Not identified");
array_unshift($colors, $unidentifiedColor);
$builder
->add('color', ChoiceType::class, [
'choice_label' => 'name',
'multiple' => false,
'expanded' => false,
'required' => false,
'choices' => $colors,
'placeholder' => 'Select a color ...',
]);
}
}
This made what I expected (Default choice is a placeholder, second choice is "Not identified" and the rest is the colors you can pick). But I need to use the EntityType now, and I can't do this anymore.
Did anyone face this problem ? How would you fix this issue ?
Long story short, in Symfony 2.8 I've got Movie entity with actors field, which is ArrayCollection of entity Actor (ManyToMany) and I wanted the field to be ajax-loaded Select2.
When I don't use Ajax, the form is:
->add('actors', EntityType::class, array(
'class' => Actor::class,
'label' => "Actors of the work",
'multiple' => true,
'attr' => array(
'class' => "select2-select",
),
))
And it works.
I tried to put there an empty Select field:
->add('actors', ChoiceType::class, array(
'mapped' => false,
'multiple' => true,
'attr'=>array(
'class' => "select2-ajax",
'data-entity'=>"actor"
)
))
The Select2 Ajax works, everything in DOM looks the same as in previous example, but on form submit I get errors in the profiler: This value is not valid.:
Symfony\Component\Validator\ConstraintViolation
Object(Symfony\Component\Form\Form).children[actors] = [0 => 20, 1 => 21]
Caused by:
Symfony\Component\Form\Exception\TransformationFailedException
Unable to reverse value for property path "actors": Could not find all matching choices for the given values
Caused by:
Symfony\Component\Form\Exception\TransformationFailedException
Could not find all matching choices for the given values
The funny part is the data received is the same as they were when it was an EntityType: [0 => 20, 1 => 21]
I marked field as not mapped, I even changed field name to other than Movie entity's field name. I tried adding empty choices, I tried to leave it as EntityType but with custom query_builder, returning empty collection. Now I'm out of ideas.
How should I do it?
EDIT after Raymond's answer:
I added DataTransformer:
use Doctrine\Common\Persistence\ObjectManager;
use CompanyName\Common\CommonBundle\Entity\Actor;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class ActorToNumberTransformer implements DataTransformerInterface
{
private $manager;
public function __construct(ObjectManager $objectManager)
{
$this->manager = $objectManager;
}
public function transform($actors)
{
if(null === $actors)
return array();
$actorIds = array();
foreach($actors as $actor)
$actorIds[] = $actor->getId();
return $actorIds;
}
public function reverseTransform($actorIds)
{
if($actorIds === null)
return array();
foreach($actorIds as $actorId)
{
$actor = $this->manager->getRepository('CommonBundle:Actor')->find($actorId);
if(null === $actor)
throw new TransformationFailedException(sprintf('An actor with id "%s" does not exist!', $actorId));
$actors[] = $actor;
}
return $actors;
}
}
Added it at the end of the MovieType buildForm():
$builder->get('actors')
->addModelTransformer(new ActorToNumberTransformer($this->manager));
$builder->get('actors')
->addViewTransformer(new ActorToNumberTransformer($this->manager));
And added service:
common.form.type.work:
class: CompanyName\Common\CommonBundle\Form\Type\MovieType
arguments: ["#doctrine.orm.entity_manager"]
tags:
- { name: form.type }
Nothing changed. On form submit, reverseTransform() gets the proper data, but profiler shows the same error. That's a big mistery for me now...
You'll need to add a DTO (Data Transformer ) to transform the value received from your form and return the appropriate object .
Since you're calling the value from Ajax it doesn't recognized it anymore as a an object but a text value.
Examples :
Symfony2 -Use of DTO
Form with jQuery autocomplete
The correct way isn't Data Transformer but Form Events, look here:
http://symfony.com/doc/current/form/dynamic_form_modification.html#form-events-submitted-data
In the example you have the field sport (an entity, like your Movie) and the field position (another entity, like actors).
The trick is to use ajax in order to reload entirely the form and use
PRE_SET_DATA and POST_SUBMIT.
I'm using Symfony 3.x but I think it's the same with 2.8.x
When you add data transformers and nothing seems to change, it sounds like the data never goes through your data transformers. The transformation probably fails before your new data transformers are called. Try to add a few lines to your code:
$builder->get('actors')->resetViewTransformers();
$builder->get('actors')->resetModelTransformers();
// and then add your own
I have a form that is the bottleneck of my ajax-request.
$order = $this->getDoctrine()
->getRepository('AcmeMyBundle:Order')
->find($id);
$order = $order ? $order : new Order();
$form = $this->createForm(new OrderType(), $order);
$formView = $form->createView();
return $this->render(
'AcmeMyBundle:Ajax:order_edit.html.twig',
array(
'form' => $formView,
)
);
For more cleaner code I deleted stopwatch statements.
My OrderType has next fields:
$builder
->add('status') // enum (string)
->add('paid_status') // enum (string)
->add('purchases_price') // int
->add('discount_price') // int
->add('delivery_price') // int
->add('delivery_real_price', null, array('required' => false)) // int
->add('buyer_name') // string
->add('buyer_phone') // string
->add('buyer_email') // string
->add('buyer_address') // string
->add('comment') // string
->add('manager_comment') // string
->add('delivery_type') // enum (string)
->add('delivery_track_id') // string
->add('payment_method') // enum (string)
->add('payment_id') // string
->add('reward') // int
->add('reward_status') // enum (string)
->add('container') // string
->add('partner') // Entity: User
->add('website', 'website') // Entity: Website
->add('products', 'collection', array( // Entity: Purchase
'type' => 'purchase',
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'property_path' => 'purchases',
'error_bubbling' => false,
));
Purchase type:
$builder
->add('amount')
->add('price')
->add('code', 'variant', array(
'property_path' => 'variantEntity',
'data_class' => '\Acme\MyBundle\Entity\Simpla\Variant'
))
;
Also Purchase type has a listener that is not significant here. It is represented in Symfony profiler below as variant_retrieve, purchase_form_creating. You can see that it takes about 200ms.
Here I put the result of profilers:
As you can see: $this->createForm(...) takes 1011ms, $form->createView(); takes 2876ms and form rendering in twig is also very slow: 4335ms. As stated by blackfire profiler all the deal in ObjectHydrator::gatherRowData() and UnitOfWork::createEntity().
Method createEntity() called 2223 times because there is some field that mapped with Variant entity and has form type Entity. But as you can see from above code there is no entity types for variant. My VariantType is simple extended text form type that has modelTransformer. To not mess up everything you can see code for similar Type class at docs.
I found with XDebug that buildView for VariantType has been called in Purchase's buildView with text form type. But after that from somewhere buildView for VariantType was called again and in this case it has entity form type. How can it be possible? I tried to define empty array in choices and preferred_choices on every my form type but it didn't change anything. What I need to do to prevent EntityChoiceList to be loaded for my form?
The described behavior looks as the work of the guesser. I have the feeling that there is need to show an some additional code (listeners, VariantType, WebsiteType, PartnerType).
Let's assume a some class has association variant to Variant and FormType for this class has code ->add('variant') without explicit specifying type (as I see there is a lot of places where the type is not specified). Then DoctrineOrmTypeGuesser comes in the game.
https://github.com/symfony/symfony/blob/2.7/src/Symfony/Bridge/Doctrine/Form/DoctrineOrmTypeGuesser.php#L46
This code assign the entity type (!) to this child. The EntityRepository::findAll() is called and all variants from DB are hydrated.
As for another form optimization ways:
Try to specify type in all possible cases to prevent a type guessing;
Use SELECT with JOINs to get an order as new sub-requests to DB are sent to set an underlying data for an every form maps relation;
Preserve keys for collection elements on a submission as a removing of a single element without a keys preserving will trigger unnecessary updates.
I also had the same problem with the entity type, I needed to list cities, there were like mire then 4000, what I did basically is to inject the choices into the form. In your controller you ask the Variants from the database, in a repository call, hydrate them as array, and you select only the id and the name, or title, and then you pass into the form, as options value. With this the database part will be much quicker.
I'm starting developing with Symfony2 and looks like I need help. I have Product entity related with SynchronizationSetting entity. I can edit product data by form maped with his entity. But I also need to modify some data related to product in SynchronizationSetting. To do that I've modified the form so it look like that (Vendor\ProductBundle\Form\ProductType.php):
...
->add('synchronization_setting', 'choice', array(
'choices' => array('daily' => 'Daily', 'weekly' => 'Weekly', 'never' => 'Never'))
After form is submitted selected checkbox values are passed to setSynchronizationSetting method in Product Entity. Then I do that (Vendor\ProductBundle\Entity\SynchronizationSetting.php):
public function setSynchronizationSetting($data)
{
$synchronizationSetting = new SynchronizationSetting();
$synchronizationSetting->setDaily(in_array('daily', $data) ? '1' : '0');
...
}
And now I need to somehow save those SynchronizationSetting entity into database. I read that calling entity manager from here is very bad practice so... how should I save this?
One possible way (I'm not sure if it's good practice)
public function setSynchronizationSetting($data)
{
$synchronizationSetting = new SynchronizationSetting();
$synchronizationSetting->setDaily(in_array('daily', $data) ? '1' : '0');
}
public function retSynchronizationSetting()
{
return $this->synchronizationSetting;
}
Then in your controller in place where you handle form data you call retSynchronizationSetting() and save entity using EntityManager.
I'm quite new to Zend & JSON, however I have the need to learn. What I want to achieve is: having a Dojo filteringselect (with autocomplete) control which is linked to zipcodes from a database (and which keeps track of the ID, so I can store that ID as a FK in another table (later on). The structure is MVC. I do get results from the database, however I can't seem to make it shine. Nothing shows up in the filteringselect control. So basicly the structure field of my database needs to get into the filteringsselect control and keeping track of that id, because I need it later on as a FK in another table.
Please help me out!
Table:
<?php
class Application_Model_Place extends FS_Model_Popo {
protected $_fields = array(
'id' => NULL,
'zip' => NULL,
'name' => NULL,
'up' => NULL,
'structure' => NULL);
protected $_primaryKey = array('id');
}
Form:
$place = new Zend_Dojo_Form_Element_FilteringSelect('Place');
$place->setLabel('Place')
->setAttrib('title', 'Add a place.')
->setAutoComplete(true)
->setStoreId('placeStore')
->setStoreType('dojox.data.QueryReadStore')
->setStoreParams(array('url' => '/graph/place/autocomplete'))
->setAttrib("searchAttr", "structure")
->setRequired(true);
Controller:
class Graph_PlaceController extends Zend_Controller_Action {
public function autocompleteAction() {
$this->_helper->viewRenderer->setNoRender();
$this->_helper->layout->disableLayout();
$structuur = $this->_getParam("structure", "");
$results = FS_Model_Factory::getInstance()->place->autocomplete(array('structure like ?'=> "%".$structure."%"));
$enc_res = array();
foreach ($results as $value) {
array_push($enc_res,$this->_helper->convert->toArray($value));
}
$this->_helper->json($enc_res);
$data = new Zend_Dojo_Data('id', $enc_res);
$this->_helper->autoCompleteDojo($data);
}
}
An example of json($enc_res) would be:
{"id":"235","zip":"3130","name":"Betekom","up":"BETEKOM","structure":"3130 Betekom"}, {"id":"268","zip":"3211","name":"Binkom","up":"BINKOM","structure":"3211 Binkom"},{"id":"377","zip":"3840","name":"Broekom","up":"BROEKOM","structure":"3840 Broekom"},{"id":"393","zip":"1081","name":"Brussel (Koekelberg)","up":"BRUSSEL (KOEKELBERG)","structure":"1081 BRUSSEL (KOEKELBERG)"},{"id":"421","zip":"1081","name":"Bruxelles (Koekelberg)","up":"BRUXELLES (KOEKELBERG)","structure":"1081 BRUXELLES (KOEKELBERG)"},{"id":"668","zip":"3670","name":"Ellikom","up":"ELLIKOM","structure":"3670 Ellikom"},{"id":"1236","zip":"3840","name":"Jesseren (Kolmont)","up":"JESSEREN (KOLMONT)","structure":"3840 Jesseren (Kolmont)"},{"id":"1275","zip":"3370","name":"Kerkom","up":"KERKOM","structure":"3370 Kerkom"}
I think you have several options :
Either you have control on the structure of the json you produce on your controller, and therefore you should generate the format expected by dojox.store.QueryReadStore (which is by default the same as dojo.data.ItemFileReadStore). See http://dojotoolkit.org/reference-guide/dojo/data/ItemFileReadStore.html#general-structure
Or you create a custom store that understands the structure of your json response. See http://dojotoolkit.org/reference-guide/dojox/data/QueryReadStore.html#query-translation
Or you are using dojo >1.6, and you can use dojo.store.JsonRest with its companion dojo.data.ObjectStore as described here
Option 1 is obviously the easiest...