21. Комунікація між модулями. Laravel Morph relations. Eloquent trait. Decorator. OneOfMorphToMany relation

Відео версія
(YouTube - для зворотнього зв'язку)

Поліморфні зв'язки

Поліморфні зв'язки в SQL дозволяють одній таблиці мати відношення з будь-якою кількістю інших таблиць через поле, яке зберігає ідентифікатори об'єктів з різних таблиць та поле - вказівку на таблицю.

Поліморфні зв'язки ідеальні для ізоляції бази даних в рамках модуля.

Декілька особистих порад при роботі в Laravel:

  • НІКОЛИ не можна використовувати ClassName як вказівник на таблицю. Завжди використовуйте синонім:
enforceMorphMap
Relation::enforceMorphMap([
    'alias_name' => SomeModel::class,
]);
  • able - суфікс рекомендований, але не зручний. Раджу стандартизувати й використовувати однакову назву для всіх поліморфних зразків, наприклад, entity_id, entity_type.
  • Також раджу стандартизувати таблицю зв'язків. Мій варіант - has_relations для базових варіантів.
  • Якщо використовуєте Postgres, синоніми рекомендую оформити в Enum, навіть якщо зараз там один зв'язок. Це дасть оптимізацію та головне розуміння в коді і в БД з чим конкретно може взаємодіяти сутність.

В рамках модуля - використовуйте будь-які зв'язки. Між модулями - поліморфні.

Migration
DB::unprepared("CREATE TYPE some_holder AS ENUM ('some_use_table1', 'some_use_table2')");

Schema::create('has_device', function (Blueprint $table) {
    $table->foreignId('some_id')->constrained();
    $table->foreignId('entity_id');
    $table->typeColumn('some_holder', 'entity_type');
    $table->index(['entity_id', 'entity_type']);
    $table->unique(['device_id', 'entity_id', 'entity_type']);
});
  • Не забувайте додати індекс для entity_id, entity_type. І раджу зробити унікальною комбінацію device_id, entity_id, entity_type - всі повтори йдуть у payload.
  • Пам'ятайте, зв'язки one of many у випадку з eager loading (with) тягнуть всі записи і прив'язують до запиту один.
  • За необхідністю створюйте екстра morph зв'язків. Це може спростити логіку та розуміння коду і, звісно, оптимізувати вибірки.
  • База даних не гарантує цілісності morph ключів! Майте це на увазі.
  • БД не може реалізувати каскадне видалення поліморфних зв'язків. Відповідальність за видалення лежить на розробниках. Не плутайте detach та видалення для MorphManyToMany. Detach можна зробити одразу (але не треба насправді), а видаляти тільки коли на об'єкт нема інших посилань.
  • Laravel не вміє в OneOfMorphToMany, але це можна реалізувати. Будьте обережні. Цей підхід витаскує ВСІ записи і повертає лише одну. Раджу використовувати лише з унікальним ключем $table->unique(['entity_id', 'entity_type'])

Підхід Eloquent trait with relations and scope для зв'язку між модулями - Decorator

Підхід полягає в ізоляції всього коду зв'язку в трейті. Це працює лише в ActiveRecord. Doctrine не зможе це обробляти, оскільки файл моделі визначає БД, а не навпаки.

Формально підхід не має назви. Хоча використовується в безлічі готових рішень. Але якщо трохи опустити формальності - це буквально Decorator.

Раджу називати підхід decorator, так принцип роботи зрозуміліший, і ви особисто виглядаєте розумніше, ніж коли називаєте це "ну та отой трейт".

Для реалізації дуже важливо розуміти принцип роботи with методу QueryBuilder. Принцип дуже простий, але його чомусь багато хто не розуміє:

  1. with бере всі ключі з основного запиту. Тому id в select запиту обов'язкове.
  2. Робить запит до прив'язаної таблиці, дістаючи лише записи, які прив'язані до сутностей з основного запиту - where entity_id in (ids) where entity_type = 'some_type''.
  3. Далі на стороні коду кожна модель прив'язується зв'язані моделі до кожного об'єкта. Тому діставати id для select зв'язку обов'язкове.

Таким чином, with запит це завжди +1 простенький запит select. Жодних n+1 проблем не буде, це Eager loading.

Не тільки в with, а взагалі всюди пишіть SELECT та вказуйте список полів, які хочете витягти. Це оптимізація, це читання коду, це безпека коду. `Select *` - антипатерн навіть коли дістаєте один запис. Використовуйте `->first(['id'])` якщо треба об'єкт. Або `->value('id')` - коли треба конкретне поле.

Особиста рекомендація - пишіть список полів у select одразу з вказанням таблиці. Це дуже спростить навігацію по коду і дозволить вільно використовувати join.

Важливо розуміти принцип роботи eloquent scoped методів. Тут все просто - це методи на QueryBuilder. Метод без префіксу scope зможете використовувати як частину запиту на вибірку.

Для підходу в трейт треба ізолювати:

  1. Функцію опису зв'язку. Ідеально morph зв'язку. Обов'язково.
  2. Scope функції для отримання прив'язаних даних. Це стандартизує дані для всіх, хто використовує зв'язок. І дозволить пере використовувати вибірку та ізольовано її тестувати.
  3. Scope функції для фільтрації. where has і т.д.
  4. Scoped функції для агрегації. Count, sum і т.д. Знову ж таки ізолює й надасть змогу описувати виключення з логіки.
  5. Функції attach, detach, set і т.д. Це дасть змогу ізолювати будь-яку специфічну логіку збереження всередині трейту. І, звісно ж, протестувати.

Eloquent Trait with Relations and Scope Approach for Module Interaction - Decorator

The approach involves isolating all relation code in a trait. This works only in ActiveRecord. Doctrine will not handle this as the model file defines the DB, not the other way around.

Formally, the approach has no name. Although it is used in many ready-made solutions. But if you slightly lower the formalities - this is literally a Decorator.

I recommend calling the approach a decorator, so the working principle is clearer, and you personally look smarter than when you call it "well, that trait."

For implementation, it is very important to understand the principle of the with method of QueryBuilder. The principle is very simple, but for some reason, many do not understand it:

  1. with takes all keys from the main query. Therefore, id in the select query is mandatory.
  2. Makes a query to the related table, pulling out only the records that are related to the entities from the main query - where entity_id in (ids) where entity_type = 'some_type''.
  3. Then on the code side, each model binds the related models to each object. Therefore, pulling id for select relation is mandatory.

Thus, the with query is always +1 simple select query. There will be no n+1 problems; this is Eager loading.

Not only in with, but everywhere, write SELECT and specify the list of fields you want to retrieve. This is optimization, code readability, and code security. `Select *` is an antipattern even when retrieving one record. Use `->first(['id'])` if you need an object. Or `->value('id')` - when you need a specific field.

A personal recommendation is to write the list of fields in select immediately with the table specification. This will greatly simplify navigation through the code and allow free use of join.

It is important to understand the principle of eloquent scoped methods. Here everything is simple - these are methods on QueryBuilder. A method without the scope prefix can be used as part of the selection query.

For the trait approach, you need to isolate:

  1. Function describing the relation. Ideally a morph relation. Mandatory.
  2. Scope functions for retrieving related data. This will standardize data for all who use the relation. And will allow reusing the selection and testing it in isolation.
  3. Scope functions for filtering. where has etc.
  4. Scoped functions for aggregation. Count, sum, etc. Again, it isolates and allows describing exceptions from logic.
  5. Functions attach, detach, set, etc. This will allow isolating any specific save logic inside the trait. And, of course, test it.

По назвах traits також рекомендую відмовитись від able префіксу. HasSomeRelation буде ок.

Комбінуйте декоратор та JsonResource. Це дуже зручно дасть змогу ізолювати та описати формати відповідей.

Для простих сутностей зазвичай досить одного ресурсу. Для комплексних - 2: короткий для списків та більш повний. Якщо у вас ресурсів багато - скоріше за все у вас проблеми з бізнес-логікою. І вас прокляне фронтенд розробник.

Trait підхід ідеально працює зі звичайними зв'язками, де очевидний зв'язок основна сутність та її залежності. Проблеми починаються коли сутності рівноцінні. Наприклад, музична банда та концерти - пряма залежність у банди є концерти. А ось для пари музична банда та фестиваль важко сказати, хто від кого залежить. Це проблема курки та яйця, і її красиво не вирішує жоден підхід.

Для багатошарових зв'язків раджу денормалізовувати. Так, це звучить страшно, але на практиці прив'язати "замовлення" до покупця і до магазину одразу - це буквально 2 рядки коду та плюс один простий запис у БД. Але це дасть неймовірну зручність у роботі та оптимізацію.

Великий мінус таких зв'язків - складність тестування. Методи, що тягнуть та фільтрують безліч scope методів з різних трейтів, будуть зустрічатися часто. Тут або реально створювати повну структуру з сутностями, або просто викликати умовний ModelEvent на scope методах та перевіряти їх виклик.

Action для комунікації

Звісно, замість scope методів можна використовувати Action з тою ж логікою, що й with, але реалізованою вручну. І так доведеться робити при винесенні модуля в окремий сервіс. Але наразі вважаю, що зручніше все обмежити трейтом.

Все ж складну логіку, великі Query запити і т.д. виношу до Action. Тестувати їх набагато легше.

Плюсом Action вважаю можливість викликати їх в черзі, наприклад, збереження зв'язків. Але це дуже погано для користувацького досвіду. Не раджу.

Поліморфні зв'язки все одно будуть єдиною адекватною структурою БД, не залежно від того, чи використовуєте ви декоратори, чи action.

Транзакції

В рамках денормалізації транзакції обов'язкові.

І без неї транзакція дає 2 дуже позитивні моменти:

  1. Зберігаєте все або нічого. Ви назавжди позбавитесь проблем з цілісністю даних при помилках. Навіть при використанні мікросервісів throw від action скаже, що все не ок.
  2. Транзакція створює одне підключення до БД. Це трохи не актуально для Octane, але дає суттєвий ефект у звичайній розробці.

Завжди тестуйте транзакції. Забути викликати commit дуже легко, а віддебажити таке дуже складно.

Test transaction example
 /**
 * @test
 */
public function canHandleTransactionError(): void
{
    $user = User::factory()->create();
    $this->actingAs($user);

    DB::shouldReceive('beginTransaction')->once();

    DB::shouldReceive('commit')
        ->once()
        ->andThrow(ModelNotFoundException::class);

    DB::shouldReceive('rollBack')
        ->once();

    $this->deleteJson('/api/v1/sessions')
        ->assertStatus(404);
}