RulerZ, specifications and Symfony are in a boat

Jump to: TL;DR

In my previous post, I tried to answer the following question: how do you keep your Doctrine repositories from growing exponentially? Long story short, I came up with a generic solution based on the Specification pattern that essentially abstracts and simplifies the way we write and compose queries. And the best part is that it works with Doctrine but also with any other data-source.

RulerZ was born.

Of course, there was a real need behind my previous question. For one of my current projects, I wanted to be able to switch from one data-source to another. My application would use Doctrine in development and production environment but for tests I wanted my data to live in memory.

First thing first, I needed to be able to manipulate my data.

To achieve that, I defined a CompanyRepository interface — it’s a small project that mainly deals with companies — and wrote two classes implementing it: one using Doctrine and another one storing my objects in memory.

In the beginning, I only needed to save a company and retrieve it by it’s slug so both implementations were simple and it all worked as expected. The real issue came when I started to implement a search engine. For each new search criteria, I had to update two classes and implement the same criteria twice.

At this point, my repositories looked like this:

 1interface CompanyRepository
 2{
 3    public function save(Company $company);
 4    public function find($slug);
 5    public function search(array $criteria = []);
 6}
 7
 8class DoctrineCompanyRepository extends EntityRepository implements CompanyRepository
 9{
10    // ...
11
12    public function search(array $criteria = [])
13    {
14        $qb = $this->createQueryBuilder('c');
15
16        if (!empty($criteria['name'])) {
17            $qb
18                ->andWhere('c.name LIKE :name')
19                ->setParameter('name', sprintf('%%%s%%', $criteria['name']));
20        }
21
22        // other criteria
23
24        return $qb->getQuery()->getResult();
25    }
26}
27
28class InMemoryCompanyRepository implements CompanyRepository
29{
30    // ...
31
32    public function search(array $criteria = [])
33    {
34        $companies = $this->companies;
35
36        if (!empty($criteria['name'])) {
37            $companies = array_filter($companies, function($company) use ($criteria) {
38                return stripos(strtolower($company->getName()), strtolower($criteria['name'])) !== false;
39            });
40        }
41
42        // other criteria
43
44        return $companies;
45    }
46}

The first issue is that my CompanyRepository::search(array $criteria) method violates the open/closed principle. This is solved by replacing the $criteria parameter by specifications. Each specification representing a single search criteria.

The second issue is that with “classic” specifications, the same specification can’t be used to filter data from several data-sources. We saw that with RulerZ, this problem is solved.

Using RulerZ, the two previous repositories can be refactored:

 1interface CompanyRepository
 2{
 3    public function save(Company $company);
 4    public function find($slug);
 5    public function matchingSpec(Specification $spec);
 6}
 7
 8class DoctrineCompanyRepository extends EntityRepository implements CompanyRepository
 9{
10    // ...
11
12    public function matchingSpec(Specification $spec)
13    {
14        $qb = $this->createQueryBuilder('c');
15
16        return $this->rulerz->filterSpec($qb, $spec);
17    }
18}
19
20class InMemoryCompanyRepository implements CompanyRepository
21{
22    private $companies = [];
23
24    // ...
25
26    public function matchingSpec(Specification $spec)
27    {
28        return $this->rulerz->filterSpec($this->companies, $spec);
29    }
30}

You probably noticed a not so subtle difference compared to the old repositories: I rely on RulerZ so I have to inject it somehow. Lucky me, I’m working on a Symfony application and there is a bundle for that!

As RulerZ is now properly integrated into my repositories and I’m able to query my data, I need to address the specification-creation issue. Remember, I was trying to implement a search engine so how do I map a search as expressed by a user (through a form for instance) to a specification?

The idea here is to map a user input — a search criteria — to a specification. A string (or a scalar) to an object. Using Symfony Form component. Looks a lot like a DataTransformer don’t you think?

With that in mind, I wrote the following form type:

 1class CompanySearchType extends AbstractType
 2{
 3    public function buildForm(FormBuilderInterface $builder, array $options)
 4    {
 5        $terms = $builder
 6            ->create('who')
 7            ->addModelTransformer(
 8                new SpecToStringTransformer(Spec\CompanyName::class, 'terms')
 9            );
10
11        $location = $builder
12            ->create('where')
13            ->addModelTransformer(
14                new SpecToStringTransformer(Spec\CompanyLocation::class, 'location')
15            );
16
17        $builder
18            ->add($terms)
19            ->add($location);
20    }
21
22    // ...
23}

The form type itself is pretty straightforward, the only thing to notice is the usage of SpecToStringTransformer. It takes two parameters: the FQCN of the specification to build and a property in this specification containing the value. The transformer’s code itself isn’t really important but if you really want to read it, it’s available as a gist.

What’s important is that combining the Form Component, a simple transformer and RulerZ leads to really simple controllers:

 1public function searchAction(Request $request)
 2{
 3    $results = [];
 4    $form = $this->formFactory->create('company_search');
 5    $form->handleRequest($request);
 6
 7    if ($form->isValid()) {
 8        $spec = new Spec\AndX(array_merge(        // aggregate specifications
 9            array_filter($form->getData()),       // remove empty fields/specs
10            array(new AppSpec\CompanyPublished()) // only display published companies
11        ));
12        $results = $this->companyRepository->matchingSpec($spec);
13    }
14
15    return $this->render('...');
16}

Do you see the magic? A classic form type can automatically build specifications that can be used to retrieve data from a Doctrine repository, an in-memory repository or virtually any other data-source. Also, adding new search criteria boils down to writing its specification and adding a new field in the form type. How cool is that?

TL;DR

Using RulerZ and Symfony you can:

  • use specification objects inside Doctrine repositories to query your data ; * make the Form component build specification objects for you.