База данных · Репозитории

Репозитории

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

Пакет flytachi/winter-ppaСборка RepositoryCoreЧтение RepositoryViewTraitЗапись RepositoryCrudTrait

Что такое репозиторий

Место, где живут запросы к одной таблице. Без него SQL расползается строками по контроллерам: одна и та же выборка пишется заново там, где понадобилась, параметры привязываются руками, а переименованная колонка обнаруживается в рантайме.

php
// без репозитория
$stmt = $pdo->prepare('SELECT * FROM users WHERE status = :s ORDER BY id DESC LIMIT 20');
$stmt->execute(['s' => 'active']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);   // массивы без типов

// с репозиторием
$users = UserRepository::instance('u')
  ->where(Qb::eq('u.status', 'active'))
  ->orderBy('u.id DESC')
  ->limit(20)
  ->findAll();                              // User[]

Три вещи, которые это даёт помимо краткости: значения всегда уходят привязками, а не конкатенацией; результат гидрируется в сущность, поэтому редактор знает поля; подзапрос, джойн и CTE принимают другой репозиторий, а не строку — и он приносит с собой свои привязки.

Объявление

main/Repositories/UserRepository.php
<?php

namespace Main\Repositories;

use Flytachi\Winter\Ppa\Stereotype\Repository;
use Main\Configurations\MainDbConfig;
use Main\Entities\User;

/** @extends Repository<User> */
class UserRepository extends Repository
{
  public static string $table         = 'users';
  protected string $entityClassName   = User::class;
  protected string $dbConfigClassName = MainDbConfig::class;
}
Свойство Обязательно Что задаёт
$table да имя таблицы; public static, потому что читается и без экземпляра
$dbConfigClassName да к какой базе обращаться
$entityClassName практически во что гидрировать строки
$schema нет схема, если таблица не в схеме по умолчанию

`@extends` — не украшение

Строка /** @extends Repository<User> */ привязывает шаблонный параметр к вашей сущности. С ней findById() возвращает ?User, и редактор знает поля. Без неё — object, и автодополнение молчит; код при этом работает одинаково.

Стереотипы

Класс Что даёт Когда брать
Repository сборка + чтение + запись обычный случай
RepositoryView сборка + чтение представление в базе, отчёт, таблица только на чтение
RepositoryCrud сборка + запись журнал, очередь — пишем, не читаем
CteRepo сборка заготовка, которая существует только как CTE или подзапрос

Выбор — не про стиль, а про то, что нельзя вызвать по ошибке: у RepositoryView нет delete(), и это видно в автодополнении.

Получить экземпляр

instance()

php
public static function instance(?string $as = null): static
Аргумент Тип По умолчанию Что делает
$as ?string null алиас таблицы в запросе

Возвращает новый экземпляр репозитория с чистым состоянием запроса.

php
UserRepository::instance();        // SELECT id, email FROM users
UserRepository::instance('u');     // SELECT u.id, u.email FROM users u

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

Каждый вызов — новый запрос

instance() возвращает новый объект, а условия копятся в объекте. Собирать запрос в одну цепочку — не стилистика: два вызова instance() дают два независимых запроса, а попытка «дособрать» ранее полученный экземпляр добавит условия к тому, что там уже накоплено.

as()

php
public function as(string $alias): static

Задаёт алиас уже полученному экземпляру — то же, что аргумент instance(), но по ходу сборки.

forBy()

php
public function forBy(string $context): static

Дописывает хвост FOR … — блокировку строк на время транзакции.

php
UserRepository::instance('u')->where(Qb::eq('u.id', $id))->forBy('UPDATE')->find();
// SELECT u.id, u.email FROM users u WHERE u.id = :iqb0 FOR UPDATE

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


Справочник методов

Сборка запроса

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

select()

php
public function select(string $option): static
Аргумент Тип Что делает
$option string список колонок строкой, как в SQL

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

php
UserRepository::instance('u')
  ->select('u.id, COUNT(*) AS n')
  ->groupBy('u.id')
  ->having('COUNT(*) > 3');
// SELECT u.id, COUNT(*) AS n FROM users u GROUP BY u.id HAVING COUNT(*) > 3

Свой `select()` меняет тип результата

Произвольный набор колонок может не совпасть с формой сущности, поэтому гидрация уходит в stdClass. Это видно по getEntityClassName() и означает, что в результате будут $row->n, а не типизированные поля.

