Various ideas #6051

Open
opened 2026-01-22 15:25:36 +01:00 by admin · 1 comment
Owner

Originally created by @theofidry on GitHub (Sep 1, 2018).

Feature Request

In some of our private projects we are "extending" Doctrine to provide some additional features. I would like to know of there would be any interest in porting them back to Doctrine, and given the current development state, I suppose Doctrine 3 rather than 2.

This issue is just a list of the stuff we have privately, so it's more to create a discussion about what could be extracted in RFCs and backported rather than being an RFC itself.

A. A cursor based pagination implementation

B. A specification pattern system.

There is some user libraries already, the difference with ours is that it supports the cursor pagination. I guess however it should backported there (in those libraries) instead.

C. An abstract ORM repository to extend

I'll describe here our current system and then I guess we can discuss about what could be in the core.

First off, we have a different repository interfaces (note that it is not ORM related):

Repository.php
    <?php declare(strict_types=1);

    namespace Acme\Domain\Common\Repository;

    use Acme\Domain\Common\Cursor\Cursor;
    use Acme\Domain\Common\NoResultFound;

    /**
     * A repository for storing aggregates.
     *
     * When you change objects that are stored in the repository, call flush() to
     * synchronize the changes back to the underlying persistence layer.
     */
    interface Repository
    {
        public function getCount(): int;

        /**
         * @throws NoResultFound
         *
         * @return Cusor|HasId[]
         */
        public function getMultiple(array $ids, bool $ignoreMissing = false): Cursor;

        /**
         * @return Cusor|HasId[]
         */
        public function getAll(): Cursor;

        /**
         * Clears any cached data in the repository.
         */
        public function free(): void;
    }
EditableRepository.php
    <?php declare(strict_types=1);

    namespace Acme\Domain\Common\Repository;

    use Acme\Domain\Common\Id\HasId;

    /**
     * A repository that can be used to manipulate the aggregates.
     *
     * When you change objects that are stored in the repository, call `flush()` to
     * synchronize the changes back to the underlying persistence layer.
     */
    interface EditableRepository extends Repository
    {
        /**
         * @param HasId[] $aggregates
         *
         * @throws DuplicateValue If a unique constraint is violated
         */
        public function addMultiple(array $aggregates): void;

        public function removeAll(): void;

        public function flush(): void;
    }

We then have an abstract ORM repository which looks like this:

ORMRepository.php
    <?php declare(strict_types=1);

    namespace Acme\Infrastructure\ORM\Repository;

    use function array_map;
    use function count;
    use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
    use Doctrine\ORM\EntityManager;
    use Doctrine\ORM\EntityNotFoundException;
    use Doctrine\ORM\EntityRepository;
    use Doctrine\ORM\Mapping\ClassMetadata;
    use LogicException;
    use Acme\Domain\Common\Cursor\ArrayCursor;
    use Acme\Domain\Common\Cursor\Cursor;
    use Acme\Domain\Common\Cursor\EmptyCursor;
    use Acme\Domain\Common\Id\HasId;
    use Acme\Domain\Common\Id\Id;
    use Acme\Domain\Common\Repository\DuplicateValue;
    use Acme\Domain\Common\Repository\EditableRepository;
    use Acme\Domain\Common\Repository\NoResultFound;

    /**
     * Base class for repositories backed by Doctrine ORM.
     */
    abstract class ORMRepository extends EntityRepository implements EditableRepository
    {
        private const UNIQUE_VIOLATION_PATTERN = '/SQLSTATE\[23505\].*\(([^\(]+)\)=\(([^\(]+)\)/s';

        /**
         * @var string
         */
        protected $idField;

        /**
         * {@inheritdoc}
         */
        public function __construct(EntityManager $em, ClassMetadata $classMetadata)
        {
            parent::__construct($em, $classMetadata);

            $this->idField = current($classMetadata->getIdentifierFieldNames());
        }

        /**
         * {@inheritdoc}
         */
        public function getCount(): int
        {
            return $this->createQueryBuilder('aggregate')
                ->select('COUNT(aggregate)')
                ->getQuery()
                ->getSingleScalarResult()
                ;
        }

        /**
         * {@inheritdoc}
         */
        public function addMultiple(array $objects): void
        {
            try {
                foreach ($objects as $object) {
                    $this->_em->persist($object);
                }

                $this->_em->flush($objects);
            } catch (UniqueConstraintViolationException $exception) {
                if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) {
                    throw $this->duplicateValueForUniqueViolation($matches);
                }

                throw new LogicException(
                    sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()),
                    $exception->getCode(),
                    $exception
                );
            }
        }

        /**
         * {@inheritdoc}
         */
        public function getMultiple(array $ids, bool $ignoreMissing = false): Cursor
        {
            if (0 === count($ids)) {
                return new EmptyCursor();
            }

            $aggregates = $this->createQueryBuilder('aggregate', sprintf('aggregate.%s', $this->idField))
                ->where(sprintf('aggregate.%s IN (:ids)', $this->idField))
                ->setParameter('ids', array_map('strval', array_values($ids)))
                ->getQuery()
                ->execute()
            ;

            $notFoundIds = array_filter(
                $ids,
                function (Id $id) use ($aggregates) {
                    return !isset($aggregates[$id->toString()]);
                }
            );

            if (!$ignoreMissing && count($notFoundIds) > 0) {
                throw NoResultFound::objectsWithIds($notFoundIds, $this->getClassName());
            }

            return new ArrayCursor($aggregates);
        }

        /**
         * {@inheritdoc}
         */
        public function getAll(): Cursor
        {
            return new ArrayCursor(
                $this->createQueryBuilder('aggregate')->getQuery()->execute()
            );
        }

        /**
         * {@inheritdoc}
         */
        public function removeAll(): void
        {
            $this->createQueryBuilder('aggregate')
                ->delete()
                ->getQuery()
                ->execute()
            ;

            $this->free();
        }

        /**
         * {@inheritdoc}
         */
        public function flush(): void
        {
            $this->doFlush($this->getOwnedClasses());
        }

        /**
         * {@inheritdoc}
         */
        public function free(): void
        {
            $this->doFree($this->getOwnedClasses());
        }

        /**
         * @return string[]
         */
        protected function getOwnedClasses(): array
        {
            return [
                $this->getEntityName(),
                // This can be overridden to include sub classes
            ];
        }

        /**
         * Synchronizes the changes of all objects of the given class names with
         * the database.
         *
         * @param string[] $classNames The class names of the aggregates to
         *                             synchronize
         *
         * @throws DuplicateValue If a unique constraint was violated
         */
        protected function doFlush(array $classNames): void
        {
            foreach ($classNames as $className) {
                // Get fresh identity map after every flush() (orphan removal!)
                $identityMap = $this->_em->getUnitOfWork()->getIdentityMap();

                if (isset($identityMap[$className])) {
                    try {
                        $this->_em->flush($identityMap[$className]);
                    } catch (UniqueConstraintViolationException $exception) {
                        if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) {
                            throw $this->duplicateValueForUniqueViolation($matches);
                        }

                        throw new LogicException(
                            sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()),
                            $exception->getCode(),
                            $exception
                        );
                    }
                }
            }
        }

        /**
         * @param string[] $classNames
         */
        protected function doFree(array $classNames): void
        {
            foreach ($classNames as $className) {
                $this->_em->clear($className);
            }
        }

        /**
         * @throws DuplicateValue
         */
        protected function doAdd(HasId $object): void
        {
            try {
                $this->_em->persist($object);
                $this->_em->flush($object);
            } catch (UniqueConstraintViolationException $exception) {
                if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) {
                    throw $this->duplicateValueForUniqueViolation($matches);
                }

                throw new LogicException(
                    sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()),
                    $exception->getCode(),
                    $exception
                );
            }
        }

        /**
         * @throws NoResultFound
         */
        protected function doGet($id): object
        {
            if (null === ($object = $this->find($id))) {
                throw NoResultFound::objectWithId($id, $this->getEntityName());
            }

            return $object;
        }

        /**
         * Returns the first aggregate matching the given criteria.
         *
         * @param array $criteria A list of field names and their values that the
         *                        returned users should have. Field names are
         *                        translated to method names by prepending "get"
         *                        and "is", for example: "accepted" -> "isAccepted"
         *
         * @throws NoResultFound
         */
        public function getBy(array $criteria): object
        {
            if (null === $aggregate = $this->findOneBy($criteria)) {
                throw NoResultFound::objectWithProperties(
                    array_keys($criteria),
                    $criteria,
                    $this->getClassName()
                );
            }

            return $aggregate;
        }

        protected function doesExist($id): bool
        {
            $identityMap = $this->_em->getUnitOfWork()->getIdentityMap();
            $idString = (string) $id;

            // Performance optimization for loaded objects
            if ($idString && isset($identityMap[$this->getEntityName()][$idString])) {
                return true;
            }

            return $this->createQueryBuilder('c')
                    ->select(sprintf('COUNT(c.%s)', $this->idField))
                    ->where(sprintf('c.%s = :id', $this->idField))
                    ->setParameter('id', $idString)
                    ->getQuery()
                    ->getSingleScalarResult() > 0;
        }

        /**
         * @return int The number of objects that has been removed
         */
        protected function doRemove($id): int
        {
            $reference = $this->_em->getReference($this->getEntityName(), $id);

            try {
                $this->_em->remove($reference);
                $this->_em->flush($reference);

                return 1;
            } catch (EntityNotFoundException $e) {
                // getReference() adds the reference to the identity map, so we need
                // to remove it again
                $this->_em->detach($reference);

                return 0;
            }
        }

        /**
         * @param array $matches
         *
         * @return DuplicateValue
         */
        protected function duplicateValueForUniqueViolation(array $matches): DuplicateValue
        {
            return DuplicateValue::properties(
                array_map('trim', explode(',', $matches[1])),
                array_map('trim', explode(',', $matches[2])),
                $this->getEntityName()
            );
        }
    }

Then, when we declare a doctrine repository (we also support other kinds like in-memory) as a service, we have a system generating a base repository interface and classes (one for each type, ORM, in-memory, ...) which looks like this:

