Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Recursos relacionados

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

Tenemos un recurso queso y un recurso usuario. ¡Vamos a relacionarlos! Bien, el verdadero problema que tenemos que resolver es el siguiente: cada CheeseListing será "propiedad" de un único usuario, lo cual es algo que tenemos que configurar en la base de datos, pero también algo que tenemos que exponer en nuestra API: cuando miro un recurso CheeseListing, ¡necesito saber qué usuario lo ha publicado!

Crear la relación de la base de datos

Primero vamos a configurar la base de datos. Busca tu terminal y ejecuta:

php bin/console make:entity

Actualicemos la entidad CheeseListing y añadamos una nueva propiedad owner. Ésta será una relation a la entidad User... que será una relación ManyToOne: cada CheeseListing tiene una User. ¿Esta nueva propiedad debe ser anulable en la base de datos? Di que no: cada CheeseListing debe tener un owneren nuestro sistema.

A continuación, formula una pregunta superimportante: ¿queremos añadir una nueva propiedad a User para poder acceder y actualizar los listados de quesos en ella, como$user->getCheeseListings(). Hacer esto es opcional, y hay dos razones por las que podrías quererlo. En primer lugar, si crees que escribir $user->getCheeseListings()en tu código puede ser conveniente, ¡lo querrás! En segundo lugar, cuando obtengas unUser en nuestra API, si quieres ser capaz de ver qué listados de queso posee este usuario como una propiedad en el JSON, también querrás esto. Pronto hablaremos de ello.

En cualquier caso, di que sí, llama a la propiedad cheeseListings y di que no a orphanRemoval. Si no conoces esa opción... entonces no la necesitas. Y... ¡bono! Un poco más adelante en este tutorial, te mostraré por qué y cuándo es útil esta opción.

¡Pulsa intro para terminar! Como es habitual, esto hizo algunas cosas: añadió una propiedad $ownera CheeseListing junto con los métodos getOwner() y setOwner(). En User, añadió una propiedad $cheeseListings con un método getCheeseListings()... pero no un método setCheeseListings(). En su lugar, make:entity generó los métodosaddCheeseListing() y removeCheeseListing(). Estos serán útiles más adelante.

... lines 1 - 37
class CheeseListing
{
... lines 40 - 84
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
*/
private $owner;
... lines 90 - 182
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
}

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner")
*/
private $cheeseListings;
... line 63
public function __construct()
{
$this->cheeseListings = new ArrayCollection();
}
... lines 68 - 156
public function getCheeseListings(): Collection
{
return $this->cheeseListings;
}
public function addCheeseListing(CheeseListing $cheeseListing): self
{
if (!$this->cheeseListings->contains($cheeseListing)) {
$this->cheeseListings[] = $cheeseListing;
$cheeseListing->setOwner($this);
}
return $this;
}
public function removeCheeseListing(CheeseListing $cheeseListing): self
{
if ($this->cheeseListings->contains($cheeseListing)) {
$this->cheeseListings->removeElement($cheeseListing);
// set the owning side to null (unless already changed)
if ($cheeseListing->getOwner() === $this) {
$cheeseListing->setOwner(null);
}
}
return $this;
}
}

Vamos a crear la migración:

php bin/console make:migration

Y abre eso... para asegurarte de que no contiene nada extra

... lines 1 - 12
final class Version20190509190403 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE cheese_listing ADD owner_id INT NOT NULL');
$this->addSql('ALTER TABLE cheese_listing ADD CONSTRAINT FK_356577D47E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_356577D47E3C61F9 ON cheese_listing (owner_id)');
}
... lines 29 - 38
}

Se ve bien: alterando la tabla y configurando la clave foránea. Ejecuta eso:

php bin/console doctrine:migrations:migrate

¡Oh, no! ¡Ha explotado!

No se puede añadir o actualizar una fila hija, falla una restricción de clave foránea