from()

php
public function from(RepositoryInterface|string $repository): static

Заменяет источник: вместо таблицы — подзапрос или другое имя.

php
UserRepository::instance('u')->from(OrderRepository::instance('o')->select('o.user_id'));
// SELECT u.id, u.email FROM (SELECT o.user_id FROM orders o) u

Репозиторий приносит с собой свои привязки; строка — просто подставляется.

where(), andWhere(), orWhere(), xorWhere()

php
public function where(?Qb $qb): static
public function andWhere(Qb $qb): static
public function orWhere(Qb $qb): static
public function xorWhere(Qb $qb): static
Метод Что делает
where() задаёт условие
andWhere() добавляет через AND
orWhere() добавляет через OR
xorWhere() добавляет через XOR

Условия строит Qb — значения в нём становятся привязками, а не частью текста запроса.

php
UserRepository::instance('u')
  ->where(Qb::eq('u.id', 1))
  ->andWhere(Qb::gt('u.age', 0))
  ->orWhere(Qb::eq('u.id', 2));
// WHERE u.id = :iqb3 AND u.age > :iqb4 OR u.id = :iqb5

null в where() ничего не делает — это не сброс условия. Чтобы очистить запрос, есть cleanCache().

join(), joinInner(), joinLeft(), joinRight(), joinCross()

php
public function join(RepositoryInterface|string $repository, Qb|string $on): static
Аргумент Тип Что делает
$repository RepositoryInterface|string что присоединяем: репозиторий или 'orders o' строкой
$on Qb|string условие соединения

joinCross() берёт только первый аргумент — у декартова произведения условия нет.

php
UserRepository::instance('u')->joinLeft(OrderRepository::instance('o'), 'o.user_id = u.id');
// SELECT u.id, u.email FROM users u LEFT JOIN orders o ON(o.user_id = u.id)

Присоединять репозиторий, а не строку, стоит по той же причине, что и в from(): он несёт своё имя таблицы, свою схему и свои привязки. Строка остаётся на случай, когда таблицы в слое нет вовсе.

with(), withRecursive()

php
public function with(string $name, RepositoryInterface $repository, ?string $modifier = null): static
public function withRecursive(string $name, RepositoryInterface $repository): static
Аргумент Тип Что делает
$name string имя CTE, по которому на него ссылаются дальше
$repository RepositoryInterface запрос, который станет телом CTE
$modifier ?string подсказка диалекта, например MATERIALIZED
php
UserRepository::instance('u')->with('recent', OrderRepository::instance('o')->where(Qb::gt('o.total', 100)));
// WITH recent AS (SELECT o.id, o.user_id, o.total FROM orders o WHERE o.total > :iqb2)
// SELECT u.id, u.email FROM users u

withRecursive() выпускает WITH RECURSIVE — для обхода деревьев и графов.

union(), unionAll()

php
public function union(RepositoryInterface $repository): static
public function unionAll(RepositoryInterface $repository): static

Объединяет выборки. union() убирает дубликаты, unionAll() — нет и потому дешевле.

php
UserRepository::instance('u')->union(UserRepository::instance('u2')->where(Qb::eq('u2.id', 2)));
// SELECT u.id, u.email FROM users u UNION SELECT u2.id, u2.email FROM users u2 WHERE u2.id = :iqb1

Списки колонок обеих частей должны совпадать — это требование SQL, а не слоя.

groupBy(), having(), orderBy()

php
public function groupBy(string $context): static
public function having(string $context): static
public function orderBy(string $context): static

Принимают фрагмент SQL строкой — как есть.

php
->groupBy('u.id')->having('COUNT(*) > 3')->orderBy('u.created_at DESC, u.id DESC')

Это фрагменты, а не значения

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

$sort = match ($request->query('sort')) {
    'newest' => 'u.created_at DESC',
    'email'  => 'u.email ASC',
    default  => 'u.id DESC',
};

limit()

php
public function limit(int $limit, int $offset = 0): static
Аргумент Тип По умолчанию Что делает
$limit int сколько строк вернуть
$offset int 0 сколько пропустить
php
UserRepository::instance('u')->limit(5, 10);
// … LIMIT 5 OFFSET 10

Для страниц удобнее пагинация: она считает смещение сама и отдаёт мету.


Чтение

Каждый метод этой группы выполняет запрос. ?string $entityClassName во всех них подменяет класс гидрации, не трогая набор колонок.

