Universal table: одна таблица вместо тридцати сущностей
Классический подход: новая сущность - новая таблица, новая миграция, новый набор колонок. Через год у тебя тридцать таблиц, половина из которых - две колонки и created_at. Universal Engine держит все прикладные сущности в одной таблице sogerien в PostgreSQL.
CREATE TABLE sogerien (
id bigserial PRIMARY KEY,
entity text NOT NULL, -- тип сущности: 'account', 'chat', 'project', 'payment'
item_key text NOT NULL, -- природный ключ внутри типа
title text,
data jsonb, -- тело сущности
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
table_name text,
name text,
table_index jsonb,
table_value jsonb,
status text,
UNIQUE (entity, item_key) -- природный уникальный ключ
);Как это адресуется
Природа сущности - в паре колонок:
entity- тип:account,chat,project,payment.item_key- природный ключ внутри типа.data jsonb- тело, которое у каждого типа своё.
Природный уникальный ключ - это (entity, item_key). Он же даёт бесплатный upsert через ON CONFLICT. Одна таблица sogerien на живом пульте держит юзеров, чаты, проекты и платежи одновременно - и это ноль миграций, когда у аккаунта появляется новое поле.
Доступ - только через DbController
Никакого прямого PDO по коду страниц. Всё идёт через один метод sql_request($alias, ['sql' => ..., 'params' => ...]), который возвращает JSON-строку:
$row = ['plan' => 'pro', 'seats' => 5, 'features' => ['api', 'export']];
$json = Sogerien::DbController()->sql_request('main', [
'sql' => "INSERT INTO sogerien (entity, item_key, title, data)
VALUES (:e, :k, :t, CAST(:d AS jsonb))
ON CONFLICT (entity, item_key)
DO UPDATE SET data = EXCLUDED.data, updated_at = now()
RETURNING id",
'params' => [
'e' => 'account',
'k' => 'acme',
't' => 'ACME Inc',
'd' => $row, // обычный PHP-массив; sanitizeParams() сам сериализует его в jsonb
],
]);
$id = json_decode($json, true)[0]['id'] ?? null;Чтение симметрично - и тут же первая ловушка:
$json = Sogerien::DbController()->sql_request('main', [
'sql' => "SELECT item_key, title, data FROM sogerien
WHERE entity = :e AND status = 'active'
ORDER BY updated_at DESC",
'params' => ['e' => 'account'],
]);
foreach (json_decode($json, true) as $acc) {
// expandJsonInRows() уже распаковал jsonb-колонку в массив -
// повторный json_decode($acc['data']) не нужен и сломает данные
echo $acc['data']['plan'];
}Три грабли DbController, каждая стоила круга отладки
- Ходить через
sql_request(), а неDbController()->pdo(). У движка два бэкенда - PDO-пул и pgsql-пул.sql_request()работает на любом, прямойpdo()кидаетRuntimeException "DB is not connected"там, где поднят pgsql-бэкенд. - Кастовать через
CAST(:p AS jsonb), никогда:p::jsonb. Нормализатор сканирует SQL регуляркой по имени параметра и во втором двоеточии::jsonbвидит параметр с именемjsonb- падаетMissing param :jsonb. - Массив в
params- обычным PHP-массивом. Санитайзер сам закодирует его в JSON. На чтенииexpandJsonInRows()вернёт jsonb-колонку уже распакованной - повторныйjson_decodeданные испортит.
Расплата и как её закрыть
У одной таблицы на всё есть цена: GRANT DELETE ON sogerien отдаёт роли сразу юзеров, платежи и проекты. Поэтому право на запись даётся не грантом на таблицу, а узкой SECURITY DEFINER-функцией с жёстко зашитыми entity и бизнес-условием внутри. Роль получает ровно одну операцию с ровно одним фильтром - а бизнес-правило заодно живёт в базе, а не только в PHP.