... en la columna owner_id de cheese_listing. Por encima de la propiedad owner, ponemos nullable=false, lo que significa que la columna owner_id de la tabla no puede ser nula. Pero... como nuestra tabla cheese_listing ya tiene algunas filas, cuando intentamos añadir esa nueva columna... no sabe qué valor utilizar para las filas existentes y explota.

Es un clásico fallo de migración. Si nuestro sitio ya estuviera en producción, tendríamos que hacer esta migración más elegante añadiendo primero la nueva columna como anulable, establecer los valores y luego cambiarla a no anulable. Pero como aún no estamos allí... podemos simplemente eliminar todos nuestros datos e intentarlo de nuevo. Ejecuta:

php bin/console doctrine:schema:drop --help

... porque esto tiene una opción que no recuerdo. Ah, aquí está: --full-database nos aseguraremos de eliminar todas las tablas, incluida migration_versions. Ejecuta: :

php bin/console doctrine:schema:drop --full-database --force

Ahora podemos ejecutar todas las migraciones para crear nuestro esquema desde cero:

php bin/console doctrine:migrations:migrate

¡Bien!

Exponer la propiedad de la relación

¡De vuelta al trabajo! En CheeseListing, tenemos una nueva propiedad y un nuevo getter y setter. Pero como estamos utilizando grupos de normalización y desnormalización, esta novedad no está expuesta en nuestra API.

Para empezar, éste es el objetivo: cuando creamos un CheeseListing, un cliente de la API debe poder especificar quién es el propietario. Y cuando leamos un CheeseListing, deberíamos poder ver quién es su propietario. Esto puede parecer un poco extraño al principio: ¿realmente vamos a permitir que un cliente de la API cree un CheeseListing y elija libremente quién es su propietario? Por ahora, sí: establecer el propietario de un listado de queso no es diferente de establecer cualquier otro campo. Más adelante, cuando tengamos un verdadero sistema de seguridad, empezaremos a bloquear las cosas para que no pueda crear unCheeseListing y decir que otro es su propietario.

De todos modos, para que owner forme parte de nuestra API, copia los @Groups() de $price... y añádelos encima de $owner.

... lines 1 - 37
class CheeseListing
{
... lines 40 - 84
/**
... lines 86 - 87
* @Groups({"cheese_listing:read", "cheese_listing:write"})
*/
private $owner;
... lines 91 - 194
}

¡Vamos a probarlo! Muévete y refresca los documentos. Pero antes de ver CheeseListing, vamos a crear un User para tener algunos datos con los que jugar. Le daré un correo electrónico, una contraseña cualquiera, un nombre de usuario y... Ejecuta. Genial - 201 éxito. Ajusta los datos y crea un usuario más.

Ahora, el momento de la verdad: haz clic para crear un nuevo CheeseListing. Interesante... dice que owner es una "cadena"... lo que puede ser sorprendente... ¿no vamos a establecerlo como un id entero? Vamos a averiguarlo. Intenta vender un bloque de queso desconocido por 20$, y añade una descripción.

Para el propietario, ¿qué ponemos aquí? Veamos... los dos usuarios que acabamos de crear tenían los ids 2 y 1. ¡Bien! Establece el propietario en 1 y ¡ejecuta!

¡Woh! ¡Falla con un código de estado 400!

Se esperaba un IRI o un documento anidado para el atributo owner, se ha dado un entero.

¡Resulta que establecer owner al id no es correcto! A continuación, vamos a arreglar esto, a hablar más de los IRI y a añadir una nueva propiedad cheeseListings a nuestro recurso de la API User.

Leave a comment!

38
Login or Register to join the conversation
Wojtek D. Avatar
Wojtek D. Avatar Wojtek D. | posted hace 2 meses

Hi, i have an issue, and im kinda lost. Ive done 1:1 like you said and im getting invalid IRI error

Owner part in db:
/**
* @ORM\ManyToOne(targetEntity=Users::class, inversedBy="servers")
* @ORM\JoinColumn(nullable=false)
*/
#[Groups("user:write")]
private $owner;

denormalizationContext: ["groups" => ["user:write"]],
Any idea what could be wrong?

Reply
Wojtek D. Avatar