find()

php
public function find(?string $entityClassName = null): ?object

Первая строка запроса.

Возвращает объект сущности либо null, если строк нет. Не бросает — отсутствие результата это ответ, а не сбой.

php
$user = UserRepository::instance('u')->where(Qb::eq('u.email', $email))->find();

if ($user === null) {
  return ResponseEntity::notFound();
}

findAll()

php
public function findAll(?string $entityClassName = null): array

Возвращает массив сущностей; пустой, если ничего не нашлось.

php
$users = UserRepository::instance('u')->where(Qb::eq('u.status', 'active'))->findAll();

Ограничения по объёму здесь нет — метод отдаст столько строк, сколько нашёл. Для больших таблиц ставьте limit() или берите пагинацию.

findById(), findBy(), findAllBy()

php
public function findById(string|int $id, ?string $entityClassName = null): ?object
public function findBy(Qb $qb, ?string $entityClassName = null): ?object
public function findAllBy(?Qb $qb = null, ?string $entityClassName = null): array

Сокращения для частого случая — условие прямо в вызове, без отдельного where().

Метод Что делает Если не нашлось
findById($id) ищет по первичному ключу null
findBy($qb) первая строка по условию null
findAllBy($qb) все строки по условию; null — все строки таблицы []
php
UserRepository::instance()->findById(1)?->email;     // 'a@x'
UserRepository::instance()->findById(999);            // null
UserRepository::instance()->findAllBy();              // все строки

Имя колонки ключа берётся из сущности — его же отдаёт mapIdentifierColumnName().

findByIdOrThrow(), findByOrThrow()

php
public function findByIdOrThrow(
  string|int $id,
  ?string $entityClassName = null,
  string $message = 'Entity not found',
  HttpCode $httpCode = HttpCode::NOT_FOUND,
): object
Аргумент Тип По умолчанию Что делает
$id / $qb string|int / Qb что искать
$entityClassName ?string null класс гидрации
$message string 'Entity not found' текст исключения
$httpCode HttpCode NOT_FOUND код ответа, если исключение долетит до HTTP-слоя

Возвращает объект — никогда null. Бросает EntityException, если строки нет:

text
FlytachiWinterPpaEntityEntityException: Entity not found
php
// вместо трёх строк с проверкой
$user = UserRepository::instance()->findByIdOrThrow($id, message: 'Пользователь не найден');

Смысл не в краткости, а в том, что исключение несёт HTTP-код: обработчик ошибок превратит его в 404 без единого if в контроллере.

count()

php
public function count(): int

Возвращает число строк, удовлетворяющих накопленным условиям.

php
UserRepository::instance()->where(Qb::gt('age', 25))->count();   // 2

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

exists()

php
public function exists(): bool

Возвращает true, если под условия попадает хотя бы одна строка.

php
if (UserRepository::instance()->where(Qb::eq('email', $email))->exists()) {
  throw new Conflict('Такой адрес уже занят');
}

Дешевле count() > 0 и честнее find() !== null: строка не передаётся по сети.

findColumn()

php
public function findColumn(int $column = 0): mixed
Аргумент Тип По умолчанию Что делает
$column int 0 номер колонки в списке SELECT, считая с нуля

Возвращает одно значение из первой строки — без гидрации и без создания объектов.

php
UserRepository::instance()->select('email')->findColumn();          // 'a@x'
UserRepository::instance()->select('COUNT(*)')->findColumn();      // '4'

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

rawFetch()

php
public function rawFetch(string $sql, array $binds = [], ?string $entityClassName = null): array
Аргумент Тип По умолчанию Что делает
$sql string запрос целиком, как есть
$binds array [] значения для плейсхолдеров
$entityClassName ?string null класс гидрации

Возвращает массив объектов.

php
$rows = UserRepository::instance()->rawFetch(
  'SELECT email FROM users WHERE age > :age ORDER BY id LIMIT 2',
  ['age' => 18],
);

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

`$binds` — единственный безопасный вход

Значения передавайте через $binds, а не подстановкой в $sql. Интерполяция здесь — это ровно та SQL-инъекция, ради устранения которой существует остальной слой.


Запись

insert()

php
public function insert(object|array $entity): mixed
Аргумент Тип Что делает
$entity object|array сущность или ассоциативный массив «колонка => значение»

