Верх страницы
Обложка к записи Делегированные типы в Laravel
Время для прочтения: 1 мин. 6 сек.

Делегированные типы в Laravel

При разработке приложений мы регулярно сталкиваемся с необходимостью реализовать наследование на уровне базы данных.

Например, у нас есть базовая модель Payment, от которой наследуются CreditCardPayment и PayPalPayment. В мире объектно-ориентированного программирования это решается элементарно, но в реляционных базах данных всё не так тривиально.

Существует два классических подхода к этой проблеме, и у обоих есть существенные недостатки.

Наследование одной таблицей (STI)

При подходе Single Table Inheritance мы создаем одну огромную таблицу, которая содержит все возможные поля для всех типов сущностей. Добавляется колонка type, а специфичные для дочерних моделей поля просто остаются пустыми (NULL), если они не относятся к текущему объекту.

// Миграция для STI
Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->string('type'); // 'credit_card', 'paypal'

    // Общие поля
    $table->decimal('amount');

    // Специфичные для кредитной карты
    $table->string('card_number')->nullable();
    $table->string('cvv')->nullable();

    // Специфичные для PayPal
    $table->string('paypal_email')->nullable();
});

На практике это быстро превращается в кошмар. Таблица разрастается, нарушается целостность данных (нельзя на уровне БД заставить заполнить card_number только для типа credit_card), а запросы становятся неоправданно тяжелыми.

Наследование таблицами классов (CTI)

При подходе Class Table Inheritance мы создаем отдельную таблицу для каждого класса. Базовая таблица хранит общие поля, а дочерние — специфичные. Связь осуществляется через внешний ключ.

// Базовая таблица
Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->string('type');
    $table->decimal('amount');
});

// Дочерняя таблица
Schema::create('credit_card_payments', function (Blueprint $table) {
    $table->foreignId('payment_id')->constrained()->primary();
    $table->string('card_number');
    $table->string('cvv');
});

Здесь нет лишних NULL-полей, и целостность данных в порядке. Но появляется новая проблема: каждый раз, когда вам нужно получить данные платежа вместе с его специфичными полями, вам придется делать JOIN. При росте количества типов сущностей количество JOIN-ов будет расти пропорционально, что убьет производительность.

Делегированные типы (Delegated Types)

Делегированные типы предлагают элегантный компромисс. Идея проста: дочерняя таблица использует тот же первичный ключ, что и базовая. По сути, это связь «один к одному», где дочерняя таблица делегирует себе часть данных, разделяя с родительской один и тот же ID.

STI делает таблицы широкими. CTI делает запросы глубокими (за счет JOIN-ов). Делегированные типы берут лучшее из обоих миров.

// Базовая таблица
Schema::create('payments', function (Blueprint $table) {
    $table->id();
    $table->decimal('amount');
});

// Дочерняя таблица с общим ID
Schema::create('credit_card_payments', function (Blueprint $table) {
    $table->id(); // Тот же самый ID, что и в payments
    $table->string('card_number');
    $table->string('cvv');
});

Обратите внимание: мы не используем foreignId. Строка в дочерней таблице имеет свой собственный id, но на практике это то же самое значение, что и у родителя. Это значит, что мы можем получить специфичные данные без JOIN-ов, просто зная ID базовой записи.

Как это работает в Laravel

В чистом Eloquent реализация этого паттерна требует небольшой настройки связей. Однако в экосистеме Laravel есть готовое решение — пакет spatie/laravel-delegated-types от команды Spatie.

С его помощью настройка моделей сводится к минимуму. Базовая модель использует трейт, указывающий, какие дочерние типы она может делегировать:

use Spatie\DelegatedTypes\Models\HasDelegatedTypes;

class Payment extends Model
{
    use HasDelegatedTypes;

    protected static array $delegatedTypes = [
        'credit_card' => CreditCardPayment::class,
        'paypal' => PayPalPayment::class,
    ];
}

Дочерняя модель указывает, от какого базового типа она наследуется:

use Spatie\DelegatedTypes\Models\DelegatedType;

class CreditCardPayment extends DelegatedType
{
    protected static string $baseType = Payment::class;

    protected $fillable = ['card_number', 'cvv'];
}

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

Теперь создание и работа с объектами выглядят максимально прозрачно:

// Создание записи
$payment = CreditCardPayment::create([
    'amount' => 1000,
    'card_number' => '4242...',
    'cvv' => '123',
]);

// Под капотом создаются две записи:
// 1. Payment с id=1, amount=1000
// 2. CreditCardPayment с id=1, card_number='4242...', cvv='123'

// Получение специфичных данных без JOIN-ов
$specificData = CreditCardPayment::find(1);

Под капотом

Когда вы обращаетесь к делегированному типу, Laravel делает запрос только к дочерней таблице, так как знает, что искомый ID там совпадает с ID базовой записи. Если вам нужны общие поля из родительской модели, они подгружаются отдельным легким запросом (или через relationship) только по необходимости.

Итог

Делегированные типы — это отличный инструмент, который стоит держать в арсенале. Они избавляют от раздутых таблиц с кучей NULL-полей при STI и от тяжелых JOIN-запросов при CTI. Используйте этот паттерн, когда у вас есть четкая иерархия сущностей с уникальными полями на каждом уровне, и вы хотите сохранить базу данных чистой и быстрой.

Ссылки

Комментарии
Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии
Предыдущая запись
Следующая запись

Давайте дружить
в Telegram

Авторский блог вашего покорного слуги в Telegram про web, программирование, алгоритмы, инструменты разработчика, WordPress, Joomla, Opencart, Symfony, Laravel, Moonshine, фильмы и сериалы