Ukázka ORM

Lemonade Blog

Ukázkový blog postavený na Doctrine ORM a SQLite.

Doctrine ORM · SQLite · ManyToMany

Živý výsledek ORM

Články se štítkem „Cli“ od adam

49 článků

Testing command output

CLI tests protect the messages operators rely on.

Testing command output — field note 0080

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

Queue retries with clear limits — field note 0095

Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.

Testing command output — field note 0130

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

Queue retries with clear limits — field note 0145

Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.

Testing command output — field note 0180

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

Queue retries with clear limits — field note 0195

Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.

Testing command output — field note 0230

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

Queue retries with clear limits — field note 0245

Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.

Testing command output — field note 0280

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

Queue retries with clear limits — field note 0295

Bounded retries make failures visible to operators. This deterministic field note expands the demo dataset.

Jak tento blog funguje

Jak je Doctrine ORM zapojené do aplikace

Stránkovaný blogový výpis čte ID aktuální stránky přes toIterable(); omezená graph dávka pak načte autory a štítky bez N+1 dotazů.

Zapojení provideru

DoctrineServiceProvider.php

Registruje Doctrine jako běžnou aplikační službu prostřednictvím Lemonade provideru.

DoctrineServiceProvider.php
PHP
<?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);
    }
}
Entita článku

OrmDemoArticle.php

Mapuje více autorů i více štítků pomocí asociací ManyToMany.

OrmDemoArticle.php
PHP
<?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);
        }
    }
}
Entita autora

OrmDemoAuthor.php

Autoři jsou samostatné entity určené jedinečným nickname.

OrmDemoAuthor.php
PHP
<?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;
    }
}
Entita štítku

OrmDemoTag.php

Štítky jsou sdílené entity propojené s články přes join tabulku.

OrmDemoTag.php
PHP
<?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;
    }
}
Repository a stránkování

OrmDemoArticleRepository.php

Kombinuje volitelné filtry se stránkováním na úrovni SQL a předchází N+1 dotazům.

OrmDemoArticleRepository.php
PHP
<?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();
    }
}

Jak filtrování funguje

Články mohou mít více autorů i více štítků. Obě vazby jsou v Doctrine ORM namapované jako ManyToMany. Při filtrování podle autora Doctrine propojí články přes spojovací tabulku orm_demo_article_authors a vybere je podle author.nickname. Při filtrování podle štítku použije spojovací tabulku orm_demo_article_tags a podmínku nad tag.slug. Oba filtry lze kombinovat.