0x450x6c/doctrine-computable-fields

Doctrine computable fields.


License
Other

Documentation

description

Usually, dependent fields can be updated in prePersist / preUpdate events, but it’s easy to forget to account for all cases.

This library makes it easier to update dependent entity fields.

features

  • Unified API for update dependent fields.
  • Ability to re-calculate dependent fields via console command.

requirements

  • ensure that your database supports MD5, COALESCE, CONCAT and CAST as text functions.

install

composer require 0x450x6c/doctrine-computable-fields

usage

The computable field flow looks like this:

  • Computable field can depend on one or multiple other fields, so we need collect data from source fields, we do this in two ways, via PHP function, and via DQL expressions.
  • We also need function that receives data of source fields and produces changes to the entity.
  • Library will automatically calculate MD5 hash of all source values, and store that hash on entity.
  • Library will collect DQL expressions, and will produce query to find computable fields that needs to be updated.

So, first, we need implement HasComputableFields interface for entity.

It is needed to add field for store hash of source values.

<?php

namespace App\Entity;

use DoctrineComputableFields\HasComputableFields;
use DoctrineComputableFields\HasComputableFieldsTrait;

class Acme implements HasComputableFields
{
    use HasComputableFieldsTrait;
}

Don’t forget update database schema.

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate -n

Now, we are ready for create computable fields handlers.

Handlers are main block of this library.

Let’s say we have following entities:

<?php

namespace App\Entity;

use DoctrineComputableFields\HasComputableFields;
use DoctrineComputableFields\HasComputableFieldsTrait;
use Doctrine\ORM\Mapping as ORM;

class Acme implements HasComputableFields
{
    use HasComputableFieldsTrait;

    /**
     * @ORM\ManyToOne(
     *     targetEntity=User::class,
     * )
     */
    public User $user;

    /**
     * @ORM\Column()
     */
    public string $myComputableField = '';
}
<?php

namespace App\Entity;

class User
{
    /**
     * @ORM\Column()
     */
    public string $firstName = '';

    /**
     * @ORM\Column()
     */
    public string $lastName = '';

    /**
     * @ORM\OneToMany(
     *     targetEntity=Acme::class,
     * )
     * @var Acme[]|Collection
     */
    public Collection $acmeList;
}

Say, we want update Acme::myComputableField depending on User::firstName and User::lastName.

Then, the handler will be looks like this:

<?php

namespace App\ComputableFields;

use DoctrineComputableFields\ComputableFieldHandler;
use App\Entity;

/**
 * @implements ComputableFieldHandler<Acme>
 */
class AcmeMyComputableFieldHandler implements ComputableFieldHandler
{
    /**
     * Must return entity class name.
     *
     * Used for know which class should be handled.
     */
    public function getEntityClass(): string
    {
        return Acme::class;
    }

    /**
     * Must return source fields list - DQL expressions and functions to retrieve source field value.
     *
     * Used for update computable fields from console command.
     *
     * The key must be acceptable in DQL select.
     *
     * The value must be a function that receives entity and returns scalar value from dependent field.
     */
    public function _invoke(QueryBuilder $qb): \Generator
    {
        // Must be used only left joins,
        //   otherwise updating from console can fail.
        $qb->leftJoin(
          't.user',
          'u'
        );

        yield 'u.firstName' => static fn (Acme $acme): string => $acme->user->firstName;

        // You can return nested values without explicit join.
        // In this case, `user` will be left joined automaticall.
        // See description of `ensure_join` function to more details.
        yield '@user@.lastName' => static fn (Acme $acme): string => $acme->user->lastName;

        // You can use aggregated functions, such as min/max.
        // In this example @invoice@ will be replaced with join alias,
        //   and
        yield [self::AGGREGATED, 'max(@invoices@.amount)'] => static fn (Acme $acme): int => $acme
            ->invoices
            ->map(static fn (Invoice $invoice) => $invoice->amount)
            ->sum();

        // You can use function that receives QueryBuilder, and returns DQL expression.
        yield static function (QueryBuilder $qb, string $alias) {
                $qb->leftJoin("{$alias}.user", 'u');

                return 'u.lastName';
            }
        ] => static fn (Acme $acme): string => $acme->user->lastName;

        // Same with aggregated SQL functions.
        yield [
            self::AGGREGATED,
            static function (QueryBuilder $qb, string $alias) {
                $qb->leftJoin("{$alias}.invoices", 'i');

                return 'max(i.amount)';
            }
        ] => static fn (Acme $acme): int => $acme
            ->invoices
            ->map(static fn (Invoice $invoice) => $invoice->amount)
            ->sum();


        // At the end the function must be returned.
        // This function must receive all source values,
        //   and must produce changes to entity.
        return static function (
            Acme $acme,
            string $firstName,
            string $lastName,
            int $invoiceAmount,
            string $lastName2,
            int $invoiceAmount2
        ) {
            $acme->myComputableField = [
                $firstName,
                $lastName,
                $invoiceAmount,
                $lastName2,
                $invoiceAmount2,
            ];
        };
    }

    /**
     * Must return list of dependency entity class names.
     *
     * When something changes on listed entities,
     *   the library will ensure that this computable field are up to date.
     *
     * @psalm-return iterable<class-string, callable(object): iterable<TEntity>>
     */
    public function dependsOn(): iterable
    {
        yield User::class => static fn (User $user) => $user->acmeList;
        yield Invoice::class => static fn (Invoice $invoice) => $invoice->acmeList;
    }
}

Handlers must be tagged with doctrine_computable_fields.handler.

Let’s do it in services.yaml:

# config/services.yaml

services:
  # ...
  _instanceof:
    DoctrineComputableFields\ComputableFieldHandler:
      tags: ["doctrine_computable_fields.handler"]
  # ...

Everything is ready.

Now, Acme::myComputableField will be automatically updated based on User::firstName and User::lastName.

Also, you can update existing entities by following command:

php bin/console dcf:calc-computable-fields

EXPERIMENTAL

This library provides experimental command to find some broken setup, such as recursion.

php bin/console dcf:find-broken-computable-fields