mediagone/doctrine-specifications

Doctrine implementation of repository Specifications pattern

0.5.0 2024-04-22 14:37 UTC

README

Latest Version on Packagist Total Downloads Software License

You probably already ended up with cluttered repositories or duplicated criteria, making it difficult to compose or maintain your queries.

But what if your queries were looking like this?

// Find all articles written by a given user
$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
);

Or also:

// Find all published articles in a given category
$articles = $repository->find(
    ManyArticles::asEntity()
    ->published()
    ->inCategory($categoryId)
    ->orderedByDateDesc()
    ->paginate($pageNumber, $itemsPerPage)
);

If you like it, you probably need this package ;)

Combinations of criteria are unlimited, without any code duplication.
It will also make hydration of custom read models (DTOs) a breeze.

Summary

  1. Introduction
  2. Example of usage
  3. Extended usages
    1. Return formats
    2. Joins
    3. Read models
    4. Using multiple Entity Managers
    5. Command bus
  4. Generic specifications
    1. Select specifications
    2. Filter specifications
    3. Additional specifications
    4. Debug specifications
  5. Naming and organizing specifications

Installation

This package requires PHP 7.4+ and Doctrine ORM 2.7+

Add it as Composer dependency:

$ composer require mediagone/doctrine-specifications

Introduction

The classic Repository pattern (a single class per entity with several methods, one per query) quickly shows its limitations as it grows toward a big messy class.

Using Query Functions partially solves the problem by splitting up queries into separate classes, but you might still get a lot of code duplication. Things get worse if query criteria can be combined arbitrarily, which may result in the creation of an exponential number of classes.

The Specifications pattern comes to the rescue helping you to split them into explicit and reusable filters, improving useability and testability of your database queries. This package is a customized flavor of this pattern (for purists), inspired by Benjamin Eberlei's article. It revolves around a simple concept: specifications.
Each specification defines a set of criteria that will be automatically applied to Doctrine's QueryBuilder and Query objects, with the help of two methods:

abstract class Specification
{
    public function modifyBuilder(QueryBuilder $builder) : void { }
    public function modifyQuery(Query $query) : void { }
}

Specifications can be freely combined to build complex queries, while remaining easily testable and maintainable separately.

Example of usage

We'll learn together how to create the following query:

$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
        ->orderedAlphabetically()
        ->maxCount(5)
);

Each method splits the query into separate "specifications":

  • asEntity => SelectArticleEntity specification
  • postedByUser => FilterArticlePostedBy specification
  • orderedAlphabetically => OrderArticleAlphabetically specification
  • maxCount => LimitMaxCount specification

It will result in this clean query class:

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'),
        );
    }
    
    public function postedByUser(UserId $userId) : self
    {
        $this->whereFieldEqual('article.user', 'userId', $userId);
        return $this;
    }
    
    public function orderedAlphabetically() : self
    {
        $this->orderResultsByAsc('article.title');
        return $this;
    }
    
    public function maxCount(int $count) : self
    {
        $this->limitResultsMaxCount($count);
        return $this;
    }
}

We'll now explain step by step how to build this class:

SpecificationCompound class

First, we need to create our main class that will be updated later in our example. It extends SpecificationCompound which provides a simple specification registration mechanism, we'll see that in details right after.

We'll use a static factory method asEntity() to build our query object and define its return type. Here we want to get back results as entities, but we could hydrate instead a read model (DTO) (eg. asModel()) or return a scalar value (eg. asCount()).

namespace App\Blog\Article\Query; // Example namespace, choose what fits best to your project

use Mediagone\Doctrine\Specifications\SpecificationCompound;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Put select specifications here (one or more)
        );
    }
    
    // We'll add more specification methods here later
}

Notes:

  • Each SpecificationCompound must be initialized with a result format and (at least) one initial specification.
  • The compound's constructor is protected to enforce the usage of static factory methods, since descriptive naming is more meaningful about what the query will return.
  • You may want to create another compound named OneArticle for queries that will always return a single result.

SelectArticleEntity specification

Our first specification defines the selected entity in our query builder by overloading the modifyBuilder method:

namespace App\Blog\Article\Query\Specifications; // Example namespace

use App\Blog\Article; // assumed FQCN of your entity
use Doctrine\ORM\QueryBuilder;
use Mediagone\Doctrine\Specifications\Specification;

final class SelectArticleEntity extends Specification
{
    public function modifyBuilder(QueryBuilder $builder) : void
    {
        $builder->from(Article::class, 'article');
        $builder->select('article');
    }
}

Let's register it in our specification compound:

...
use App\Blog\Article\Query\Specifications\SelectArticleEntity;
use Mediagone\Doctrine\Specifications\SpecificationRepositoryResult;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            new SelectArticleEntity()
        );
    }
}