Well to be honest i sovled it, dunno how but i just did not manyToOne but OneToMany and targeted other table

Reply

Hey Vortex,

I'm happy to hear you were able to solve this yourself, well done! Well, it sounds like a cache issue to me :) Anyway, I'm glad it works for you now

Cheers!

Reply
Alex T. Avatar

Hello, i have an issue: If i have a Entity, lets say Cat, and i have pictures of my cat which are connected in a ManyToMany relation (one image can include multiple cats), if i save the cat as
{
cat: 'James',
images: [
'iri1', 'iri2', 'iri3'
]
}
and then reorder the images and save the entity with
images: [
'iri1', 'iri3', 'iri2'
]
then this is not saved, the order of the related images is not saved as i specified it in my api call. Is there any smart way i can order my pictures whithout creating a intermediate Entity who contains an index?

Reply

Hey Alex

I think the only option would be to delete and recreate the image records in the new order. Otherwise, you'll need, as you said, an intermediate entity to store the order of each image

Cheers!

Reply
Raul M. Avatar

Hi again Ryan!

I have a new problem with many to many relations, but in this case with self-referencing. I have the entity employee with a many to many self-referencing with the property manager with the next configuration:


/**
* @ORM\ManyToMany(targetEntity=Empleado::class, mappedBy="subordinados")
* @Groups({
* "datos_personales:read",
* })
*
* @MaxDepth(1)
*/
private $responsables;

/**
* @ORM\ManyToMany(targetEntity=Empleado::class, inversedBy="responsables")
* @ORM\JoinTable(
* name="responsables",
* joinColumns={
* @ORM\JoinColumn(name="responsable_id",referencedColumnName="id")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="persona_id",referencedColumnName="id")
* }
* )
*
* @Groups({
* "datos_personales:read",
* })
*
* @MaxDepth(1)
*/
private $subordinados;

Sorry but part of this code is in spanish.....

when I execute the query I didn't get any reply and the loading is spinning without end. Could you help me with this issue?

Thanks in advance!

Cheers,

Raúl.

Reply

Hey Raul,

Hm, it's like you wait for 30 seconds and then the script dies? If the request is spinning without end - it sounds like you may have a recursion. I'd recommend you to install and enable Xdebug for your PHP - it will detect any recursions and throw an exception, so it will be more obvious where the problem is. Manually, it's difficult to find what's wrong, but first of all you need to double-check any recursive calls if you have.

Also, I'd recommend you to check the logs, they may contain some tips about what went wrong.

Btw, if you're not sure in your entity mapping config -you may want to execute "bin/console doctrine:schema:validate" command to validate the mapping and the schema in your project - it should give the output with both mapping and schema green, otherwise you have problems in relations that you have to fix first.

I hope this helps! Sorry can't help more

Cheers!

Reply
Raul M. Avatar

Hi Victor,

Thank you for your reply!

I made a mistake with the confirguration of the relation, but now it is solved:


/**
* @ORM\ManyToMany(targetEntity=DatosPersonales::class, mappedBy="subordinados")
* @Groups({
* "datos_personales:read"
* })
* @MaxDepth(1)
*/
private $responsables;

/**
* @ORM\ManyToMany(targetEntity=DatosPersonales::class, inversedBy="responsables")
* @ORM\JoinTable(name="responsables",
* joinColumns={@ORM\JoinColumn(name="responsable_id", referencedColumnName="id")},
* inverseJoinColumns={@ORM\JoinColumn(name="subordinado_id", referencedColumnName="id")}
* )
* @Groups({
* "datos_personales:read"
* })
* @MaxDepth(1)
*/
private $subordinados;

Now I have a doubt about the serialization. In the reply of this end point I have a full object of the employee entity in the responsables and subordinados array, but I only need name and surname. How could I solve it?

I has been reading about custom normalizers but I don't know how I can implement it in my project.

Thank you for your help!

Cheers,

Raúl

Reply

Hey Raul Martin!

Yea, this gets tricky :). The key is serialization groups. We talk about it here: https://symfonycasts.com/sc...

