База данных · PPA

PHP Persistence API

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

Пакет flytachi/winter-ppaПоверх CDO (PDO)Драйверы PostgreSQL · MySQL · SQLite · Oracle

Что такое PPA

PPA (PHP Persistence API) — слой работы с базой данных в Winter.

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

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

Решение. PPA закрывает обе задачи и состоит из четырёх частей. Пользоваться можно любой из них по отдельности.

Устанавливается отдельно

PPA — пакет, а не часть ядра: приложение, которое ходит только в Redis или во внешний API, не должно тащить с собой ORM, пул соединений и движок миграций.

bash
composer require flytachi/winter-ppa

Пока пакет не установлен, call db и генераторы call make -R/-E/-C честно отказывают и называют команду установки, а /actuator/health просто не показывает раздел пулов. Всё остальное в приложении работает как обычно.

Пул соединений

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

Это не оптимизация ради скорости, а то, без чего резидентное приложение не переживает перезапуск базы. Подробно — ниже на этой странице.

Репозитории

Класс, привязанный к таблице. Запрос собирается методами, а не строкой:

php
// 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 и пакетная вставка уже есть — писать их для каждой таблицы не нужно.

Сущности

Строка таблицы описывается классом, колонки — атрибутами:

main/User.php
#[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, и то, ради чего слой вообще появился. Корутина берёт соединение при первом обращении к базе и возвращает автоматически, когда завершается; освобождать вручную не нужно нигде.

text
воркер
└── пул (на класс конфигурации)
    ├── соединение 1  ← корутина A взяла на время запроса
    ├── соединение 2  ← корутина B
    └── соединение 3    свободно

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

Как это устроено, как настроить размер и что делать, когда пул забит, — Пул соединений.

Как это выглядит целиком

Репозиторий — три свойства: откуда подключаться, во что гидрировать, какая таблица.

main/UserRepository.php
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';
}

Этого достаточно, чтобы работали и статические шорткаты, и конструктор запросов, и запись:

php
$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 Разовый запрос без своей таблицы Отчёт на стыке нескольких таблиц

Разовый запрос можно сделать и без репозитория — прямо от конфигурации:

php
MainDbConfig::cte()
  ->from('orders o')
  ->where(Qb::eq('o.status', 'new'))
  ->findAll();

MainDbConfig::instance()->query('SELECT version()');   // straight down to CDO

Примеры запросов

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

1. Список с фильтром

Самый частый запрос вообще: страница списка с условием и сортировкой.

php
$users = UserRepository::instance('u')
  ->where(Qb::eq('u.status', 'active'))
  ->orderBy('u.created_at DESC')
  ->limit(20)
  ->findAll();
sql
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. Фильтры, которых может не быть

Форма поиска: пользователь заполнил не все поля, и условие собирается из того, что пришло.

php
$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();
sql
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. Агрегат по связанной таблице

Отчёт «сколько заказов и на какую сумму у каждого клиента» — джойн, группировка и отсев по агрегату.

php
$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);
sql
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. Подзапрос как источник джойна

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

php
$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);
sql
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 — вынести подготовку наверх

То же самое, но подзапрос вынесен в именованное выражение. Читается лучше, а если результат нужен дважды, база посчитает его один раз.

php
$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);
sql
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 DESC

with() принимает имя и репозиторий; дальше на это имя ссылаются как на обычную таблицу. Вызовов with() может быть несколько — они соберутся в один WITH через запятую. Третьим аргументом передаётся подсказка планировщику PostgreSQL: 'MATERIALIZED' или 'NOT MATERIALIZED'.

6. Рекурсивный CTE — обход дерева

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

php
$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);
sql
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(), и результат так же гидрируется в объекты:

php
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], генерация схемы