This is how we create a custom specification, but having to create a new class for each criterion is really cumbersome. Hopefully, the library provides many generic specifications you can reuse for common usages (see the Generic specifications section below).

So, we can replace our custom specification by the generic one:

use App\Blog\Article;
use Mediagone\Doctrine\Specifications\SpecificationRepositoryResult;
use Mediagone\Doctrine\Specifications\Universal\SelectEntity;

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'), // from + select
        );
    }
}

Filtering specifications

Our second specification will filter articles by author:

final class FilterArticlePostedByUser extends Specification
{
    private UserId $userId;

    public function __construct(UserId $userId)
    {
        $this->userId = $userId;
    }

    public function modifyBuilder(QueryBuilder $builder) : void
    {
        $builder->addWhere('article.authorId = :authorId');
        $builder->setParameter('authorId', $this->userId, 'app_userid');
    }
}

Add it in the compound but this time using a fluent instance method:

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        $this->addSpecification(new FilterArticlePostedByUser($userId));
        return $this;
    }
}

Again, a generic specification exists, but this time you can use the following helper method to do that without using addSpecification() (the method uses it internally):

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        $this->whereFieldEqual('article.user', 'userId', $userId);
        return $this;
    }
}

Now we can do exactly the same for our two last filters: orderedAlphabetically and maxCount:

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function orderedAlphabetically() : self
    {
        // equivalent to: $doctrineQuerybuilder->addOrderBy('article.title', 'ASC');
        $this->orderResultsByAsc('article.title');
        return $this;
    }
    
    public function maxCount(int $count) : self
    {
        // equivalent to: $doctrineQuerybuilder->setMaxResults($count);
        $this->limitResultsMaxCount($count);
        return $this;
    }
}

Execute the query

Finally, we can easily retrieve results according to our specification compound, by using the SpecificationRepository class (which fully replaces traditional Doctrine repositories):

use Mediagone\Doctrine\Specifications\SpecificationRepository;

$repository = new SpecificationRepository($doctrineEntityManager);

$articles = $repository->find(
    ManyArticles::asEntity()
        ->postedByUser($userId)
        ->orderedAlphabetically()
        ->maxCount(5)
);

Notes:

  • Use Dependency Injection (if available) to instantiate the DoctrineSpecificationRepository.
  • You can also use this service class as base to implement your own (eg. bus middlewares).

Extended usages

Return formats

The package allows results to get retrieved in different formats:

  • MANY_OBJECTS : returns an array of hydrated objects (similar to $query->getResult())
  • MANY_OBJECTS_AS_ITERABLE : returns an iterator over the query results (similar to $query->toIterable())
  • SINGLE_OBJECT : returns a single hydrated object or null (similar to $query->getOneOrNullResult())
  • SINGLE_SCALAR : returns a single scalar (similar to $query->getSingleScalarResult())

Thereby, you can use the same specifications for different result types by adding multiple static factory methods in a compound.

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Return results as Article instances
            SelectEntity::specification(Article::class, 'article')
        );
    }
    
    public static function asModel() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            // Return results as ArticleModel instances
            SelectReadModel::specification(Article::class, 'article', ArticleModel::class) 
        );
    }

    public static function asCount() : self
    {
        return new self(
            SpecificationRepositoryResult::SINGLE_SCALAR,
            // Return the number of results
            SelectCount::specification(Article::class, 'article')
        );
    }
    
    // some filtering methods...
}

Exemple of usage:

$articleCount = $repository->find(
    ManyArticles::asCount() // retrieve the count instead of entities
        ->postedByUser($userId)
        ->inCategory($category)
);

Joins

You can define query joins very easily by adding them in the static constructor. \

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article')
            // Join will be applied anytime
            JoinLeft::specification('article.category', 'category'),
        );
    }
}

Note that the join will be applied to all your queries.
But if you want to join only on demand, you can define it only for a given specification:

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article')
        );
    }
    
    public static function byCategoryName(string $categoryName) : self
    {
        $this->joinLeft('article.category', 'category');
        $this->whereFieldEqual('category.name', 'cateName', $categoryName);
    }
}

Joins using the same alias are only added once:

final class ManyArticles extends SpecificationCompound
{
    public static function asEntity() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectEntity::specification(Article::class, 'article'),
            JoinLeft::specification('article.category', 'category') // Join declaration
        );
    }
    
    public static function byCategoryName(string $categoryName) : self
    {
        // Ignored, since the join was already declared in the constructor,
        // it would be the same if declared in another called method.
        $this->joinLeft('article.category', 'category');
        $this->whereFieldEqual('category.name', 'catName', $categoryName);
    }
    
    public static function byParentCategoryName(string $categoryName) : self
    {
        // Not ignored since it uses a different alias ("pcat").
        $this->joinLeft('category.parent', 'pcat');
        $this->whereFieldEqual('pcat.name', 'pcatName', $categoryName);
    }
}

