This library gives you a new way for writing queries. Using the Specification pattern you will get small Specification classes that are highly reusable.
The problem with writing Doctrine queries is that it soon will be messy. When your application grows you will have 20+ function in your Doctrine repositories. All with long and complicated QueryBuilder calls. You will also find that you are using a lot of parameters to the same method to accommodate different use cases.
After a discussion with Kacper Gunia on Sound of Symfony podcast about how to test your Doctrine repositories properly, we (Kacper and Tobias) decided to create this library. We have been inspired by Benjamin Eberlei's thoughts in his blog post.
You are probably wondering why we created this library. Your entity repositories are working just fine as they are, right?
But if your friend open one of your repository classes he/she would probably find that the code is not as perfect as you thought. Entity repositories have a tendency to get messy. Problems may include:
findActiveUser
, findActiveUserWithPicture
, findUserToEmail
, etc)The solution should have the following features:
This is an example of how you use the lib. Say that you want to fetch some Adverts and close them. We should select all Adverts that have their endDate
in the past. If endDate
is null make it 4 weeks after the startDate
.
// Not using the lib
$qb = $this->em->getRepository('HappyrRecruitmentBundle:Advert')
->createQueryBuilder('r');
return $qb->where('r.ended = 0')
->andWhere(
$qb->expr()->orX(
'r.endDate < :now',
$qb->expr()->andX(
'r.endDate IS NULL',
'r.startDate < :timeLimit'
)
)
)
->setParameter('now', new \DateTime())
->setParameter('timeLimit', new \DateTime('-4weeks'))
->getQuery()
->getResult();
// Using the lib
$spec = Spec::andX(
Spec::eq('ended', 0),
Spec::orX(
Spec::lt('endDate', new \DateTime()),
Spec::andX(
Spec::isNull('endDate'),
Spec::lt('startDate', new \DateTime('-4weeks'))
)
)
);
return $this->em->getRepository('HappyrRecruitmentBundle:Advert')->match($spec);
Yes, it looks pretty much the same. But the later is reusable. Say you want another query to fetch Adverts that we should close but only for a specific company.
class AdvertsWeShouldClose extends BaseSpecification
{
public function getSpec()
{
return Spec::andX(
Spec::eq('ended', 0),
Spec::orX(
Spec::lt('endDate', new \DateTime()),
Spec::andX(
Spec::isNull('endDate'),
Spec::lt('startDate', new \DateTime('-4weeks'))
)
)
);
}
}
class OwnedByCompany extends BaseSpecification
{
private $companyId;
public function __construct(Company $company, ?string $context = null)
{
parent::__construct($context);
$this->companyId = $company->getId();
}
public function getSpec()
{
return Spec::andX(
Spec::join('company', 'c'),
Spec::eq('id', $this->companyId, 'c')
);
}
}
class SomeService
{
/**
* Fetch Adverts that we should close but only for a specific company
*/
public function myQuery(Company $company)
{
$spec = Spec::andX(
new AdvertsWeShouldClose(),
new OwnedByCompany($company)
);
return $this->em->getRepository('HappyrRecruitmentBundle:Advert')->match($spec);
}
}
If you were about to do the same thing with only the QueryBuilder it would look like this:
class AdvertRepository extends EntityRepository
{
protected function filterAdvertsWeShouldClose(QueryBuilder $qb)
{
$qb
->andWhere('r.ended = 0')
->andWhere(
$qb->expr()->orX(
'r.endDate < :now',
$qb->expr()->andX('r.endDate IS NULL', 'r.startDate < :timeLimit')
)
)
->setParameter('now', new \DateTime())
->setParameter('timeLimit', new \DateTime('-4weeks'))
;
}
protected function filterOwnedByCompany(QueryBuilder $qb, Company $company)
{
$qb
->join('company', 'c')
->andWhere('c.id = :company_id')
->setParameter('company_id', $company->getId())
;
}
public function myQuery(Company $company)
{
$qb = $this->em->getRepository('HappyrRecruitmentBundle:Advert')->createQueryBuilder('r');
$this->filterAdvertsWeShouldClose($qb);
$this->filterOwnedByCompany($qb, $company);
return $qb->getQuery()->getResult();
}
}
The issues with the QueryBuilder implementation are:
filterOwnedByCompany
and filterAdvertsWeShouldClose
inside AdvertRepository.filterOwnedByCompany()
in that case.You can apply specifications to validate specific entities or dataset.
$highRankFemalesSpec = Spec::andX(
Spec::eq('gender', 'F'),
Spec::gt('points', 9000)
);
// an array of arrays
$playersArr = [
['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500],
['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230],
['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001],
];
// or an array of objects
$playersObj = [
new Player('Joe', 'M', 40, 2500),
new Player('Moe', 'M', 55, 1230),
new Player('Alice', 'F', 27, 9001),
];
foreach ($playersArr as $playerArr) {
if ($highRankFemalesSpec->isSatisfiedBy($playerArr)) {
// do something
}
}
foreach ($playersObj as $playerObj) {
if ($highRankFemalesSpec->isSatisfiedBy($playerObj)) {
// do something
}
}
You can apply specifications to filter collection of entities or datasets.
$highRankFemalesSpec = Spec::andX(
Spec::eq('gender', 'F'),
Spec::gt('points', 9000)
);
// an array of arrays
$playersArr = [
['pseudo' => 'Joe', 'gender' => 'M', 'points' => 2500],
['pseudo' => 'Moe', 'gender' => 'M', 'points' => 1230],
['pseudo' => 'Alice', 'gender' => 'F', 'points' => 9001],
];
// or an array of objects
$playersObj = [
new Player('Joe', 'M', 40, 2500),
new Player('Moe', 'M', 55, 1230),
new Player('Alice', 'F', 27, 9001),
];
$highRankFemales = $highRankFemalesSpec->filterCollection($playersArr);
$highRankFemales = $highRankFemalesSpec->filterCollection($playersObj);
$highRankFemales = $this->em->getRepository(Player::class)->match($highRankFemalesSpec);
You may want to take a look at some usage examples or find out how to create your own spec.