Dtos, mapeo y profundidad máxima de las relaciones
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeDirígete a /api/users.jsonld para ver... una referencia circular procedente del serializador. ¡Caramba! Pensemos: API Platform serializa todo lo que devolvemos del proveedor de estado. Así que dirígete a .... y busca dónde se crea la colección. Vuelca los DTOs. Esto es lo que se está serializando, así que el problema debe estar aquí.
| // ... lines 1 - 14 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 17 - 25 | |
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
| { | |
| // ... line 28 | |
| if ($operation instanceof CollectionOperationInterface) { | |
| // ... lines 30 - 36 | |
| dd($dtos); | |
| // ... lines 38 - 44 | |
| } | |
| // ... lines 46 - 53 | |
| } | |
| // ... lines 55 - 59 | |
| } |
Actualiza y... ninguna sorpresa: vemos 5 objetos UserApi. Ah, pero aquí está el problema: el campo dragonTreasures contiene una matriz de objetos de entidad DragonTreasure... y cada uno tiene un owner que apunta a una entidad User... y que vuelve a apuntar a una colección de entidades DragonTreasure... lo que hace que el serializador serialice eternamente. ¡Pero ése ni siquiera es el verdadero problema! Lo sé, estoy lleno de buenas noticias. El verdadero problema es que el objeto UserApi debería referirse realmente a una entidad DragonTreasureApi, no a una DragonTreasure.
En UserApi, éste será ahora un array de DragonTreasureApi. Una vez que empecemos a seguir la ruta de los DTO, para conseguir la máxima fluidez, deberíamos relacionar los DTO con otros DTO... en lugar de mezclarlos con entidades.
| // ... lines 1 - 42 | |
| class UserApi | |
| { | |
| // ... lines 45 - 61 | |
| /** | |
| * @var array<int, DragonTreasureApi> | |
| */ | |
| (writable: false) | |
| public array $dragonTreasures = []; | |
| // ... lines 67 - 69 | |
| } |
Para rellenar los objetos DTO, ve al mapeador: UserEntityToApiMapper. Aquí abajo, en dragonTreasures, ya no podemos hacerlo porque eso nos dará objetos entidadDragonTreasure. Lo que queremos hacer básicamente es convertir deDragonTreasure a DragonTreasureApi. Así que, una vez más, ¡el micromapeador al rescate!
Micro-Mapeo DragonTreasure -> DragonTreasureApi
Añade public function __construct() con private MicroMapperInterface $microMapper. Aquí abajo, añade algo de código extravagante: $dto->dragonTreasures = ajustado a array_map(), con una función que tiene un argumento DragonTreasure. Lo terminaremos en un segundo... pero primero pasa el array sobre el que hará el bucle:$entity->getPublishedDragonTreasures()->toArray().
Así que: obtenemos un array de los objetos publicados DragonTreasure y PHP hace un bucle sobre ellos y llama a nuestra función para cada uno - pasándole elDragonTreasure. Lo que devolvamos se convertirá en un elemento dentro de una nueva matriz que se establece en dragonTreasures. Y lo que queremos devolver es un objeto DragonTreasureApi. Hazlo con$this->microMapper->map($dragonTreasure, DragonTreasureApi::class).
| // ... lines 1 - 4 | |
| use App\ApiResource\DragonTreasureApi; | |
| // ... line 6 | |
| use App\Entity\DragonTreasure; | |
| // ... lines 8 - 10 | |
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |
| // ... lines 12 - 13 | |
| class UserEntityToApiMapper implements MapperInterface | |
| { | |
| public function __construct( | |
| private MicroMapperInterface $microMapper, | |
| ) | |
| { | |
| } | |
| // ... lines 21 - 41 | |
| $dto->dragonTreasures = array_map(function(DragonTreasure $dragonTreasure) { | |
| return $this->microMapper->map($dragonTreasure, DragonTreasureApi::class); | |
| }, $entity->getPublishedDragonTreasures()->getValues()); | |
| // ... lines 45 - 47 | |
| } | |
| } |
Relaciones circulares
¡Genial! Cuando actualizamos para probarlo... nos encontramos con un problema de referencia circular diferente. ¡Qué divertido! Éste viene de MicroMapper... y es un problema que ocurrirá siempre que tengas relaciones que hagan referencia unas a otras.
Piénsalo: pedimos a Micro Mapper que convierta una entidad DragonTreasure enDragonTreasureApi. Sencillo. Para ello, utiliza nuestro mapeador. ¿Y adivina qué? En nuestro mapeador, le pedimos que convierta la owner -una entidad User - en una instancia deUserApi. Para ello, el micro mapeador vuelve a UserEntityToApiMapper y... el proceso se repite. Estamos en un bucle: para convertir una entidad User, necesitamos convertir una entidad DragonTreasure... lo que significa que necesitamos convertir su owner... que es esa misma entidad User.
Establecer la profundidad del mapeo
La solución está en tu mapeador, cuando llamas a la función map(). Pasa un tercer argumento, que es un "contexto"... una especie de matriz de opciones. Puedes pasar lo que quieras, pero Micro Mapper sólo tiene una opción que le interese. Pon MicroMapperInterface::MAX_DEPTH a 1.
| // ... lines 1 - 4 | |
| use App\ApiResource\DragonTreasureApi; | |
| // ... line 6 | |
| use App\Entity\DragonTreasure; | |
| // ... lines 8 - 10 | |
| use Symfonycasts\MicroMapper\MicroMapperInterface; | |
| // ... lines 12 - 13 | |
| class UserEntityToApiMapper implements MapperInterface | |
| { | |
| public function __construct( | |
| private MicroMapperInterface $microMapper, | |
| ) | |
| { | |
| } | |
| // ... lines 21 - 41 | |
| $dto->dragonTreasures = array_map(function(DragonTreasure $dragonTreasure) { | |
| return $this->microMapper->map($dragonTreasure, DragonTreasureApi::class, [ | |
| MicroMapperInterface::MAX_DEPTH => 1, | |
| ]); | |
| }, $entity->getPublishedDragonTreasures()->getValues()); | |
| // ... lines 47 - 49 | |
| } | |
| } |
Veamos qué hace eso. Cuando actualizamos... mira el volcado, que viene del proveedor de estado. Mapea las entidades User a objetos UserApi... y vemos 5. También podemos ver que la propiedad dragonTreasures se rellena con objetosDragonTreasureApi. Así que ha realizado el mapeo de DragonTreasure aDragonTreasureApi. Pero cuando fue a mapear el owner de ese DragonTreasurea un UserApi, está ahí... pero está vacío. Es un mapeo superficial.
Cuando pasamos MAX_DEPTH => 1, estamos diciendo:
¡Eh! Quiero que mapees completamente esta entidad
DragonTreasureaDragonTreasureApi. Esa es la profundidad 1. Pero si se vuelve a llamar al micro mapeador para mapear más profundamente, sáltate eso.
Bueno, no saltar exactamente. Cuando se llama al mapeador la 2ª vez para mapear la entidadUser a UserApi, se llama al método load() de ese mapeador... pero no a populate(). Así que acabamos con un objeto UserApi con un id... pero nada más. Eso soluciona nuestro bucle circular. Y, en realidad, no nos importa que la propiedad ownersea un objeto vacío... ¡porque nuestro JSON nunca se renderiza tan profundamente!
Observa. Elimina el dd() para que podamos ver los resultados. Y... ¡perfecto! ¡El resultado es exactamente el esperado! Para DragonTreasures, sólo estamos mostrando la IRI.
Así que, por regla general, cuando llames a un micro mapeador desde dentro de una clase mapeadora, probablemente querrás establecer MAX_DEPTH en 1. ¡Diablos, podríamos establecer MAX_DEPTH en 0! Aunque la única razón para hacerlo sería una ligera mejora del rendimiento.
Esta vez, cuando mapeemos $dragonTreasure a DragonTreasureApi, prueba con MAX_DEPTH => 0.
| // ... lines 1 - 13 | |
| class UserEntityToApiMapper implements MapperInterface | |
| { | |
| // ... lines 16 - 32 | |
| public function populate(object $from, object $to, array $context): object | |
| { | |
| // ... lines 35 - 41 | |
| $dto->dragonTreasures = array_map(function(DragonTreasure $dragonTreasure) { | |
| return $this->microMapper->map($dragonTreasure, DragonTreasureApi::class, [ | |
| MicroMapperInterface::MAX_DEPTH => 0, | |
| ]); | |
| }, $entity->getPublishedDragonTreasures()->getValues()); | |
| // ... lines 47 - 49 | |
| } | |
| } |
Esto hará que la profundidad sea golpeada inmediatamente. Cuando vaya a mapear la entidad DragonTreasure a DragonTreasureApi, utilizará el mapeador, pero sólo llamará al método load(). El método populate() nunca será llamado. Vuelve a colocar el dd(). Lo que obtenemos es un objeto superficial para DragonTreasureApi.
Esto puede parecer raro, pero técnicamente está bien... porque esta matriz dragonTreasuresse va a representar como cadenas IRI... y lo único que necesita API Platform para construir ese IRI es... ¡el id! ¡Compruébalo! Elimina el volcado y vuelve a cargar la página. Tiene exactamente el mismo aspecto. Acabamos de ahorrarnos un poquito de trabajo.
Así que, para ir sobre seguro -en caso de que incrustes el objeto- utiliza MAX_DEPTH => 1. Pero si sabes que estás utilizando IRIs, puedes poner MAX_DEPTH en 0.
Por aquí, hagamos lo mismo: MicroMapperInterface::MAX_DEPTH puesto a 0 porque sabemos que aquí también sólo mostramos el IRI.
| // ... lines 1 - 13 | |
| class DragonTreasureEntityToApiMapper implements MapperInterface | |
| { | |
| // ... lines 16 - 33 | |
| public function populate(object $from, object $to, array $context): object | |
| { | |
| // ... lines 36 - 41 | |
| $dto->owner = $this->microMapper->map($entity->getOwner(), UserApi::class, [ | |
| MicroMapperInterface::MAX_DEPTH => 0, | |
| ]); | |
| // ... lines 45 - 52 | |
| } | |
| } |
Forzar una matriz JSON
Otra cosa que habrás notado es que dragonTreasures de repente parece un objeto, con sus corchetes en lugar de corchetes. Bueno, en PHP es un array - array_map devuelve un array con la clave 0 establecida en algo y la clave 2 establecida en algo. Pero debido a que falta la clave 1, cuando se serializa a JSON parece una matriz asociativa, o un "objeto" en JSON.
Si cambiamos toArray() por getValues() y actualizamos la página... ¡perfecto! Volvemos a tener una matriz normal de elementos.
Siguiente: Podemos leer de nuestro nuevo recurso DragonTreasureApi, pero aún no podemos escribir en él. Creemos un DragonTreasureApiToEntityMapper y volvamos a añadir cosas como la seguridad y la validación.
10 Comments
Hi,
Using
$entities = $this->collectionProvider->provide(...)causes an N+1 query problem: Doctrine lazily loads associations per entity, leading to many queries and slow performance.So, Icheck if the repository implements EagerLoadingRepositoryInterface and, if so, fetch paginated entities with relations eager-loaded. Map them to DTOs and return via TraversablePaginator. Otherwise, fall back to the default provider.
Repository
Is this a good solution, or can it be optimized further?
Thnks
Hey Anas,
I think you're on the right track! I have never do this myself but it sounds logical to me, and if it helped you with that N+1 problem - great job!
But if your goal is generic performance improvement, I would suggest you to take a look into API Platform’s built-in
EagerLoadingExtensionand only disable lazy joins where really needed. So yes, your solution works and is valid, but the optimized API Platform way is usually to let itsEagerLoadingExtensionhandle this, and only override with a customQueryCollectionExtensionif you need fine-grained control.I hope that helps!
Cheers!
Hi,
Apologies, I'm relatively new to Symfony/API Platform, so my terminology maybe slightly off. But I have been following this tutorial and creating my own API, and I am having an issue whereby when using
DTOs, aManyToManyrelationship is being returned as the fully hydrated model, not theIRIsas I would of expected.Broadly, this is an example what I have put in place (I have removed the mapper from the provider etc to make this as stripped back as possible, so what is being done is minimal):
The test provider just wraps the Doctrine Provider and maps the returned entity to the
BookDTO.The
ownerwill come back as expected, but theauthorswill come back fully hydrated, and not be converted to IRIs. I'm not sure why it works fine for the owner but not authors:Note that if I tag the related Doctrine Entities with
APIResourceit all works perfectly. Also note that theOneToManyon the model seems to be working as expected as well.I did originally have a
UserDTObut have stripped it out to make the example simpler (and to try and get this working with the Doctrine entity first).Any help would be greatly appreciated, I'm sure I'm doing something basic wrong.
Hey @jorganson!
This is almost certainly too late to help, but here we go anyway!
I'm not sure about this, but generally speaking: if you have a relation AND it doesn't contain any groups that your serializing the paren with AND the related class is an
#[ApiResource]you should get an IRI.Sorry for such a slow reply!
Being in this chapter of the course, my already tired head tells me that if I want to add custom data that comes from business logic operations, I would have to do it here in the created Mapper, injecting the service that performs the logic I need and then assign it in populate() as it is done for example with
$dto->isMine = $this->security->getUser() && $this->security->getUser() === $entity->getOwner();<br />Am I right ? or better somewhere else ?
Hey @Rodrypaladin!
I'd say that you're right. Technically, the place for this stuff a would be the data provider. But we've purposely made our provider generic & reusable: pushing all of the specific details into the mapper. So this makes the mapper, in a sense, the data provider code that's specific to just one ApiResource and thus the place I'd add the logic.
tl;dr yes, I agree with you!
Cheers!
Hello, how can we ensure that in the following example we at least have the entire nested object?
I can't seem to get more than an array of URIs, even when increasing MAX_DEPTH to 2 or more.
I would like to get the top level information for each dragon, including the road collection.
Unfortunately, I figured out that the annotation /** @var */ does not seems to work.
You might try with this, it's more verbose, but it definitely solves all my issues with serialization :
(readableLink allows to serialize relation as an entity, writeableLink allows to create new entities through relations in a Post, you might not need this one here)
Hope this helps :)
@Jeremy it works if you add the variable
$dragonTreasuresname to the@varannotation. Then you don't need the#[ApiProperty]directive.Hey @Billy!
The
MAX_DEPTHwill make sure that the embedded PHP objects have their data. But the determination of whether those embedded PHP objects will become an IRI or embedded data in the JSON is done by the serialization groups. What's annoying is that, with DTO's, you don't need serialization groups: your DTO only holds the fields that will be in your API. But... if you have an embedded object that is also an#[ApiResource]and you want it to be returned as embedded data in JSON, then you need to use serialization groups to opt into this: https://symfonycasts.com/screencast/api-platform/embedded#embedding-vs-iri-via-normalization-groupsBtw, another option that you might choose is to embed an object in your DTO that is... just a random object, and not an
#[ApiResource]. In this case, the data is embedded always - https://symfonycasts.com/screencast/api-platform-extending/embedded-objectCheers!
"Houston: no signs of life"
Start the conversation!