Read models

Retrieving data through dedicated classes, instead of entities might be very powerful (if we don't need to update the entity), because it speeds up complex queries (it limits the number of hydrated objects) and allows to flatten relations.

Let's take these two basic entities:

#[ORM\Entity]
class Article
{
    #[Id]
    #[GeneratedValue]
    #[Column(type: 'integer')]
    private int $id;
    
    #[Column(type: 'string')]
    private string $title;
    
    #[Column(type: 'string')]
    private string $content;
    
    #[ManyToOne(targetEntity: Category::class)]
    private Category $category;
}

#[ORM\Entity]
class Category
{
    #[Id]
    #[GeneratedValue]
    #[Column(type: 'integer')]
    private int $id;
    
    #[Column(type: 'string')]
    private string $name;
}

The normal way to get an Article with the name of it's category would be to query the entity and the related Category entity. But it leads to both objects hydration, and potentially multiple queries (depending on the fetch mode used).

Helpfully, Doctrine offers a way to hydrate custom classes by using the NEW operator (see official documentation).

Keeping in sync the query's selected fields and the DTO's constructor's parameters might be tedious, that's why the package also provides an interface to handle everything for you:

final class ArticleModel implements SpecificationReadModel
{
    private int $id;
    private string $title;
    private string $content;
    private int $categoryId;
    private string $categoryName;
    
    // Keep field list close to the constructor's definition that uses it.
    public static function getDqlConstructorArguments(): array
    {
        return [
            'article.id',
            'article.title',
            'article.content',
            'category.id',
            'category.name',
        ];
    }
    
    public function __construct(
        int $id,
        string $title,
        string $content,
        int $categoryId,
        string $categoryName,
    ) {
        $this->id = $id;
        $this->title = $title;
        $this->content = $content;
        $this->categoryId = $categoryId;
        $this->categoryName = $categoryName;
    }
}

Selecting a Read Model in place of an Entity is very straightforward by registering a SelectReadModel specification in the factory method:

final class ManyArticles extends SpecificationCompound
{
    public static function asModel() : self
    {
        return new self(
            SpecificationRepositoryResult::MANY_OBJECTS,
            SelectReadModel::specification(Article::class, 'article', ArticleModel::class),
            JoinLeft::specification('article.category', 'category') // Join declaration
        );
    }
    
    // ...
}

The previous asModel method translates to the following DQL:

SELECT NEW ArticleModel(article.id, article.title, article.content, category.id, category.name) FROM Article article JOIN article.category category

Using multiple Entity Managers

By default, the default entity manager is used, but you can specify for each Compound which entity manager to use by overloading the getEntityManager method:

final class ManyArticles extends SpecificationCompound
{
    public function getEntityManager(ManagerRegistry $registry) : EntityManager
    {
        return $registry->getManagerForClass(Article::class);
    }
    
}

You can also get it by the name used in the ORM configuration:

public function getEntityManager(ManagerRegistry $registry) : EntityManager
{
    return $registry->getManager('secondary');
}

Command bus

Specification queries are best used through a Query bus, that suits very well with DDD, however it's not mandatory. You can easily tweak your own adapter for any bus or another kind of service.

Your query classes might extend SpecificationCompound, making them automatically handleable by a dedicated bus middleware.

If you're looking for a bus package (or just want to see how it's done), you can use mediagone/cqrs-bus which proposes a SpecificationQuery base class and the SpecificationQueryFetcher middleware.

Generic specifications

To remove the hassle of creating custom specifications for most common usages, the library comes with built-in generic specifications. They can be easily registered using the specific compound's protected methods:

Select specifications

Specification name Description
SelectEntity Select and return the entity as query result.
SelectReadModel Select and return a DTO class as query result.
SelectCount Count and return the number of results of the query.
JoinLeft Declare a Left join.
JoinInner Declare an Inner join.
GroupBy Declare a GroupBy clause.
Having Declare a Hanving clause.

Filter specifications

Specifications usable in criteria methods:

Compound method name Specification name QueryBuilder condition
->whereClause(...) WhereClause custom where clause
->whereFieldDifferent(...) WhereFieldDifferent field != value
->whereFieldEqual(...) WhereFieldEqual field = value
->whereFieldGreater(...) WhereFieldGreater field > value
->whereFieldGreaterOrEqual(...) WhereFieldGreaterOrEqual field >= value
->whereFieldLesser(...) WhereFieldLesser field < value
->whereFieldLesserOrEqual(...) WhereFieldLesserOrEqual field <= value
->whereFieldIn(...) WhereFieldIn field IN (value)
->whereFieldNotIn(...) WhereFieldNotIn field NOT IN (value)
->whereFieldInArray(...) WhereFieldInArray field IN (values,generated,list)
->whereFieldNotInArray(...) WhereFieldNotInArray field NOT IN (values,generated,list)
->whereFieldIsNull(...) WhereFieldIsNull field IS NULL
->whereFieldIsNotNull(...) WhereFieldIsNotNull field IS NOT NULL
->whereFieldLike(...) WhereFieldLike field LIKE 'value'
->whereFieldBetween(...) WhereFieldBetween field >= min AND field <= max
->whereFieldBetweenExclusive(...) WhereFieldBetweenExclusive field > min AND field < max
->orderResultsByAsc(...) OrderResultsByAsc ORDER BY expression ASC
->orderResultsByDesc(...) OrderResultsByDesc ORDER BY expression DESC