GeneratedRepository.php
    <?php declare(strict_types=1);

    namespace Acme\Generated\Domain\MyEntity;

    use Acme\Domain\Common\Cursor\Cursor;
    use Acme\Domain\Common\Repository\DuplicateValue;
    use Acme\Domain\Common\Repository\EditableRepository;
    use Acme\Domain\Common\Repository\NoResultFound;
    use Acme\Domain\MyEntity\MyEntity;
    use Acme\Domain\MyEntity\MyEntityId;

    interface GeneratedMyEntityRepository extends EditableRepository
    {
        /**
         * @throws DuplicateValue
         */
        public function add(MyEntity $myEntity): void;

        /**
         * @param MyEntity[] $myEntities
         *
         * @throws DuplicateValue
         */
        public function addMultiple(array $myEntities): void;

        /**
         * @throws NoResultFound
         */
        public function get(MyEntityId $id): MyEntity;

        /**
         * @param MyEntityId[] $ids
         *
         * @throws NoResultFound
         *
         * @return Cursor|MyEntity[]
         */
        public function getMultiple(array $ids): Cursor;

        /**
         * @return Cursor|MyEntity[]
         */
        public function getAll(): Cursor;

        /**
         * @param MyEntityId $id The ID
         */
        public function exists(MyEntityId $id): bool;

        /**
         * @return int The number of aggregates that were actually removed
         *             from the repository
         */
        public function remove(MyEntityId $id): int;
    }
GeneratedORMRepository.php
    <?php declare(strict_types=1);

    namespace Acme\Generated\Domain\MyEntity;

    use Acme\Domain\MyEntity\MyEntity;
    use Acme\Domain\MyEntity\MyEntityRepository;
    use Acme\Domain\MyEntity\MyEntityId;
    use Acme\Infrastructure\ORM\Repository\ORMRepository;

    abstract class GeneratedORMMyEntityRepository extends ORMRepository implements MyEntityRepository
    {
        public function add(MyEntity $myEntity): void
        {
            $this->doAdd($myEntity);
        }

        public function get(MyEntityId $id): MyEntity
        {
            return $this->doGet($id);
        }

        public function exists(MyEntityId $id): bool
        {
            return $this->doesExist($id);
        }

        public function remove(MyEntityId $id): int
        {
            return $this->doRemove($id);
        }
    }

We then have our regular repository interface, ORM/in-memory/... classes which extend their respective generated class. This allows to have typehinted methods for each entity without having to write them down manually which is both tedious and error prone.

An extension to deal with foreign keys

We extend the doctrine schema to allow something like the following:

    <id name="my_entity_id" type="my_entity_id">
            <options>
                <option name="references">
                    <option name="entity">FQCN\Of\AnotherEntity</option>
                    <option name="field">id</option>
                    <option name="onDelete">CASCADE</option>
                </option>
            </options>
        </id>

which would generate something like:

