Сущности
Сущность — обычный PHP-класс, у которого две работы: описывать таблицу и принимать её строки. Схема берётся из атрибутов над свойствами, а результат запроса гидрируется в объекты того же класса. Ниже — как из свойства получается колонка, и разбор каждого атрибута с готовым SQL для PostgreSQL и MySQL.
Что такое сущность и зачем
Проблема. Структура таблицы существует в двух местах сразу: в базе и в голове разработчика. Добавили колонку — надо не забыть про миграцию, про класс, который эту строку принимает, и про запрос, который её выбирает. Расхождение обнаруживается в рантайме и обычно на продакшене.
Решение. Структура описывается один раз — атрибутами над свойствами обычного PHP-класса. Из этого описания строится и таблица в базе, и объект, в который приходит строка. Источник правды один, и это код.
<?php
namespace Main\Entities;
use Flytachi\Winter\Ppa\Mapping\Attributes\Entity\Table;
use Flytachi\Winter\Ppa\Mapping\Attributes\Hybrid\BigId;
use Flytachi\Winter\Ppa\Mapping\Attributes\Primal\{Decimal, SmallInteger, Timestamp, Varchar};
use Flytachi\Winter\Ppa\Mapping\Attributes\Idx\Unique;
use Flytachi\Winter\Ppa\Mapping\Attributes\Additive\DefaultVal;
#[Table]
class Order
{
#[BigId]
public ?int $id = null;
#[Varchar(32)]
#[Unique]
public string $number = '';
#[SmallInteger]
public int $status = 0;
#[Decimal(12, 2)]
public string $total = '0';
#[Timestamp]
#[DefaultVal('NOW()')]
public string $created_at = '';
}Из этого класса получается таблица:
CREATE TABLE public.orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
number VARCHAR(32) NOT NULL DEFAULT '',
status SMALLINT NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL DEFAULT '0',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX orders_number_udx ON public.orders USING BTREE (number);И тот же класс принимает строки обратно: findById() вернёт объект Order с заполненными
полями.
Три правила, которые стоит знать сразу
Имя колонки — это имя свойства. Отдельного атрибута для имени нет: свойство
created_at даёт колонку created_at. Поэтому свойства именуют так, как должны
называться колонки.
Значение свойства по умолчанию становится значением колонки по умолчанию. Свойство
public int $status = 0 даёт DEFAULT 0 в таблице. Это не всегда очевидно и иногда
нежелательно — если умолчание в базе не нужно, свойство объявляют без значения.
Пустое значение разрешает знак вопроса в типе свойства. public string $number
даёт NOT NULL, а public ?string $comment = null — колонку, принимающую NULL.
Отдельный атрибут для этого не нужен: типы PHP и так говорят всё необходимое, и
#[NullableIs] существует только для случаев, когда решение базы должно
разойтись с типом свойства.
Атрибуты нужны не всегда
Без #[Table] класс в миграции не участвует. Это законный случай: сущность, которая
только принимает строки существующей таблицы и ничего не описывает, обходится без единого
атрибута — гидрация работает по именам свойств.
Атрибуты нужны там, где схему должен строить код.
Справочник
#[Table]
Помечает класс как описание таблицы.
Без него класс не попадёт в миграцию: call db migrate его не увидит, и таблица не будет
создана. Имя таблицы атрибут не задаёт — оно берётся из свойства $table репозитория,
который на эту сущность ссылается.
Синтаксис
#[Table]Параметры
Нет.
Пример
#[Table]
class Order
{
// …
}Имя таблицы задаёт репозиторий, а не атрибут
#[Table('orders')] не вызовет ошибки, но аргумент будет проигнорирован: конструктор
атрибута не принимает ничего. Имя берётся из public static string $table репозитория.
Ключи
Первичный ключ объявляется одним атрибутом, который сразу задаёт и тип колонки, и
автогенерацию, и участие в PRIMARY KEY. Отдельно помечать колонку типом не нужно.
#[Id], #[BigId], #[SmallId]
Целочисленный первичный ключ с автоинкрементом.
Три атрибута отличаются только шириной типа. Выбор — про то, сколько строк таблица
переживёт: SmallId кончается на 32 тысячах, Id — на двух миллиардах, BigId
практически не кончается.
Синтаксис
#[Id(bool $always = false)]
#[BigId(bool $always = false)]
#[SmallId(bool $always = false)]Параметры
$always — запрещает вставлять значение ключа вручную. По умолчанию false: своё
значение подставить можно, и генератор используется, только когда его не передали.
С true база отвергнет любую попытку задать ключ явно.
Что получается
| Атрибут | PostgreSQL | MySQL |
|---|---|---|
#[SmallId] |
SMALLINT GENERATED BY DEFAULT AS IDENTITY |
SMALLINT AUTO_INCREMENT |
#[Id] |
INTEGER GENERATED BY DEFAULT AS IDENTITY |
INT AUTO_INCREMENT |
#[BigId] |
BIGINT GENERATED BY DEFAULT AS IDENTITY |
BIGINT AUTO_INCREMENT |
Пример
#[BigId]
public ?int $id = null;Результат
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
…
PRIMARY KEY (id)Почему свойство объявляют как `?int` с `null`
До вставки ключа ещё нет — его выдаёт база. Свойство public ?int $id = null честно это
отражает и не мешает создать объект, который ещё не сохранён.
#[UuidPk]
Первичный ключ в виде UUID, генерируемый базой.
Берут там, где идентификатор не должен быть предсказуемым или должен создаваться до обращения к базе: распределённые системы, публичные адреса, слияние данных из разных источников.
Синтаксис
#[UuidPk]Параметры
Нет.
Пример
#[UuidPk]
public ?string $id = null;Результат
id UUID NOT NULL DEFAULT gen_random_uuid(),
…
PRIMARY KEY (id)PostgreSQL: нужно расширение
gen_random_uuid() появляется вместе с расширением pgcrypto. Объявите его на
конфигурации базы — иначе таблица не создастся:
#[Migratable]
#[Extension('pgcrypto')]
class MainDbConfig extends PgDbConfig { … }Подробнее — на странице Конфигурация БД.
#[Primary]
Помечает колонку как часть первичного ключа, не задавая тип и не включая автогенерацию.
Нужен в двух случаях: составной первичный ключ из нескольких колонок, и ключ, значение которого приходит извне, а не выдаётся базой.
Синтаксис
#[Primary]Параметры
Нет.
Пример
// Составной ключ: одна строка на пару «пользователь + роль»
#[BigInteger] #[Primary]
public int $user_id = 0;
#[BigInteger] #[Primary]
public int $role_id = 0;#[AutoIncrement]
Добавляет автоинкремент к колонке, тип которой объявлен отдельно.
Нужен редко: #[Id] и его родственники уже включают автоинкремент.
Этот атрибут остаётся для случая, когда тип задан вручную — например через
#[Type].
Синтаксис
#[AutoIncrement(bool $always = false)]Параметры
$always — то же, что у #[Id]: запрещает подставлять значение
вручную.
Типы колонок
Один атрибут на свойство — он задаёт тип колонки. Конкретный SQL-тип подбирается под базу, к которой привязан репозиторий: диалект берётся из его конфигурации, а не из места вызова.
#[Varchar]
Строка ограниченной длины — самый частый тип для текстовых полей.
Синтаксис
#[Varchar(int $length = 255)]Параметры
$length — максимальная длина в символах. По умолчанию 255.
Пример
#[Varchar(32)]
public string $number = '';Результат
number VARCHAR(32) NOT NULL DEFAULT ''Обратите внимание на DEFAULT '': он появился из значения свойства. Чтобы умолчания в
базе не было, объявляйте свойство без значения — public string $number;.
#[Char]
Строка фиксированной длины. Короче объявленной — база дополнит пробелами.
Применяют для значений, длина которых действительно постоянна: код страны, код валюты,
контрольный символ. Для всего остального берут #[Varchar].
Синтаксис
#[Char(int $length)]Параметры
$length — длина в символах. Аргумент обязателен: тип без длины смысла не имеет.
Пример
#[Char(2)]
public string $lang = 'ru';Результат
lang CHAR(2) NOT NULL DEFAULT 'ru'#[Decimal]
Точное десятичное число. Единственный тип, пригодный для денег.
Числа с плавающей точкой (#[FloatType], #[Double]) хранят приближение: 0.1 + 0.2 в
них не равно 0.3. Для сумм это означает копейки, расходящиеся при суммировании тысяч
строк, поэтому деньги хранят здесь.
Синтаксис
#[Decimal(int $precision = 12, int $scale = 2)]Параметры
$precision — всего значащих цифр, включая дробную часть. По умолчанию 12.
$scale — сколько из них после запятой. По умолчанию 2.
Значение Decimal(12, 2) вмещает до 9999999999.99.
Пример
#[Decimal(12, 2)]
public string $total = '0';Результат
total NUMERIC(12, 2) NOT NULL DEFAULT '0' -- PostgreSQL
total DECIMAL(12, 2) NOT NULL DEFAULT '0' -- MySQLПочему свойство объявлено как `string`, а не `float`
float в PHP — то самое приближение, от которого этот тип и уходит. База отдаёт значение
строкой, и держать его строкой до самых вычислений — способ не потерять точность по
дороге. Для арифметики берут библиотеку произвольной точности.
#[Timestamp]
Момент времени — дата вместе со временем.
Синтаксис
#[Timestamp(bool $withTimeZone = true)]Параметры
$withTimeZone — хранить ли часовой пояс. По умолчанию true.
Пояс стоит хранить всегда, когда момент имеет отношение к реальному времени: заказ
оформлен, письмо отправлено, сессия истекает. Без пояса 12:00 — это полдень неизвестно
где, и при переезде сервера или смене летнего времени восстановить смысл нельзя.
Пример
#[Timestamp]
#[DefaultVal('NOW()')]
public string $created_at = '';Результат
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -- PostgreSQL
created_at TIMESTAMP NOT NULL DEFAULT NOW() -- MySQL#[Uuid]
Колонка с UUID — обычная, не первичный ключ.
Для первичного ключа есть #[UuidPk]: он дополнительно включает генерацию и
PRIMARY KEY.
Синтаксис
#[Uuid(bool $asBinary = false)]Параметры
$asBinary — хранить ли значение в двоичном виде. По умолчанию false, то есть UUID
хранится как есть.
Двоичное хранение занимает 16 байт вместо 36 и заметно экономит на большой таблице, но значение перестаёт быть читаемым в консоли базы.
Пример
#[Uuid]
public ?string $author_id = null;#[Binary]
Двоичные данные фиксированной длины: хеш, подпись, ключ.
Синтаксис
#[Binary(int $length = 255)]Параметры
$length — длина в байтах. По умолчанию 255.
#[Blob]
Двоичные данные произвольного размера: файл, изображение, архив.
Синтаксис
#[Blob(string $size = 'default')]Параметры
$size — размерная разновидность типа: 'tiny', 'default', 'medium', 'long'.
Влияет на верхнюю границу и на выбираемый базой тип.
Файлы обычно не хранят в базе
Строка с несколькими мегабайтами внутри попадает в каждый SELECT *, раздувает резервные
копии и мешает репликации. Чаще хранят файл в файловой системе или объектном хранилище, а
в базе — путь и метаданные.
Отдавать такой файл клиенту умеет
ResponseStreamFile.
#[Type]
Задаёт SQL-тип буквально — как есть.
Запасной выход для типов, у которых нет своего атрибута: специфичные для базы inet,
tsvector, geometry, пользовательские перечисления, расширения.
Синтаксис
#[Type(string $definition)]Параметры
$definition — определение типа так, как оно должно попасть в CREATE TABLE.
Пример
#[Type('inet')]
public ?string $ip = null;Результат
ip inet DEFAULT NULLПереносимость остаётся на вас
Текст подставляется в CREATE TABLE без изменений и без проверки. Тип, существующий
только в PostgreSQL, сделает миграцию непереносимой на MySQL — и узнаете вы об этом при
первой же попытке.
Типы без аргументов
Остальные атрибуты типов настраивать нечем — они просто называют тип.
| Атрибут | PostgreSQL | MySQL | Для чего |
|---|---|---|---|
#[SmallInteger] |
SMALLINT |
SMALLINT |
небольшое целое: статус, счётчик |
#[Integer] |
INTEGER |
INT |
обычное целое |
#[BigInteger] |
BIGINT |
BIGINT |
большое целое, внешний ключ на #[BigId] |
#[Boolean] |
BOOLEAN |
BOOLEAN |
флаг |
#[Text] |
TEXT |
TEXT |
текст без ограничения длины |
#[TextArray] |
TEXT[] |
— | массив строк; PostgreSQL |
#[Json] |
JSONB |
JSON |
структура произвольной формы |
#[Date] |
DATE |
DATE |
дата без времени: день рождения, срок |
#[Time] |
TIME |
TIME |
время без даты: начало рабочего дня |
#[DateTime] |
TIMESTAMP |
DATETIME |
момент без часового пояса |
#[FloatType] |
REAL |
FLOAT |
приближённое число |
#[Double] |
DOUBLE PRECISION |
DOUBLE |
приближённое число двойной точности |
`#[FloatType]` и `#[Double]` — не для денег
Оба хранят приближение. Для сумм берите #[Decimal]; эти два — для величин,
где приближение допустимо: координаты, показания датчиков, доли.
Индексы
Индекс — вспомогательная структура, по которой база находит строки, не перебирая таблицу
целиком. Без него запрос WHERE number = 'A-1042' читает все строки подряд; с ним —
приходит к нужной сразу. Платой служит место на диске и небольшое замедление вставок:
каждый индекс обновляется вместе с таблицей.
Оба атрибута ставятся на свойство, и колонка этого свойства автоматически становится первой в индексе.
#[Index]
Обычный индекс — ускоряет поиск, не ограничивая значения.
Синтаксис
#[Index(
array $columns = [],
?string $name = null,
IndexMethod $method = IndexMethod::BTREE,
?string $where = null,
?string $opClass = null,
)]Параметры
$columns — дополнительные колонки для составного индекса. Колонка самого свойства
добавляется первой автоматически, перечислять её не нужно. По умолчанию индекс
одноколоночный.
$name — средняя часть имени индекса. Полное имя собирается как
<таблица>_<name>_idx; если не задать, вместо name подставятся имена колонок через
подчёркивание.
$method — структура индекса: IndexMethod::BTREE (по умолчанию), HASH, GIN,
GIST. BTREE подходит почти всегда — он умеет и равенство, и диапазоны, и сортировку.
GIN берут для поиска внутри составных значений: ключи JSON, элементы массива, слова
текста.
$where — условие частичного индекса: в индекс попадут только строки, ему
удовлетворяющие. Индекс по условию shipped_at IS NOT NULL на таблице, где отгружена
десятая часть заказов, займёт в десять раз меньше места и будет обслуживать те же
запросы.
$opClass — класс операторов PostgreSQL для первой колонки. Приписывается к имени
колонки в CREATE INDEX и определяет, по каким операторам индекс применим: например
gin_trgm_ops включает поиск по подстроке.
Атрибут повторяемый — на одном свойстве может стоять несколько #[Index], если
колонка участвует в разных индексах.
Пример
#[BigInteger]
#[Index]
public int $customer_id = 0;
#[Json]
#[Index(method: IndexMethod::GIN)]
public array $meta = [];
#[Timestamp]
#[Index(where: 'shipped_at IS NOT NULL')]
public ?string $shipped_at = null;Результат
CREATE INDEX orders_customer_id_idx ON orders USING BTREE (customer_id);
CREATE INDEX orders_meta_idx ON orders USING GIN (meta);
CREATE INDEX orders_shipped_at_idx ON orders USING BTREE (shipped_at) WHERE shipped_at IS NOT NULL;Составной индекс
// Индекс по паре (author, year) — свойство идёт первым
#[Varchar(64)]
#[Index(['year'])]
public string $author = '';CREATE INDEX books_author_year_idx ON books USING BTREE (author, year);Порядок колонок важен и он не произволен
Составной индекс работает слева направо: (author, year) ускорит поиск по автору и по
паре «автор + год», но не поиск по одному только году. Первой ставят колонку, по которой
ищут чаще.
`method` и `where` переносимы не полностью
GIN и GIST существуют только в PostgreSQL, а частичные индексы MySQL не поддерживает.
Генератор не пытается это скрыть: USING GIN попадёт и в MySQL-версию CREATE INDEX
(база откажется его выполнять), а WHERE будет молча отброшен — индекс создастся, но
охватит всю таблицу.
Если схема должна жить на обеих базах, эти два параметра лучше не трогать.
#[Unique]
Уникальный индекс — ускоряет поиск и одновременно запрещает повторы.
Отличается от #[Index] одним: база отвергнет вставку строки, чьё значение уже
есть в таблице. Это единственный надёжный способ обеспечить уникальность — проверка
«сначала SELECT, потом INSERT» в коде проигрывает гонку двум одновременным запросам,
а индекс не проигрывает никогда.
Синтаксис
#[Unique(
array $columns = [],
?string $name = null,
IndexMethod $method = IndexMethod::BTREE,
?string $where = null,
?string $opClass = null,
)]Параметры
Те же, что у #[Index]. Суффикс имени — _udx вместо _idx.
Атрибут повторяемый.
Пример
#[Varchar(32)]
#[Unique]
public string $number = '';Результат
CREATE UNIQUE INDEX orders_number_udx ON orders USING BTREE (number);Составная уникальность
// Одно и то же название книги может повторяться, но не в один год
#[Varchar(64)]
#[Unique(['year'], name: 'title_year')]
public string $title = '';CREATE UNIQUE INDEX books_title_year_udx ON books USING BTREE (title, year);`$name` — не всё имя целиком
Переданное значение подставляется в середину: name: 'title_year' на таблице books
даёт books_title_year_udx. Если написать полное имя со всеми частями, они удвоятся —
books_books_title_year_udx_udx. Передавайте только середину.
Ограничения
Ограничение — правило, которое база проверяет сама при каждой записи. Смысл в том, что
обойти его нельзя: ни ошибка в коде, ни ручной UPDATE из консоли, ни второй сервис,
пишущий в ту же таблицу, не смогут оставить данные в состоянии, которое правило
запрещает.
#[Check]
Условие, которому обязана удовлетворять каждая строка.
Выражение пишется на SQL и может ссылаться на любые колонки таблицы, не только на ту, где стоит атрибут. Строка, для которой оно ложно, не будет записана.
Синтаксис
#[Check(string $expression, ?string $name = null)]Параметры
$expression — условие на SQL, как оно попадёт в CHECK (…).
$name — имя ограничения. Если не задать, будет сгенерировано из хеша выражения.
Атрибут повторяемый.
Пример
#[Decimal(12, 2)]
#[Check('total >= 0')]
public string $total = '0';Результат
ALTER TABLE orders
ADD CONSTRAINT chk_orders_a0f6358602c3db66492a6ac4bcdb3862 CHECK (total >= 0);Имя стоит задавать
Сгенерированное имя содержит хеш выражения: оно уникально, но ничего не говорит. Когда
база откажет во вставке, в тексте ошибки будет именно это имя — и chk_orders_total_positive
объясняет причину сразу, а chk_orders_a0f63586… заставляет лезть в схему.
#[CheckEnum]
Ограничивает колонку значениями PHP-перечисления.
Избавляет от дублирования: список допустимых значений уже есть в enum, и переписывать его
руками в #[Check] — значит завести второй источник правды, который однажды разойдётся с
первым. Атрибут читает варианты из класса перечисления и строит условие IN (…) сам.
Синтаксис
#[CheckEnum(string $enumClassName, ?string $name = null)]Параметры
$enumClassName — имя класса перечисления, обычно OrderStatus::class.
$name — имя ограничения; по умолчанию генерируется из хеша.
Ошибки
InvalidArgumentException — если класса-перечисления не существует или он не
типизирован. Подойдёт только backed enum (enum X: string или enum X: int):
у обычного перечисления нет значений, которые можно записать в колонку.
Пример
enum OrderStatus: string
{
case NEW = 'new';
case PAID = 'paid';
case SHIPPED = 'shipped';
}#[Varchar(16)]
#[CheckEnum(OrderStatus::class)]
public string $status = 'new';Результат
ALTER TABLE orders
ADD CONSTRAINT chk_orders_51f931f425fae7ce2821bcbe50a8660c
CHECK (status IN ('new', 'paid', 'shipped'));Новый вариант перечисления требует миграции
Условие вписано в схему при создании таблицы. Добавили case REFUNDED — база об этом не
узнает и отвергнет 'refunded', пока ограничение не пересоздано.
#[ForeignKey]
Внешний ключ — связь с колонкой другой таблицы.
База начинает следить за тем, чтобы значение в колонке существовало в таблице, на которую
она ссылается: вставить заказ с несуществующим customer_id станет невозможно. Она же
решает, что делать, когда строка на той стороне удаляется или меняет ключ.
Синтаксис
#[ForeignKey(
string $referencedTable,
string $referencedColumn,
FKAction $onUpdate = FKAction::RESTRICT,
FKAction $onDelete = FKAction::RESTRICT,
?string $name = null,
)]Параметры
$referencedTable — имя таблицы, на которую указывает ключ.
$referencedColumn — колонка в ней; почти всегда первичный ключ.
$onUpdate — что делать, когда значение в родительской колонке меняется.
$onDelete — что делать, когда родительская строка удаляется.
$name — имя ограничения. По умолчанию fk_<таблица>_<колонка>.
Действия FKAction
| Значение | Что произойдёт |
|---|---|
RESTRICT |
операция запрещена, пока есть ссылающиеся строки — умолчание |
NO_ACTION |
то же, но проверка откладывается до конца транзакции |
CASCADE |
изменение повторяется здесь: удалили клиента — удалились его заказы |
SET_NULL |
колонка обнуляется; требует, чтобы она допускала NULL |
SET_DEFAULT |
колонка получает своё значение по умолчанию |
Атрибут повторяемый.
Пример
#[BigInteger]
#[ForeignKey('customers', 'id', onDelete: FKAction::CASCADE)]
public int $customer_id = 0;Результат
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer_id FOREIGN KEY (customer_id)
REFERENCES customers(id) ON DELETE CASCADE ON UPDATE RESTRICT;`CASCADE` при удалении удаляет по-настоящему
Удаление одного клиента с onDelete: CASCADE унесёт все его заказы, а с ними — всё, что
каскадом висит на заказах. Восстанавливать придётся из резервной копии. Там, где история
важна, берут RESTRICT и удаляют осознанно, либо помечают строку удалённой вместо
удаления.
Типы колонок должны совпадать
Ключ на #[BigId] $id требует #[BigInteger] на этой стороне. #[Integer] против
BIGINT база отвергнет при создании ограничения — и это хорошо, потому что иначе
несовпадение вылезло бы на первом заказе с большим номером.
#[ForeignRepo]
Тот же внешний ключ, но целевая таблица и колонка берутся из репозитория.
Отличие от #[ForeignKey] — в том, откуда берутся имена. Здесь вы
указываете класс репозитория, а имя таблицы и имя первичного ключа он сообщает сам.
Переименование таблицы в репозитории тогда доходит до внешнего ключа автоматически, а
опечатка в имени становится невозможной: класс либо существует, либо код не запустится.
Синтаксис
#[ForeignRepo(
string $referencedRepoClass,
FKAction $onUpdate = FKAction::RESTRICT,
FKAction $onDelete = FKAction::RESTRICT,
?string $name = null,
)]Параметры
$referencedRepoClass — класс репозитория целевой таблицы, CustomerRepository::class.
$onUpdate, $onDelete, $name — те же, что у #[ForeignKey].
Ошибки
InvalidArgumentException — если указанный класс не является репозиторием PPA.
Атрибут повторяемый.
Пример
#[BigInteger]
#[ForeignRepo(CustomerRepository::class, onDelete: FKAction::CASCADE)]
public int $customer_id = 0;Результат — тот же ALTER TABLE, что и у #[ForeignKey]; имя таблицы
customers и колонка id пришли из репозитория.
Дополнения
Два атрибута, которые ничего не описывают сами, а поправляют то, что вывелось из типа и значения свойства.
#[NullableIs]
Явно задаёт, принимает ли колонка NULL.
Нужен там, где решение базы должно разойтись с типом свойства. В обычном случае этого не
требуется: ?string уже даёт колонку с NULL, а string — NOT NULL.
Синтаксис
#[NullableIs(bool $isNullable = true)]Параметры
$isNullable — разрешён ли NULL. По умолчанию true.
Пример
// Свойство обязано быть строкой в PHP,
// но в старых строках таблицы на этом месте NULL.
#[Text]
#[NullableIs]
public string $comment = '';Результат
comment TEXT DEFAULT ''Слова NOT NULL нет — колонка приняла бы NULL, хотя тип свойства этого не допускает.
DEFAULT '' осталось от значения свойства.
`NullableIs(false)` не убирает значение свойства
На свойстве public ?string $note = null атрибут даст note TEXT NOT NULL DEFAULT NULL —
колонку, которая запрещает NULL и подставляет NULL по умолчанию. Такая таблица
создастся, но первая же вставка без явного значения провалится. Убирайте = null у
свойства вместе с установкой атрибута.
#[DefaultVal]
Задаёт значение колонки по умолчанию выражением SQL.
Отличается от значения свойства тем, что вычисляет его база, а не PHP: NOW() даст момент
вставки строки, а не момент, когда объект создали в коде. Этим же атрибутом пользуются,
когда нужного значения в PHP просто нет — функция базы, вызов расширения, обращение к
другой колонке.
Синтаксис
#[DefaultVal(string $definition)]Параметры
$definition — выражение SQL, подставляемое в DEFAULT как есть. Строковый литерал
нужно закавычивать самому: #[DefaultVal("'new'")].
Пример
#[Timestamp]
#[DefaultVal('NOW()')]
public string $created_at = '';Результат
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()Атрибут сильнее значения свойства
Свойство объявлено как = '', но в схему попало NOW(): #[DefaultVal] перекрывает то,
что вывелось из PHP. Пустая строка в свойстве остаётся лишь начальным состоянием объекта
до сохранения.
Как это собирается вместе
Имена в схеме
Ничего из имён не задаётся вручную — всё выводится, и знать правила стоит заранее, потому что именно эти имена вы увидите в тексте ошибок базы.
| Что | Откуда берётся | Пример |
|---|---|---|
| Таблица | свойство $table репозитория |
orders |
| Схема | свойство $schema репозитория; PostgreSQL |
public.orders |
| Колонка | имя свойства сущности | created_at |
| Индекс | <таблица>_<колонки>_idx |
orders_customer_id_idx |
| Уникальный индекс | <таблица>_<колонки>_udx |
orders_number_udx |
| Внешний ключ | fk_<таблица>_<колонка> |
fk_orders_customer_id |
| Проверка | chk_<таблица>_<хеш выражения> |
chk_orders_a0f63586… |
Полный пример
Заказ со всем, что встречается на практике: ключ, уникальный номер, связь с клиентом, ограниченный список статусов, деньги, документ произвольной формы и два момента времени.
<?php
namespace Main\Entities;
use Flytachi\Winter\Ppa\Mapping\Attributes\Entity\Table;
use Flytachi\Winter\Ppa\Mapping\Attributes\Hybrid\BigId;
use Flytachi\Winter\Ppa\Mapping\Attributes\Primal\{BigInteger, Char, Decimal, Json, Text, Timestamp, Varchar};
use Flytachi\Winter\Ppa\Mapping\Attributes\Idx\{Index, Unique};
use Flytachi\Winter\Ppa\Mapping\Attributes\Constraint\{Check, CheckEnum, ForeignKey};
use Flytachi\Winter\Ppa\Mapping\Attributes\Additive\DefaultVal;
use Flytachi\Winter\Ppa\Mapping\Constants\{FKAction, IndexMethod};
#[Table]
class Order
{
#[BigId]
public ?int $id = null;
#[Varchar(32)]
#[Unique]
public string $number = '';
#[BigInteger]
#[ForeignKey('customers', 'id', onDelete: FKAction::CASCADE)]
#[Index]
public int $customer_id = 0;
#[Varchar(16)]
#[CheckEnum(OrderStatus::class)]
public string $status = 'new';
#[Decimal(12, 2)]
#[Check('total >= 0')]
public string $total = '0';
#[Char(3)]
public string $currency = 'USD';
#[Json]
#[Index(method: IndexMethod::GIN)]
public array $meta = [];
#[Text]
public ?string $comment = null;
#[Timestamp]
#[DefaultVal('NOW()')]
public string $created_at = '';
#[Timestamp]
#[Index(where: 'shipped_at IS NOT NULL')]
public ?string $shipped_at = null;
}Что получится в PostgreSQL
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
number VARCHAR(32) NOT NULL DEFAULT '',
customer_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'new',
total NUMERIC(12, 2) NOT NULL DEFAULT '0',
currency CHAR(3) NOT NULL DEFAULT 'USD',
meta JSONB NOT NULL DEFAULT '[]'::jsonb,
comment TEXT DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
shipped_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX orders_number_udx ON orders USING BTREE (number);
CREATE INDEX orders_customer_id_idx ON orders USING BTREE (customer_id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_id FOREIGN KEY (customer_id)
REFERENCES customers(id) ON DELETE CASCADE ON UPDATE RESTRICT;
ALTER TABLE orders ADD CONSTRAINT chk_orders_51f931f425fae7ce2821bcbe50a8660c
CHECK (status IN ('new', 'paid', 'shipped'));
ALTER TABLE orders ADD CONSTRAINT chk_orders_a0f6358602c3db66492a6ac4bcdb3862
CHECK (total >= 0);
CREATE INDEX orders_meta_idx ON orders USING GIN (meta);
CREATE INDEX orders_shipped_at_idx ON orders USING BTREE (shipped_at)
WHERE shipped_at IS NOT NULL;Что получится в MySQL
Тот же класс, другая база — различия видно построчно:
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT NOT NULL,
number VARCHAR(32) NOT NULL DEFAULT '',
customer_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'new',
total DECIMAL(12, 2) NOT NULL DEFAULT '0',
currency CHAR(3) NOT NULL DEFAULT 'USD',
meta JSON NOT NULL DEFAULT ('[]'),
comment TEXT DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
shipped_at TIMESTAMP DEFAULT NULL,
PRIMARY KEY (id)
);Автоинкремент выражен по-своему, NUMERIC стал DECIMAL, JSONB — JSON, часовой пояс
у TIMESTAMP исчез: в MySQL этого типа с поясом нет. Индексы и ограничения совпадают, за
исключением двух мест, про которые предупреждает раздел #[Index]: USING GIN
MySQL не выполнит, а WHERE из частичного индекса будет отброшен.
Диалект берётся из конфигурации репозитория
Какая именно из двух схем получится, решает не сущность, а база, к которой привязан репозиторий: диалект приходит из его класса конфигурации. Одна и та же сущность на PostgreSQL-репозитории и на MySQL-репозитории даст разный DDL.
Применение к базе
Сущность сама по себе таблицу не создаёт. Схему приводит в соответствие команда:
php call db migrateОна обходит классы, помеченные #[Table], и создаёт то, чего в базе ещё нет —
таблицы, индексы, ограничения. Что именно будет выполнено, можно посмотреть заранее, не
трогая базу:
php call db sqlПодробности — на странице Миграции.
Дальше
- Репозитории — как читать и записывать эти объекты
- Миграции — как описание доходит до базы
- Конфигурация БД — подключение, схема, расширения
- Пагинация — постраничная выдача списков