So the key should be to add the datos_personales:read to your employee entity only above the fields you want (name and surname). I'm not sure, however, how MaxDepth works with this, I don't really use that (I don't think you should actually need MaxDepth, but you can try with and without).

Cheers!

Reply
Raul M. Avatar

Hi @weaverryan !

Thank you for your reply.

As this is a self referencing entity all the fields has the serialization group datos_personales:read, including $responsables and $subordinados.

This two fields are referencing also the self entity employee, but I need to normalize this fields only with name and surname, and not with all the fields of the entity.

I've seen the video but is about relation between two entities and this is not my case.

Thank you for your help!

Cheers,

Raúl.

1 Reply

Hey Raul Martin!

Hmm. In this case, it's trick and I'm actually not sure what the best way is to handle thing. My guess is that a custom normalizer might be able to do this... where you use some context key to "keep track" of which level you're on, but I'm guessing a bit. I did have a gist that originally did something kind of similar - https://gist.github.com/wea... - and later a user added an alternate solution (I'm not sure this gist does exactly what you want, but it shows using decoration to "extend" the normalizer https://gist.github.com/vol... - here is his original comment: https://symfonycasts.com/sc...

Anyways, I know those links aren't terribly specific, but let me know if they help :).

Cheers!

Reply
Raul M. Avatar

Hiweaverryan!

Thank you for your help and I'll take a look at the links you sent me.

Cheers!

Raúl.

Reply
Raul M. Avatar

Hi Ryan,

How we deal with many to many relations and Api Platform? Do you information about this case?

Thank you!

Raúl.

Reply

Hey Raul Martin!

Hmm. I don't think we cover that directly. However, it should be basically the same as a OneToMany relationship. For example, imagine you have an Article ApiResource and a Tag ApiResource. You would set the tags on an Article via something like this:


PUT /api/articles/1

{
"tags": [
"/api/tags/1",
"/api/tags/2"
]
}
</coe>



If you set things up correctly (just like with a OneToMany relationship), I believe that you should even be able to create tags in this request. This is probably the area of the tutorial to look at - https://symfonycasts.com/screencast/api-platform/collections-write. From Api Platform's perspective, a ManyToMany and OneToMany are basically the same because in both cases you have an entity (e.g. Article or User) that has an array of some other entity (e.g. Tags or Articles).

Cheers!

Reply
Raul M. Avatar

I´ve got a similary question but a bit more specific,
I have 4 tables: User, Manger(has user_id column), Teams and User-Teams as many to many relationship, and i want to filtter on Manager operation GET by team_id, how i supposed to call this on the fillter annotation?
Thanks for all
Mario

Reply

Hey Mario Yuste García!

Apologies for the slow reply! There are two possibilities... but I'm not sure if the first will work. We'll find out!

1) Follow the advice here: https://symfonycasts.com/sc...

Basically, the final API request would look something like this: GET /api/managers?user.teams=3 where 3 is the team id. I KIND of think that this won't work, but it's worth a try. You might also need to try GET /api/managers?user.teams[]=3. But again, I'm not sure this will do it.

2) Your other option is to create a custom filter. You can find info about that here: https://symfonycasts.com/sc...

Good luck!

Reply

Hey The team,

For someone who want to use SF5 with API platform 2.6 and PHP 8, I succeed to write this and it is working perfectly fine :


#[ApiResource(
collectionOperations: ['get', 'post'],
itemOperations: ['get', 'put'],
shortName: 'Cheeses',
attributes: [
'pagination_items_per_page' => 2,
'formats' => [
'jsonld',
'json',
'html',
'jsonhal',
'csv' => ['text/csv']
]
],
denormalizationContext: ['groups' => 'writeCheese'],
normalizationContext: [ 'groups' => 'readCheese'],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'ipartial', 'description' => 'ipartial'])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
class CheeseListing
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', length: 255)]
#[ApiProperty(description: 'Le nom de ce fromage.')]
#[Groups(['writeCheese', 'readCheese'])]
#[Assert\NotBlank()]
#[Assert\Length(['min' => 2, 'max' => 50, 'maxMessage' => 'Describe your cheese in 50 chars or less'])]
private string $title;

