Symfony2 data transformer string to entity (reverseTransform) - symfony

I am pretty new to Symfony and hope someone can help me. I have an entity called Material and an associated entity called MaterialKeyword, which are basically tags. I am displaying the keywords comma delimited as a string in a text field on a form. I created a data transformer to do that. Pulling the keywords from the database and displaying them is no problem, but I have a problem with the reversTransform function when I want to submit existing or new keywords to the database.
Material class (MaterialKeyword):
/**
* #Assert\Type(type="AppBundle\Entity\MaterialKeyword")
* #Assert\Valid()
* #ORM\ManyToMany(targetEntity="MaterialKeyword", inversedBy="material")
* #ORM\JoinTable(name="materials_keyword_map",
* joinColumns={#ORM\JoinColumn(name="materialID", referencedColumnName="materialID", nullable=false)},
* inverseJoinColumns={#ORM\JoinColumn(name="keywordID", referencedColumnName="id", nullable=false)})
*/
public $materialkeyword;
/**
* Constructor
*/
public function __construct()
{
$this->MaterialKeyword = new ArrayCollection();
}
/**
* Set materialkeyword
*
* #param array $materialkeyword
*
*/
public function setMaterialkeyword(MaterialKeyword $materialkeyword=null)
{
$this->materialkeyword = $materialkeyword;
}
/**
* Get materialkeyword
*
* #Assert\Type("\array")
* #return array
*/
public function getMaterialkeyword()
{
return $this->materialkeyword;
}
Here is my code from the data transformer:
This part is working:
class MaterialKeywordTransformer implements DataTransformerInterface
{
/**
* #var EntityManagerInterface
*/
private $manager;
public function __construct(ObjectManager $manager)
{
$this->manager = $manager;
}
/**
* Transforms an object (materialkeyword) to a string.
*
* #param MaterialKeyword|null $materialkeyword
* #return string
*/
public function transform($material)
{
$result = array();
if (null === $material) {
return '';
}
foreach ($material as $materialkeyword) {
$result[] = $materialkeyword->getKeyword();
}
return implode(", ", $result);
}
This part is not working:
/**
* Transforms a string (keyword) to an object (materialkeyword).
*
* #param string $materialkeyword
* #return MaterialKeyword|null
* #throws TransformationFailedException if object (materialkeyword) is not found.
*/
public function reverseTransform($keywords)
{
// no keyword? It's optional, so that's ok
if (!$keywords) {
return;
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$keyword_array = explode(", ", $keywords);
foreach($keyword_array as $keyword){
$materialkeyword = new MaterialKeyword();
$keyword_entry = $repository->findBy(array('keyword' => $keyword));
if(array_key_exists(0, $keyword_entry)){
$keyword_entry_first = $keyword_entry[0];
}else{
$keyword_entry_first = $keyword_entry;
}
if (null === $keyword_entry_first) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keywords
));
}
$materialkeyword->setKeyword($keyword_entry_first);
}
return $materialkeyword;
}
There will be several keywords, so how do I store them. I tried Arrays and ArrayCollections (new ArrayCollection()) without any success.
The error that I am getting currently with the code above:
Catchable Fatal Error: Argument 1 passed to Doctrine\Common\Collections\ArrayCollection::__construct() must be of the type array, object given, called in /.../vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php on line 605 and defined

TL;DR;
Your reverseTransform function should return an array containing zero or n MaterialKeyword.
It should not return a single MaterialKeyword object because the reverse transformation of MaterialKeyord[] --> string is not string --> MaterialKeyword, it is string --> MaterialKeyword[].
Thinking about this, the doctrine ArrayCollection exception you have make sense as it is trying to do new ArrayCollection(/** Single MaterialKeyword object */) instead of new ArrayCollection(/** Array of MaterialKeyword objects */).

