Репозитории
Репозиторий — класс, привязанный к таблице. Запрос собирается вызовами методов, значения уезжают привязками, а результат приходит объектами вашей сущности. Ниже — как объявить, как собрать и полный разбор каждого метода: что принимает, что возвращает, что делает, когда ничего не нашлось.
Что такое репозиторий
Место, где живут запросы к одной таблице. Без него SQL расползается строками по контроллерам: одна и та же выборка пишется заново там, где понадобилась, параметры привязываются руками, а переименованная колонка обнаруживается в рантайме.
// без репозитория
$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 принимают другой репозиторий, а не строку — и он приносит с собой свои привязки.
Объявление
<?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()
public static function instance(?string $as = null): static| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$as |
?string |
null |
алиас таблицы в запросе |
Возвращает новый экземпляр репозитория с чистым состоянием запроса.
UserRepository::instance(); // SELECT id, email FROM users
UserRepository::instance('u'); // SELECT u.id, u.email FROM users uАлиас нужен всюду, где в запросе больше одной таблицы: без него колонки джойна не различить. Привычка ставить его сразу избавляет от переписывания при первом же джойне.
Каждый вызов — новый запрос
instance() возвращает новый объект, а условия копятся в объекте. Собирать запрос в
одну цепочку — не стилистика: два вызова instance() дают два независимых запроса, а
попытка «дособрать» ранее полученный экземпляр добавит условия к тому, что там уже
накоплено.
as()
public function as(string $alias): staticЗадаёт алиас уже полученному экземпляру — то же, что аргумент instance(), но по ходу
сборки.
forBy()
public function forBy(string $context): staticДописывает хвост FOR … — блокировку строк на время транзакции.
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()
public function select(string $option): static| Аргумент | Тип | Что делает |
|---|---|---|
$option |
string |
список колонок строкой, как в SQL |
По умолчанию выбираются колонки сущности. Свой список нужен для агрегатов и для того, чтобы не тащить лишнее.
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()
public function from(RepositoryInterface|string $repository): staticЗаменяет источник: вместо таблицы — подзапрос или другое имя.
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()
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 — значения в нём становятся привязками, а не
частью текста запроса.
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 = :iqb5null в where() ничего не делает — это не сброс условия. Чтобы очистить запрос,
есть cleanCache().
join(), joinInner(), joinLeft(), joinRight(), joinCross()
public function join(RepositoryInterface|string $repository, Qb|string $on): static| Аргумент | Тип | Что делает |
|---|---|---|
$repository |
RepositoryInterface|string |
что присоединяем: репозиторий или 'orders o' строкой |
$on |
Qb|string |
условие соединения |
joinCross() берёт только первый аргумент — у декартова произведения условия нет.
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()
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 |
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 uwithRecursive() выпускает WITH RECURSIVE — для обхода деревьев и графов.
union(), unionAll()
public function union(RepositoryInterface $repository): static
public function unionAll(RepositoryInterface $repository): staticОбъединяет выборки. union() убирает дубликаты, unionAll() — нет и потому дешевле.
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()
public function groupBy(string $context): static
public function having(string $context): static
public function orderBy(string $context): staticПринимают фрагмент SQL строкой — как есть.
->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()
public function limit(int $limit, int $offset = 0): static| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$limit |
int |
— | сколько строк вернуть |
$offset |
int |
0 |
сколько пропустить |
UserRepository::instance('u')->limit(5, 10);
// … LIMIT 5 OFFSET 10Для страниц удобнее пагинация: она считает смещение сама и отдаёт мету.
Чтение
Каждый метод этой группы выполняет запрос. ?string $entityClassName во всех них
подменяет класс гидрации, не трогая набор колонок.
find()
public function find(?string $entityClassName = null): ?objectПервая строка запроса.
Возвращает объект сущности либо null, если строк нет. Не бросает — отсутствие
результата это ответ, а не сбой.
$user = UserRepository::instance('u')->where(Qb::eq('u.email', $email))->find();
if ($user === null) {
return ResponseEntity::notFound();
}findAll()
public function findAll(?string $entityClassName = null): arrayВозвращает массив сущностей; пустой, если ничего не нашлось.
$users = UserRepository::instance('u')->where(Qb::eq('u.status', 'active'))->findAll();Ограничения по объёму здесь нет — метод отдаст столько строк, сколько нашёл. Для больших
таблиц ставьте limit() или берите пагинацию.
findById(), findBy(), findAllBy()
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 — все строки таблицы |
[] |
UserRepository::instance()->findById(1)?->email; // 'a@x'
UserRepository::instance()->findById(999); // null
UserRepository::instance()->findAllBy(); // все строкиИмя колонки ключа берётся из сущности — его же отдаёт
mapIdentifierColumnName().
findByIdOrThrow(), findByOrThrow()
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, если строки нет:
FlytachiWinterPpaEntityEntityException: Entity not found// вместо трёх строк с проверкой
$user = UserRepository::instance()->findByIdOrThrow($id, message: 'Пользователь не найден');Смысл не в краткости, а в том, что исключение несёт HTTP-код: обработчик ошибок превратит
его в 404 без единого if в контроллере.
count()
public function count(): intВозвращает число строк, удовлетворяющих накопленным условиям.
UserRepository::instance()->where(Qb::gt('age', 25))->count(); // 2Это отдельный запрос COUNT, а не длина выборки: строки не читаются и не гидрируются.
exists()
public function exists(): boolВозвращает true, если под условия попадает хотя бы одна строка.
if (UserRepository::instance()->where(Qb::eq('email', $email))->exists()) {
throw new Conflict('Такой адрес уже занят');
}Дешевле count() > 0 и честнее find() !== null: строка не передаётся по сети.
findColumn()
public function findColumn(int $column = 0): mixed| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$column |
int |
0 |
номер колонки в списке SELECT, считая с нуля |
Возвращает одно значение из первой строки — без гидрации и без создания объектов.
UserRepository::instance()->select('email')->findColumn(); // 'a@x'
UserRepository::instance()->select('COUNT(*)')->findColumn(); // '4'Для агрегатов и одиночных значений это самый прямой путь: результат не превращается в сущность и не проходит гидрацию.
rawFetch()
public function rawFetch(string $sql, array $binds = [], ?string $entityClassName = null): array| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$sql |
string |
— | запрос целиком, как есть |
$binds |
array |
[] |
значения для плейсхолдеров |
$entityClassName |
?string |
null |
класс гидрации |
Возвращает массив объектов.
$rows = UserRepository::instance()->rawFetch(
'SELECT email FROM users WHERE age > :age ORDER BY id LIMIT 2',
['age' => 18],
);Нужен для того, что конструктором не выражается: оконные функции, специфика диалекта, тяжёлый отчёт. Соединение и гидрация при этом остаются от репозитория — теряется только сборка.
`$binds` — единственный безопасный вход
Значения передавайте через $binds, а не подстановкой в $sql. Интерполяция здесь — это
ровно та SQL-инъекция, ради устранения которой существует остальной слой.
Запись
insert()
public function insert(object|array $entity): mixed| Аргумент | Тип | Что делает |
|---|---|---|
$entity |
object|array |
сущность или ассоциативный массив «колонка => значение» |
Возвращает идентификатор вставленной строки. На PostgreSQL и MariaDB — через
RETURNING по первой колонке, на остальных — lastInsertId.
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()
public function insertBatch(Traversable|object|array ...$entities): voidВставляет много строк пачками, одним запросом на пачку.
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()
public function update(object|array $entity, Qb $qb): string|int| Аргумент | Тип | Что делает |
|---|---|---|
$entity |
object|array |
что записать |
$qb |
Qb |
какие строки менять — обязателен |
Возвращает число изменённых строк: 0, если под условие ничего не попало.
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()
public function delete(Qb $qb): string|intВозвращает число удалённых строк.
UserRepository::instance()->delete(Qb::eq('email', 'd@x')); // 1
UserRepository::instance()->delete(Qb::lt('created_at', $cutoff));Условие обязательно по той же причине, что и в update().
upsert()
public function upsert(
object|array $entity,
array $conflictColumns,
?array $updateColumns = null,
): mixed«Вставить, а если такая строка уже есть — обновить».
| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$entity |
object|array |
— | что вставляем |
$conflictColumns |
array |
— | по каким колонкам определяется «такая же строка» |
$updateColumns |
?array |
null |
карта «колонка => выражение», что делать при конфликте |
$updateColumns — это карта, а не список колонок. В выражении доступны два
плейсхолдера: :new — входящее значение, :current — то, что уже лежит в базе.
// перезаписать значением
$repo->upsert(['email' => 'a@x', 'age' => 30], ['email'], ['age' => ':new']);
// прибавить к сохранённому
$repo->upsert(['sku' => 'ABC', 'qty' => 5], ['sku'], ['qty' => ':current + :new']);Список вместо карты не пройдёт молча — CDO отвечает подсказкой с исправленным вызовом:
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()
public function upsertBatch(
iterable $entities,
array $conflictColumns,
?array $updateColumns = null,
): voidТо же самое для многих строк, пачками. $entities — любой iterable, включая генератор.
UserRepository::instance()->upsertBatch(
[['email' => 'g1@x', 'age' => 1], ['email' => 'z@x', 'age' => 7]],
['email'],
['age' => ':current + :new'],
);
// существовавшему g1@x с age=111 стало 112, z@x вставилсяНичего не возвращает.
Транзакции
Транзакция открывается на соединении, а соединение у единицы работы одно — поэтому все репозитории внутри запроса попадают в одну транзакцию автоматически.
$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()
public function buildSql(array $ignoreParts = []): string| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$ignoreParts |
array |
[] |
какие части пропустить при сборке |
Возвращает готовый SQL с плейсхолдерами — именно в таком виде он и уйдёт в базу.
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()
public function getSql(?string $param = null): mixed| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$param |
?string |
null |
какую часть вернуть: 'where', 'limit', 'binds'; null — весь запрос |
$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()
public function sqlPartsCount(): int
public function cleanCache(?string $param = null): voidsqlPartsCount() отдаёт число накопленных частей — быстрый способ проверить, что
экземпляр действительно чист. cleanCache() сбрасывает одну часть или весь запрос:
$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() нужен, когда часть условия собрана строкой, а её значения должны уехать
привязками:
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, по которому и различают «дубликат ключа» и
«соединение потеряно».