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 SubscribeTenemos 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!
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 owner
en 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 $owner
a 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!
¡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
.
Hi,
I have an strange issue with executing migration which I've made. When I run doctrine:migrations:migrate
it executes most of queries but on of them causes an error:
In AbstractMySQLDriver.php line 128:
An exception occurred while executing 'ALTER TABLE cheese_listing ADD CONST
RAINT FK_356577D47E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)':
SQLSTATE[HY000]: General error: 1826 Duplicate foreign key constraint name
'FK_356577D47E3C61F9'
In Exception.php line 18:
SQLSTATE[HY000]: General error: 1826 Duplicate foreign key constraint name
'FK_356577D47E3C61F9'
In PDOConnection.php line 55:
SQLSTATE[HY000]: General error: 1826 Duplicate foreign key constraint name
'FK_356577D47E3C61F9'
I'm using Symfony 4.4.32 and OpenAPI 3.0.2.
I'm a begginer in programming and I'm confused becuse when I check the structure of cheese_listing table it seems to be ok. It has owner_id column, but... I don't know why it is nullable allowed (I think it shouldn't because there is "owner_id INT NOT NULL" in sql query. I changed it manually to be consistent with Your code).
For the moment (chapter 24, everything work fine). Could You explain what is wrong and if I can ignore this error and move on?
I would be very grateful.
Kuba
Hey Jakub,
In short, you can totally ignore this and continue this course. Well, I'd recommend you to run:
bin/console doctrine:schema:validate
And make sure that your DB is in sync with your metadata. If the output is green - great, nothing to worry about.
But in case you would like to deploy this to production - you might want to fix your migration. Why it's failing - difficult to say! First of all, it depends on queries inyour migration. You need to validate that queries in that migration make sense and look good... and that there are no any duplicated queries. Also, make sure that you don't have any other migration that already has executed the same query. Also, it might be so that you mistakenly executed bin/console doctrine:schema:update --force
command and so it applied some changes to your DB schema, and then it makes sense your migration is failing now. The best idea - drop the DB completely and rerun migrations from scratch locally in case you are not worrying about data loss. If all the migrations will be executed successfully - most probably the problem in the mistakenly executed doctrine:schema:update
command that I mentioned earlier. But if they are failed at some point - most probably you have. duplicated queries, i.e. queries that are trying to apply the same schema change in your DB, and the second migration will always fail.
I hope this helps!
Cheers!
Thank you for your reply. bin/console doctrine:schema:validate output is green so I've ignored this error at this moment.
Hey Jakub,
Great! Yeah, you can totally continue the course and ignore that at this moment, but it may cause problems with other migrations you will generate further in this course (if any). Then the same workaround with doctrine:schema:update --force
will work for you until you finally fix your migrations.
Cheers!
Hi everybody
Note i learn Api platform with the version 3.0 and Symfony 6.1
I created the User Entity with the maker as explained in the video but the relation is not an IRI but a User Class in my CheeseListing entity.
User.php
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:write']]
)]
#[UniqueEntity(
fields: ['username'],
message: 'ce username existe déjà')]
#[UniqueEntity(
fields: ['email'],
message: 'email déjà utilisé')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['user:read', 'user:write'])]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
#[Groups(['user:read', 'user:write'])]
private ?string $password = null;
#[ORM\Column(length: 255, unique: true)]
#[Groups(['user:read', 'user:write'])]
private ?string $username = null;
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: CheeseListing::class)]
private Collection $cheeseListings;
public function __construct()
{
$this->cheeseListings = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = $username;
return $this;
}
/**
* @return Collection<int, CheeseListing>
*/
public function getCheeseListings(): Collection
{
return $this->cheeseListings;
}
public function addCheeseListing(CheeseListing $cheeseListing): self
{
if (!$this->cheeseListings->contains($cheeseListing)) {
$this->cheeseListings->add($cheeseListing);
$cheeseListing->setOwner($this);
}
return $this;
}
public function removeCheeseListing(CheeseListing $cheeseListing): self
{
if ($this->cheeseListings->removeElement($cheeseListing)) {
// set the owning side to null (unless already changed)
if ($cheeseListing->getOwner() === $this) {
$cheeseListing->setOwner(null);
}
}
return $this;
}
}
Cheese_listing.php
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\CheeseListingRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Query\Expr\GroupBy;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
#[ApiResource(
routePrefix: '/',
operations: [
new Get(
uriTemplate: '/cheese/{id}.{_format}'
),
new GetCollection(
uriTemplate: '/cheeses.{_format}'
),
new Post(
uriTemplate: '/cheese'
),
new Delete(
uriTemplate: '/cheese/{id}.{_format}'
)
],
normalizationContext: ['groups' => 'user:read'],
denormalizationContext: ['groups' => 'user:write'],
formats: ['json', 'jsonld', 'html', 'jsonhal', 'csv' => ['text/csv']]
)]
class CheeseListing
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['user:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(
min: 3,
max: 50
)]
#[Groups(['user:read', 'user:write'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
#[Assert\Length(
min: 5,
max: 255,
minMessage: 'Votre description doit faire plus de 5 caractères',
maxMessage: 'Votre description ne doit pas faire plus de 255 caractères'
)]
#[Groups(['user:read', 'user:write'])]
private ?string $description = null;
#[ORM\Column]
#[Assert\NotBlank]
#[Groups(['user:read', 'user:write'])]
private ?int $price = null;
#[ORM\Column]
#[Groups(['user:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column]
#[Groups(['user:read', 'user:write'])]
private ?bool $isPublished = null;
#[ORM\ManyToOne(inversedBy: 'cheeseListings')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['user:read', 'user:write'])]
private ?User $owner = null;
public function __construct()
{
// $this->createdAt = new DateTimeImmutable;
}
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 getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
public function getPrice(): ?int
{
return $this->price;
}
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function isIsPublished(): ?bool
{
return $this->isPublished;
}
public function setIsPublished(bool $isPublished): self
{
$this->isPublished = $isPublished;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
}
and the json example is :
[
{
"id": 0,
"title": "string",
"description": "string",
"price": 0,
"createdAt": "2022-10-06T16:19:58.323Z",
"isPublished": true,
"owner": {
"email": "string",
"password": "string",
"username": "string"
}
}
]
No IRI, is that normal ? Did i make some mistake ?
I found nothing about that in the documentation but i'm not expert and my english is not so good.
Thanks :)
I reply on my question,
I found my mistake.
It's about groups in normalizationContext and denormalizationContext.
i used the same name of groups for my entity User and Cheese_listing, that's why, that create confusion.
To fix that i just rename the name of groups in User (adding 's', this is probably not a great idea, but that's ok for the exercise)
Cheese_listing.php
...
#[ApiResource(
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:write']]
)]
...
User.php
...
#[ApiResource(
normalizationContext: ['groups' => ['users:read']],
denormalizationContext: ['groups' => ['users:write']]
)]
...
Hey David, might be better to just use user:read/write
and cheese_lising_read/write
but that's up to you.
Cheers!
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?
Well to be honest i sovled it, dunno how but i just did not manyToOne but OneToMany and targeted other table
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!
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?
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!
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.
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!
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
Hey Raul M.!
Yea, this gets tricky :). The key is serialization groups. We talk about it here: https://symfonycasts.com/screencast/api-platform/embedded
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!
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.
Hey Raul M.!
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!
Hiweaverryan!
Thank you for your help and I'll take a look at the links you sent me.
Cheers!
Raúl.
Hi Ryan,
How we deal with many to many relations and Api Platform? Do you information about this case?
Thank you!
Raúl.
Hey Raul M.!
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!
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
Hey Raul M.!
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!
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
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!
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.
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!
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/docs/core/serialization/#denormalization), 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
Hey Sebastian K.!
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-platform/core/blob/ce06420868afb289676478450a3e76569dc20a02/src/JsonLd/Serializer/ItemNormalizer.php#L109-L118
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!
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.
<br />{<br /> "name": "test",<br /> "description": "test",<br /> "orderNumber": 1,<br /> "published": true,<br /> "menuAudience": "/index.php/api/menu_audiences/1"<br />}
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.
<br />// Works after I clear cache. <br />{<br /> "name": "test 2",<br /> "description": "test 2",<br /> "orderNumber": 2,<br /> "published": true,<br /> "menuAudience": "/index.php/api/menu_audiences/1"<br />}
Any ideas as to what might be causing this?
Hey David B. !
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!
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.
Hey David B.!
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!
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?
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.
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!
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.
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!
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
Hey bquarta
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!
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.
Hey Sung L.
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!
// 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
}
}
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(
)]
#[UniqueEntity(fields: 'username')]
#[UniqueEntity(fields: 'email')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '
user
')]class User implements UserInterface, PasswordAuthenticatedUserInterface
{