Symfony deserialize null value on ManyToOne relation - symfony

I can't seem to find out to deserialize a json object to a null value on a ManyToOne relation.
So as example my entities would look something like this:
Product
#[ORM\Column(type: 'string', nullable: true)]
#[Groups([SerializationGroupConstants::PRODUCT])]
private ?string $name = null;
#[ORM\JoinColumn(nullable: true, onDelete: 'set null')]
#[ORM\ManyToOne(
targetEntity: Group::class
)]
#[Groups([SerializationGroupConstants::PRODUCT])]
private ?Group $group = null;
Group
#[ORM\Column(type: 'string', nullable: true)]
#[Groups([SerializationGroupConstants::GROUP])]
private ?string $name = null;
An example of the product json would be as follows:
{
"id": 123,
"name": "product 123",
"groupId": null
}
The deserializer is as follows:
$this->serializer->deserialize(
$data,
Product::class,
'json',
[
AbstractNormalizer::OBJECT_TO_POPULATE => $existingEntity,
AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true,
AbstractObjectNormalizer::SKIP_NULL_VALUES => false
]
);
My issue is that whenever the groupId property contains an id, the relation field is updated accordingly in the database. But when the groupId property is null, the relation field is not updated to null, instead it still contains the old id.
I'm currently still on Symfony 5.3.*

Related

How to deserialize a nested array of objects declared on the constructor via promoted properties, with Symfony Serializer?

Take the following DTO classes:
class UserDTO {
/**
* #param AddressDTO[] $addressBook
*/
public function __construct(
public string $name,
public int $age,
public ?AddressDTO $billingAddress,
public ?AddressDTO $shippingAddress,
public array $addressBook,
) {
}
}
class AddressDTO {
public function __construct(
public string $street,
public string $city,
) {
}
}
I'd like to serialize and deserialize them to/from JSON.
I'm using the following Serializer configuration:
$encoders = [new JsonEncoder()];
$extractor = new PropertyInfoExtractor([], [
new PhpDocExtractor(),
new ReflectionExtractor(),
]);
$normalizers = [
new ObjectNormalizer(null, null, null, $extractor),
new ArrayDenormalizer(),
];
$serializer = new Serializer($normalizers, $encoders);
But when serializing/deserializing this object:
$address = new AddressDTO('Rue Paradis', 'Marseille');
$user = new UserDTO('John', 25, $address, null, [$address]);
$jsonContent = $serializer->serialize($user, 'json');
dd($serializer->deserialize($jsonContent, UserDTO::class, 'json'));
I get the following result:
UserDTO^ {#54
+name: "John"
+age: 25
+billingAddress: AddressDTO^ {#48
+street: "Rue Paradis"
+city: "Marseille"
}
+shippingAddress: null
+addressBook: array:1 [
0 => array:2 [
"street" => "Rue Paradis"
"city" => "Marseille"
]
]
}
When I would expect:
UserDTO^ {#54
+name: "John"
+age: 25
+billingAddress: AddressDTO^ {#48
+street: "Rue Paradis"
+city: "Marseille"
}
+shippingAddress: null
+addressBook: array:1 [
0 => AddressDTO^ {#48
+street: "Rue Paradis"
+city: "Marseille"
}
]
}
As you can see, $addressBook is deserialized as an array of array, instead of an array of AddressDTO. I expected the PhpDocExtractor to read the #param AddressDTO[] from the constructor, but this does not work.
It only works if I make $addressBook a public property documented with #var.
Is there a way to make it work with a simple #param on the constructor?
(Non-)working-demo: https://phpsandbox.io/n/gentle-mountain-mmod-rnmqd
What I've read and tried:
Extract types of constructor parameters from docblock comment
symfony deserialize nested objects
How can I deserialize an array of objects in Symfony Serializer?
None of the proposed solutions seem to work for me.
Apparently the issue is that the PhpDocExtractor does not extract properties from constructors. You need to use a specific extractor for this:
use Symfony\Component\PropertyInfo;
use Symfony\Component\Serializer;
$phpDocExtractor = new PropertyInfo\Extractor\PhpDocExtractor();
$typeExtractor = new PropertyInfo\PropertyInfoExtractor(
typeExtractors: [ new PropertyInfo\Extractor\ConstructorExtractor([$phpDocExtractor]), $phpDocExtractor,]
);
$serializer = new Serializer\Serializer(
normalizers: [
new Serializer\Normalizer\ObjectNormalizer(propertyTypeExtractor: $typeExtractor),
new Serializer\Normalizer\ArrayDenormalizer(),
],
encoders: ['json' => new Serializer\Encoder\JsonEncoder()]
);
With this you'll get the desired results. Took me a bit to figure it out. The multiple denormalizer/extractor chains always get me.
Alternatively, for more complex os specialized situations, you could create your own custom denormalizer:
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait
class UserDenormalizer
implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
public function denormalize($data, string $type, string $format = null, array $context = [])
{
$addressBook = array_map(fn($address) => $this->denormalizer->denormalize($address, AddressDTO::class), $data['addressBook']);
return new UserDTO(
name: $data['name'],
age: $data['age'],
billingAddress: $this->denormalizer->denormalize($data['billingAddress'], AddressDTO::class),
shippingAddress: $this->denormalizer->denormalize($data['shippingAddress'], AddressDTO::class),
addressBook: $addressBook
);
}
public function supportsDenormalization($data, string $type, string $format = null)
{
return $type === UserDTO::class;
}
}
Setup would become this:
$extractor = new PropertyInfoExtractor([], [
new PhpDocExtractor(),
new ReflectionExtractor(),
]);
$userDenormalizer = new UserDenormalizer();
$normalizers = [
$userDenormalizer,
new ObjectNormalizer(null, null, null, $extractor),
new ArrayDenormalizer(),
];
$serializer = new Serializer($normalizers, [new JsonEncoder()]);
$userDenormalizer->setDenormalizer($serializer);
Output becomes what you would expect:
^ UserDTO^ {#39
+name: "John"
+age: 25
+billingAddress: AddressDTO^ {#45
+street: "Rue Paradis"
+city: "Marseille"
}
+shippingAddress: null
+addressBook: array:2 [
0 => AddressDTO^ {#46
+street: "Rue Paradis"
+city: "Marseille"
}
]
}

Symfony Conditional Form Validation

I'm working on a Form in Symfony (2.6). The user can select a free product, which will be shipped to the user. The user has to fill in some personal details and his address (obligated). If he wants to specify another delivery address, he checks a checkbox that is not mapped to an Entity, and completes the delivery address. Now, I want to submit the form, and only validate the delivery address fields if the user has checked this checkbox. How can this be done?
The address Fields and the Delivery Address Fields use the same Form Class mapped to the same Entity. I Use a YAML-file for my constraints.
(part of) validation.yml:
AppBundle\Entity\Address:
properties:
street:
- NotBlank: { message: "Please fill in your first name." }
- Length:
min: 3
max: 256
minMessage: "Please fill in your street name."
maxMessage: "Please fill in your street name."
number:
- NotBlank: { message: "Please fill in your house number." }
- Length:
min: 1
max: 10
minMessage: "Please fill in your house number."
maxMessage: "Please fill in your house number."
postCode:
- NotBlank: { message: "Please fill in your postal code." }
- Length:
min: 2
max: 10
minMessage: "Please fill in your postal code."
maxMessage: "Please fill in your postal code."
city:
- NotBlank: { message: "Please fill in your city." }
- Length:
min: 2
max: 256
minMessage: "Please fill in your city."
maxMessage: "Please fill in your city."
- Type:
type: alpha
message: "Please fill in your city."
country:
- NotBlank: { message: "Please select your country." }
- Country: ~
AppBundle\Entity\Product:
properties:
product:
- NotBlank: { message: "Please select your product." }
- Type:
type: integer
message: "Please select your product."
contact:
- Type:
type: AppBundle\Entity\Contact
- Valid: ~
deliveryAddress:
- Type:
type: AppBundle\Entity\Address
- Valid: ~
Product Form Class:
<?php
class ProductFormType extends AbstractType
{
/**
*
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Product'
));
}
/**
* Returns the name of this type.
*
* #return string The name of this type
*/
public function getName()
{
return 'product';
}
/**
*
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('contact', new ContactFormType()); //CONTACTFORMTYPE also has an AddressFormType for the Address Fields
$builder->add('differentDeliveryAddress', 'checkbox', array( //delivery address is only specific for this Form
'label' => 'Different Shipping Address',
'required' => false,
'mapped' => false
));
$builder->add('deliveryAddress', new AddressFormType());
//specific
$builder->add('product', 'choice', array(
'choices' => array('a'=>'product x','b' => 'product y'),
'required' => true,
'invalid_message' => 'This field is required',
'label' => 'Your Free Product',
));
$builder->add('submit', 'button', array('label' => 'Submit'));
}
}
Finally the getProductFormAction in My Controller
public function getProductFormAction(Request $request)
{
$product = new Product();
$form = $this->get('form.factory')->create(new ProductFormType($product);
$form->handleRequest($request);
if($form->isValid()){
return new Response('Success'); //just to test
}
return $this->render(
'productForm.html.twig',
array(
'pageTitle' => 'Test',
'form' => $form->createView()
)
);
}
This can relatively easily be achieved through groups.
First, add groups to the fields you only want to validate on certain occasions (your delivery address) fields.
street:
- NotBlank:
message: "Please fill in your first name."
groups: [delivery]
- Length:
min: 3
max: 256
minMessage: "Please fill in your street name."
maxMessage: "Please fill in your street name."
groups: [delivery]
Now that these validations are in a specific group, they will not be validated unless explicitly told to do so.
So now, we let the form determine when to validate this group.
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'validation_groups' => function (FormInterface $form) {
$data = $form->getData();
if ($data->differentDeliveryAddress()) {
return array('Default', 'delivery');
}
return array('Default');
},
));
}
Here the form will always validate the 'Default' group (all validations without any groups set), and will also validate the 'delivery' group when differentDeliveryAddress is set.
Hope this helps
There is also a lot of material in the Symfony docs about dynamic forms and entity/form validation groups that should point you in the right direction; e.g:
How to Dynamically Modify Forms Using Form Events
How to Choose Validation Groups Based on the Submitted Data
This use case seems to come up a lot on SO. I'd suggest doing a quick search for to see if you can unearth a similar question.
Hope this helps :)

elastica search how to convert Elastica\Result to actual doctrine object

I am using FOSElasticaBundle with symfony2 and doctrine 2.
I have trouble understanding how to retrieve actual doctrine objets from a search result. I am under the impression that it is the default behaviour but I get this kind of result :
object(Elastica\Result)[1239]
protected '_hit' =>
array (size=5)
'_index' => string 'foodmeup' (length=8)
'_type' => string 'recipes' (length=7)
'_id' => string '2' (length=1)
'_score' => float 2.2963967
'_source' =>
array (size=5)
'name' => string 'Bavaroise vanille' (length=17)
'nickName' => string 'Bavaroise vanille' (length=17)
'content' => null
'userRecipes' =>
array (size=1)
...
'tags' =>
array (size=0)
Here is my FOSElasticaBundle configuration:
#Elastic Search
fos_elastica:
default_manager: orm
clients:
default: { host: localhost, port: 9200 }
indexes:
search:
client: default
index_name: foodmeup
types:
recipes:
mappings:
name: { type: string, boost: 5}
nickName: { type: string }
content: { type: string }
userRecipes:
type: "nested"
properties:
name: { type: string }
content: { type: string }
tags:
type: "nested"
boost: 5
properties:
name: { type: string }
persistence:
driver: orm
model: AppBundle\Entity\FoodAnalytics\Recipe
repository: AppBundle\Repository\FoodAnalytics\RecipeRepository
provider: ~
finder: ~
listener: ~ # by default, listens to "insert", "update" and "delete"
And the code in my controller :
public function searchAction(Request $request)
{
$search = $request->query->get('search');
$finder = $this->get('fos_elastica.index.search.recipes');
$results = $finder->search($search)->getResults();
return array(
'search' => $search,
'results' => $results
);
}
I understood I could use a custom repository method to get the objects, but before I reach that point, what is the default way to get objects ? (Here I want a Recipe Object, an instance of my model).
Thanks a lot !
Got it!
I called the wrong service. The correct controller code to retrieve directly object instances is:
public function searchAction(Request $request)
{
$search = $request->query->get('search');
$finder = $this->get('fos_elastica.finder.search.recipes');
$results = $finder->find($search);
return array(
'search' => $search,
'results' => $results
);
}

symfony2: multiple entities one form

I have 2 entities:
ADS\LinkBundle\Entity\Link:
type: entity
table: null
repositoryClass: ADS\LinkBundle\Entity\LinkRepository
id:
id:
type: integer
id: true
generator:
strategy: AUTO
fields:
dateAdded:
type: datetime
expirationDate:
type: datetime
nullable: true
designator:
type: string
length: 255
nullable: false
unique: true
slug:
type: string
length: 255
nullable: true
unique: true
manyToOne:
company:
targetEntity: ADS\UserBundle\Entity\Company
inversedBy: link
joinColumn:
name: company_id
referencedColumnName: id
nullable: true
createdBy:
targetEntity: ADS\UserBundle\Entity\User
inversedBy: link
joinColumn:
name: createdBy_id
referencedColumnName: id
domain:
targetEntity: ADS\DomainBundle\Entity\Domain
inversedBy: link
joinColumn:
name: domain_id
referencedColumnNames: id
oneToMany:
paths:
targetEntity: ADS\LinkBundle\Entity\Path
mappedBy: link
cascade: [persist]
lifecycleCallbacks: { }
and
ADS\LinkBundle\Entity\Path:
type: entity
table: null
repositoryClass: ADS\LinkBundle\Entity\PathRepository
id:
id:
type: integer
id: true
generator:
strategy: AUTO
fields:
pathAddress:
type: string
length: 255
pathWeight:
type: string
length: 255
manyToOne:
link:
targetEntity: ADS\LinkBundle\Entity\Link
inversedBy: paths
joinColumn:
name: link_id
referencedColumnName: id
lifecycleCallbacks: { }
I have everything figured out except for the paths portion of the entity. This is for an A/B split test, so each link can have 2 paths. Each path will consist of a web address, and a number ( 0 - 100 )
Here is my form in it's current state:
<?php
namespace ADS\LinkBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class PathType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('pathAddress')
->add('pathWeight')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array('data_class' => 'ADS\LinkBundle\Entity\Path'));
}
public function getName() { return 'ads_linkbundle_link'; }
}
and
<?php
namespace ADS\LinkBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class LinkType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('designator')
->add('domain', 'entity', array(
'class' => 'ADS\DomainBundle\Entity\Domain',
'property' => 'domainAddress'
))
->add('paths', 'collection', array('type' => new PathType(), 'allow_add' => true))
->add('Submit', 'submit')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array('data_class' => 'ADS\LinkBundle\Entity\Link'));
}
public function getName() { return 'ads_linkbundle_link'; }
}
What I need to figure out, is when creating a link, I need to also be able to create the correct path and weight to go with it. The paths won't be in the database before a link is created.
Here is what I have for my controller:
public function newAction(Request $request) {
$entity = new Link();
$form = $this->createForm(new LinkType(), $entity);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$code = $this->get('ads.default');
$em = $this->getDoctrine()->getManager();
$user = $this->getUser();
$entity->setDateAdded(new \DateTime("now"));
$entity->setCreatedBy($user);
$entity->setSlug($code->generateToken(5));
$entity->setCompany($user->getParentCompany());
$em->persist($entity);
$em->flush();
return new Response(json_encode(array('error' => '0', 'success' => '1')));
}
return new Response(json_encode(array('error' => count($form->getErrors()), 'success' => '0')));
}
return $this->render('ADSLinkBundle:Default:form.html.twig', array(
'entity' => $entity,
'saction' => $this->generateUrl('ads.link.new'),
'form' => $form->createView()
));
}
Thanks to #Onema ( read the comments above ), I've figured this out. By reading the documentation at http://symfony.com/doc/current/cookbook/form/form_collections.html It gave me information I needed to get this done.
First step in doing what I needed to do, was to create a new form type called PathsType.php which houses the fields associated with the Paths Entity
<?php
namespace ADS\LinkBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class PathType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('pathAddress')
->add('pathWeight')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array('data_class' => 'ADS\LinkBundle\Entity\Path'));
}
public function getName() { return 'ads_linkbundle_path'; }
}
Then modifying the LinkType.php to utilize this new form
<?php
namespace ADS\LinkBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class LinkType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('designator')
->add('domain', 'entity', array(
'class' => 'ADS\DomainBundle\Entity\Domain',
'property' => 'domainAddress'
))
->add('paths', 'collection', array(
'type' => new PathType(),
'allow_add' => true,))
->add('Submit', 'submit')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array('data_class' => 'ADS\LinkBundle\Entity\Link'));
}
public function getName() { return 'ads_linkbundle_link'; }
}
The addition of allow_add makes it so that you can add multiple instances of that form.
Within the view, I now utilize the data-prototype attribute. In the documentation, it has the example using a list item - so that's where I started.
<ul class="tags" data-prototype="{{ form_widget(form.paths.vars.prototype)|e }}"></ul>
Then came the jQuery functions ( listed on the documentation link above, simple copy/paste will work )
This got the system working, with 1 small issue and that in my paths entity, I have a relationship to the Link entity but it was not noticing this relationship and had the link_id field as null
To combat this, we edit LinkType.php one more time, and add by_reference = false to the collection definition. We then edit the addPath method inside the entity to look like so:
public function addPath(\ADS\LinkBundle\Entity\Path $paths)
{
$paths->setLink($this);
$this->paths->add($paths);
}
This sets the current link object, as the link the path is associated with.
At this point, the system is working flawlessly. It's creating everything that it needs to, only need to adjust the display a little bit. I personally opted to use a twig macro to modify the html output contained in data-prototype
my macro as it currently sits (incomplete - but working ) which I added to the beginning of my form.html.twig
{% macro path_prototype(paths) %}
<div class="form-group col-md-10">
<div class="col-md-3">
<label class="control-label">Address</label>
</div>
<div class="col-md-9">
{{ form_widget(paths.pathAddress, { 'attr' : { 'class' : 'form-control required' }}) }}
</div>
</div>
{% endmacro %}
In the HTML for the form itself, I removed the list creation, and replaced it with:
<div class="form-group">
{{ form_label(form.paths,'Destination(s)', { 'label_attr' : {'class' : 'col-md-12 control-label align-left text-left' }}) }}
<div class="tags" data-prototype="{{ _self.path_prototype(form.paths.vars.prototype)|e }}">
</div>
</div>
I then modified my javascript to use the div as a starting point instead of the ul in the example.
<script type="text/javascript">
var $collectionHolder;
// setup an "add a tag" link
var $addTagLink = $('Add Another Destination');
var $newLinkLi = $('<div></div>').append($addTagLink);
jQuery(document).ready(function() {
// Get the ul that holds the collection of tags
$collectionHolder = $('div.tags');
// add the "add a tag" anchor and li to the tags ul
$collectionHolder.append($newLinkLi);
// count the current form inputs we have (e.g. 2), use that as the new
// index when inserting a new item (e.g. 2)
$collectionHolder.data('index', $collectionHolder.find(':input').length);
addTagForm($collectionHolder, $newLinkLi);
$addTagLink.on('click', function(e) {
// prevent the link from creating a "#" on the URL
e.preventDefault();
// add a new tag form (see next code block)
addTagForm($collectionHolder, $newLinkLi);
});
});
function addTagForm($collectionHolder, $newLinkLi) {
// Get the data-prototype explained earlier
var prototype = $collectionHolder.data('prototype');
// get the new index
var index = $collectionHolder.data('index');
// Replace '__name__' in the prototype's HTML to
// instead be a number based on how many items we have
var newForm = prototype.replace(/__name__/g, index);
// increase the index with one for the next item
$collectionHolder.data('index', index + 1);
console.log(index);
if (index == 1) {
console.log('something');
$('a.add_tag_link').remove();
}
// Display the form in the page in an li, before the "Add a tag" link li
var $newFormLi = newForm;
$newLinkLi.before($newFormLi);
}
</script>
Being that these paths are destination addresses for an A/B split test within my marketing app, I opted to limit the paths to 2 per link. And with this, I have successfully setup a form to use a collections type.

choice field symfony "Notice: Array to string conversion "

Trying to validate a choice field (multiple checkboxes) Im having this problem:
"Notice: Array to string conversion "
My validation file looks like this one:
Cgboard\AppBundle\Forms\UploadImageEntity:
properties:
image:
...
cgnetworks:
- Choice:
choices: [flickr, tumblr] //<--- this is giving me problems!!!
My form entity class (Im not going to save this to db for now):
class UploadImageEntity {
public $image;
public $cgnetworks;
}
And my form class:
class UploadImageForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('image', 'file')
->add('cgnetworks', 'choice', [
'choices' => $this->getCgNetworks(),
'multiple' => TRUE,
'expanded' => TRUE
]
);
}
public function getCgNetworks()
{
return [
'tumblr' => 'Tumblr',
'flickr' => 'Flickr'
];
}
}
Any idea?
perhaps you need to specify multiple in your validation
cgnetworks:
- Choice:
choices: [flickr, tumblr] //<--- this is giving me problems!!!
multiple: true
Check your Entity field getter. If you have something else instead of
public function getValue(){
return $this->value;
}
You can reach this error.
Form builder uses get and set entity methods, that's why you need to return an allowable value.

Resources