#[ORM\Column(type: 'text')]
#[ApiProperty(description: 'La description de ce fromage.')]
#[Groups(['readCheese'])]
#[Assert\NotBlank()]
private string $description;

#[ORM\Column(type: 'integer')]
#[ApiProperty(description: 'Le prix de ce délicieux fromage en centimes.')]
#[Groups(['writeCheese', 'readCheese'])]
#[Assert\NotBlank()]
#[Assert\GreaterThan(['value' => 0])]
private int $price;


}



#[ApiResource(
denormalizationContext: ['groups' => 'writeUser'],
normalizationContext: ['groups' => 'readUser'],
)]
#[UniqueEntity(fields: 'username')]
#[UniqueEntity(fields: 'email')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Groups(['writeUser', 'readUser'])]
#[Assert\NotBlank()]
#[Assert\Email()]
private string $email;

#[ORM\Column(type: 'json')]
private array $roles = [];

#[ORM\Column(type: 'string')]
#[Groups('writeUser')]
private string $password;

#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Groups(['readUser', 'writeUser'])]
#[Assert\NotBlank()]
private string $username;

#[ORM\OneToMany(mappedBy: 'owner', targetEntity: CheeseListing::class)]
#[Groups(['readUser'])]
private $cheeseListings;
Reply

Hey Stéphane!

Thank you for sharing this new alternative way with others! :)

Cheers!

Reply
Hazhir A. Avatar
Hazhir A. Avatar Hazhir A. | posted hace 1 año

I have a question how can we have a multi level child parent entity in one table in api platform!!! It does not show the relation when i create it in one table!! I have implemented the one to many!! This is just when using api-platform swagger

here is my code and database schema:

subject is the table name also parent_id could contain an id of subject entity, "N" level parent/childs



namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\SubjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "delete" },
* shortName="Subjects",
* normalizationContext={"groups"={"subjects:read"}, "swagger_definition_name"="Read"},
* denormalizationContext={"groups"={"subjects:write"}, "swagger_definition_name"="Write"},
* attributes={"pagination_items_per_page" = 30}
* )
* @ORM\Entity(repositoryClass=SubjectRepository::class)
* @ORM\Table(name="`subject`")
*/
class Subject
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\Column(type="string", length=255)
* @Groups({"subjects:read", "subjects:write"})
*/
private $title;

/**
* @ORM\ManyToOne(targetEntity=Subject::class, inversedBy="subjects")
* @Groups({"subjects:read", "subjects:write"})
*/
private $parent;

/**
* @ORM\OneToMany(targetEntity=Subject::class, mappedBy="parent")
*/
private $subjects;

/**
* @ORM\OneToMany(targetEntity=Ticket::class, mappedBy="subject", orphanRemoval=true)
*/
private $tickets;

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

public function getId(): ?int
{
return $this->id;
}

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(string $title): self
{
$this->title = $title;

return $this;
}

public function getParent(): ?self
{
return $this->parent;
}

public function setParent(?self $parent): self
{
$this->parent = $parent;

return $this;
}

/**
* @return Collection|self[]
*/
public function getSubjects(): Collection
{
return $this->subjects;
}

public function addSubject(self $subject): self
{
if (!$this->subjects->contains($subject)) {
$this->subjects[] = $subject;
$subject->setParent($this);
}

return $this;
}

public function removeSubject(self $subject): self
{
if ($this->subjects->contains($subject)) {
$this->subjects->removeElement($subject);
// set the owning side to null (unless already changed)
if ($subject->getParent() === $this) {
$subject->setParent(null);
}
}

return $this;
}

/**
* @return Collection|Ticket[]
*/
public function getTickets(): Collection
{
return $this->tickets;
}

public function addTicket(Ticket $ticket): self
{
if (!$this->tickets->contains($ticket)) {
$this->tickets[] = $ticket;
$ticket->setSubject($this);
}

return $this;
}

