serializer icon indicating copy to clipboard operation
serializer copied to clipboard

Add control to deserialization of null values

Open fghirlanda opened this issue 7 years ago • 23 comments

Q A
Bug report? no
Feature request? yes
BC Break report? no
RFC? no

Given a class which has a collection or an array, like for instance:

   /**
     * @var ArrayCollection
     * @JMS\Type("ArrayCollection<MyObject>")
     */
    protected $collection;

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

and serialized data such as ['collection' => null], what currently happens when the data is deserialized, is that the property $collection becomes null rather than, for instance, an empty collection.

It would be nice to be able to control the value which is being set when a null input value is found. Some possible solutions :

  • use the DeserializationContext to determine whether to set the value to null or not
  • delegate the decision to the handlers
  • use the annotations (either per class or property) to specify callbacks or default values to use in this case, or do that per object type

Please let us know your thoughts and we would like to contribute.

Thank you!

fghirlanda avatar Sep 21 '17 15:09 fghirlanda

I guess here a custom setter here should already solve the issue

/**
 * @var ArrayCollection
 * @JMS\Type("ArrayCollection<MyObject>")
 * @Accessor(setter="setCollection")
 */
protected $collection;

public function __construct()
{
    $this->collection = new ArrayCollection();
}
public function setCollection($value)
{
    $this->collection = $value instanceof ArrayCollection? $value : new ArrayCollection();
}

Am I missing something?

goetas avatar Sep 21 '17 15:09 goetas

@goetas , thank you for your reply.

Yes, that would obviously work, but it would mean a check in each setter for each single variable which is of that type(s).

fghirlanda avatar Sep 21 '17 15:09 fghirlanda

use the DeserializationContext to determine whether to set the value to null or not delegate the decision to the handlers

Delegating it to handlers technically might work, but will make more complex the handler logic as they will have to handle explicitly the null/not-null values (by some configuration that will need to be exposed somehow)

use the annotations (either per class or property) to specify callbacks or default values to use in this case, or do that per object type

callbacks are exactly what is my solution proposed before. default values will not work as they need some logic (constructor args as example) and to do so we will need some code to be somewhere... that will end-up in a callback...

goetas avatar Sep 21 '17 15:09 goetas

The difference is that using a callback this way we would need as many callbacks as properties of that type. A callback that would return an empty ArrayCollection (for instance) on the other hand could be shared among all the properties who need it.

Default values can already be set via the constructor setting the ObjectConstructor in the SerializerBuilder. Being then simply able to skip overwriting them with null values would allow us to keep the default values.

fghirlanda avatar Sep 21 '17 15:09 fghirlanda

hmm... something as

@Accessor(setter="expr(service('my_setter_service').myMethod(value))")

might be a nice idea. what do you think?

goetas avatar Sep 21 '17 16:09 goetas

To be very honest this seems to me like a bit of misuse of the @Accessor annotation. I would rather introduce a new annotation or, just for the sake of simplicity, add a flag in the DeserializationContext, similar to what is already available when serializing. Neither of the solutions would introduce breaking changes.

fghirlanda avatar Sep 21 '17 16:09 fghirlanda

What do you want to add in the DeserializationContext ? probably I'm not understanding your solution as inside it you have to encode some logic (and DeserializationContext is not meant for it)

Can you make me an example?

goetas avatar Sep 21 '17 16:09 goetas

What currently happens in the GenericDeserializationVisitor, in visitProperty is this:

$v = $data[$name] !== null ? $this->navigator->accept($data[$name], $metadata->type, $context) : null;
$this->accessor->setValue($this->currentObject, $v, $metadata);

That is, if the provided data is null, the value is always set to null.

The point is that in that method we have access to the Context. So, assuming we add a flag like skipDeserializeNull (obviously defaulted to false), we could do this when $v is null:

if ($context->skipDeserializeNull() === false) {
            $this->accessor->setValue($this->currentObject, $v, $metadata);
}

This way, if we have already set default values via the constructor, they would be untouched.

What do you think?

fghirlanda avatar Sep 21 '17 16:09 fghirlanda

This way, if we have already set default values via the constructor, they would be untouched.

sorry to disappoint you but the constructor is never called by the jms serializer. the object instantiation is done via reflection

goetas avatar Sep 21 '17 16:09 goetas

see http://www.doctrine-project.org/2010/03/21/doctrine-2-give-me-my-constructor-back.html

goetas avatar Sep 21 '17 16:09 goetas

I know the constructor is not called, but in the SerializerBuilder it is possible to set the ObjectConstructor. and what we currently inject is a SimpleObjectConstructor which calls the constructor.

fghirlanda avatar Sep 21 '17 16:09 fghirlanda

Calling the constructor in the serialization process is a bad idea. the constructor should be called only in your business logic

goetas avatar Sep 21 '17 16:09 goetas

The constructor is called in our implementation of the ObjectConstructorInterface, maybe my explanation was not clear :)

fghirlanda avatar Sep 21 '17 16:09 fghirlanda

skipDeserializeNull looks a bad idea to me as it requires other components to be in place to work (your custom constructor in this case).