From what you're telling I assume that Material and MaterialKeyword are connected by a ManyToMany association, in which case each Material has an array of MaterialKeyword objects associated to it.
Which means, that your Data Transformer should work with arrays as well, but you're only working with single objects.
Specifically, reverseTransform should return an array of MaterialKeyword objects, whereas you're only returning one (the last one handled in the loop.)
Another issue is that your method created new objects every time, even though $repository->findBy(...) would already return a MaterialKeyword instance. Creating a new object would cause that entry to be copied instead of simply used.
So the correct method might look like this:
public function reverseTransform($keywords)
{
// no keyword? It's optional, so that's ok
if (!$keywords) {
return array();
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$keyword_array = explode(", ", $keywords);
$result_list = array(); // This will contain the MaterialKeyword objects
foreach($keyword_array as $keyword){
$keyword_entry = $repository->findOneBy(array('keyword' => $keyword));
if (null === $keyword_entry) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keyword
));
}
$result_list[] = $keyword_entry;
}
return $result_list;
}

#Hanzi put me on the correct track. It has to be an array of MaterialKeywords objects.
Here is my final working code in class MaterialKeywordTransformer:
/**
* Transforms a string (keyword) to an object (materialkeyword).
*
* #param string $materialkeyword
* #return MaterialKeyword|null
* #throws TransformationFailedException if object (materialkeyword) is not found.
*/
public function reverseTransform($keywords)
{
// keyword are optional, so that's ok
if (!$keywords) {
return;
}
$repository = $this->manager
->getRepository('AppBundle:MaterialKeyword');
$repository_m = $this->manager
->getRepository('AppBundle:Material');
$keyword_array = explode(", ", $keywords);
foreach($keyword_array as $keyword){
$materialkeyword = new MaterialKeyword();
$materialkeyword->setKeyword($keyword);
if($this->opt["data"]->getMaterialID() !== null) {
$materialkeyword->setMaterialID($this->opt["data"]->getMaterialID());
} else {
$material = $repository_m->findOne();
$materialID = $material[0]->getMaterialID();
$materialkeyword->setMaterialID($materialID);
}
$materialkeywords[] = $materialkeyword;
if (null === $keywords) {
throw new TransformationFailedException(sprintf('There is no "%s" exists',
$keywords
));
}
}
return $materialkeywords;
}

Related

GET result empty when using transformer with api platform and symfony

I'm using api platform in symfony (4) and without using a transformer (or rather: without using the output property) I'm getting the correct result.
However as I need to transform a logo (add a path) I need to integrate a transformer. As a result the response is empty.
ApiResource definition in Entity:
/**
*
* #ApiResource(
* collectionOperations = {
* "get"
* },
* normalizationContext={"groups" = {"frontend:read"}},
* itemOperations={
"get"
* },
* order={"name"="ASC"},
* paginationEnabled=false,
* output=EntityApiOutput::class
* )
*/
EntityApiOutput:
class EntityApiOutput
{
public $id;
}
EntityApiOutputDataTransformer:
class EntityApiOutputDataTransformer implements DataTransformerInterface
{
/**
* {#inheritdoc}
*/
public function transform($object, string $to, array $context = [])
{
$eao = new EntityApiOutput();
$eao->id = 3;
return $eao;
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return EntityApiOutput::class === $to && $data instanceof Entity;
}
}
entry in services.yaml:
App\DataTransformer\EntityApiOutputDataTransformer:
tags:
- { name: api_platform.data_transformer }
I simplified the transformer for reading purposes.
Putting a
dump($eao)
exit;
into the transform method confirms that the transformer is called and the EntityApiOutput object is filled.
Mhm unfortunately the api platform doc forgets to mention to also put the group into the output class:
class EntityApiOutput
{
/*
*
* #Groups({"frontend:read"})
*/
public $id;
}
That's how it should look like.

Handling imporper data during deserialization when using Symfony Serializer Component