public function removeTicket(Ticket $ticket): self
{
if ($this->tickets->contains($ticket)) {
$this->tickets->removeElement($ticket);
// set the owning side to null (unless already changed)
if ($ticket->getSubject() === $this) {
$ticket->setSubject(null);
}
}
return $this;
}
}

each parent_id is the same id of the subject entity , which means each subject can be either a parent when the parent_id is null or be the child of another parent entity, the above code should display parent_id in the swagger but there is only title

Reply

Hey @hazhir!

Hmm. Other than this being a self-referencing relation, it seems pretty straightforward and I don't see any issues with your code.

I have a question: do you actually "receive"/see the "parent" field when you *use* the API endpoint? You mentioned that Swagger doesn't display parent_id. I'm wondering if this is just a documentation problem (it does not show in swagger but DOES exist when using the API endpoint) or if the field does not show anywhere.

Also, you mentioned that you're expecting "parent_id". Why that field? I would expect that "parent" would actually be an embedded object, with the normal @type and @id but also title.

Cheers!

Reply
Hazhir A. Avatar

no the "parent field" does not show up in post method which should be displayed so i can related object to it, but when using get method it will return the "parent" field this is really wierd situation and it's been month i am looking for a solution i also checked the whole stackoverflow , is this some kind of bug? or am i doing something wrong cause i assume people should have done something simillar to what i have done here.

Reply

Hey @hazhir!

Ah, so the problem is that you can't SET the parent field on POST, correct? Can you take a screenshot of what the Swagger UI looks like?

So I *can* think of one possible issue. Normally, I would expect that you could POST to create a new subject and include a "parent" field set to the IRI of some other subject (e.g. /api/subjects/2). However, because this is a self-referencing relationship, API Platform will see that the "title" field is in the subjects:write group, and it may think that you intend for the "parent" field to be an embedded object - e.g. {"title": "the new subject", parent: { title: "an embedded subject, which will actually create a new subject" }}.

To see if I'm correct, here are a few things to try / tell me:

1) Please take a screenshot of the swagger UI - specifically the POST operations for Subject where you can fill in the fields to "try it".

2) Try sending a POST to /api/subjects with the data {"title": "the new subject", parent: { title: "an embedded subject, which will actually create a new subject" }}. Does that work?

3) Try sending a POST to /api/subjects with the data {"title": "the new subject", parent: { "@id": "/api/subjects/1" }} where /api/subjects/1 is the IRI to a real subject. Does it work?

Let me know :).

Cheers!

Reply
Sebastian K. Avatar
Sebastian K. Avatar Sebastian K. | posted hace 2 años

I have a problem with a OneToOne relations. In my case: A User (API Resource) and UserSettings (just an entity).

I added my user:read and user:write groups to private $settings and also my 2 properties in UserSettings. I want to have this:


{
"@id": "/users/123",
...
"settings": {
"darkMode": true,
"language": 'de'
}
}

I can create a new user with settings, but when I want to update a user and change a key in my settings ("language" to "fr"), Symfony (Doctrine) tries to create a new entity (But runs into an Exception due to Unique-Key on user_id from OneToOne) instead of updating the existing one.

I know why, because I have no @id in my settings (https://api-platform.com/do..., but I think I don't need one, because it's a OneToOne. Either there is already a settings entity, then update it, or there is no entity, then create a new one.

This is my current solution. I added a check if the settings already exists, then I update the keys (very tedious and error prone)


public function setSettings(UserSettings $settings): self
{
if ($this->settings) {
$this->settings->setDarkMode($settings->getDarkMode());
$this->settings->setLanguage($settings->getLanguage());
} else {
$this->settings = $settings;
if ($settings->getUser() !== $this) {
$settings->setUser($this);
}
}

return $this;
}

I am looking for a better one

Reply

Hey Sebastian Klocke!

Sorry for the slow reply - you get the prize for asking a hard enough question that it had to wait for me ;).

First, you did a great job diagnosing the problem. If I've jumped correctly into the code, the place that looks for the @id is this: https://github.com/api-plat...

