Queue work after the request
Messages let slow work continue in a CLI worker.
Un piccolo blog basato su Doctrine ORM e SQLite.
Doctrine ORM · SQLite · ManyToMany
147 articoli
Messages let slow work continue in a CLI worker.
Verify configuration, storage, and workers before release.
Pass identifiers instead of serializing a whole object graph.
Dispatch events only once state changes are durable.
Bounded retries make failures visible to operators.
Confirm workers, cache, storage, and configuration before launch.
Messages let slow work continue in a CLI worker. This deterministic field note expands the demo dataset.
Verify configuration, storage, and workers before release. This deterministic field note expands the demo dataset.
Pass identifiers instead of serializing a whole object graph. This deterministic field note expands the demo dataset.
Dispatch events only once state changes are durable. This deterministic field note expands the demo dataset.
Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.
Confirm workers, cache, storage, and configuration before launch. This deterministic field note expands the demo dataset.
Il feed paginato legge gli ID della pagina corrente tramite toIterable(); un batch del grafo limitato carica quindi autori e tag senza query N+1.
Registra Doctrine come normale servizio applicativo tramite un provider 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);
}
}
Mappa più autori e più tag tramite associazioni 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);
}
}
}
Gli autori sono entità separate identificate da un nickname univoco.
<?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;
}
}
I tag sono entità condivise collegate agli articoli tramite una tabella di 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;
}
}
Combina filtri facoltativi con la paginazione a livello SQL ed evita query 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();
}
}
Gli articoli possono avere più autori e più tag. Entrambe le associazioni sono mappate in Doctrine ORM come ManyToMany. Nel filtraggio per autore, Doctrine collega gli articoli tramite la tabella di join orm_demo_article_authors e li seleziona in base a author.nickname. Nel filtraggio per tag, usa la tabella di join orm_demo_article_tags con una condizione su tag.slug. I due filtri possono essere combinati.