I am new to the Symfony serializer component. I am trying to properly deserialize a JSON body to the following DTO:
class PostDTO
{
/** #var string */
private $name;
/**
* #return string
*/
public function getName(): string
{
return $this->name;
}
/**
* #param string $name
*/
public function setName(string $name): void
{
$this->name = $name;
}
}
The controller method as follows:
/**
* #Route (path="", methods={"POST"}, name="new_post")
* #param Request $request
* #return Response
*/
public function create(Request $request): Response
{
$model = $this->serializer->deserialize($request->getContent(), PostDTO::class, 'json');
// call the service with the model
return new JsonResponse();
}
My problem is that I wanted to handle business-validation after the body was deserialized. However, if i specify an invalid value for the name, such as false or [], the deserialization will fail with an exception: Symfony\Component\Serializer\Exception\NotNormalizableValueException: "The type of the "name" attribute for class "App\Service\PostDTO" must be one of "string" ("array" given)..
I do understand that it is because I intentionally set "name": []. However, I was looking for a way to set the fields to a default value or even perform some validation pre-deserialization.
I have found the proper way to handle this. That exception was thrown because the serializer was not able to create the PostDTO class using the invalid payload I have provided.
To handle this, I have created my custom denormalizer which kicks in only for this particular class. To do this, I have implemented the DenormalizerInterface like so:
use App\Service\PostDTO;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
class PostDTODeserializer implements DenormalizerInterface
{
/** #var ObjectNormalizer */
private $normalizer;
/**
* PostDTODeserializer constructor.
* #param ObjectNormalizer $normalizer
*/
public function __construct(ObjectNormalizer $normalizer)
{
$this->normalizer = $normalizer;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
return $type === PostDTO::class;
}
/**
* #param mixed $data
* #param string $type
* #param string|null $format
* #return array|bool|object
* #throws ExceptionInterface
*/
public function supportsDenormalization($data, string $type, string $format = null)
{
// validate the array which will be normalized (you should write your validator and inject it through the constructor)
if (!is_string($data['name'])) {
// normally you would throw an exception and leverage the `ErrorController` functionality
// do something
}
// convert the array to the object
return $this->normalizer->denormalize($data, $type, $format);
}
}
If you want to access the context array, you can implement the DenormalizerAwareInterface. Normally, you would create your custom validation and inject it into this denormalizer and validate the $data array.
Please not that I have injected the ObjectNormalizer here so that when the data successfully passed the validation, I can still construct the PostDTO using the $data.
PS: in my case, the autowiring has automatically registered my custom denormalizer. If yours is not autowired automatically, go to services.yaml and add the following lines:
App\Serializer\PostDTODeserializer:
tags: ['serializer.normalizer']
(I have tagged the implementation with serializer.normalizer so as it is recognized during the deserialization pipeline)

symfony many normalizers for one entity

I have several user roles with access to orders and controllers for each of them. Are there ways to change the normalizer for one entity, for example...
in this action i need to get the normalizer for the courier:
## CourierController
/**
* #Rest\Get()
*/
public function orders()
{
$serializer = $this->get('serializer');
$orders = $this->getDoctrine()
->getRepository(Order::class)
->findBy(['courier' => $this->getUser()->getCourierAccount()]);
$data = $serializer->normalize($orders); // <--------- 1) how to choose the right normalizer?
return $this->json($data);
}
But in this i need for something like 'ClientOrderNormalizer'
## ClientController
/**
* #Rest\Get()
*/
public function orders()
{
$serializer = $this->get('serializer');
$orders = $this->getDoctrine()
->getRepository(Order::class)
->findBy(['client' => $this->getUser()->getClientAccount()]);
$data = $serializer->normalize($orders); // <--------- 2) how to choose the right normalizer?
return $this->json($data);
}
1) Using Serialization Groups Annotations -> look here
2) Create custom DTO then serialize this object for a response.
Example of how to create custom DTO.
final class SignInResponse implements ResponseInterface
{
/**
* #var string
*
* #SWG\Property(type="string", description="Token.")
*/
private string $token;
public function __construct(UserSession $userSession)
{
$this->token = $userSession->getToken();
}
/**
* Get Token
*
* #return string
*/
public function getToken(): string
{
return $this->token;
}
}

Add dynamic property on entity to be serialized

I have this REST API. Whenever request comes to get a resource by id ( /resource/{id}) I want to add a permissions array on that object on the fly (entity itself does not have that field).
What I came up with is this event listener. It checks the result the controller has returned:
class PermissionFinderListener {
...
public function onKernelView(GetResponseForControllerResultEvent $event) {
$object = $event->getControllerResult();
if (!is_object($object) || !$this->isSupportedClass($object)) {
return;
}
$permissions = $this->permissionFinder->getPermissions($object);
$object->permissions = $permissions;
$event->setControllerResult($object);
}
....
}
The problem is that the JMS Serializer opts out this dynamic property on serialization. I tried making the onPostSerialize event subscriber on JMS serializer, but then there are no clear way to check if this is a GET ONE or GET COLLECTION request. I don't need this behaviour on GET COLLECTION and also it results a huge performance hit on collection serialization. Also I don't want to create any base entity class with permission property.
Maybe there is some other way to deal with this scenario?
What I could imagine is a combination of Virtual Property and Serialization Group:
Add a property to your entity like:
/**
* #Serializer\VirtualProperty
* #Serializer\SerializedName("permissions")
* #Serializer\Groups({"includePermissions"}) */
*
* #return string
*/
public function getPermissions()
{
return $permissionFinder->getPermissions($this);
}
Only thing you need to do then is to serialize 'includePermissions' group only in your special case (see http://jmsyst.com/libs/serializer/master/cookbook/exclusion_strategies)
If you don't have access to $permissionFinder from your entity you could as well set the permission attribute of an entity from a Controller/Service before serializing it.
EDIT:
This is a bit more code to demonstrate what I mean by wrapping your entity and using VirtualProperty together with SerializationGroups. This code is not tested at all - it's basically a manually copied and stripped version of what we're using. So please use it just as an idea!
1) Create something like a wrapping class for your entity:
<?php
namespace Acquaim\ArcticBundle\Api;
use JMS\Serializer\Annotation as JMS;
/**
* Class MyEntityApi
*
* #package My\Package\Api
*/
class MyEntityApi
{
/**
* The entity which is wrapped
*
* #var MyEntity
* #JMS\Include()
*/
protected $entity;
protected $permissions;
/**
* #param MyEntity $entity
* #param Permission[] $permissions
*/
public function __construct(
MyEntity $entity,
$permissions = null)
{
$this->entity = $entity;
$this->permissions = $permissions;
}
/**
* #Serializer\VirtualProperty
* #Serializer\SerializedName("permissions")
* #Serializer\Groups({"includePermissions"})
*
* #return string
*/
public function getPermissions()
{
if ($this->permissions !== null && count($this->permissions) > 0) {
return $this->permissions;
} else {
return null;
}
}
/**
* #return object
*/
public function getEntity()
{
return $this->entity;
}
}
2) In your controller don't return your original Entity, but get your permissions and create your wrapped class with entity and permissions.
Set your Serialization Context to include permissions and let the ViewHandler return your serialized object.
If you don't set Serialization Context to includePermissions it will be excluded from the serialized result.
YourController:
$myEntity = new Entity();
$permissions = $this->get('permission_service')->getPermissions();
$context = SerializationContext::create()->setGroups(array('includePermissions'));
$myEntityApi = new MyEntityApi($myEntity,$permissions);
$view = $this->view($myEntityApi, 200);
$view->setSerializationContext($context);
return $this->handleView($view);

Symfony2 and ParamConverter(s)

Accessing my route /message/new i'm going to show a form for sending a new message to one or more customers. Form model has (among others) a collection of Customer entities:
class MyFormModel
{
/**
* #var ArrayCollection
*/
public $customers;
}
I'd like to implement automatic customers selection using customers GET parameters, like this:
message/new?customers=2,55,543
This is working now by simply splitting on , and do a query for getting customers:
public function newAction(Request $request)
{
$formModel = new MyFormModel();
// GET "customers" parameter
$customersIds = explode($request->get('customers'), ',');
// If something was found in "customers" parameter then get entities
if(!empty($customersIds)) :
$repo = $this->getDoctrine()->getRepository('AcmeHelloBundle:Customer');
$found = $repo->findAllByIdsArray($customersIds);
// Assign found Customer entities
$formModel->customers = $found;
endif;
// Go on showing the form
}
How can i do the same using Symfony 2 converters? Like:
public function newAction(Request $request, $selectedCustomers)
{
}
Answer to my self: there is not such thing to make you life easy. I've coded a quick and dirty (and possibly buggy) solution i'd like to share, waiting for a best one.
EDIT WARNING: this is not going to work with two parameter converters with the same class.
Url example
/mesages/new?customers=2543,3321,445
Annotations:
/**
* #Route("/new")
* #Method("GET|POST")
* #ParamConverter("customers",
* class="Doctrine\Common\Collections\ArrayCollection", options={
* "finder" = "getFindAllWithMobileByUserQueryBuilder",
* "entity" = "Acme\HelloBundle\Entity\Customer",
* "field" = "id",
* "delimiter" = ",",
* }
* )
*/
public function newAction(Request $request, ArrayCollection $customers = null)
{
}
Option delimiter is used to split GET parameter while id is used for adding a WHERE id IN... clause. There are both optional.
Option class is only used as a "signature" to tell that converter should support it. entity has to be a FQCN of a Doctrine entity while finder is a repository method to be invoked and should return a query builder (default one provided).
Converter
class ArrayCollectionConverter implements ParamConverterInterface
{
/**
* #var \Symfony\Component\DependencyInjection\ContainerInterface
*/
protected $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
function apply(Request $request, ConfigurationInterface $configuration)
{
$name = $configuration->getName();
$options = $this->getOptions($configuration);
// Se request attribute to an empty collection (as default)
$request->attributes->set($name, new ArrayCollection());
// If request parameter is missing or empty then return
if(is_null($val = $request->get($name)) || strlen(trim($val)) === 0)
return;
// If splitted values is an empty array then return
if(!($items = preg_split('/\s*'.$options['delimiter'].'\s*/', $val,
0, PREG_SPLIT_NO_EMPTY))) return;
// Get the repository and logged user
$repo = $this->getEntityManager()->getRepository($options['entity']);
$user = $this->getSecurityContext->getToken()->getUser();
if(!$finder = $options['finder']) :
// Create a new default query builder with WHERE user_id clause
$builder = $repo->createQueryBuilder('e');
$builder->andWhere($builder->expr()->eq("e.user", $user->getId()));
else :
// Call finder method on repository
$builder = $repo->$finder($user);
endif;
// Edit the builder and add WHERE IN $items clause
$alias = $builder->getRootAlias() . "." . $options['field'];
$wherein = $builder->expr()->in($alias, $items);
$result = $builder->andwhere($wherein)->getQuery()->getResult();
// Set request attribute and we're done
$request->attributes->set($name, new ArrayCollection($result));
}
public function supports(ConfigurationInterface $configuration)
{
$class = $configuration->getClass();
// Check if class is ArrayCollection from Doctrine
if('Doctrine\Common\Collections\ArrayCollection' !== $class)
return false;
$options = $this->getOptions($configuration);
$manager = $this->getEntityManager();
// Check if $options['entity'] is actually a Dcontrine one
try
{
$manager->getClassMetadata($options['entity']);
return true;
}
catch(\Doctrine\ORM\Mapping\MappingException $e)
{
return false;
}
}
protected function getOptions(ConfigurationInterface $configuration)
{
return array_replace(
array(
'entity' => null,
'finder' => null,
'field' => 'id',
'delimiter' => ','
),
$configuration->getOptions()
);
}
/**
* #return \Doctrine\ORM\EntityManager
*/
protected function getEntityManager()
{
return $this->container->get('doctrine.orm.default_entity_manager');
}
/**
* #return \Symfony\Component\Security\Core\SecurityContext
*/
protected function getSecurityContext()
{
return $this->container->get('security.context');
}
}
Service definition
arraycollection_converter:
class: Acme\HelloBundle\Request\ArrayCollectionConverter
arguments: ['#service_container']
tags:
- { name: request.param_converter}
It's late, but according to latest documentation about #ParamConverter, you can achieve it follow way:
* #ParamConverter("users", class="AcmeBlogBundle:User", options={
* "repository_method" = "findUsersByIds"
* })
you just need make sure that repository method can handle comma (,) separated values

Resources