ALTER TABLE my_entity ADD CONSTRAINT FK_4CE7603DC98B82DF FOREIGN KEY (my_entity_id) REFERENCES my_entity (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE;

(works on a regular field and ids)

Synchronization of owned values

When dealing with a one-to-many in Doctrine, it can be pretty annoying. To deal with that, we have an OwnedValue base class:

OwnedValue.php
    <?php

/*
 * This file is part of the ÖWM API.
 *
 * (c) 2016-2018 cwd.at GmbH <office@cwd.at>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types=1);

    namespace Acme\Domain\Value;

    use Doctrine\Common\Collections\Collection;
    use Webmozart\Assert\Assert;

    /**
     * Helper entity for persisting collections of value objects with Doctrine.
     *
     * In relational databases, single value objects can be stored embedded in the
     * entity that contains them. However, if an entity owns a collection of value
     * objects (like a contact does phone numbers), these need to be stored in
     * a separate relation.
     *
     * These separate relations need an ID and a reference (foreign key) back to
     * the object that owns them. None of that exists in a value object. To add
     * this kind of information to a value object, it is wrapped into a OwnedValue.
     *
     * If you want to store a collection of value objects, create a new subclass
     * of OwnedValue and override the OWNER_CLASS and VALUE_CLASS constants with:
     *
     *  * The class name of the owning class (e.g. Contact)
     *  * The class name of the value object (e.g. PhoneNumber)
     *
     * Example:
     *
     * class ContactPhoneNumber extends OwnedValue
     * {
     *     protected const OWNER_CLASS = Contact::class;
     *
     *     protected const VALUE_CLASS = PhoneNumber::class;
     * }
     *
     * In the owning class, use the methods synchronize() and synchronizeAll() to
     * synchronize OwnedValue entities with actual value objects.
     *
     * Example:
     *
     * public function changePhoneNumbers(array $phoneNumbers)
     * {
     *     // $this->phoneNumbers is a collection of ContactPhoneNumbers
     *     ContactPhoneNumber::synchronizeAll($this->phoneNumbers, $phoneNumbers, $this);
     * }
     */
    abstract class OwnedValue
    {
        /**
         * The name of the class owning the value object.
         *
         * Should be overridden in subclasses.
         */
        protected const OWNER_CLASS = '';

        /**
         * The class name of the value object.
         *
         * Should be overridden in subclasses.
         */
        protected const VALUE_CLASS = '';

        /**
         * An auto-generated ID.
         *
         * @var int
         */
        private $id;

        /**
         * The owning object.
         *
         * This object has the type stored in self::OWNER_CLASS.
         *
         * @var object
         */
        private $owner;

        /**
         * The value object.
         *
         * This object has the type stored in self::VALUE_CLASS.
         *
         * @var object
         */
        private $value;

        /**
         * Synchronizes a single owned value with a value object.
         *
         * This method copies the data of the value object into the owned value.
         *
         * @param null|static $ownedValue The owned value
         * @param object      $value      The value object
         * @param object      $owner      The owner
         */
        final public static function synchronize(&$ownedValue, object $value, object $owner): void
        {
            if ($ownedValue instanceof static) {
                $ownedValue->setValue($value);
            } else {
                $ownedValue = static::create($owner, $value);
            }
        }

        /**
         * Synchronizes a collection of owned values with a list of value objects.
         *
         * This method copies the data of the value objects into the owned values
         * and resizes the list accordingly.
         *
         * @param Collection $ownedValues The owned values
         * @param object[]   $values      The value objects
         * @param object     $owner       The owner
         */
        final public static function synchronizeAll(Collection $ownedValues, array $values, object $owner): void
        {
            $i = 0;

            foreach ($values as $value) {
                Assert::isInstanceOf($value, static::VALUE_CLASS);

                if (isset($ownedValues[$i])) {
                    $ownedValues[$i]->setValue($value);
                } else {
                    $ownedValues[$i] = static::create($owner, $value);
                }

                ++$i;
            }

            for ($l = count($ownedValues); $i < $l; ++$i) {
                $ownedValues->remove($i);
            }
        }

        /**
         * Extracts the value object of an owned value.
         *
         * @param null|OwnedValue $ownedValue The owned value. May be null
         *
         * @return null|object The value object or null
         */
        final public static function unwrap(?self $ownedValue): ?object
        {
            return $ownedValue ? $ownedValue->getValue() : null;
        }

        /**
         * Extracts the value objects of a list of owned values.
         *
         * @param Collection $ownedValues The owned values
         *
         * @return object[] The value objects
         */
        final public static function unwrapAll(Collection $ownedValues): array
        {
            return array_map([__CLASS__, 'unwrap'], $ownedValues->toArray());
        }

        /**
         * Creates a new owned value.
         *
         * @param object $owner The owner. Must be of the type stored in
         *                      static::OWNER_CLASS
         * @param object $value The value object. Must be of the type stored in
         *                      static::VALUE_CLASS
         *
         * @return static The owned value
         */
        final public static function create($owner, $value): self
        {
            return new static($owner, $value);
        }

        /**
         * Returns the ID of the owned value.
         *
         * @return int|null The ID. Returns null if the owned value has not yet been
         *                  persisted yet
         */
        final public function getId(): ?int
        {
            return $this->id;
        }

        /**
         * Returns the owner.
         *
         * @return object An object of the type stored in static::OWNER_CLASS
         */
        final public function getOwner(): object
        {
            return $this->owner;
        }

        /**
         * Returns the wrapped value object.
         *
         * @return object An object of the type stored in static::VALUE_CLASS
         */
        final public function getValue(): object
        {
            return $this->value;
        }

        /**
         * Sets the wrapped value object.
         *
         * @param object $value An object of the type stored in static::VALUE_CLASS
         */
        final public function setValue($value): void
        {
            Assert::isInstanceOf($value, static::VALUE_CLASS);

            $this->value = $value;
        }

        final private function __construct($owner, $value)
        {
            Assert::isInstanceOf($owner, static::OWNER_CLASS);
            Assert::isInstanceOf($value, static::VALUE_CLASS);

            $this->owner = $owner;
            $this->value = $value;
        }
    }

The code of the class above is non-trivial, but then it declaring a one-to-many relation is relatively simple:

Owner.orm.xml
    <?xml version="1.0" encoding="UTF-8" ?>
    <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                      xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
        http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

        <entity name="Acme\Domain\Owner"
                repository-class="Acme\Infrastructure\ORM\Repository\Domain\ORMOwnedRepository">
            <id name="id" type="owner_id">
                <generator strategy="NONE" />
            </id>

            <one-to-many
                target-entity="Acme\Domain\MyOwnedEntity"
                field="emails"
                mapped-by="owner"
                orphan-removal="true">
                <cascade>
                    <cascade-persist/>
                </cascade>
                <order-by>
                    <!-- Preserve the order of the collection -->
                    <order-by-field name="id" direction="ASC"/>
                </order-by>
            </one-to-many>
        </entity>

    </doctrine-mapping>
    ```
    </details>

    <details>
        <summary>MyOwnedEntity.orm.xml</summary>
        ```xml
        <?xml version="1.0" encoding="UTF-8" ?>
        <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                          xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
        http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

            <entity name="Acme\Domain\MyOwnedEntity">
                <id name="id" type="integer">
                    <generator strategy="AUTO" />
                </id>

                <many-to-one target-entity="Acme\Domain\Owner"
                             field="owner"
                             inversed-by="emails">
                    <join-column nullable="false" on-delete="CASCADE"/>
                </many-to-one>
            </entity>
        </doctrine-mapping>

With the three following entities:

Owner.php
<?php declare(strict_types=1);

namespace Acme\Domain;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Acme\Domain\Common\Id\HasId;
use Acme\Domain\OwnerOwnedEntity;
use Acme\Domain\OwnedEntity;

class Owner implements HasId
{
    private $id;

    /**
     * @var Collection|OwnerOwnedEntity[]
     */
    private $ownedEntities;

    public function __construct(OwnerId $id)
    {
        $this->id = $id;
        $this->ownedEntities = new ArrayCollection();
    }

    /**
     * {@inheritdoc}
     */
    public function getId(): OwnerId
    {
        return $this->id;
    }

    /**
     * @param OwnedEntity[] $ownedEntities
     */
    public function changeEmails(array $ownedEntities)
    {
        OwnerOwnedEntity::synchronizeAll($this->ownedEntities, $ownedEntities, $this);
    }

    /**
     * @return OwnedEntity[]
     */
    public function getEmails(): array
    {
        return OwnerOwnedEntity::unwrapAll($this->ownedEntities);
    }

    public function __clone()
    {
        $this->ownedEntities = new ArrayCollection($this->ownedEntities->toArray());
    }
}

And the magic entity:

OwnerOwnedEntity.php
<?php declare(strict_types=1);

namespace Acme\Domain;

use Acme\Domain\OwnedEntity;
use Acme\Domain\OwnedValue;

/**
 * @internal
 */
class OwnerOwnedEntity extends OwnedValue
{
    /**
     * {@inheritdoc}
     */
    protected const OWNER_CLASS = Owner::class;

    /**
     * {@inheritdoc}
     */
    protected const VALUE_CLASS = OwnedEntity::class;
}

Any interest merging any of this in the core?

Originally created by @theofidry on GitHub (Sep 1, 2018). ### Feature Request In some of our private projects we are "extending" Doctrine to provide some additional features. I would like to know of there would be any interest in porting them back to Doctrine, and given the current development state, I suppose Doctrine 3 rather than 2. This issue is just a list of the stuff we have privately, so it's more to create a discussion about what could be extracted in RFCs and backported rather than being an RFC itself. ### A. [A cursor based pagination](https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination/) implementation ### B. [A specification pattern](https://en.wikipedia.org/wiki/Specification_pattern) system. There is some user libraries already, the difference with ours is that it supports the cursor pagination. I guess however it should backported there (in those libraries) instead. ### C. An abstract ORM repository to extend I'll describe here our current system and then I guess we can discuss about what could be in the core. First off, we have a different repository interfaces (note that it is not ORM related): <details> <summary>Repository.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Domain\Common\Repository; use Acme\Domain\Common\Cursor\Cursor; use Acme\Domain\Common\NoResultFound; /** * A repository for storing aggregates. * * When you change objects that are stored in the repository, call flush() to * synchronize the changes back to the underlying persistence layer. */ interface Repository { public function getCount(): int; /** * @throws NoResultFound * * @return Cusor|HasId[] */ public function getMultiple(array $ids, bool $ignoreMissing = false): Cursor; /** * @return Cusor|HasId[] */ public function getAll(): Cursor; /** * Clears any cached data in the repository. */ public function free(): void; } ``` </details> <details> <summary>EditableRepository.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Domain\Common\Repository; use Acme\Domain\Common\Id\HasId; /** * A repository that can be used to manipulate the aggregates. * * When you change objects that are stored in the repository, call `flush()` to * synchronize the changes back to the underlying persistence layer. */ interface EditableRepository extends Repository { /** * @param HasId[] $aggregates * * @throws DuplicateValue If a unique constraint is violated */ public function addMultiple(array $aggregates): void; public function removeAll(): void; public function flush(): void; } ``` </details> We then have an abstract ORM repository which looks like this: <details> <summary>ORMRepository.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Infrastructure\ORM\Repository; use function array_map; use function count; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; use LogicException; use Acme\Domain\Common\Cursor\ArrayCursor; use Acme\Domain\Common\Cursor\Cursor; use Acme\Domain\Common\Cursor\EmptyCursor; use Acme\Domain\Common\Id\HasId; use Acme\Domain\Common\Id\Id; use Acme\Domain\Common\Repository\DuplicateValue; use Acme\Domain\Common\Repository\EditableRepository; use Acme\Domain\Common\Repository\NoResultFound; /** * Base class for repositories backed by Doctrine ORM. */ abstract class ORMRepository extends EntityRepository implements EditableRepository { private const UNIQUE_VIOLATION_PATTERN = '/SQLSTATE\[23505\].*\(([^\(]+)\)=\(([^\(]+)\)/s'; /** * @var string */ protected $idField; /** * {@inheritdoc} */ public function __construct(EntityManager $em, ClassMetadata $classMetadata) { parent::__construct($em, $classMetadata); $this->idField = current($classMetadata->getIdentifierFieldNames()); } /** * {@inheritdoc} */ public function getCount(): int { return $this->createQueryBuilder('aggregate') ->select('COUNT(aggregate)') ->getQuery() ->getSingleScalarResult() ; } /** * {@inheritdoc} */ public function addMultiple(array $objects): void { try { foreach ($objects as $object) { $this->_em->persist($object); } $this->_em->flush($objects); } catch (UniqueConstraintViolationException $exception) { if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) { throw $this->duplicateValueForUniqueViolation($matches); } throw new LogicException( sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()), $exception->getCode(), $exception ); } } /** * {@inheritdoc} */ public function getMultiple(array $ids, bool $ignoreMissing = false): Cursor { if (0 === count($ids)) { return new EmptyCursor(); } $aggregates = $this->createQueryBuilder('aggregate', sprintf('aggregate.%s', $this->idField)) ->where(sprintf('aggregate.%s IN (:ids)', $this->idField)) ->setParameter('ids', array_map('strval', array_values($ids))) ->getQuery() ->execute() ; $notFoundIds = array_filter( $ids, function (Id $id) use ($aggregates) { return !isset($aggregates[$id->toString()]); } ); if (!$ignoreMissing && count($notFoundIds) > 0) { throw NoResultFound::objectsWithIds($notFoundIds, $this->getClassName()); } return new ArrayCursor($aggregates); } /** * {@inheritdoc} */ public function getAll(): Cursor { return new ArrayCursor( $this->createQueryBuilder('aggregate')->getQuery()->execute() ); } /** * {@inheritdoc} */ public function removeAll(): void { $this->createQueryBuilder('aggregate') ->delete() ->getQuery() ->execute() ; $this->free(); } /** * {@inheritdoc} */ public function flush(): void { $this->doFlush($this->getOwnedClasses()); } /** * {@inheritdoc} */ public function free(): void { $this->doFree($this->getOwnedClasses()); } /** * @return string[] */ protected function getOwnedClasses(): array { return [ $this->getEntityName(), // This can be overridden to include sub classes ]; } /** * Synchronizes the changes of all objects of the given class names with * the database. * * @param string[] $classNames The class names of the aggregates to * synchronize * * @throws DuplicateValue If a unique constraint was violated */ protected function doFlush(array $classNames): void { foreach ($classNames as $className) { // Get fresh identity map after every flush() (orphan removal!) $identityMap = $this->_em->getUnitOfWork()->getIdentityMap(); if (isset($identityMap[$className])) { try { $this->_em->flush($identityMap[$className]); } catch (UniqueConstraintViolationException $exception) { if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) { throw $this->duplicateValueForUniqueViolation($matches); } throw new LogicException( sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()), $exception->getCode(), $exception ); } } } } /** * @param string[] $classNames */ protected function doFree(array $classNames): void { foreach ($classNames as $className) { $this->_em->clear($className); } } /** * @throws DuplicateValue */ protected function doAdd(HasId $object): void { try { $this->_em->persist($object); $this->_em->flush($object); } catch (UniqueConstraintViolationException $exception) { if (preg_match(self::UNIQUE_VIOLATION_PATTERN, $exception->getMessage(), $matches)) { throw $this->duplicateValueForUniqueViolation($matches); } throw new LogicException( sprintf('Unknown unique constraint violation: "%s"', $exception->getMessage()), $exception->getCode(), $exception ); } } /** * @throws NoResultFound */ protected function doGet($id): object { if (null === ($object = $this->find($id))) { throw NoResultFound::objectWithId($id, $this->getEntityName()); } return $object; } /** * Returns the first aggregate matching the given criteria. * * @param array $criteria A list of field names and their values that the * returned users should have. Field names are * translated to method names by prepending "get" * and "is", for example: "accepted" -> "isAccepted" * * @throws NoResultFound */ public function getBy(array $criteria): object { if (null === $aggregate = $this->findOneBy($criteria)) { throw NoResultFound::objectWithProperties( array_keys($criteria), $criteria, $this->getClassName() ); } return $aggregate; } protected function doesExist($id): bool { $identityMap = $this->_em->getUnitOfWork()->getIdentityMap(); $idString = (string) $id; // Performance optimization for loaded objects if ($idString && isset($identityMap[$this->getEntityName()][$idString])) { return true; } return $this->createQueryBuilder('c') ->select(sprintf('COUNT(c.%s)', $this->idField)) ->where(sprintf('c.%s = :id', $this->idField)) ->setParameter('id', $idString) ->getQuery() ->getSingleScalarResult() > 0; } /** * @return int The number of objects that has been removed */ protected function doRemove($id): int { $reference = $this->_em->getReference($this->getEntityName(), $id); try { $this->_em->remove($reference); $this->_em->flush($reference); return 1; } catch (EntityNotFoundException $e) { // getReference() adds the reference to the identity map, so we need // to remove it again $this->_em->detach($reference); return 0; } } /** * @param array $matches * * @return DuplicateValue */ protected function duplicateValueForUniqueViolation(array $matches): DuplicateValue { return DuplicateValue::properties( array_map('trim', explode(',', $matches[1])), array_map('trim', explode(',', $matches[2])), $this->getEntityName() ); } } ``` </details> Then, when we declare a doctrine repository (we also support other kinds like in-memory) as a service, we have a system generating a base repository interface and classes (one for each type, ORM, in-memory, ...) which looks like this: <details> <summary>GeneratedRepository.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Generated\Domain\MyEntity; use Acme\Domain\Common\Cursor\Cursor; use Acme\Domain\Common\Repository\DuplicateValue; use Acme\Domain\Common\Repository\EditableRepository; use Acme\Domain\Common\Repository\NoResultFound; use Acme\Domain\MyEntity\MyEntity; use Acme\Domain\MyEntity\MyEntityId; interface GeneratedMyEntityRepository extends EditableRepository { /** * @throws DuplicateValue */ public function add(MyEntity $myEntity): void; /** * @param MyEntity[] $myEntities * * @throws DuplicateValue */ public function addMultiple(array $myEntities): void; /** * @throws NoResultFound */ public function get(MyEntityId $id): MyEntity; /** * @param MyEntityId[] $ids * * @throws NoResultFound * * @return Cursor|MyEntity[] */ public function getMultiple(array $ids): Cursor; /** * @return Cursor|MyEntity[] */ public function getAll(): Cursor; /** * @param MyEntityId $id The ID */ public function exists(MyEntityId $id): bool; /** * @return int The number of aggregates that were actually removed * from the repository */ public function remove(MyEntityId $id): int; } ``` </details> <details> <summary>GeneratedORMRepository.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Generated\Domain\MyEntity; use Acme\Domain\MyEntity\MyEntity; use Acme\Domain\MyEntity\MyEntityRepository; use Acme\Domain\MyEntity\MyEntityId; use Acme\Infrastructure\ORM\Repository\ORMRepository; abstract class GeneratedORMMyEntityRepository extends ORMRepository implements MyEntityRepository { public function add(MyEntity $myEntity): void { $this->doAdd($myEntity); } public function get(MyEntityId $id): MyEntity { return $this->doGet($id); } public function exists(MyEntityId $id): bool { return $this->doesExist($id); } public function remove(MyEntityId $id): int { return $this->doRemove($id); } } ``` </details> We then have our regular repository interface, ORM/in-memory/... classes which extend their respective generated class. This allows to have typehinted methods for each entity without having to write them down manually which is both tedious and error prone. ### An extension to deal with foreign keys We extend the doctrine schema to allow something like the following: ```xml <id name="my_entity_id" type="my_entity_id"> <options> <option name="references"> <option name="entity">FQCN\Of\AnotherEntity</option> <option name="field">id</option> <option name="onDelete">CASCADE</option> </option> </options> </id> ``` which would generate something like: ```sql ALTER TABLE my_entity ADD CONSTRAINT FK_4CE7603DC98B82DF FOREIGN KEY (my_entity_id) REFERENCES my_entity (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE; ``` (works on a regular field and ids) ### Synchronization of owned values When dealing with a one-to-many in Doctrine, it can be pretty annoying. To deal with that, we have an `OwnedValue` base class: <details> <summary>OwnedValue.php</summary> ```php <?php /* * This file is part of the ÖWM API. * * (c) 2016-2018 cwd.at GmbH <office@cwd.at> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ declare(strict_types=1); namespace Acme\Domain\Value; use Doctrine\Common\Collections\Collection; use Webmozart\Assert\Assert; /** * Helper entity for persisting collections of value objects with Doctrine. * * In relational databases, single value objects can be stored embedded in the * entity that contains them. However, if an entity owns a collection of value * objects (like a contact does phone numbers), these need to be stored in * a separate relation. * * These separate relations need an ID and a reference (foreign key) back to * the object that owns them. None of that exists in a value object. To add * this kind of information to a value object, it is wrapped into a OwnedValue. * * If you want to store a collection of value objects, create a new subclass * of OwnedValue and override the OWNER_CLASS and VALUE_CLASS constants with: * * * The class name of the owning class (e.g. Contact) * * The class name of the value object (e.g. PhoneNumber) * * Example: * * class ContactPhoneNumber extends OwnedValue * { * protected const OWNER_CLASS = Contact::class; * * protected const VALUE_CLASS = PhoneNumber::class; * } * * In the owning class, use the methods synchronize() and synchronizeAll() to * synchronize OwnedValue entities with actual value objects. * * Example: * * public function changePhoneNumbers(array $phoneNumbers) * { * // $this->phoneNumbers is a collection of ContactPhoneNumbers * ContactPhoneNumber::synchronizeAll($this->phoneNumbers, $phoneNumbers, $this); * } */ abstract class OwnedValue { /** * The name of the class owning the value object. * * Should be overridden in subclasses. */ protected const OWNER_CLASS = ''; /** * The class name of the value object. * * Should be overridden in subclasses. */ protected const VALUE_CLASS = ''; /** * An auto-generated ID. * * @var int */ private $id; /** * The owning object. * * This object has the type stored in self::OWNER_CLASS. * * @var object */ private $owner; /** * The value object. * * This object has the type stored in self::VALUE_CLASS. * * @var object */ private $value; /** * Synchronizes a single owned value with a value object. * * This method copies the data of the value object into the owned value. * * @param null|static $ownedValue The owned value * @param object $value The value object * @param object $owner The owner */ final public static function synchronize(&$ownedValue, object $value, object $owner): void { if ($ownedValue instanceof static) { $ownedValue->setValue($value); } else { $ownedValue = static::create($owner, $value); } } /** * Synchronizes a collection of owned values with a list of value objects. * * This method copies the data of the value objects into the owned values * and resizes the list accordingly. * * @param Collection $ownedValues The owned values * @param object[] $values The value objects * @param object $owner The owner */ final public static function synchronizeAll(Collection $ownedValues, array $values, object $owner): void { $i = 0; foreach ($values as $value) { Assert::isInstanceOf($value, static::VALUE_CLASS); if (isset($ownedValues[$i])) { $ownedValues[$i]->setValue($value); } else { $ownedValues[$i] = static::create($owner, $value); } ++$i; } for ($l = count($ownedValues); $i < $l; ++$i) { $ownedValues->remove($i); } } /** * Extracts the value object of an owned value. * * @param null|OwnedValue $ownedValue The owned value. May be null * * @return null|object The value object or null */ final public static function unwrap(?self $ownedValue): ?object { return $ownedValue ? $ownedValue->getValue() : null; } /** * Extracts the value objects of a list of owned values. * * @param Collection $ownedValues The owned values * * @return object[] The value objects */ final public static function unwrapAll(Collection $ownedValues): array { return array_map([__CLASS__, 'unwrap'], $ownedValues->toArray()); } /** * Creates a new owned value. * * @param object $owner The owner. Must be of the type stored in * static::OWNER_CLASS * @param object $value The value object. Must be of the type stored in * static::VALUE_CLASS * * @return static The owned value */ final public static function create($owner, $value): self { return new static($owner, $value); } /** * Returns the ID of the owned value. * * @return int|null The ID. Returns null if the owned value has not yet been * persisted yet */ final public function getId(): ?int { return $this->id; } /** * Returns the owner. * * @return object An object of the type stored in static::OWNER_CLASS */ final public function getOwner(): object { return $this->owner; } /** * Returns the wrapped value object. * * @return object An object of the type stored in static::VALUE_CLASS */ final public function getValue(): object { return $this->value; } /** * Sets the wrapped value object. * * @param object $value An object of the type stored in static::VALUE_CLASS */ final public function setValue($value): void { Assert::isInstanceOf($value, static::VALUE_CLASS); $this->value = $value; } final private function __construct($owner, $value) { Assert::isInstanceOf($owner, static::OWNER_CLASS); Assert::isInstanceOf($value, static::VALUE_CLASS); $this->owner = $owner; $this->value = $value; } } ``` </details> The code of the class above is non-trivial, but then it declaring a one-to-many relation is relatively simple: <details> <summary>Owner.orm.xml</summary> ```xml <?xml version="1.0" encoding="UTF-8" ?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="Acme\Domain\Owner" repository-class="Acme\Infrastructure\ORM\Repository\Domain\ORMOwnedRepository"> <id name="id" type="owner_id"> <generator strategy="NONE" /> </id> <one-to-many target-entity="Acme\Domain\MyOwnedEntity" field="emails" mapped-by="owner" orphan-removal="true"> <cascade> <cascade-persist/> </cascade> <order-by> <!-- Preserve the order of the collection --> <order-by-field name="id" direction="ASC"/> </order-by> </one-to-many> </entity> </doctrine-mapping> ``` </details> <details> <summary>MyOwnedEntity.orm.xml</summary> ```xml <?xml version="1.0" encoding="UTF-8" ?> <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="Acme\Domain\MyOwnedEntity"> <id name="id" type="integer"> <generator strategy="AUTO" /> </id> <many-to-one target-entity="Acme\Domain\Owner" field="owner" inversed-by="emails"> <join-column nullable="false" on-delete="CASCADE"/> </many-to-one> </entity> </doctrine-mapping> ``` </details> With the three following entities: <details> <summary>Owner.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Domain; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Acme\Domain\Common\Id\HasId; use Acme\Domain\OwnerOwnedEntity; use Acme\Domain\OwnedEntity; class Owner implements HasId { private $id; /** * @var Collection|OwnerOwnedEntity[] */ private $ownedEntities; public function __construct(OwnerId $id) { $this->id = $id; $this->ownedEntities = new ArrayCollection(); } /** * {@inheritdoc} */ public function getId(): OwnerId { return $this->id; } /** * @param OwnedEntity[] $ownedEntities */ public function changeEmails(array $ownedEntities) { OwnerOwnedEntity::synchronizeAll($this->ownedEntities, $ownedEntities, $this); } /** * @return OwnedEntity[] */ public function getEmails(): array { return OwnerOwnedEntity::unwrapAll($this->ownedEntities); } public function __clone() { $this->ownedEntities = new ArrayCollection($this->ownedEntities->toArray()); } } ``` </details> And the magic entity: <details> <summary>OwnerOwnedEntity.php</summary> ```php <?php declare(strict_types=1); namespace Acme\Domain; use Acme\Domain\OwnedEntity; use Acme\Domain\OwnedValue; /** * @internal */ class OwnerOwnedEntity extends OwnedValue { /** * {@inheritdoc} */ protected const OWNER_CLASS = Owner::class; /** * {@inheritdoc} */ protected const VALUE_CLASS = OwnedEntity::class; } ``` </details> Any interest merging any of this in the core?
Author
Owner

@setineos commented on GitHub (Jul 3, 2025):

For my part, I'd be very interested in a cursor based pagination implementation

@setineos commented on GitHub (Jul 3, 2025): For my part, I'd be very interested in a cursor based pagination implementation
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: doctrine/archived-orm#6051