Example of usage:

use Mediagone\Doctrine\Specifications\Universal\WhereFieldEqual;

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByUser(UserId $userId) : self
    {
        // the following line
        $this->whereFieldEqual('article.authorId', 'authorId',  $userId, 'app_userid');
        // is equivalent to
        $this->addSpecification(WhereFieldEqual::specification('article.authorId', 'authorId',  $userId, 'app_userid'));
        
        return $this;
    }
}

Additional specifications

Compound method name Specification name Note
->setParameter(...) SetParameter Define a query builder parameter.
->limitResultsOffset(...) LimitResultsOffset (Pagination) Defines how many results to skip.
->limitResultsMaxCount(...) LimitResultsMaxCount (Pagination) Defines the (max) number of returned results.
->limitResultsPaginate(...) LimitResultsPaginate (Pagination) Combines MaxCount and Offset effects, with different parameters.

Exemple of usage:

$pageNumber = 2;
$articlesPerPage = 10;

$articles = $repository->find(
    ManyArticles::asEntity()
    ->postedByUser($userId)
    ->inCategory($category)

    // Add results specifications separately (LimitResultsMaxCount and LimitResultsOffset)
    ->maxResult($articlesPerPage)
    ->resultOffset(($pageNumber - 1) * $articlesPerPage)
    
    // Or use the pagination specification (LimitResultsPaginate)
    ->paginate($pageNumber, $articlesPerPage)
);

A last couple of specifications provide even more flexibility by allowing you to modify the Doctrine QueryBuilder/Query without having to create separate classes:

Compound method name Specification name
->modifyBuilder(...) ModifyBuilder
->modifyQuery(...) ModifyQuery
use Doctrine\ORM\QueryBuilder;
use Mediagone\Doctrine\Specifications\SpecificationCompound;

final class ManyArticles extends SpecificationCompound
{
    // ...
    
    public function postedByOneOfBothUsers(UserId $userId, UserId $userId2) : self
    {
        $this->modifyBuilder(static function(QueryBuilder $builder) use ($userId, $userId2) {
            $builder
                ->andWhere('article.authorId = :authorId OR article.authorId = :authorId2')
                ->setParameter('authorId', $userId)
                ->setParameter('authorId2', $userId2)
            ;
        });
        
        return $this;
    }
}

Debugging specifications

The SpecificationCompound class comes with built-in methods that adds debug oriented specifications to all compound classes, you don't have to include them in your own compounds:

Compound method name Specification name
->dumpDQL(...) DebugDumpDQL
->dumpSQL(...) DebugDumpSQL

So you can easily dump the generated DQL and SQL with few method calls:

$articles = $repository->find(
    ManyArticles::asEntity()
    ->published()
    ->postedByUser($userId)
    
    ->dumpDQL() //  <--- equivalent of   dump($query->getDQL());
    ->dumpSQL() //  <--- equivalent of   dump($query->getSQL());
);

Organizing specifications

Naming specifications

Naming convention used in this exemple is only a suggestion, feel free to adapt to your needs or preferences.

There is no hard requirement about naming, but you should use defined prefixes to differentiate between your specifications:

  • Filter... : specifications that filter out results, but allowing multiple results.
  • Get... : specifications that filter out results, in order to get a unique (or null) result.
  • Order... : specifications that change the results order.
  • Select... : specifications that define selected result data (entities, DTO, joins, groupBy...) ...

Files organization

You'll probably want to create a separate compound for querying single article (eg. OneArticle) since the specification filters are usually not the same for single or array results (shared specifications can be easily added to both compounds).

Hence a suggested file structure might be:

Article
  ├─ Query
  │   ├─ Specifications
  │   │   ├─ FilterArticlePostedBy.php
  │   │   ├─ GetArticleById.php
  │   │   ├─ OrderArticleAlphabetically.php
  │   │   ├─ SelectArticleCount.php
  │   │   ├─ SelectArticleDTO.php
  │   │   └─ SelectArticleEntity.php
  │   │
  │   ├─ ManyArticles.php
  │   └─ OneArticle.php
  │
  ├─ Article.php
  └─ ArticleDTO.php

License

Doctrine Specifications is licensed under MIT license. See LICENSE file.