The key during the deserialization process is that the OBJECT_TO_POPULATE context needs to be set. Unfortunately, I'm not aware (which doesn't mean it's not possible - I just don't know) of a way to change the context for just *one* part of the deserialization process - other than creating a custom denormalizer that handles UserSettings and passes a different context to the "inner" deserializer.

So... I think the option is to either try messing with the custom deserializer or your solution (which is a bit messy, but quite clever).

Cheers!

Reply
David B. Avatar
David B. Avatar David B. | posted hace 2 años

Running into a strange issue with relationships and swagger.

Code: https://pastebin.com/M8aZ9g1v

I currently have MenuAudience - OneTOMany - MenuAudienceCategory. In Swagger UI I'm able to post many MenuAudience without issue. When I try to post MenuAudienceCategory using the following code it works fine.


{
"name": "test",
"description": "test",
"orderNumber": 1,
"published": true,
"menuAudience": "/index.php/api/menu_audiences/1"
}

However, if I try to post another MenuAudienceCategory using the code below it throws this error "hydra:description": "Could not denormalize object of type \"App\\Entity\\menuAudience\", no supporting normalizer found."; This is strange because I was able to post a 'MenuAudienceCategory' without any issues seconds before. If i clear my cache and try to post again it works fine. It appears that I can only create one MenuAudienceCategory at a time, then I must clear my cache to post another.


// Works after I clear cache.
{
"name": "test 2",
"description": "test 2",
"orderNumber": 2,
"published": true,
"menuAudience": "/index.php/api/menu_audiences/1"
}

Any ideas as to what might be causing this?

Reply

Hey awwyes !

Ah, this is *super* weird! Quick question, when you see the error:

> Could not denormalize object of type \"App\\Entity\\menuAudience\", no supporting normalizer found."

Does it literally say menuAudience with a lowercase M on Menu? If so, that looks weird to me. And, oh! Yes, indeed - I see the cause now, inside MenuAudienceCategory, you have:


/**
* @ORM\ManyToOne(targetEntity=menuAudience::class, inversedBy="mealAudienceCategories")
* @ORM\JoinColumn(nullable=false)
*/
private $menuAudience;

See that lowercase "m" on menuAudience::class? Try changing that to a big M. I'm actually surprised that's working at all.. but maybe some parts of the system are being extra friendly (at least for the first request).

I could also be *totally* off - but that looks weird enough that I wanted to check it first.

Cheers!

Reply
David B. Avatar

Thanks for the reply! I think I managed to get it. I believe it was an issue with my docker setup and cache permissions. I tested it on a vagrant setup and it worked fine. I changed some permissions around in docker and regenerated the entities using make:entity and it seems to be working now.

Reply

Hey awwyes!

Yea, that makes sense on a high level - it's *such* weird behavior, that I was also thinking that it must be some super-odd caching or other system-related problem. Glad you got it sorted!

Cheers!

Reply

Is there a way to order a collection by field in related entity?

Suppose, I have an entity "Category". It has a relation OneToMany to "Tag" entity. Tag entity has a relation to "TagTranslation" entity(knp translatable is used). So, when I'm getting Category with it's tags I want to order all the tags(only this collection, not Categories) by tags.translations.title property. Any ideas?

Reply

I've come up with a solution, which is not perfect, but works


public function getTags(): Collection
{
$iterator = $this->tags->getIterator();
$iterator->uasort(function (Tag $a, Tag $b) {
/** getTitle() is a shortcut to $this->translate() method which reads data from TagTranslation entity **/
return strnatcasecmp($a->getTitle(), $b->getTitle());
});

$this->tags->clear();

foreach ($iterator as $item) {
$this->tags->add($item);
}


return $this->tags;
}

As I use serializer to return an API response, using API Platform, there was an issue returning

 return new ArrayCollection(iterator_to_array($iterator));

It returns responses in different formats: array of objects or object with objects.
Array of objects is a correct one from PersistentCollection

 [0:{...}, 1:{...}]


