PHP Persistence API
Winter не заставляет пользоваться своим слоем доступа к данным: соединение можно открыть самому, обычным бином. Но приложение живёт месяцами, а соединение с базой — нет, и вот тут появляется PPA: пул, который держит соединения живыми, плюс репозитории, сущности и миграции поверх него.
Что такое PPA
PPA (PHP Persistence API) — слой работы с базой данных в Winter.
Проблема. Работа через голый PDO выглядит одинаково во всех проектах: SQL собирается строками, параметры привязываются руками, результат приходит массивом без типов. Одна и та же выборка пишется заново в каждом месте, где понадобилась. Переименовали колонку — компилятор промолчит, редактор не подскажет, узнаете в рантайме. Забыли плейсхолдер и склеили строку — получили SQL-инъекцию.
К этому резидентный рантайм добавляет своё: соединение живёт неделями, и его недостаточно просто открыть — за ним нужно следить.
Решение. PPA закрывает обе задачи и состоит из четырёх частей. Пользоваться можно любой из них по отдельности.
Устанавливается отдельно
PPA — пакет, а не часть ядра: приложение, которое ходит только в Redis или во внешний API, не должно тащить с собой ORM, пул соединений и движок миграций.
composer require flytachi/winter-ppaПока пакет не установлен, call db и генераторы call make -R/-E/-C честно отказывают
и называют команду установки, а /actuator/health просто не показывает раздел пулов.
Всё остальное в приложении работает как обычно.
Пул соединений
Соединения открываются заранее, раздаются корутинам на время работы и возвращаются сами. Пул проверяет их перед выдачей, заменяет умершие и меняет слишком старые до того, как их закроет сервер.
Это не оптимизация ради скорости, а то, без чего резидентное приложение не переживает перезапуск базы. Подробно — ниже на этой странице.
Репозитории
Класс, привязанный к таблице. Запрос собирается методами, а не строкой:
// PDO
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE status = :status ORDER BY id DESC LIMIT 20'
);
$stmt->execute(['status' => 'active']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // array of arrays
// PPA
$users = UserRepository::instance()
->where(Qb::eq('status', 'active'))
->orderBy('id DESC')
->limit(20)
->findAll(); // array of UserРазница не только в длине. Всё, что попало в Qb::eq(), уезжает в подготовленный
запрос отдельно от текста SQL — склеить строку и получить инъекцию просто нечем.
Запрос собирается по частям, поэтому условие можно добавить в зависимости от
входных данных, не переписывая SQL. А insert, update, delete, upsert и
пакетная вставка уже есть — писать их для каждой таблицы не нужно.
Сущности
Строка таблицы описывается классом, колонки — атрибутами:
#[Table]
class User
{
#[Id]
public ?int $id = null;
#[Varchar(255)]
public string $email;
#[Boolean]
public bool $active = true;
}Выборка возвращает такие объекты, а не массивы: $user->email вместо
$row['email'], с типом, автодополнением и переходом по имени. Опечатка в имени
поля становится ошибкой, которую видно сразу, а не пустым значением.
Миграции
Та же разметка сущности служит описанием схемы: call db migrate сравнивает её с
базой и приводит базу в соответствие. Отдельный каталог миграций, который надо
держать в согласии с кодом, не нужен — источник один.
PPA — репозитории, а не полноценная ORM
Стоит понимать границу. PPA даёт конструктор запросов, гидрацию в объекты и CRUD. Чего в нём нет по замыслу: identity map, отслеживания изменений («сохрани всё, что я поменял»), ленивой подгрузки связей.
Связанные данные вы забираете джойном или вторым запросом — явно. Это меньше
удобства в простых случаях и заметно меньше сюрпризов в сложных: нет ни скрытых
запросов в цикле, ни неожиданного UPDATE при выходе из области видимости.
Если вы пришли из Java
Многое здесь узнаваемо, но соответствия не один-в-один. Карта, чтобы не искать привычное там, где его нет:
| В Java | В Winter |
|---|---|
| HikariCP | Пул PPA — те же понятия: maxLifetime, connectionTimeout, keepaliveTime, minimumIdle |
JdbcTemplate |
CDO — расширенный PDO |
Spring Data Repository |
Repository, но без методов по имени (findByEmailAndStatus) — условие пишется через Qb |
JPA @Entity |
#[Table] — только разметка; ни EntityManager, ни persistence context |
@Transactional |
Явный db()->transaction(...) — аспектов и прокси нет |
Hibernate session.flush() |
Нет: изменения отправляются вызовом update(), а не при выходе из области |
Ленивые связи, @OneToMany |
Нет: связанные данные забираются джойном или вторым запросом |
| Flyway, Liquibase | call db migrate создаёт недостающее, но не версионирует — для эволюции прода берут отдельный инструмент |
Главное отличие мировоззренческое: объект сущности ничего не знает о базе. Он
не отслеживается, не «грязный», не сохраняется сам. Изменение поля — это изменение
поля; чтобы оно попало в базу, нужно вызвать update().
Это меньше магии и меньше удобства в простых сценариях — и заметно меньше вопросов
«почему этот UPDATE вообще выполнился» в сложных.
Ниже по стеку лежит CDO — расширенный PDO. Буквально:
class CDO extends PDO, то есть тот же объект соединения, к которому добавлены
insert, update, delete, пакетные операции, определение драйвера и
согласование часового пояса с PHP. PPA его не прячет: пул раздаёт именно CDO, и
в любой момент можно спуститься на этот уровень и выполнить свой SQL.
Подключиться можно и без PPA
Фреймворк ничего не навязывает. Соединение — это обычная зависимость, и её можно
объявить бином со Scope::Request: по соединению на запрос, без всякого пула. Как
это делается — для PDO, CDO и Redis — показано в
Базовых подключениях.
Такой способ корректен, и для многих приложений его достаточно. Дальше — о том, чего ему не хватает, когда нагрузка растёт.
Чего не хватает такому подключению
Scope::Request корректен, но оплачивается на каждом запросе:
Соединение открывается заново. TCP-рукопожатие, TLS, аутентификация, а у PostgreSQL ещё и форк процесса на стороне сервера — миллисекунды на каждый запрос, которые в классическом PHP были неизбежны, а здесь платятся зря.
Их количество ничем не ограничено. Тысяча одновременных запросов — тысяча
попыток подключиться. База ответит too many connections, и упадёт не только
всплеск, а всё приложение.
Сбой базы больше не лечится сам. Вот это — главное, и оно неочевидно.
Что изменилось по сравнению с обычным PHP
Раньше перезапуск базы переживался бесплатно: процесс PHP умирал после запроса, и следующий подключался заново. Резидентный воркер живёт неделями — он держит сокет, который сервер уже закрыл, и продолжает считать его рабочим.
Соединение, открытое на запрос, эту проблему смягчает, но не решает: сокет может умереть и в середине запроса, между двумя вашими запросами к базе.
Ровно эти три вещи и закрывает пул.
Пул соединений — коротко
Главное, что даёт PPA, и то, ради чего слой вообще появился. Корутина берёт соединение при первом обращении к базе и возвращает автоматически, когда завершается; освобождать вручную не нужно нигде.
воркер
└── пул (на класс конфигурации)
├── соединение 1 ← корутина A взяла на время запроса
├── соединение 2 ← корутина B
└── соединение 3 свободноСоединения не просто переиспользуются — они поддерживаются работоспособными: простоявшее проверяется перед выдачей, состарившееся заменяется заранее, а когда все заняты, запрос ждёт ограниченное время и получает понятный отказ вместо вечного зависания.
Как это устроено, как настроить размер и что делать, когда пул забит, — Пул соединений.
Как это выглядит целиком
Репозиторий — три свойства: откуда подключаться, во что гидрировать, какая таблица.
use Flytachi\Winter\Ppa\Stereotype\Repository;
class UserRepository extends Repository
{
protected string $dbConfigClassName = MainDbConfig::class;
protected string $entityClassName = User::class;
public static string $table = 'users';
}Этого достаточно, чтобы работали и статические шорткаты, и конструктор запросов, и запись:
$user = UserRepository::instance()->findById(42);
$users = UserRepository::instance('u')
->joinLeft(OrderRepository::instance('o'), 'u.id = o.user_id')
->where(Qb::eq('u.status', 'active'))
->limit(20)
->findAll();
$user = new User();
$user->email = 'alice@example.com';
$id = new UserRepository()->insert($user);Присоединяемая сторона — тоже репозиторий, а не строка с именем таблицы. Имя таблицы остаётся в одном месте, а на присоединяемый репозиторий можно навесить собственные условия — тогда он подставится подзапросом, и его параметры уедут в общий запрос сами.
Стереотип выбирается по нужному объёму доступа — репозиторий, открывающий только чтение, не даст случайно записать:
| Стереотип | Что умеет | Когда |
|---|---|---|
Repository |
Чтение, запись, конструктор запросов | Большинство случаев |
RepositoryView |
Только чтение | Представления, отчёты, проекции |
RepositoryCrud |
Только запись | Таблицы, которые только пополняются |
CteRepo |
Разовый запрос без своей таблицы | Отчёт на стыке нескольких таблиц |
Разовый запрос можно сделать и без репозитория — прямо от конфигурации:
MainDbConfig::cte()
->from('orders o')
->where(Qb::eq('o.status', 'new'))
->findAll();
MainDbConfig::instance()->query('SELECT version()'); // straight down to CDOПримеры запросов
Шесть задач, которые встречаются чаще всего, — от простой выборки до рекурсивного обхода дерева. Под каждым примером показан SQL, который из него получается.
1. Список с фильтром
Самый частый запрос вообще: страница списка с условием и сортировкой.
$users = UserRepository::instance('u')
->where(Qb::eq('u.status', 'active'))
->orderBy('u.created_at DESC')
->limit(20)
->findAll();SELECT u.id, u.name, u.email, u.status, u.created_at
FROM users u
WHERE u.status = :iqb0
ORDER BY u.created_at DESC
LIMIT 20Список колонок взялся из сущности — * в запросах не появляется. Значение
'active' ушло параметром :iqb0, а не в текст.
2. Фильтры, которых может не быть
Форма поиска: пользователь заполнил не все поля, и условие собирается из того, что пришло.
$filter = Qb::empty();
if ($status !== null) {
$filter->addAnd(Qb::eq('u.status', $status));
}
if ($domain !== null) {
$filter->addAnd(Qb::like('u.email', "%@{$domain}", insensitive: true));
}
$users = UserRepository::instance('u')
->where($filter)
->orderBy('u.id DESC')
->limit(20)
->findAll();SELECT u.id, u.name, u.email, u.status, u.created_at
FROM users u
WHERE (u.status = :iqb0 AND u.email ILIKE :iqb1)
ORDER BY u.id DESC
LIMIT 20Ни WHERE 1=1, ни склейки строк: незаполненные поля просто не добавляют условий, а
пустой фильтр не добавляет WHERE вовсе. insensitive: true разворачивается в
ILIKE на PostgreSQL и в регистронезависимое сравнение на MySQL.
3. Агрегат по связанной таблице
Отчёт «сколько заказов и на какую сумму у каждого клиента» — джойн, группировка и отсев по агрегату.
$rows = OrderRepository::instance('o')
->joinInner(UserRepository::instance('u'), 'o.user_id = u.id')
->select('u.id, u.name, COUNT(o.id) AS orders_count, SUM(o.total) AS total_spent')
->where(Qb::eq('o.status', 'paid'))
->groupBy('u.id, u.name')
->having('SUM(o.total) > 10000')
->orderBy('total_spent DESC')
->limit(50)
->findAll(CustomerTotal::class);SELECT u.id, u.name, COUNT(o.id) AS orders_count, SUM(o.total) AS total_spent
FROM orders o
INNER JOIN users u ON(o.user_id = u.id)
WHERE o.status = :iqb0
GROUP BY u.id, u.name
HAVING SUM(o.total) > 10000
ORDER BY total_spent DESC
LIMIT 50Здесь select() задан вручную, поэтому колонки берутся из него, а не из сущности.
Класс для гидрации передан в findAll() — обычный класс с полями id, name,
orders_count, total_spent; таблице он не соответствует и атрибутов схемы не
несёт.
`having()` принимает строку
В отличие от where(), условие HAVING — обычная строка: агрегатные выражения
Qb-предикатами не описываются. Значит, подставлять в него пользовательский ввод
нельзя — только константы, как здесь.
4. Подзапрос как источник джойна
Предыдущий отчёт терял клиентов без заказов. Чтобы они остались, агрегат считается отдельно и присоединяется левым джойном.
$spent = OrderRepository::instance('o')
->select('o.user_id, SUM(o.total) AS total_spent')
->where(Qb::eq('o.status', 'paid'))
->groupBy('o.user_id');
$rows = UserRepository::instance('u')
->joinLeft($spent, 'u.id = o.user_id')
->select('u.id, u.name, COALESCE(o.total_spent, 0) AS total_spent')
->orderBy('total_spent DESC')
->limit(100)
->findAll(CustomerTotal::class);SELECT u.id, u.name, COALESCE(o.total_spent, 0) AS total_spent
FROM users u
LEFT JOIN (SELECT o.user_id, SUM(o.total) AS total_spent
FROM orders o
WHERE o.status = :iqb0
GROUP BY o.user_id) o ON(u.id = o.user_id)
ORDER BY total_spent DESC
LIMIT 100Обёртка в подзапрос появилась сама: у присоединяемого репозитория есть свои
select, where и groupBy. Его параметр :iqb0 переехал в общий запрос — думать
о нумерации не нужно.
Переменную $spent удобно вынести в метод репозитория и переиспользовать в
нескольких отчётах.
5. CTE — вынести подготовку наверх
То же самое, но подзапрос вынесен в именованное выражение. Читается лучше, а если результат нужен дважды, база посчитает его один раз.
$recentBuyers = OrderRepository::instance('o')
->select('o.user_id, COUNT(*) AS cnt')
->where(Qb::gt('o.created_at', $since))
->groupBy('o.user_id');
$rows = UserRepository::instance('u')
->with('recent_buyers', $recentBuyers)
->joinInner('recent_buyers rb', 'rb.user_id = u.id')
->select('u.id, u.name, rb.cnt')
->where(Qb::gte('rb.cnt', 3))
->orderBy('rb.cnt DESC')
->findAll(BuyerActivity::class);WITH recent_buyers AS (
SELECT o.user_id, COUNT(*) AS cnt
FROM orders o
WHERE o.created_at > :iqb0
GROUP BY o.user_id)
SELECT u.id, u.name, rb.cnt
FROM users u
INNER JOIN recent_buyers rb ON(rb.user_id = u.id)
WHERE rb.cnt >= :iqb1
ORDER BY rb.cnt DESCwith() принимает имя и репозиторий; дальше на это имя ссылаются как на обычную
таблицу. Вызовов with() может быть несколько — они соберутся в один WITH через
запятую. Третьим аргументом передаётся подсказка планировщику PostgreSQL:
'MATERIALIZED' или 'NOT MATERIALIZED'.
6. Рекурсивный CTE — обход дерева
Категории, комментарии, оргструктура — всё, что ссылается само на себя. Задача: получить ветку целиком, от заданного узла вниз.
$anchor = CategoryRepository::instance()
->select('id, parent_id, name, 1 AS depth')
->where(Qb::eq('id', $rootId));
$tree = $anchor->union(
CategoryRepository::instance('c')
->select('c.id, c.parent_id, c.name, t.depth + 1')
->joinInner('tree t', 'c.parent_id = t.id'),
);
$branch = CategoryRepository::instance()
->withRecursive('tree', $tree)
->select('id, name, depth')
->from('tree')
->orderBy('depth ASC, name ASC')
->findAll(CategoryNode::class);WITH RECURSIVE tree AS (
SELECT id, parent_id, name, 1 AS depth
FROM categories
WHERE id = :iqb0
UNION
SELECT c.id, c.parent_id, c.name, t.depth + 1
FROM categories c
INNER JOIN tree t ON(c.parent_id = t.id))
SELECT id, name, depth
FROM tree
ORDER BY depth ASC, name ASCКонструкция читается как есть: первая часть — стартовый узел, вторая — шаг вниз,
UNION их соединяет. Поле depth считает уровень вложенности и заодно даёт
сортировку.
У рекурсии должно быть дно
Если в данных есть цикл — категория, оказавшаяся предком самой себя, — запрос будет
идти по кругу, пока не кончится память сервера. UNION (а не UNION ALL) отсекает
повторы и защищает от простых циклов; для надёжности добавляют ограничение глубины
условием t.depth < 10 в рекурсивной части.
Когда конструктора мало
Оконные функции, LATERAL, специфика конкретной СУБД — всё это пишется сырым SQL
через rawFetch(), и результат так же гидрируется в объекты:
use Flytachi\Winter\Cdo\CDOBind;
$rows = OrderRepository::instance()->rawFetch(
'SELECT user_id, total,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn
FROM orders
WHERE created_at > :from',
[new CDOBind('from', $since)],
OrderRank::class,
);Спуститься ещё ниже — до самого соединения — можно через db(); см.
Repository.
Что дальше в разделе
| Страница | О чём |
|---|---|
| Подключение | Класс конфигурации, драйверы, .env, привязка репозитория |
| Сущности | Колонки атрибутами, ключи, связи |
| Repository | Сборка запроса, Qb::, выборка, insert / update / delete |
| Пагинация | Постраничная выборка |
| Миграции | call db migrate, #[Migratable], генерация схемы |