В блог
15 апреля 2026 Sogerien dbcontrollerjsonbpostgresqluniversal-tableданные

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.