Skip to content

Instantly share code, notes, and snippets.

@roxblnfk
Last active July 14, 2021 16:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save roxblnfk/de8f3663f4f032e315558406cc997ba3 to your computer and use it in GitHub Desktop.
Save roxblnfk/de8f3663f4f032e315558406cc997ba3 to your computer and use it in GitHub Desktop.
Cycle ORM 2.0: Proxy Entity & Custom collections

Proxy Entity

Let's look at an example. We have an entity User, which is related to other entities by the relation HasOne and HasMany:

User {
    id: int
    name: string
    profile: ?Profile (HasOne, nullable, lazy load)
    posts: collection (HasMany, lazy load)
}

When we load the entity User using code $user = (new Select($this->orm, User::class))->fetchOne(); (without eager loading of related entities), then we get the User entity, in which relations to other entities are references (objects of the ReferenceInterface class).

In Cycle ORM v1, users faced issues when these references had to be resolved. Yes, sometimes it is more expedient to load the relation of one entity from a large collection than to preload relations for the entire collection.
Our separate package cycle/proxy-factory could help with this issue, the task of which is to replace Reference with a proxy object. When accessing such a proxy object, the reference is automatically resolved:

$email = $user->profile->email; // when accessing the profile property, the proxy object automatically
                                // makes a request to the database

However, in the case of a nullable One to One relation, we cannot use this code:

$userHasProfile = $user->profile === null;

Indeed, the proxy $user->profile will not be able to rewrite itself into null if the required profile does not exist in the DB.

There were also problems with typing: in the User class it is not possible to set the profile property with the ?Profile type, because ORM without eager loading tries to write ReferenceInterface there.

We have changed a few things in Cycle ORM v2. Now all entities are created as proxies by default.

The advantages that we get by doing it:

  • The user in the usual use will not encounter the ReferenceInterface.
  • Property typing works:
    class User {
        public iterable $posts;
        private ?Profile $profile;
        public function getProfile(): Profile
        {
            if ($this->profile === null) {
                $this->profile = new Profile();
            }
            return $this->profile;
        }
    }
  • We have preserved the usability of references for those who used them:
    /** @var \Cycle\ORM\ORMInterface $orm */
    
    // Create a proxy for the User entity
    $user = $orm->make(User::class, ['name' => 'John']);
    
    // We know the group id, but we don't want to load it from DB.
    // This is enough for us to fill in the User>(belongsTo)>Group relation
    $user->group = new \Cycle\ORM\Reference\Reference('user_group', ['id' => 1]);
    
    (new \Cycle\ORM\Transaction($orm))->persist($user)->run();
    
    $group = $user->group; // if desired, we can load a group from the heap or database using our Reference
    To get raw entity data, use the mapper: $rawData = $mapper()

Usage

The rules for creating entities are determined by their mappers. You can set which entities will be created as proxies and which ones will not.

Mappers from the box:

  • \Cycle\ORM\Mapper\Mapper - generates proxies for entity classes.
  • \Cycle\ORM\Mapper\PromiseMapper - works directly with the entity class. It also writes objects of the \Cycle\ORM\Reference\Promise class to unloaded relation properties.
  • \Cycle\ORM\Mapper\StdMapper - for working with classless entities. Generates stdClass objects with \Cycle\ORM\Reference\Promise objects on unloaded relation properties.
  • \Cycle\ORM\Mapper\ClasslessMapper - for working with classless entities. Generates proxy entities.

To use proxy entities, you need to follow a few simple rules:

  • Entity classes should not be final.
    The proxy class extends the entity class, and we would not like to use hacks for this.
  • Do not use code like this in the application: get_class($entity) === User::class. Use $entity instanceof User.
  • Write the code of the entity without taking into account the fact that it can become a proxy object.
    Use typing and private fields.
    Even if you directly access the $this->profile field, the relation will be loaded and you will not get a ReferenceInterface object.

Custom Collections

We've added support for custom collections for the hasMany and ManyToMany relations.

Custom collections can be configured individually for each relation by specifying aliases and interfaces (base classes):

