symfony many normalizers for one entity - symfony

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;
}
}

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.

How can i limit the number of nested entities in API Platform?

Having two related entities, let's say Author and Book, I can limit (or paginate) the results of Authors but not the number of results of its related entity Books which always shows the whole collection.
The issue is that Authors may have hundreds of Books making the resulting JSON huge and heavy to parse so I'm trying to get, for example, only the last 5 books.
I'm sure I'm missing something since I think this is probably a common scenario but I can't find anything on the docs nor here in StackOverflow.
I'm starting with Api Platform, any hint would be appreciated!
I finally solved it creating a normalizer for the entity but I still think that it has to be a simpler solution.
Here's what I had to do, following the Authors / Books example:
Add a setter to the Author entity to override the Author's Book collection:
// src/Entity/Author.php
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
// ...
/**
* #ApiResource
* #ORM\Entity(repositoryClass="App\Repository\AuthorRepository")
*/
class Author
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
*/
private $name;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Book", mappedBy="author", orphanRemoval=true)
*/
private $books;
public function __construct()
{
$this->books = new ArrayCollection();
}
// Getters and setters
//...
public function setBooks($books): self
{
$this->books = $books;
return $this;
}
}
Create a normalizer for the Author's entity:
// App/Serializer/Normalizer/AuthorNormalizer.php
<?php
namespace App\Serializer\Normalizer;
use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
class AuthorNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
use SerializerAwareTrait;
private $normalizer;
public function __construct(
NormalizerInterface $normalizer,
IriConverterInterface $iriConverter
) {
if (!$normalizer instanceof DenormalizerInterface) {
throw new \InvalidArgumentException('The normalizer must implement the DenormalizerInterface');
}
if (!$normalizer instanceof AbstractItemNormalizer) {
throw new \InvalidArgumentException('The normalizer must be an instance of AbstractItemNormalizer');
}
$handler = function ($entity) use ($iriConverter) {
return $iriConverter->getIriFromItem($entity);
};
$normalizer->setMaxDepthHandler($handler);
$normalizer->setCircularReferenceHandler($handler);
$this->normalizer = $normalizer;
}
public function denormalize($data, $class, $format = null, array $context = [])
{
return $this->normalizer->denormalize($data, $class, $format, $context);
}
public function supportsDenormalization($data, $type, $format = null)
{
return $this->normalizer->supportsDenormalization($data, $type, $format);
}
public function normalize($object, $format = null, array $context = [])
{
// Number of desired Books to list
$limit = 2;
$newBooksCollection = new ArrayCollection();
$books = $object->getBooks();
$booksCount = count($books);
if ($booksCount > $limit) {
// Reverse iterate the original Book collection as I just want the last ones
for ($i = $booksCount; $i > $booksCount - $limit; $i--) {
$newBooksCollection->add($books->get($i - 1));
}
}
// Setter previously added to the Author entity to override its related Books
$object->setBooks($newBooksCollection);
$data = $this->normalizer->normalize($object, $format, $context);
return $data;
}
public function supportsNormalization($data, $format = null)
{
return $data instanceof \App\Entity\Author;
}
}
And finally register the normalizer as a service manually (using autowire led me to circular reference issues):
services:
App\Serializer\Normalizer\AuthorNormalizer:
autowire: false
autoconfigure: true
arguments:
$normalizer: '#api_platform.jsonld.normalizer.item'
$iriConverter: '#ApiPlatform\Core\Api\IriConverterInterface'

Symfony2 data transformer string to entity (reverseTransform)

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;
}

Symfony2 OneToMany does not use custom repository class

I have to tables
preset_item
id
and
preset_item_element
preset_item_id -> reference to prese_item
element_type
element_id
in PresetItemElement entity there is:
/**
* #var \GGG\ManagerBundle\Entity\PresetItem
*
* #ORM\ManyToOne(targetEntity="GGG\ManagerBundle\Entity\PresetItem", inversedBy="elements")
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="preset_item_id", referencedColumnName="id")
* })
*/
private $presetItem;
and in PresetItem
/**
* #ORM\OneToMany(targetEntity="PresetItemElement", mappedBy="presetItem")
*/
private $elements;
public function getElements()
{
return $this->elements;
}
And preset Item have custom Repository class:
/**
* PresetItem
*
* #ORM\Table(name="preset_item")
* #ORM\Entity(repositoryClass="GGG\ManagerBundle\Entity\PresetItemRepository")
*/
class PresetItem
it's looks like that:
<?php
namespace GGG\ManagerBundle\Entity;
use Doctrine\ORM\EntityRepository;
use Doctrine\DBAL\LockMode;
class PresetItemElementRepository extends EntityRepository
{
public function findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) {
return $this->decorate(parent::findBy($criteria, $orderBy, $limit, $offset));
}
public function find($id, $lockMode = LockMode::NONE, $lockVersion = null) {
return $this->decorateElement(parent::find($id, $lockMode, $lockVersion));
}
public function findOneBy(array $criteria, array $orderBy = null) {
return $this->decorateElement(parent::findOneBy($criteria, $orderBy));
}
private function decorateElement($element) {
$object = $this->getEntityManager()
->getRepository(
'GGGManagerBundle:'.$element
->getPresetItemElementType()
->getRepresentationObject()
)->findOneBy(array('id' => $element->getElementId()));
$element->setObject($object);
}
private function decorate($elements) {
foreach($elements as $element) {
$this->decorateElement($element);
}
return $elements;
}
}
So i'm decorating each PresetItemElement with some additional data
and it's work when i;m getting single PresetItemElement object but when i'm getting PresetItem and try iterate getElements()
$entity = $em->getRepository('GGGManagerBundle:PresetItem')->find($id);
foreach($entity->getElements() as $a) {
var_dump($a->getObject());
}
i have null here, it looks like custom PresetItemElementRepository was not executed
What i do wrong?
Doctrine doesn't use repositories internally when loading related entities. Also, when using for example a query or the query builder to retrieve entities, the repository wouldn't be used either.
To do something every time an entity is loaded from the database, you should register an event handler for the postLoad event. See http://symfony.com/doc/current/cookbook/doctrine/event_listeners_subscribers.html for how to write and register event listeners, and http://docs.doctrine-project.org/en/latest/reference/events.html#lifecycle-events for the available lifecycle events.

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