Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Crear objetos incrustados

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

En lugar de asignar un CheeseListing existente al usuario, ¿podríamos crear uno totalmente nuevo incrustando sus datos? ¡Vamos a averiguarlo!

Esta vez, no enviaremos una cadena IRI, sino un objeto de datos. Veamos... necesitamos un title y... Haré trampa y miraré la ruta POST para los quesos. Bien: necesitamos title, price owner y description. Establece priceen 20 dólares y pasa un description. Pero no voy a enviar una propiedad owner. ¿Por qué? Bueno... olvídate de la Plataforma API e imagina que utilizas esta API. Si enviamos una petición POST a /api/users para crear un nuevo usuario... ¿no es bastante obvio que queremos que el nuevo listado de quesos sea propiedad de este nuevo usuario? Por supuesto, es nuestro trabajo hacer que esto funcione realmente, pero así es como yo querría que funcionara.

Ah, y antes de que lo intentemos, cambia el email y el username para asegurarte de que son únicos en la base de datos.

¿Preparado? ¡Ejecuta! ¡Funciona! No, no, estoy mintiendo: no es tan fácil. Tenemos un error conocido:

No se permiten documentos anidados para el atributo "cheeseListings". Utiliza en su lugar IRIs.

Permitir que los cheeseListings incrustados se desnormalicen

Bien, retrocedamos. El campo cheeseListings es escribible en nuestra API porque la propiedad cheeseListings tiene el grupo user:write encima. Pero si no hiciéramos nada más, esto significaría que podemos pasar una matriz de IRIs a esta propiedad, pero no un objeto JSON de datos incrustados.

Para permitirlo, tenemos que entrar en CheeseListing y añadir ese grupo user:write a todas las propiedades que queramos permitir pasar. Por ejemplo, sabemos que, para crear un CheeseListing, necesitamos poder establecer title,description y price. Así que, ¡añadamos ese grupo! user:write por encima de title,price y... aquí abajo, busca setTextDescription()... y añádelo ahí.

... lines 1 - 39
class CheeseListing
{
... lines 42 - 48
/**
... line 50
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"})
... lines 52 - 57
*/
private $title;
... lines 60 - 67
/**
... lines 69 - 71
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"})
... line 73
*/
private $price;
... lines 76 - 134
/**
... lines 136 - 137
* @Groups({"cheese_listing:write", "user:write"})
... line 139
*/
public function setTextDescription(string $description): self
... lines 142 - 197
}

Me encanta lo limpio que es elegir los campos que quieres que se incrusten... pero la vida se complica. Ten en cuenta ese coste de "complejidad" si decides admitir este tipo de cosas en tu API

Persistencia en cascada

En cualquier caso, ¡probemos! Ooh - un error 500. ¡Estamos más cerca! ¡Y también conocemos este error!

Se ha encontrado una nueva entidad a través de la relación User.cheeseListings que no estaba configurada para persistir en cascada.

¡Excelente! Esto me dice que la Plataforma API está creando un nuevo CheeseListingy lo está configurando en la propiedad cheeseListings del nuevo User. Pero nada llama a $entityManager->persist() en ese nuevo CheeseListing, por lo que Doctrine no sabe qué hacer cuando intenta guardar el Usuario.

Si se tratara de una aplicación Symfony tradicional, en la que yo escribiera personalmente el código para crear y guardar estos objetos, probablemente me limitaría a encontrar dónde se está creando ese CheeseListingy llamaría a $entityManager->persist() sobre él. Pero como la Plataforma API se encarga de todo eso por nosotros, podemos utilizar una solución diferente.

Abre User, busca la propiedad $cheeseListings, y añade cascade={"persist"}. Gracias a esto, cada vez que se persista un User, Doctrine persistirá automáticamente cualquier objeto CheeseListing en esta colección.

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"})
... line 61
*/
private $cheeseListings;
... lines 64 - 184
}

Bien, veamos qué ocurre. ¡Ejecuta! Woh, ¡ha funcionado! Esto ha creado un nuevo User, un nuevo CheeseListing y los ha vinculado en la base de datos.

Pero, ¿quién estableció CheeseListing.owner?

Pero... ¿cómo sabía Doctrine... o la Plataforma API que debía establecer la propiedad owner del nuevo CheeseListing en el nuevo User... si no pasábamos una clave owner en el JSON? Si creas un CheeseListing de la forma normal, ¡es totalmente necesario!

Esto funciona... no por ninguna plataforma de la API ni por la magia de Doctrine, sino gracias a un código bueno, anticuado y bien escrito en nuestra entidad. Internamente, el serializador instala un nuevo CheeseListing, le pone datos y luego llama a$user->addCheeseListing(), pasando ese nuevo objeto como argumento. Y ese código se encarga de llamar a$cheeseListing->setOwner() y establecerlo en $thisUsuario. Me encanta eso: nuestro código generado de make:entity y el serializador están trabajando juntos. ¿Qué va a funcionar? ¡El trabajo en equipo!

Validación incrustada

