mediagone / doctrine-specifications
Doctrine implementation of repository Specifications pattern
Installs: 3 415
Dependents: 2
Suggesters: 0
Security: 0
Stars: 33
Watchers: 2
Forks: 3
Open Issues: 0
Requires
- php: ^7.4|^8.0
- doctrine/orm: ^2.7|^3.0
- symfony/var-dumper: ^5.1|^6.0|^7.0
Requires (Dev)
- phpunit/phpunit: ^9.0
README
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
- Introduction
- Example of usage
- Extended usages
- Generic specifications
- 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
Filter specifications
Specifications usable in criteria methods:
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
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:
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:
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.