Возвращает идентификатор вставленной строки. На PostgreSQL и MariaDB — через RETURNING по первой колонке, на остальных — lastInsertId.

php
UserRepository::instance()->insert(['email' => 'a@x', 'age' => 30]);   // "1"

$user = new User();
$user->email = 'b@x';
UserRepository::instance()->insert($user);                            // "2"

Свойства со значением null не отправляются — поэтому ?int $id = null в сущности позволяет базе проставить ключ самой.

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

RETURNING берёт первую колонку, а порядок колонок — это порядок свойств в классе сущности. Если первым объявлен не ключ, вернётся не идентификатор:

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

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

insertBatch()

php
public function insertBatch(Traversable|object|array ...$entities): void

Вставляет много строк пачками, одним запросом на пачку.

php
UserRepository::instance()->insertBatch(
  ['email' => 'c@x', 'age' => 20],
  ['email' => 'd@x', 'age' => 25],
);

// генератор: в памяти одновременно живёт одна пачка, а не весь список
UserRepository::instance()->insertBatch((function () {
  foreach ($hugeSource as $row) {
      yield ['email' => $row->email, 'age' => $row->age];
  }
})());

Ничего не возвращает — идентификаторов у пачки нет. Если они нужны, вставляйте по одной через insert().

update()

php
public function update(object|array $entity, Qb $qb): string|int
Аргумент Тип Что делает
$entity object|array что записать
$qb Qb какие строки менять — обязателен

Возвращает число изменённых строк: 0, если под условие ничего не попало.

php
UserRepository::instance()->update(['age' => 31], Qb::eq('email', 'a@x'));   // 1
UserRepository::instance()->update(['age' => 99], Qb::eq('email', 'нет'));   // 0

Условие — обязательный аргумент, а не значение по умолчанию: обновление всей таблицы должно быть написано явно (Qb::raw('1=1')), а не получиться из забытого параметра.

delete()

php
public function delete(Qb $qb): string|int

Возвращает число удалённых строк.

php
UserRepository::instance()->delete(Qb::eq('email', 'd@x'));           // 1
UserRepository::instance()->delete(Qb::lt('created_at', $cutoff));

Условие обязательно по той же причине, что и в update().

upsert()

php
public function upsert(
  object|array $entity,
  array $conflictColumns,
  ?array $updateColumns = null,
): mixed

«Вставить, а если такая строка уже есть — обновить».

Аргумент Тип По умолчанию Что делает
$entity object|array что вставляем
$conflictColumns array по каким колонкам определяется «такая же строка»
$updateColumns ?array null карта «колонка => выражение», что делать при конфликте

$updateColumns — это карта, а не список колонок. В выражении доступны два плейсхолдера: :new — входящее значение, :current — то, что уже лежит в базе.

php
// перезаписать значением
$repo->upsert(['email' => 'a@x', 'age' => 30], ['email'], ['age' => ':new']);

// прибавить к сохранённому
$repo->upsert(['sku' => 'ABC', 'qty' => 5], ['sku'], ['qty' => ':current + :new']);

Список вместо карты не пройдёт молча — CDO отвечает подсказкой с исправленным вызовом:

text
updateColumns expects a column => expression map, got a plain list at position 0.
Did you mean ['age' => ':new']? Use ':new' for the incoming value and ':current' for the
stored one; pass an empty array to ignore conflicts.

`null` — это «ничего не делать», а не «обновить всё»

null и [] означают одно и то же: конфликт игнорируется, сохранённая строка остаётся прежней. Проверено: upsert(['email' => 'a@x', 'age' => 20], ['email']) при существующем a@x оставляет age равным 10.

Чтобы что-то обновилось, колонки нужно назвать.

Возвращает идентификатор — как insert().

upsertBatch()

php
public function upsertBatch(
  iterable $entities,
  array $conflictColumns,
  ?array $updateColumns = null,
): void

То же самое для многих строк, пачками. $entities — любой iterable, включая генератор.

php
UserRepository::instance()->upsertBatch(
  [['email' => 'g1@x', 'age' => 1], ['email' => 'z@x', 'age' => 7]],
  ['email'],
  ['age' => ':current + :new'],
);
// существовавшему g1@x с age=111 стало 112, z@x вставился

Ничего не возвращает.


Транзакции

Транзакция открывается на соединении, а соединение у единицы работы одно — поэтому все репозитории внутри запроса попадают в одну транзакцию автоматически.

php
$db = UserRepository::instance()->db();

$db->beginTransaction();
try {
  $id = UserRepository::instance()->insert(['email' => $email]);
  OrderRepository::instance()->insert(['user_id' => $id, 'total' => 0]);
  $db->commit();
} catch (Throwable $e) {
  $db->rollBack();
  throw $e;
}

Два разных репозитория здесь пишут в одной транзакции, хотя ни один из них о ней не знает: db() обоих отдаёт одно и то же соединение текущей единицы работы.

Транзакция живёт на соединении, а не на объекте

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


Отладка

Запрос уходит в базу только в момент чтения или записи, поэтому до этого его можно осмотреть.

buildSql()

php
public function buildSql(array $ignoreParts = []): string
Аргумент Тип По умолчанию Что делает
$ignoreParts array [] какие части пропустить при сборке

Возвращает готовый SQL с плейсхолдерами — именно в таком виде он и уйдёт в базу.

php
echo UserRepository::instance('u')->where(Qb::gt('u.age', 20))->orderBy('u.id DESC')->limit(5, 10)->buildSql();
// SELECT u.id, u.email, u.age FROM users u WHERE u.age > :iqb11 ORDER BY u.id DESC LIMIT 5 OFFSET 10

Значений здесь нет — смотрите их отдельно через getSql('binds').

getSql()

php
public function getSql(?string $param = null): mixed
Аргумент Тип По умолчанию Что делает
$param ?string null какую часть вернуть: 'where', 'limit', 'binds'; null — весь запрос
php
$q = UserRepository::instance('u')->where(Qb::eq('u.id', 1))->limit(3);

$q->getSql();          // "SELECT u.id, u.email FROM users u WHERE u.id = :iqb7 LIMIT 3"
$q->getSql('where');   // "WHERE u.id = :iqb7"
$q->getSql('binds');   // значения, которые будут привязаны

sqlPartsCount(), cleanCache()

php
public function sqlPartsCount(): int
public function cleanCache(?string $param = null): void

sqlPartsCount() отдаёт число накопленных частей — быстрый способ проверить, что экземпляр действительно чист. cleanCache() сбрасывает одну часть или весь запрос:

php
$repo->cleanCache('where');   // убрать условия
$repo->cleanCache();          // сбросить всё; sqlPartsCount() → 0

Готовый SQL нужен не только в отладке

Операции записи дополнительно пишутся в лог на уровне debug: при LOG_LEVEL=debug готовые INSERT, UPDATE и DELETE видны в выводе. Выборки туда не попадают — их смотрят через buildSql().

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


Служебное

Метод Что возвращает
db() соединение CDO текущей единицы работы
originTable() имя таблицы — "users"
getSchema() схему или null
mapIdentifierColumnName() колонку первичного ключа — "id"
getDbConfigClassName() класс конфигурации базы
getEntityClassName() класс гидрации; при своём select()stdClass
binding(?array $binds) подмешивает свои CDOBind к накопленным
state() protected — состояние запроса текущей корутины
useBind($stmt) protected — привязывает значения к подготовленному запросу

binding() нужен, когда часть условия собрана строкой, а её значения должны уехать привязками:

php
use Flytachi\Winter\Cdo\CDOBind;

UserRepository::instance('u')
  ->where(Qb::raw('u.created_at > :since'))
  ->binding([new CDOBind('since', $date)]);

Почему у запроса состояние на корутину

Репозиторий может быть синглтоном в контейнере, а where(), join() и остальные копят части в объекте. Под Swoole один экземпляр обслуживает несколько запросов сразу, и без изоляции они собрали бы друг другу условия.

state() возвращает состояние текущей корутины: под Swoole — свой объект в контексте корутины, вне корутины — сам репозиторий, то есть прежнее поведение и ноль накладных расходов. Наследнику это нужно, если он добавляет свои части запроса.

Ошибки

Исключение Когда
EntityException *OrThrow не нашёл строку; несёт HTTP-код
RepositoryException ошибка выполнения: нарушение ограничения, неверный SQL, отказ драйвера

RepositoryException заворачивает CDOException, и оригинал доступен через getPrevious() — там лежит SQLSTATE, по которому и различают «дубликат ключа» и «соединение потеряно».

Дальше

  • Сущности — что гидрируется в результат и откуда берутся колонки
  • Пагинация — страницы вместо limit() вручную
  • Миграции — как описанная схема доезжает до базы