Pero, al igual que cuando incrustamos los datos de owner al editar un CheeseListing, cuando permites que se cambien o creen recursos incrustados como éste, tienes que prestar especial atención a la validación. Por ejemplo, cambia los email y usernamepara que vuelvan a ser únicos. Ahora se trata de un usuario válido. Pero establece el title delCheeseListing a una cadena vacía. ¿La validación detendrá esto?

No Ha permitido que el CheeseListing se guarde sin título, ¡a pesar de que tenemos la validación para evitarlo! Esto se debe a que, como hemos hablado antes, cuando el validador procesa el objeto User, no baja automáticamente a la matriz cheeseListings y valida también esos objetos. Puedes forzarlo añadiendo @Assert\Valid().

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
... lines 60 - 61
* @Assert\Valid()
*/
private $cheeseListings;
... lines 65 - 185
}

Asegurémonos de que eso ha servido de algo: vuelve a subir, haz que los objetos email y usernamevuelvan a ser únicos y... ¡Ejecuta! ¡Perfecto! Un código de estado 400 porque

el campo cheeseListings[0].title no debería estar en blanco.

Vale, hemos hablado de cómo añadir nuevos listados de quesos a un usuario, ya sea pasando el IRI de un CheeseListing existente o incrustando datos para crear un nuevoCheeseListing. Pero, ¿qué pasaría si un usuario tuviera 2 listados de quesos... y realizáramos una petición para editar ese User... y sólo incluyéramos el IRI de uno de esos listados? Eso debería... eliminar el CheeseListing que le falta al usuario, ¿no? ¿Funciona? Y si es así, ¿pone el owner de ese CheeseListing en cero? ¿O lo elimina por completo? ¡Busquemos algunas respuestas a continuación!

Leave a comment!

15
Login or Register to join the conversation
Musa Avatar

Just a little side-note if anyone runs into the same issue I did.
The owning side must contain all autogenerated Entity field methods for this to work (at least addEntity and removeEntity method).
In my use case removeEntity was just in the way due to the setEntity(null) issue on required relation.

Expected behaviour (IMO) was for doctrine to complain about cascade persist (2:49) or tell me a required function is missing, but instead it ignored the addEntity method all together and threw no errors.

Reply
Andrew M. Avatar
Andrew M. Avatar Andrew M. | posted hace 11 meses

I'm getting "Cannot create metadata for non-objects." I'm using Symfony 5 here. Not sure where to go with this, Google searches are not helping.

Reply

Hey Andy Myers!

Hmm, this is a new one for me too! It looks like this is probably coming from deep in the serializer. Do you have a stack trace on this? What request are you making (e.g. a GET request to some object)? Is there any way to see the data that's being serialized?

Cheers!

Reply
Andrew M. Avatar

Here's the request:


curl -X 'POST' \
'https://127.0.0.1:8000/api/users' \
-H 'accept: application/ld+json' \
-H 'Content-Type: application/ld+json' \
-d '{
"email": "use065465r@example.com",
"password": "654654",
"username": "2254455",
"cheeseListings": [
{
"title": "Cheese 654654",
"price": 6587,
"description": "descknjdn"
}
]
}'
Reply
Andrew M. Avatar

Hi, thanks for the reply. Here's the full trace; it's pretty long.

{
"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Cannot create metadata for non-objects. Got: \"string\".",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 653,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateGenericNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 516,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateClassNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 313,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateObject",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 138,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveValidator.php",
"line": 93,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/TraceableValidator.php",
"line": 66,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "TraceableValidator",
"class": "Symfony\\Component\\Validator\\Validator\\TraceableValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Bridge/Symfony/Validator/Validator.php",
"line": 67,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator",
"short_class": "Validator",
"class": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator\\Validator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Validator/EventListener/ValidateListener.php",
"line": 68,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Validator\\EventListener",
"short_class": "ValidateListener",
"class": "ApiPlatform\\Core\\Validator\\EventListener\\ValidateListener",
"type": "->",
"function": "onKernelView",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/WrappedListener.php",
"line": 117,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "WrappedListener",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener",
"type": "->",
"function": "__invoke",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 230,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "callListeners",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 59,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php",
"line": 151,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "TraceableEventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 161,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handleRaw",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 78,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/Kernel.php",
"line": 199,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "Kernel",
"class": "Symfony\\Component\\HttpKernel\\Kernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php",
"line": 37,
"args": []
},
{
"namespace": "Symfony\\Component\\Runtime\\Runner\\Symfony",
"short_class": "HttpKernelRunner",
"class": "Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner",
"type": "->",
"function": "run",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php",
"line": 35,
"args": []
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "require_once",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/public/index.php",
"line": 5,
"args": [
[
"string",
"/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php"
]
]
}
]
}
Reply

Hey Andy Myers!

Ok, I think I know what's going on... and you already figured it out :). Remove @Assert\Valid from above the username property. @Assert\Valid is only needed/used when a property is an *object*. It tells the validator system to recursively make sure that the object is ALSO valid. It's meaningless (and in fact gives you this error) if it's applied to a non-object property. For your username property, just put the normal NotBlank type of constraints that you need on it.

Let me know if that makes sense!

Cheers!!

2 Reply
Andrew M. Avatar