expression language inside a custom accessor might look an abuse but imo has less side-effects (it will work just as a callback before the "set")

goetas avatar Sep 21 '17 16:09 goetas

I don't understand how having more options can be a problem :) The fact that you can use it in conjunction with a custom constructor (something which is already in place) does not mean you have to.

Besides, I think the skipDeserializeNull flag is actually useful on its own, because the same problem arises if you have properties with a default value, such as:

class MyClass{
    /**
     * @var int
     * @JMS\Type("int")
     */
    protected $myProperty = 123;
}

fghirlanda avatar Sep 22 '17 08:09 fghirlanda

The problem is the delicate balance between features and stability, obtained via options and constraints... but i see your point here

@schmittjoh what is your opinion in this case?

goetas avatar Sep 22 '17 09:09 goetas

There is a much cleaner solution (in my opinion). The real problem is that the serializer skips the constructor because it uses reflection to set properties. Using the context 'target' we can set a empty object as a starting point which ofcourse goes through the constructor during instantiation. Then when the serializer fills the object it will ignore the null value and keep the objects original constructor value.

  • Create InitializedObjectConstructor class
<?php

/*
 * Copyright 2016 Johannes M. Schmitt <[email protected]>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace MyNamespace\Serializer;

use JMS\Serializer\VisitorInterface;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\Construction\ObjectConstructorInterface;

/**
 * Object constructor that allows deserialization into already constructed
 * objects passed through the deserialization context
 */
class InitializedObjectConstructor implements ObjectConstructorInterface
{
    private $fallbackConstructor;

    /**
     * Constructor.
     *
     * @param ObjectConstructorInterface $fallbackConstructor Fallback object constructor
     */
    public function __construct(ObjectConstructorInterface $fallbackConstructor)
    {
        $this->fallbackConstructor = $fallbackConstructor;
    }

    /**
     * {@inheritdoc}
     */
    public function construct(VisitorInterface $visitor, ClassMetadata $metadata, $data, array $type, DeserializationContext $context)
    {
        if ($context->attributes->containsKey('target') && $context->getDepth() === 1) {
            return $context->attributes->get('target')->get();
        }

        return $this->fallbackConstructor->construct($visitor, $metadata, $data, $type, $context);
    }

}

  • Configure and use serializer
use JMS\Serializer\SerializerBuilder;
use JMS\Serializer\Construction\UnserializeObjectConstructor;
use JMS\Serializer\DeserializationContext;
use MyNamespace\InitializedObjectConstructor;

$fallback = new UnserializeObjectConstructor();
$objectConstructor = new InitializedObjectConstructor($fallback);
$serializer = SerializerBuilder::create()
                ->setObjectConstructor($objectConstructor)
                ->build();
 
$context = DeserializationContext::create();
$context->attributes->set('target', new MyClass());
$object = $serializer->deserialize($requestBody, MyClass, 'json', $context);

edited, excuse my late-night-writing forgetfulness, I have added the missing configuration and custom class

InfopactMLoos avatar Oct 11 '17 23:10 InfopactMLoos

It is quite funny that deserializing array of nulls has opposite effect (nulls are never preserved):

$serializer = \JMS\Serializer\SerializerBuilder::create()->build();
var_dump($serializer->fromArray(['item' => null], 'array<string, stdClass>'));
array(1) {
  'item' =>
  class stdClass#53 (0) {
  }
}

As you probably already have guessed, my needs are straight opposite to that (i would like to preserve nulls)

etki avatar Dec 27 '17 18:12 etki

fixed in https://github.com/schmittjoh/serializer/commit/94c319db88883757a14ac267115272be030aef28

goetas avatar Apr 15 '18 09:04 goetas

@goetas Please can you explain how your commit, which only affects Serialization, is supposed to have fixed the present issue, which is all about Deserialization? For instance, the code of @etki just above (version 1.10.0 at the time) is still giving the same result today in version 2.3.0

guilliamxavier avatar Apr 18 '19 14:04 guilliamxavier

Hmm, you might be right. can https://github.com/schmittjoh/serializer/pull/1005 be a solution for it?

goetas avatar Apr 18 '19 14:04 goetas

@goetas Thanks for your work on this lib =) #1005 indeed seems related (but I can't speak for others); for example, it would give

$serializer = \JMS\Serializer\SerializerBuilder::create()->build();
var_dump($serializer->fromArray(['item' => null], 'array<string, stdClass>'));
array(1) {
  'item' =>
  NULL
}

and

$serializer = \JMS\Serializer\SerializerBuilder::create()->build();
$dCtx = \JMS\Serializer\DeserializationContext::create()->setDeserializeNull(true);
var_dump($serializer->fromArray(['item' => null], 'array<string, stdClass>', $dCtx));
array(1) {
  'item' =>
  class stdClass#53 (0) {
  }
}

(don't know for the ArrayCollection though)

guilliamxavier avatar Apr 19 '19 07:04 guilliamxavier

Not sure it #1005 is exactly about that. I do not have a need of #1005 but if you want to work on it, feel free to continue on #1005 and if the result will lead somewhere, we can add it to the jms core

goetas avatar Apr 19 '19 07:04 goetas