Object with objects for ArrayCollection

 {0:{...}, 1:{...}};

The only difference is that a default class is a PersistentCollection and I was returning an ArrayCollection. So I've come up with a quick fix with clearing the Persistent collection and readding elements in a correct order.

Anyway it works, but it's interesting if there are some better solutions to this.

Reply

Hey Horlyk,

You can take a look at @ORM\OrderBy() annotation for ArrayCollection fields, see an example here: https://symfonycasts.com/sc... - I think it might be useful in this case.

Also, Doctrine Criteria will be a more complex but good and most flexible solution, take a look at: https://symfonycasts.com/sc...

You can create a new method like getTagsOrdered() for example apply the doctrine criteria to order array collection of $this->tags field.

I hope this helps!

Cheers!

Reply

Hi Victor,

Thanks for the response. But @ORM\OrderBy() and then Criteria - were the first steps which I've tried. And they both don't allow to make any joins so I wasn't able to access the needed field for ordering. @ORM\OrderBy({"translations.title" = "ASC"}) returned an error that it is unknown field. And The criteria does not support joins to add orderBy.

Reply

Hey horlyk!

I believe that's correct - I believe both OrderBy & Criteria are limited: they don't work across a relationship. Normally, I would warn that the solution you chose above could be inefficient: because you're querying for all the tags and then ordering them in PHP. But... because you are ultimately going to be displaying all the related tags in JSON anyways, then you will always be querying and hydrating all the objects anyways :). So of course, since you are rendering all the tags, you need to make sure that something doesn't have 100 tags (that would make the endpoint very slow), but otherwise, I don't see a problem with this.

Cheers!

1 Reply
bquarta Avatar

Hello there,

i've got a question about "modularity". All this stuff in the tutorial works well for me, but given that I want to add components that are reusable and modular, I probably might want not to have those relations directly in the user entity.

Think of the following: We have a base User Class and the job of the User class is to authenticate to the API. So let's say I want to build some components around the user class for different use-cases: A cheese-listing component, a blog-component and so on. Given, that eventually I want to drop the cheese-listing-component, i will have to clean up the User-Entity too.

Is there a more modular way, such as having an CheeseOwner-Class that is "extending" User and that is holding only the cheese-listing-relations?
And the other way round: If i inherit by different modules i might have a CheeseOwner, that "knows" all those User-properties, but not about the properties of the BlogAuthor-Entity. But what if eventually I will need an endpoint that holds all the information of the components that are currently installed?

best regards
Ben

Reply

Hey Benjamin Quarta

Let's see, if you want your User class to be "agnostic" of any other module within your system, then it can't hold any reference to the other entities or functionality. What you can do is to invert the dependencies, if your users can post comments, then the Comment entity would hold the reference to its user, and the same applies to the CheeseLisiting entity. If you want to fetch all comments for a given user, then you have to do it from the Blog (or CheeseListing) repository $blogRepo->findAllForUser($user);
By doing so, you can remove entirely your Blog component and the User component won't even notice it

I hope this makes any sense to you :) Cheers!

1 Reply
Sung L. Avatar

Hi,

With the awesome tutorials, I am able to handle One-to-Many relations pretty easily and Many-to-Many relations with the bridge table. However, there are extra information in bridge table and cannot pull the data from API. Here is example schema:


Users
---
id
name
age

Teams
---
id
name

User_Teams
---
user_id
team_id
is_leader

I am able to pull data from User API with team info (id and name), and Team data with Users (id, name, and age) in it. But is_leader from the bridge table cannot be pulled from anywhere. How can I add is_leader in the Users/Teams api? Thanks for your help.

Reply

Hey Sung Lee

Adding extra fields to a "bridge" table when using Doctrine's ManyToMany relationship is not possible, you have to do a workaround. Read my answer here: https://symfonycasts.com/sc...
and you can watch this chapter where Ryan explains it even more: https://symfonycasts.com/sc...

Cheers!

Reply
Sung L. Avatar

Exactly what I was looking for! Thanks, Diego for your kind reply.

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