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

CRUD и выборка

После сборки запроса его выполняет fetch-метод (find, findAll, count), а запись — методы insert, update, delete, upsert. Есть и статические шорткаты (findById, findByIdOrThrow) на частые случаи. Все методы работают с таблицей репозитория и защищены от инъекций.

Чтение ViewTraitЗапись CrudTraitОшибки RepositoryException

Что такое CRUD и зачем

CRUD — Create, Read, Update, Delete: четыре базовые операции над данными.

Проблема. Писать эти операции сырым SQL — однообразно и опасно: INSERT с интерполяцией значений, UPDATE ... WHERE руками, отдельная забота про типы и инъекции. Один и тот же код повторяется в каждом репозитории.

Решение. PPA даёт готовые типизированные методы: fetch выполняет собранный запрос, а insert/update/delete/upsert инкапсулируют запись с параметрами. Об этом и раздел.

Выполнение выборки

Fetch-методы завершают цепочку конструктора запросов:

php
final public function find(?string $entityClassName = null): ?object   // первая строка или null
final public function findAll(?string $entityClassName = null): array  // все строки
final public function findColumn(int $column = 0): mixed               // одна колонка
final public function count(): int                                     // COUNT(*)
final public function exists(): bool                                   // EXISTS
final public function rawFetch(string $sql, array $binds = [], ?string $entityClassName = null): array
php
use Flytachi\Winter\K2\Ppa\Repository\Qb;

$user  = UserRepository::instance()->where(Qb::eq('email', $email))->find();
$users = UserRepository::instance('u')->where(Qb::eq('u.status', 'active'))->findAll();
$total = UserRepository::instance()->where(Qb::eq('status', 'active'))->count();
$has   = UserRepository::instance()->where(Qb::eq('email', $email))->exists();

Статические finders

Шорткаты, которые сами создают инстанс, применяют условие и выполняют выборку — для частых случаев без цепочки:

Метод Условие Результат Бросает при отсутствии
findById($id) PK = $id ?object нет
findBy($qb) свой Qb ?object нет
findAllBy($qb = null) свой Qb / все array нет
findByIdOrThrow($id) PK = $id object да — EntityException
findByOrThrow($qb) свой Qb object да — EntityException
php
$user  = UserRepository::findById(42);                          // ?object
$user  = UserRepository::findBy(Qb::eq('email', $email));       // ?object
$users = UserRepository::findAllBy(Qb::eq('status', 'active')); // array
$all   = UserRepository::findAllBy();                           // все строки

Варианты *OrThrow бросают EntityException с HTTP-кодом (по умолчанию 404) — удобно прямо в контроллере/сервисе, ошибка сама станет корректным ответом:

php
use Flytachi\Winter\Base\HttpCode;

$user = UserRepository::findByIdOrThrow($id);   // → 404, если нет

$user = UserRepository::findByOrThrow(
  Qb::eq('token', $resetToken),
  message: 'Invalid or expired reset token',
  httpCode: HttpCode::UNPROCESSABLE_ENTITY,
);

EntityException — это HTTP-ошибка

EntityException несёт HTTP-код и логируется на уровне warning. Брошенная из сервиса, она превращается в ответ автоматически (см. Обработка ошибок).

Вставка

php
public function insert(object|array $entity): mixed              // → сгенерированный PK
public function insertGroup(array|object ...$entities): void     // пакетная вставка

insert() возвращает сгенерированный первичный ключ. null-значения в вставку не попадают — БД подставит дефолты:

php
$repo = new UserRepository();

$id = $repo->insert([
  'id'     => null,             // исключается → авто-инкремент/дефолт
  'name'   => 'Alice',
  'email'  => 'alice@example.com',
  'status' => 'active',
]);

// Пакетно (строки чанкуются под лимиты плейсхолдеров):
$repo->insertGroup(
  ['name' => 'Bob',   'email' => 'b@example.com'],
  ['name' => 'Carol', 'email' => 'c@example.com'],
);

Обновление и удаление

php
public function update(object|array $entity, Qb $qb): int|string  // → число затронутых строк
public function delete(Qb $qb): int|string                        // → число удалённых

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

У update() и delete() всегда требуется Qb-условие — варианта «обновить/ удалить всё» нет. Это защита от случайного изменения всей таблицы.

php
$repo = new UserRepository();

$affected = $repo->update(['status' => 'inactive'], Qb::eq('id', 42));

$affected = $repo->update(
  ['status' => 'inactive', 'updated_at' => date('Y-m-d H:i:s')],
  Qb::and(
      Qb::lt('last_login', '2024-01-01'),
      Qb::eq('status', 'active'),
  ),
);

$deleted = $repo->delete(Qb::eq('id', 42));

Upsert

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

Вставляет запись; при конфликте по $conflictColumns — обновляет. $updateColumns = null означает «ничего не делать» (ignore). В выражениях обновления доступны токены :new и :current:

php
$repo = new ProductRepository();

// Вставить или обновить при конфликте по sku:
$repo->upsert(
  ['sku' => 'A-100', 'price' => 250, 'stock' => 5],
  ['sku'],
  ['price' => ':new', 'stock' => ':current + :new'],
);
// pgsql: ON CONFLICT (sku) DO UPDATE SET price = EXCLUDED.price, ...

// Игнорировать при конфликте:
$repo->upsert(['sku' => 'A-100', 'price' => 250], ['sku']);  // updateColumns = null

Обработка ошибок

Методы записи ловят CDOException и перебрасывают как RepositoryException (лог alert). Оригинал доступен через getPrevious():

php
use Flytachi\Winter\K2\Ppa\Repository\RepositoryException;

try {
  $repo->insert($user);
} catch (RepositoryException $e) {
  $e->getMessage();    // человекочитаемое сообщение
  $e->getPrevious();   // исходное CDOException
}

Дальше