use Cycle\ORM\Relation;
$schema = [
  User::class => [
    //...
    Schema::RELATIONS   => [
      'posts' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Post::class,
        Relation::COLLECTION_TYPE => null, // <= The default collection is used
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'comments' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => 'doctrine', // <= Matching by the alias `doctrine`
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'tokens' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Token::class,
        Relation::COLLECTION_TYPE => \Doctrine\Common\Collections\Collection::class, // <= Matching by the class
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ],
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => \App\CommentsCollection::class,    // <= Mapping by the class of
                                                                        //    an extendable collection
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

Aliases and interfaces can be configured in the \Cycle\ORM\Factory object, which is passed to the ORM class constructor.

$arrayFactory = new \Cycle\ORM\Collection\ArrayCollectionFactory();
$doctrineFactory = new \Cycle\ORM\Collection\DoctrineCollectionFactory();
$illuminateFactory = new \Cycle\ORM\Collection\IlluminateCollectionFactory();
$orm = new \Cycle\ORM\ORM(
    (new \Cycle\ORM\Factory(
        $dbal,
        null,
        null,
        $arrayFactory    // <= Default Collection Factory
    ))
        ->withCollectionFactory(
            'doctrine',     // <= An alias that can be used in the DB Schema
             $doctrineFactory,
              \Doctrine\Common\Collections\Collection::class // <= Interface for collections that the factory can create
        )
        // For the Illuminate Collections factory to work, you need to install the `illuminate/collections` package
        ->withCollectionFactory('illuminate', $illuminateFactory, \Illuminate\Support\Collection::class)
);

The collection interface is used for those cases when you extend collections to suit your needs.

// Extend the collection
class CommentCollection extends \Doctrine\Common\Collections\ArrayCollection {
    public function filterActive(): self { /* ... */ }
    public function filterHidden(): self { /* ... */ }
}

// Specify it in the DB Schema
$schema = [
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => CommentCollection::class,  // <=
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

// Use it
$user = (new Select($this->orm, User::class))->load('comments')->fetchOne();

/** @var CommentCollection $comments */
$comments = $user->comments->filterActive()->filterHidden();

An important difference between the 'Many to Many' and 'Has Many' relations is that it involves Pivots — intermediate entities from the cross-table.

The 'Many to Many' relation has been rewritten in such a way that now there is no need to collect pivots in the entity collection. You can even use arrays. However, if there is a need to work with pivots, your collection factory will have to produce a collection that implements the PivotedCollectionInterface interface. An example of such a factory is DoctrineCollectionFactory.

Proxy Entity

Рассмотрим пример. Есть у нас сущность User, которая связана с другими сущностями связями HasOne и HasMany:

User {
    id: int
    name: string
    profile: ?Profile (HasOne, nullable, lazy load)
    posts: collection (HasMany, lazy load)
}

Когда мы загружаем сущность User кодом $user = (new Select($this->orm, User::class))->fetchOne();, который не подразумевает жадную загрузку связанных сущностей, то получаем сущность User, у которой связи на другие сущности являются ссылками (объекты класса ReferenceInterface).

В Cycle ORM v1 пользователи сталкивались с проблемами, когда эти ссылки надо было раскрывать. Да, иногда целесообразнее подгрузить связь одной сущности из большой коллекции, чем предзагружать связи для всей коллекции.
С этой задачей мог помочь наш отдельный пакет cycle/proxy-factory, задача которого подменять Reference на прокси-объект. При обращении к такому объекту связь автоматически подгружается:

$email = $user->profile->email; // при обращении к свойству profile прокси автоматически делает запрос в базу

Однако, в случае nullable One to One отношения мы не можем использовать такой код:

$userHasProfile = $user->profile === null;

Ведь при отсутствии профиля в базе, прокси $user->profile не сможет превратить себя в null.

Проблемы были и с типизацией: в классе User не получится установить свойство profile с типом ?Profile, т.к. ORM без жадной загрузки будет пытаться записать туда ReferenceInterface.

В Cycle ORM v2 мы кое-что изменили. Теперь все сущности по умолчанию создаются как прокси.
Плюсы, которые мы получаем при этом:

  • Пользователь в привычном использовании не столкнётся с ReferenceInterface.
  • Типизация работает:
    class User {
        public iterable $posts;
        private ?Profile $profile;
        public function getProfile(): Profile
        {
            if ($this->profile === null) {
                $this->profile = new Profile();
            }
            return $this->profile;
        }
    }
  • Сохранили удобство использования ссылок для тех, кто ими пользовался:
    /** @var \Cycle\ORM\ORMInterface $orm */
    
    // Создаём прокси сущности User
    $user = $orm->make(User::class, ['name' => 'John']);
    
    // Мы знаем id группы, но загружать её не хотим. Нам этого достаточно, чтобы заполнить связь User>(BelongsTo)>Group
    $user->group = new \Cycle\ORM\Reference\Reference('user_group', ['id' => 1]);
    
    (new \Cycle\ORM\Transaction($orm))->persist($user)->run();
    
    $group = $user->group; // при желании мы можем подгрузить группу из кучи или БД, используя наш Reference
    Для получения сырых данных сущности пользуйтесь маппером: $rawData = $mapper()

Использование

Сами по себе правила создания сущностей определяются их мапперами. Вы можете сами задать, какие сущности будут создаваться как прокси, а какие — нет.

Мапперы из коробки:

  • \Cycle\ORM\Mapper\Mapper - генерирует прокси для классов сущностей.
  • \Cycle\ORM\Mapper\PromiseMapper - работает напрямую с классом сущности. Записывает объекты класса \Cycle\ORM\Reference\Promise в незагруженные связи.
  • \Cycle\ORM\Mapper\StdMapper - для работы с сущностями без класса. Генерирует объекты stdClass с объектами \Cycle\ORM\Reference\Promise на незагруженных связях.
  • \Cycle\ORM\Mapper\ClasslessMapper - для работы с сущностями без класса. Генерирует прокси.

Чтобы пользоваться прокси-сущностями, вам нужно соблюдать несколько простых правил:

  • Классы сущностей не должны быть финальными.
    Класс прокси расширяет класс сущности, и мы не хотели бы использовать для этого хаки.
  • Не используйте в приложении код вроде этого: get_class($entity) === User::class. Используйте $entity instanceof User.
  • Пишите код сущности без учёта того, что она может стать Proxy объектом.
    Пользуйтесь типизацией и приватными полями.
    Даже при прямом обращении к полю $this->profile связь будет раскрыта и вы не получите объект ReferenceInterface.

Custom Collections

Добавлена поддержка пользовательских коллекций для связей HasMany и ManyToMany.

Пользовательские коллекции можно настраивать индивидуально для каждой связи, указывая псевдонимы и интерфейсы:

use Cycle\ORM\Relation;
$schema = [
  User::class => [
    //...
    Schema::RELATIONS   => [
      'posts' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Post::class,
        Relation::COLLECTION_TYPE => null, // <= Используется коллекция по умолчанию
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'comments' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => 'doctrine', // <= Сопоставление по псевдониму `doctrine`
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'tokens' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Token::class,
        Relation::COLLECTION_TYPE => \Doctrine\Common\Collections\Collection::class, // <= Сопоставление по интерфейсу
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ],
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => \App\CommentsCollection::class, // <= Сопоставление по классу
                                                                     //    расширяемой коллекции
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

Настраиваются псевдонимы и интерфейсы в объекте класса \Cycle\ORM\Factory, который передаётся в конструктор ORM.

$arrayFactory = new \Cycle\ORM\Collection\ArrayCollectionFactory();
$doctrineFactory = new \Cycle\ORM\Collection\DoctrineCollectionFactory();
$illuminateFactory = new \Cycle\ORM\Collection\IlluminateCollectionFactory();
$orm = new \Cycle\ORM\ORM(
    (new \Cycle\ORM\Factory(
        $dbal,
        null,
        null,
        $arrayFactory    // <= Фабрика коллекций по умолчанию
    ))
        ->withCollectionFactory(
            'doctrine',     // <= Псевдоним, который можно использовать в схеме
             $doctrineFactory,
              \Doctrine\Common\Collections\Collection::class // <= Интерфейс коллекций, которые может создавать фабрика
        )
        // Для работы фабрики Illuminate коллекций необходимо установить пакет `illuminate/collections`
        ->withCollectionFactory('illuminate', $illuminateFactory, \Illuminate\Support\Collection::class)
);

Интерфейс коллекции используется для тех случаев, когда вы расширяете коллекции под свои нужды.

// Расширяем коллекцию
class CommentCollection extends \Doctrine\Common\Collections\ArrayCollection {
    public function filterActive(): self { /* ... */ }
    public function filterHidden(): self { /* ... */ }
}

// Указываем в схеме
$schema = [
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => CommentCollection::class,  // <=
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

// Пользуемся
$user = (new Select($this->orm, User::class))->load('comments')->fetchOne();

/** @var CommentCollection $comments */
$comments = $user->comments->filterActive()->filterHidden();

Важное отличие связи Many to Many от Has Many заключается в том, в ней участвуют Pivot'ы — промежуточные сущности из кросс-таблицы.

Связь Many to Many переписана таким образом, что теперь нет необходимости таскать pivot'ы в коллекции сущности. Вы можете использовать даже массивы. Однако, если возникнет необходимость в работе с pivot'ами, ваша фабрика коллекций должна будет произвести коллекцию, реализующую интерфейс PivotedCollectionInterface. Пример такой фабрики — DoctrineCollectionFactory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment