База данных · Сущности

Сущности

Сущность — обычный PHP-класс, у которого две работы: описывать таблицу и принимать её строки. Схема берётся из атрибутов над свойствами, а результат запроса гидрируется в объекты того же класса. Ниже — как из свойства получается колонка, и разбор каждого атрибута с готовым SQL по трём диалектам.

Пакет flytachi/winter-ppaАтрибутов 37Диалекты PostgreSQL · MySQL · SQLite

Что такое сущность

main/Entities/User.php
<?php

namespace Main\Entities;

use Flytachi\Winter\Ppa\Mapping\Attributes\Entity\Table;
use Flytachi\Winter\Ppa\Mapping\Attributes\Hybrid\Id;
use Flytachi\Winter\Ppa\Mapping\Attributes\Primal\{Timestamp, Varchar};

#[Table]
class User
{
  #[Id] public ?int $id = null;
  #[Varchar(255)] public string $email;
  #[Varchar(64)] public string $status;
  #[Timestamp] public string $created_at;
}

Из этого класса получается и CREATE TABLE, и тип результата: findById() вернёт User с заполненными полями.

Две роли одного класса

Роль Кто использует Что берёт
Описание схемы миграции атрибуты над свойствами
Цель гидрации репозиторий имена и типы свойств

Роли независимы: класс без атрибутов гидрируется как обычно, просто миграции о нём ничего не скажут. И наоборот — атрибуты не мешают классу быть обычным объектом.

Сущность — не модель предметной области

В ней нет ленивой загрузки, отслеживания изменений и связей-объектов. Репозиторий отдаёт заполненные экземпляры; что с ними делать дальше — дело приложения. Это сознательная граница: каждая из перечисленных возможностей переносит часть состояния из вашего кода в слой данных.

Как из свойства получается колонка

Три источника, в таком порядке:

text
тип свойства PHP        →  тип колонки по умолчанию
атрибут типа            →  переопределяет его
атрибут-модификатор     →  дописывает к результату (AUTO_INCREMENT и подобное)

Плюс два независимых источника, которые смотрят не на тип:

Значение свойства по умолчанию становится DEFAULT в DDL.

php
#[Integer] public int $views = 0;    // → views INT NOT NULL DEFAULT 0

null в типе делает колонку обнуляемой.

php
#[Integer] public ?int $rating = null;   // → rating INT DEFAULT NULL
#[Integer] public int $views = 0;        // → views INT NOT NULL DEFAULT 0

Свойство вообще без атрибутов колонкой всё равно станет — по типу PHP:

php
public string $note;       // → note VARCHAR(255) NOT NULL
public ?int $flags = null; // → flags INT DEFAULT NULL

Это удобно для черновика, но полагаться на это в рабочей схеме не стоит: VARCHAR(255) здесь — умолчание слоя, а не ваше решение.


Справочник атрибутов

Разметка класса

#[Table]

php
#[Table]
class User {  }

Помечает класс как сущность. Без него PPAMapping пройдёт мимо: класс останется годным для гидрации, но в схему не попадёт.

Имя таблицы задаётся не здесь

Атрибут аргументов не принимает — а #[Table('users')] не ошибка только потому, что лишний аргумент молча игнорируется. Имя таблицы берётся из репозитория:

class UserRepository extends Repository
{
    public static string $table = 'users';   // ← вот отсюда
}

Схема — оттуда же, из $schema репозитория.

#[Migratable]

php
#[Migratable(priority: MigratablePriority::High)]
Аргумент Тип По умолчанию Что делает
$priority MigratablePriority Normal в каком порядке применять таблицу

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

#[Extension]

php
#[Extension('uuid-ossp')]
#[Extension('pg_trgm', version: '1.6', cascade: true)]
Аргумент Тип По умолчанию Что делает
$name string имя расширения
$version ?string null требуемая версия
$schema ?string null схема установки
$cascade bool false ставить зависимости

Тоже на классе конфигурации. Миграция выпустит CREATE EXTENSION IF NOT EXISTS до создания таблиц — иначе колонка типа UUID с gen_random_uuid() окажется без функции.


Типы колонок

Один и тот же атрибут даёт разный SQL в разных базах — это и есть работа слоя. Таблица снята с рендера, а не составлена по памяти.

Атрибут PostgreSQL MySQL SQLite
#[Varchar(255)] VARCHAR(255) VARCHAR(255) VARCHAR(255)
#[Char(2)] CHAR(2) CHAR(2) CHAR(2)
#[Text] TEXT TEXT TEXT
#[SmallInteger] SMALLINT SMALLINT SMALLINT
#[Integer] INT INT INT
#[BigInteger] BIGINT BIGINT BIGINT
#[Decimal(10, 2)] NUMERIC(10, 2) DECIMAL(10, 2) NUMERIC(10, 2)
#[FloatType] REAL FLOAT REAL
#[Double] DOUBLE PRECISION DOUBLE REAL
#[Boolean] BOOLEAN BOOLEAN BOOLEAN
#[Date] DATE DATE DATE
#[Time] TIME TIME TIME
#[DateTime] TIMESTAMP WITHOUT TIME ZONE DATETIME DATETIME
#[Timestamp] TIMESTAMP WITH TIME ZONE TIMESTAMP DATETIME
#[Json] JSONB JSON JSON
#[Uuid] UUID CHAR(36) TEXT
#[TextArray] TEXT[] JSON JSON
#[Binary(16)] BYTEA VARBINARY(16) BLOB
#[Blob] BYTEA BLOB BLOB

Три различия стоит держать в голове, потому что они меняют поведение, а не только слово в DDL:

#[DateTime] против #[Timestamp]. В PostgreSQL первый даёт время без зоны, второй — с зоной. Для момента времени, который читают в разных часовых поясах, нужен второй; для «даты и времени как написано» — первый.

#[Uuid] в MySQL — это CHAR(36), то есть текст: сравнение и индексация идут как по строке, а не по 16 байтам.

#[TextArray] — родной массив только в PostgreSQL. В MySQL и SQLite он превращается в JSON, и запросы по элементам придётся писать по-разному.

#[Type]

php
#[Type('inet')] public string $ip;      // → ip inet NOT NULL
#[Type('geometry(Point, 4326)')] public string $location;
Аргумент Тип Что делает
$definition string тип, который уйдёт в DDL дословно

Запасной выход для того, чего в списке нет: inet, tsvector, geometry, домены. Слой ничего про такой тип не знает, поэтому и не переводит его между диалектами — переносимость здесь ваша забота.


Первичные ключи

#[Id], #[BigId], #[SmallId]

php
#[Id]      public ?int $id = null;
#[BigId]   public ?int $id = null;
#[SmallId] public ?int $id = null;
Аргумент Тип По умолчанию Что делает
$always bool false GENERATED ALWAYS вместо BY DEFAULT (только PostgreSQL)

Это гибриды: каждый разворачивается в четыре обычных атрибута — #[Primary], #[AutoIncrement], #[NullableIs(false)] и тип (Integer, BigInteger или SmallInteger). Отсюда результат:

text
PostgreSQL:  id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL   +  PRIMARY KEY (id)
MySQL:       id BIGINT AUTO_INCREMENT NOT NULL                    +  PRIMARY KEY (id)
SQLite:      id INTEGER NOT NULL                                  +  PRIMARY KEY (id)

Тип свойства — ?int со значением null: свойства со значением null при вставке не отправляются, поэтому идентификатор проставляет база.

#[Id(always: true)] в PostgreSQL запрещает вставлять свой идентификатор — база откажет даже при явном значении. Полезно, когда ключ обязан быть только от базы; мешает при переносе данных со старыми идентификаторами.

Первичный ключ объявляйте первым свойством

На PostgreSQL и MariaDB вставка дописывает RETURNING по первой колонке — то есть по первому объявленному свойству, — и именно её значение возвращает insert().

$id = $repo->insert(['name' => 'Alice', 'id' => null]);   // вернёт 'Alice'

Правило: ключ — первое свойство сущности и первый ключ в массиве.

#[UuidPk]

php
#[UuidPk] public ?string $id = null;

Гибрид для ключа-UUID: PRIMARY KEY, тип UUID, NOT NULL и умолчание — gen_random_uuid() в PostgreSQL, UUID() в MySQL.

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

#[Primary]

php
#[Primary] #[BigInteger] public int $user_id;
#[Primary] #[BigInteger] public int $role_id;

Аргументов нет. На нескольких свойствах собирается в один составной ключ в порядке объявления:

text
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);

Это обычный случай для связующих таблиц: своего id у них нет, а пара и есть ключ.

#[AutoIncrement]

php
#[Primary] #[AutoIncrement] #[BigInteger] public ?int $id = null;   // то же, что #[BigId]
#[AutoIncrement] #[Integer] public ?int $seq = null;               // счётчик, не ключ
Аргумент Тип По умолчанию Что делает
$always bool false GENERATED ALWAYS вместо BY DEFAULT (PostgreSQL)

Отдельно нужен редко — гибриды разворачиваются именно в него. Руками ставят, когда колонка растёт сама, но ключом не является, или когда нужна ширина, которой среди гибридов нет.

Принимает только целочисленное свойство (int или mixed, null допустим) и только типы SMALLINT, INT, BIGINT — на остальном сборка схемы остановится с ошибкой, а не выпустит неверный DDL. В SQLite объявленная ширина игнорируется: колонка становится псевдонимом rowid, только если тип написан ровно INTEGER.


Индексы

#[Index], #[Unique]

php
#[Varchar(255)] #[Index] public string $title;
#[Varchar(160)] #[Unique] public string $slug;
Аргумент Тип По умолчанию Что делает
$columns array [] дополнительные колонки — для составного индекса
$name ?string null своё имя; по умолчанию собирается из таблицы и колонки
$method IndexMethod BTREE метод: BTREE, HASH, GIN, GIST
$where ?string null условие частичного индекса (PostgreSQL)
$opClass ?string null класс операторов (PostgreSQL)
text
PostgreSQL:  CREATE UNIQUE INDEX posts_slug_udx ON posts USING BTREE (slug);
           CREATE INDEX posts_title_idx ON posts USING BTREE (title);
MySQL:       CREATE UNIQUE INDEX posts_slug_udx ON posts (slug);
           CREATE INDEX posts_title_idx ON posts (title);

Имя собирается как {таблица}_{колонка}_idx и _udx — предсказуемо, поэтому в миграциях индекс всегда узнаётся, даже если его переносили между окружениями.

Составной индекс объявляют на одном свойстве, перечисляя остальные колонки:

php
#[Index(['status', 'created_at'])] #[Varchar(64)] public string $status;

Частичный — только PostgreSQL, и это часто лучший способ сделать «уникальный среди активных»:

php
#[Unique(where: 'deleted_at IS NULL')] #[Varchar(255)] public string $email;

Ограничения

#[ForeignKey]

php
#[Integer]
#[ForeignKey('users', 'id', onDelete: FKAction::CASCADE)]
public int $author_id;
Аргумент Тип По умолчанию Что делает
$referencedTable string таблица, на которую ссылаемся
$referencedColumn string её колонка
$onUpdate FKAction RESTRICT что делать при изменении ключа
$onDelete FKAction RESTRICT что делать при удалении строки
$name ?string null своё имя ограничения
text
ALTER TABLE posts ADD CONSTRAINT fk_posts_author_id
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;

FKActionRESTRICT, CASCADE, SET NULL, SET DEFAULT, NO ACTION. Умолчание RESTRICT выбрано намеренно: удаление, у которого есть зависимые строки, должно падать, а не уносить их молча.

#[ForeignRepo]

php
#[Integer]
#[ForeignRepo(UserRepository::class, onDelete: FKAction::CASCADE)]
public int $author_id;
Аргумент Тип По умолчанию Что делает
$referencedRepoClass class-string репозиторий, на таблицу которого ссылаемся
$onUpdate / $onDelete FKAction RESTRICT то же, что выше
$name ?string null своё имя

То же самое, но таблица и колонка ключа берутся у репозитория, а не пишутся строкой. Переименовали таблицу в одном месте — внешние ключи поехали за ней, и опечатка в имени становится невозможной.

Предпочитайте его; #[ForeignKey] остаётся для таблиц, у которых репозитория нет.

#[Check]

php
#[Integer] #[Check('views >= 0')] public int $views = 0;
Аргумент Тип По умолчанию Что делает
$expression string условие, которое обязано выполняться
$name ?string null своё имя ограничения
text
ALTER TABLE posts ADD CONSTRAINT chk_posts_a1739ad7… CHECK (views >= 0);

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

#[CheckEnum]

php
enum Status: string { case Draft = 'draft'; case Live = 'live'; }

#[Varchar(16)] #[CheckEnum(Status::class)] public string $status;
Аргумент Тип По умолчанию Что делает
$enumClassName class-string перечисление, задающее допустимые значения
$name ?string null своё имя ограничения
text
ALTER TABLE posts ADD CONSTRAINT chk_posts_b8679390… CHECK (status IN ('draft', 'live'));

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


Пустота и умолчания

#[NullableIs]

php
#[Integer] #[NullableIs(true)] public ?int $rating = null;
Аргумент Тип Что делает
$nullable bool разрешить или запретить NULL явно

Нужен редко: обнуляемость и так берётся из типа свойства. Ставят его, когда тип и схема должны разойтись намеренно — например, свойство типизировано строго, а колонка обязана принимать NULL из-за старых данных.

#[DefaultVal]

php
#[Timestamp] #[DefaultVal('CURRENT_TIMESTAMP')] public string $created_at;
Аргумент Тип Что делает
$definition string выражение, которое уйдёт в DEFAULT дословно

Это выражение SQL, а не значение PHP

Строка подставляется как есть, поэтому #[DefaultVal('new')] даёт DEFAULT new — а это ссылка на колонку new, и база такую таблицу не создаст.

Строковое значение нужно писать с кавычками внутри аргумента:

#[DefaultVal("'new'")]        // → DEFAULT 'new'
#[DefaultVal('CURRENT_TIMESTAMP')]   // → DEFAULT CURRENT_TIMESTAMP
#[DefaultVal('0')]                   // → DEFAULT 0

Для простых значений проще обойтись без атрибута: значение свойства по умолчанию само становится DEFAULT.


Гидрация

Репозиторий заполняет свойства по именам колонок результата.

Ситуация Что происходит
Колонка совпала со свойством значение записывается
Колонки нет в результате свойство остаётся со своим значением по умолчанию
В результате колонка, которой нет в классе создаётся динамическое свойство — PHP выдаст Deprecated
Свой select() в запросе гидрация уходит в stdClass

Отсюда практическое следствие: если сущность отдаётся в нескольких формах, сужать нужно select() и брать под это отдельный класс через $entityClassName — иначе лишние колонки осядут динамическими свойствами.

Полный пример

main/Entities/Post.php
#[Table]
class Post
{
  #[BigId] public ?int $id = null;

  #[Varchar(160)] #[Unique] public string $slug;
  #[Varchar(255)] #[Index] public string $title;

  #[Integer] #[ForeignRepo(UserRepository::class, onDelete: FKAction::CASCADE)]
  public int $author_id;

  #[Varchar(16)] #[CheckEnum(Status::class)] public string $status;
  #[Integer] #[Check('views >= 0')] public int $views = 0;

  #[Timestamp] #[DefaultVal('CURRENT_TIMESTAMP')] public string $created_at;
  #[Timestamp] public ?string $deleted_at = null;
}
text
CREATE TABLE posts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
slug VARCHAR(160) NOT NULL,
title VARCHAR(255) NOT NULL,
author_id INT NOT NULL,
status VARCHAR(16) NOT NULL,
views INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX posts_slug_udx ON posts USING BTREE (slug);
CREATE INDEX posts_title_idx ON posts USING BTREE (title);
ALTER TABLE posts ADD CONSTRAINT fk_posts_author_id
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;
ALTER TABLE posts ADD CONSTRAINT chk_posts_b8679390… CHECK (status IN ('draft', 'live'));
ALTER TABLE posts ADD CONSTRAINT chk_posts_a1739ad7… CHECK (views >= 0);

Дальше