That's great, I understand, thanks.

Reply

Hello Ryan!
I'm trying to make an embedded entity but with a non-doctrine object

How could I create a form with Embedding custom DTO Object property (This object is not a doctrine entity)?
For example:

Dto model:


class Point
{
/**
* @Groups({"point:write"})
*/
public $latitude;

/**
* @Groups({"point:write"})
*/
public $longitude;
}

Entity //@ORM\Entity


class City
{
....
private $point;
}

Greetings!!!

Reply

Hey Juan,

To create a form - you need to use a custom form type for that "City::$point" field, that will contain 2 text fields: one for $latitude and one for $longitude. Then, Symfony Form component will know how to render that form, and how to read/write those values. But you will still need to think about how to store that non-doctrine object, probably you will need to use a serialization or json encode for that field - Doctrine has corresponding field types for this.

Cheers!

1 Reply
Vishal T. Avatar
Vishal T. Avatar Vishal T. | posted hace 1 año

Hi Ryan,

I am having two entity with OneToOne relationship.

 
class User
{
/**
* @ORM\OneToOne(targetEntity=ClientProfile::class, mappedBy="user", cascade={"persist", "remove"})
* @Groups({"user:write"})
*/
private $clientProfile;
}

class ClientProfile
{
/**
* @ORM\OneToOne(targetEntity=User::class, inversedBy="clientProfile", cascade={"persist", "remove"})
*/
private $user;

/**
* @ORM\Column(type="string")
* @Groups({"user:write"})
*/
private $test;
}

so can we add embedded field test inside "User" table ?

Reply

Hi Vishal Tanna!

Sorry for the slow reply!

> so can we add embedded field test inside "User" table ?

I think you are asking whether or not you can make a POST /api/users request and send { "clientProfile": { "test": "foo" } } as the JSON so that you can change the embedded "test" property when updating/creating a user. Is this correct?

If so... then... yea! You have user:write groups on both User.clientProfile and ClientProfile.test, so you should be able to write that field in an embedded way. The interactive documentation should also reflect that fact. You would, of course, need methods like getClientProfile(), setClientProfile() and setTest(0 to make it work, but I think you are just not showing those to keep things short :).

Is this not working for you? If so, let me know what's going on - like any errors you are seeing.

Cheers!

Reply
Auro Avatar

Hi Ryan, thank you for this awesome serie of tutorials, really helpful.

Could you please give an example on how to accomplish this using DTO ?

I'm trying to adapt your code to create an Order entity that embed OrderItems, but I'm struggle.

Reply

Hey Julien Kirsch

I believe this other tutorial may give you good ideas of how to do so
https://symfonycasts.com/sc...

Cheers!

Reply
Christopher hoyos Avatar
Christopher hoyos Avatar Christopher hoyos | posted hace 3 años

Is it possible to update individual items in a collection during a PUT operation? Similar to how the CollectionType Field would updated enities if the id was present.

The body of my request (PUT /resource/uri/1) follows this structure:

{
"a_prop": "some value",
"collection": [
"@id": "/another_resources/uri/1",
"another_prop": "new value"
]
}

The response will always return new uri for the item in the collection that is been updated (the previous one is getting deleted form db).
Resonse would look like this:

{
...
"collection": [
"@id": "/another_resources/uri/2", // new uri
"another_prop": "new value" // Updated value
]
}

I know the scenerario might be a little odd, but its all part of a old big form which the user may need to go back to and edit after it was already persisted to db. I'm considering splitting up the form to handle edits on a diferent view, but if possible i would like to avoid it. I wonder if its a configuration that i am missing (similar to the allow_add, allow_remove on the CollectionType), or is just not possible.

Reply

Hey Christopher Hoyos!

Ha! Nice question. Tough question :). I think the answer is... yes! Um, maybe :P.

So, I tried this using this tutorial. Specifically, a made a PUT request to /api/users/8 and tried to update the "price" field on an existing cheeseListing with this body


{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
}
]
}

This DOES work. Assuming you've got all of your serialization groups set up (in this case, a user:write group is used when deserializing a User object and I've also added that same group to the CheeseListing.price property so that it can be updated), then it works just fine. This is a bit of a different result than you were getting... and I'm not sure why (well, your request and response body didn't look quite right to me - there should be an extra { } around each item inside the collection [] but I wasn't sure if that was a typo).

But, apart from needing the groups to be set up correctly, there is one catch: if the User has 2 cheese listings and you only want to edit one of them, you'll need to make sure you include ALL the cheese listings in the request, else the others will be removed. Something like this:


{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
},
"/api/cheeses/4"
]
}

Personally, to manage complexity, I'd *prefer* to update these individual cheese listings by making a PUT request to /api/cheeses/1 instead of trying to do it all inside on request to update the user. But, I also understand that if you're refactoring a giant form... it may be more natural to combine it all at once. But, it's something to think about :). And now that your form is submitting via JavaScript, you could even start updating each CheeseListing (or whatever your other resource really is) on "blur" - i.e. when the user clicks off a field, send a PUT request right then to update just that one item.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

Este tutorial funciona muy bien para Symfony 5 y la Plataforma API 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}