Doctrine metadata belongs to the app — field note 0085
Entity mapping remains an application concern. This deterministic field note expands the demo dataset.
Niewielki blog oparty na Doctrine ORM i SQLite.
Doctrine ORM · SQLite · ManyToMany
1234 artykułów
Entity mapping remains an application concern. This deterministic field note expands the demo dataset.
Temporary files should leave storage when their work is complete. This deterministic field note expands the demo dataset.
Attach access rules where the route is declared. This deterministic field note expands the demo dataset.
A cache should reduce work without hiding correctness problems. This deterministic field note expands the demo dataset.
Keep runtime startup separate from schema evolution. This deterministic field note expands the demo dataset.
Dispatch events only once state changes are durable. This deterministic field note expands the demo dataset.
An article can credit several contributors without a primary-author rule. This deterministic field note expands the demo dataset.
Association joins keep filtering portable across database engines. This deterministic field note expands the demo dataset.
Validate size, type, and lifecycle before accepting a file. This deterministic field note expands the demo dataset.
Test the public response instead of framework internals. This deterministic field note expands the demo dataset.
Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.
Fetch the graph needed for one page and avoid N+1 queries. This deterministic field note expands the demo dataset.
Stronicowany blog odczytuje ID bieżącej strony przez toIterable(); ograniczona partia grafu następnie ładuje autorów i tagi bez zapytań N+1.
Rejestruje Doctrine jako zwykłą usługę aplikacji za pośrednictwem providera Lemonade.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Repository\OrmDemoArticleRepository;
use App\Repository\OrmDemoAuthorRepository;
use App\Repository\OrmDemoTagRepository;
use App\Services\DoctrineEntityManagerFactory;
use App\Services\OrmDemoService;
use Doctrine\ORM\EntityManagerInterface;
use Lemonade\Framework\Container\ContainerInterface;
use Lemonade\Framework\Core\ServiceProviderInterface;
final class DoctrineServiceProvider implements ServiceProviderInterface
{
public function register(ContainerInterface $container): void
{
$container->singleton(DoctrineEntityManagerFactory::class, DoctrineEntityManagerFactory::class);
$container->singleton(
EntityManagerInterface::class,
static fn (ContainerInterface $container): EntityManagerInterface => $container
->get(DoctrineEntityManagerFactory::class)
->create(),
);
$container->set(OrmDemoArticleRepository::class, OrmDemoArticleRepository::class);
$container->set(OrmDemoTagRepository::class, OrmDemoTagRepository::class);
$container->set(OrmDemoAuthorRepository::class, OrmDemoAuthorRepository::class);
$container->singleton(OrmDemoService::class, OrmDemoService::class);
}
}
Mapuje wielu autorów i wiele tagów za pomocą asocjacji ManyToMany.
<?php
declare(strict_types=1);
namespace App\Entity;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_articles')]
final class OrmDemoArticle
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $title;
#[ORM\Column(type: 'text')]
private string $perex;
/** @var Collection<int, OrmDemoAuthor> */
#[ORM\ManyToMany(targetEntity: OrmDemoAuthor::class, inversedBy: 'articles', cascade: ['persist'])]
#[ORM\JoinTable(name: 'orm_demo_article_authors')]
private Collection $authors;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
/** @var Collection<int, OrmDemoTag> */
#[ORM\ManyToMany(targetEntity: OrmDemoTag::class, inversedBy: 'articles', cascade: ['persist'])]
#[ORM\JoinTable(name: 'orm_demo_article_tags')]
private Collection $tags;
/**
* @param non-empty-list<OrmDemoAuthor> $authors
*/
public function __construct(string $title, string $perex, array $authors, DateTimeImmutable $createdAt)
{
if ($authors === []) {
throw new InvalidArgumentException('An ORM demo article must have at least one author.');
}
$this->title = $title;
$this->perex = $perex;
$this->createdAt = $createdAt;
$this->authors = new ArrayCollection();
$this->tags = new ArrayCollection();
foreach ($authors as $author) {
$this->addAuthor($author);
}
}
public function id(): ?int
{
return $this->id;
}
public function title(): string
{
return $this->title;
}
public function perex(): string
{
return $this->perex;
}
/** @return list<OrmDemoAuthor> */
public function authors(): array
{
return $this->authors->toArray();
}
public function addAuthor(OrmDemoAuthor $author): void
{
if (!$this->authors->contains($author)) {
$this->authors->add($author);
}
}
public function createdAt(): DateTimeImmutable
{
return $this->createdAt;
}
/** @return Collection<int, OrmDemoTag> */
public function tags(): Collection
{
return $this->tags;
}
public function addTag(OrmDemoTag $tag): void
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
}
}
}
Autorzy są osobnymi encjami identyfikowanymi przez unikalny nickname.
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_authors')]
final class OrmDemoAuthor
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 80, unique: true)]
private string $nickname;
/** @var Collection<int, OrmDemoArticle> */
#[ORM\ManyToMany(targetEntity: OrmDemoArticle::class, mappedBy: 'authors')]
private Collection $articles;
public function __construct(string $nickname)
{
$this->nickname = $nickname;
$this->articles = new ArrayCollection();
}
public function id(): ?int
{
return $this->id;
}
public function nickname(): string
{
return $this->nickname;
}
}
Tagi są współdzielonymi encjami połączonymi z artykułami przez tabelę join.
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_tags')]
final class OrmDemoTag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 80)]
private string $name;
#[ORM\Column(type: 'string', length: 80, unique: true)]
private string $slug;
/** @var Collection<int, OrmDemoArticle> */
#[ORM\ManyToMany(targetEntity: OrmDemoArticle::class, mappedBy: 'tags')]
private Collection $articles;
public function __construct(string $name, string $slug)
{
$this->name = $name;
$this->slug = $slug;
$this->articles = new ArrayCollection();
}
public function id(): ?int
{
return $this->id;
}
public function name(): string
{
return $this->name;
}
public function slug(): string
{
return $this->slug;
}
}
Łączy opcjonalne filtry ze stronicowaniem na poziomie SQL i eliminuje zapytania N+1.
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\OrmDemoArticle;
use App\Services\DoctrineEntityManagerFactory;
use Doctrine\ORM\EntityManagerInterface;
final class OrmDemoArticleRepository
{
private const GRAPH_BATCH_SIZE = 25;
public function __construct(private readonly DoctrineEntityManagerFactory $entityManagerFactory)
{
}
public function paginate(int $page, int $perPage, ?string $tag = null, ?string $author = null): OrmDemoArticlePage
{
$perPage = max(1, $perPage);
$count = $this->entityManager()
->createQueryBuilder()
->select('COUNT(DISTINCT article.id)')
->from(OrmDemoArticle::class, 'article');
if ($tag !== null) {
$count
->innerJoin('article.tags', 'tag')
->andWhere('tag.slug = :tag')
->setParameter('tag', $tag);
}
if ($author !== null) {
$count
->innerJoin('article.authors', 'author')
->andWhere('author.nickname = :author')
->setParameter('author', $author);
}
$total = (int) $count->getQuery()->getSingleScalarResult();
$page = min(max(1, $page), max(1, (int) ceil($total / $perPage)));
$idsQuery = $this->entityManager()
->createQueryBuilder()
->select('DISTINCT article.id AS id')
->from(OrmDemoArticle::class, 'article');
if ($tag !== null) {
$idsQuery
->innerJoin('article.tags', 'tag')
->andWhere('tag.slug = :tag')
->setParameter('tag', $tag);
}
if ($author !== null) {
$idsQuery
->innerJoin('article.authors', 'author')
->andWhere('author.nickname = :author')
->setParameter('author', $author);
}
$idsQuery
->orderBy('article.createdAt', 'DESC')
->addOrderBy('article.id', 'DESC')
->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage);
$articles = (function () use ($idsQuery): iterable {
$entityManager = $this->entityManager();
$ids = [];
foreach ($idsQuery->getQuery()->toIterable() as $row) {
$ids[] = (int) $row['id'];
if (count($ids) === self::GRAPH_BATCH_SIZE) {
yield from $this->fetchGraphBatch($entityManager, $ids);
$ids = [];
$entityManager->clear();
}
}
if ($ids !== []) {
yield from $this->fetchGraphBatch($entityManager, $ids);
$entityManager->clear();
}
})();
return new OrmDemoArticlePage($articles, $total, $page);
}
/** @param list<int> $ids @return iterable<OrmDemoArticle> */
private function fetchGraphBatch(EntityManagerInterface $entityManager, array $ids): iterable
{
/** @var list<OrmDemoArticle> $fetched */
$fetched = $entityManager->createQueryBuilder()
->select('article, author, tag')
->from(OrmDemoArticle::class, 'article')
->leftJoin('article.authors', 'author')
->leftJoin('article.tags', 'tag')
->where('article.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
$byId = [];
foreach ($fetched as $article) {
$byId[$article->id() ?? 0] = $article;
}
foreach ($ids as $id) {
if (isset($byId[$id])) {
yield $byId[$id];
}
}
}
public function hasArticles(): bool
{
return $this->entityManager()->getRepository(OrmDemoArticle::class)->count([]) > 0;
}
public function add(OrmDemoArticle $article): void
{
$this->entityManager()->persist($article);
$this->entityManager()->flush();
}
/** @param iterable<OrmDemoArticle> $articles */
public function addAll(iterable $articles, int $batchSize): void
{
$entityManager = $this->entityManager();
$processed = 0;
$batchSize = max(1, $batchSize);
foreach ($articles as $article) {
$entityManager->persist($article);
++$processed;
if ($processed % $batchSize === 0) {
$entityManager->flush();
}
}
$entityManager->flush();
}
private function entityManager(): EntityManagerInterface
{
return $this->entityManagerFactory->create();
}
}
Artykuły mogą mieć wielu autorów i wiele tagów. Obie relacje są w Doctrine ORM mapowane jako ManyToMany. Przy filtrowaniu według autora Doctrine łączy artykuły przez tabelę pośrednią orm_demo_article_authors i wybiera je według author.nickname. Przy filtrowaniu według tagu używa tabeli pośredniej orm_demo_article_tags z warunkiem dla tag.slug. Oba filtry można łączyć.