romanfedorskij/table-registry

PHP-first table registry core for explicit column contracts, PostgreSQL query compilation, row projection and XLSX export.

Maintainers

Package info

github.com/wolfcharaa/table-registry

pkg:composer/romanfedorskij/table-registry

Transparency log

Statistics

Installs: 45

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.2 2026-08-23 04:08 UTC

This package is auto-updated.

Last update: 2026-08-23 04:12:48 UTC


README

TableRegistry - PHP-библиотека для явного описания табличных read-моделей, сборки PostgreSQL-запросов, нормализации строк под frontend-контракт и выгрузки данных в XLSX.

Библиотека не зависит от конкретного web-фреймворка, DI-контейнера или HTTP-слоя. Она описывает только то, что backend должен знать о таблице: источник данных, колонки, фильтры, сортировки, поиск, пагинацию и подготовку строк.

Возможности

  • описание таблицы через PHP-классы без YAML/JSON-конфигурации;
  • обязательная primary key колонка;
  • явные sortable/filterable/searchable правила на уровне колонок;
  • сериализация definition в стабильный JSON-контракт для frontend;
  • компиляция rows/count/export SQL для PostgreSQL;
  • validation query параметров относительно definition без привязки к HTTP;
  • payload-only/action-only колонки для служебных значений строк без попадания в настройки отображения;
  • output-only/computed колонки для значений, добавленных приложением до projection;
  • export policy на уровне колонок для безопасного выбора XLSX-полей;
  • runtime constraints через callback или invokable class;
  • value object нормализация значений строк;
  • label/hydration resolvers для человекочитаемого payload;
  • serializable rows result contract для страницы таблицы;
  • flatten projected rows для export/print;
  • XLSX export через OpenSpout.

Библиотека не управляет действиями пользователя, кнопками, layout таблицы, проверками доступа на уровне маршрута, CRUD-командами и HTTP-роутингом. Эти решения должны оставаться в приложении, которое использует библиотеку.

Требования

  • PHP 8.1 или выше;
  • ext-json;
  • ext-mbstring;
  • psr/container ^1.1 || ^2.0;
  • openspout/openspout ^4.28 || ^5.0 для XLSX export;
  • phpunit/phpunit только для разработки и тестов.

Конкретная major/minor версия OpenSpout выбирается Composer-ом с учетом PHP версии окружения.

Установка

composer require romanfedorskij/table-registry

Для локальной разработки:

composer install
composer test

Более подробные сценарии использования вынесены в docs/examples:

  • описание table definition;
  • сборка PostgreSQL SQL;
  • projection rows;
  • runtime constraints;
  • PSR-container invoker;
  • XLSX export.

Быстрый Старт

<?php

use Wolfcharaa\TableRegistry\Column\ColumnRegistryDefinition;
use Wolfcharaa\TableRegistry\Column\ColumnSelectDefinition;
use Wolfcharaa\TableRegistry\Column\PrimaryKeyColumnRegistryDefinition;
use Wolfcharaa\TableRegistry\Definition\TableRegistryDefinition;
use Wolfcharaa\TableRegistry\Filter\SelectFilterRegistryDefinition;
use Wolfcharaa\TableRegistry\Filter\TextFilterRegistryDefinition;
use Wolfcharaa\TableRegistry\Sort\RegistryDefaultSortDefinition;
use Wolfcharaa\TableRegistry\Sort\RegistrySortDefinition;
use Wolfcharaa\TableRegistry\Source\RegistrySourceDefinition;

$definition = (new TableRegistryDefinition(
    registryKey: 'users',
    title: 'Пользователи',
    source: RegistrySourceDefinition::from('public.users u')
        ->withLeftJoin('public.user_profile p', 'p.user_id = u.id'),
    columns: [
        (new PrimaryKeyColumnRegistryDefinition('id'))
            ->withSelect(ColumnSelectDefinition::sqlExpression('id', 'u.id')),

        (new ColumnRegistryDefinition('fullName', 'ФИО'))
            ->withSelect(ColumnSelectDefinition::sqlExpression(
                'fullName',
                "concat_ws(' ', p.last_name, p.first_name)"
            ))
            ->withSearchable()
            ->withSort(RegistrySortDefinition::byExpression('fullName', 'p.last_name, p.first_name'))
            ->withFilter(TextFilterRegistryDefinition::contains(
                queryKey: 'filter_fullName',
                columnKey: 'fullName',
                title: 'ФИО'
            )),

        (new ColumnRegistryDefinition('status', 'Статус'))
            ->withSelect(ColumnSelectDefinition::sqlExpression('status', 'u.status'))
            ->withFilter(SelectFilterRegistryDefinition::static(
                queryKey: 'filter_status',
                columnKey: 'status',
                title: 'Статус',
                options: [
                    ['value' => 'active', 'label' => 'Активен'],
                    ['value' => 'blocked', 'label' => 'Заблокирован'],
                ]
            )),

        (new ColumnRegistryDefinition('actionToken', 'Action token'))
            ->withSelect(ColumnSelectDefinition::sqlExpression('actionToken', 'u.action_token'))
            ->withPayloadOnly(),

        (new ColumnRegistryDefinition('roleId', 'ID роли'))
            ->withSelect(ColumnSelectDefinition::sqlExpression('roleId', 'u.role_id'))
            ->withFilter(SelectFilterRegistryDefinition::static(
                queryKey: 'filter_roleId',
                columnKey: 'roleId',
                title: 'Роль',
                options: []
            ))
            ->withExportable(false),
    ]
))->withDefaultSort(RegistryDefaultSortDefinition::desc('id'));

JSON-Контракт

TableRegistryDefinition реализует JsonSerializable.

$payload = $definition->jsonSerialize();

Пример payload:

{
  "schemaVersion": 2,
  "registryKey": "users",
  "title": "Пользователи",
  "primaryKey": "id",
  "columns": [
    {
      "key": "fullName",
      "title": "ФИО",
      "searchable": true,
      "sortable": true,
      "filterable": true,
      "filter": {
        "queryKey": "filter_fullName",
        "label": "ФИО",
        "columnKey": "fullName",
        "valueFormat": "string",
        "operators": ["contains"]
      }
    }
  ],
  "query": {
    "search": {
      "enabled": true,
      "queryKey": "search"
    },
    "pagination": {
      "type": "page",
      "pageKey": "page",
      "perPageKey": "per_page",
      "defaultPerPage": 25,
      "maxPerPage": 250
    },
    "defaultSort": {
      "column": "id",
      "direction": "desc"
    }
  }
}

В JSON не попадают backend-only детали: source, joins, SQL expressions, runtime constraints, hydration callbacks и label resolvers.

Колонки, помеченные withPayloadOnly(), также не попадают в columns JSON definition. При этом они остаются частью backend definition: SQL compiler выбирает их в rows-запросе, RegistryRowsProjector отдаёт их в row.values, а frontend может использовать эти значения для row actions, detail modal или других feature-specific сценариев без появления таких полей в настройках отображения таблицы.

Колонки, помеченные withExportable(false), остаются в JSON definition, rows payload, сортировках и фильтрах, но не попадают в набор колонок, доступных для XLSX export. Это удобно для технических id-полей, которые нужны frontend для фильтра или перехода, но не должны попадать в файл выгрузки. withPayloadOnly() колонки также считаются неэкспортируемыми.

Output-only колонка описывает значение, которое приложение добавляет в raw row после SQL и до projection:

(new ColumnRegistryDefinition('personLabel', 'ФИО'))
    ->withOutputOnly();

Такая колонка попадает в JSON definition и rows payload, но PostgresRegistrySqlCompiler не добавляет её в SELECT. По умолчанию она не участвует в search/sort/filter/export. Если приложению нужно выгружать уже подготовленное output-only значение, export можно включить явно через withExportable(true).

Source

RegistrySourceDefinition описывает SQL-фрагмент после FROM и optional joins.

$source = RegistrySourceDefinition::from('public.orders o')
    ->withLeftJoin('public.users u', 'u.id = o.user_id')
    ->withConnection('default')
    ->withCountSource('public.orders o');

withConnection() хранит opaque logical scope источника данных. Библиотека не открывает connection и не проверяет доступность scope; приложение само решает, как сопоставить строку default, warehouse или другое имя с реальным PDO/DBAL connection.

from() может быть таблицей, view, materialized view или читаемым SQL-фрагментом:

RegistrySourceDefinition::from('(SELECT * FROM public.users WHERE deleted_at IS NULL) u');

withCountSource() задаёт отдельный FROM только для compileCount(). Joins, filters, search expressions и runtime constraints остаются теми же, поэтому count source должен сохранять alias и SQL-поля, на которые ссылается definition. Это полезно, когда rows читаются из view или тяжелого source, а count можно безопасно считать по более простому source. Если приложение исторически хранит в countSource opaque key для собственного snapshot/cache-счётчика, например fis_export_package:active, compiler не использует такой key как SQL FROM и вернётся к обычному from().

Не подставляйте пользовательский ввод в source и joins. Пользовательские значения должны проходить через filters, query input и параметры SQL compiler-а.

Columns

Минимальная колонка знает ключ и человекочитаемый заголовок:

new ColumnRegistryDefinition('email', 'Email');

Если withSelect() не указан, compiler использует quoted identifier:

"email" AS "email"

Для сложного выражения используйте ColumnSelectDefinition:

(new ColumnRegistryDefinition('fullName', 'ФИО'))
    ->withSelect(ColumnSelectDefinition::sqlExpression(
        'fullName',
        "concat_ws(' ', last_name, first_name, middle_name)"
    ));

Primary key обязателен:

new PrimaryKeyColumnRegistryDefinition('id');

Служебная колонка только для payload/action:

(new ColumnRegistryDefinition('actionToken', 'Action token'))
    ->withSelect(ColumnSelectDefinition::sqlExpression('actionToken', 'u.action_token'))
    ->withPayloadOnly();

Primary key нельзя сделать payload-only: он всегда остаётся частью публичного contract как primaryKey.

Фильтры

Встроенные фильтры:

  • TextFilterRegistryDefinition::contains() и ::exact();
  • IntegerFilterRegistryDefinition::exact();
  • BooleanFilterRegistryDefinition::exact();
  • NullabilityFilterRegistryDefinition;
  • DateRangeFilterRegistryDefinition;
  • TimeRangeFilterRegistryDefinition;
  • SelectFilterRegistryDefinition::static();
  • SelectFilterRegistryDefinition::preload();
  • SelectFilterRegistryDefinition::async().

Select filter может отдавать frontend статичные options:

SelectFilterRegistryDefinition::static(
    queryKey: 'filter_status',
    columnKey: 'status',
    title: 'Статус',
    options: [
        ['value' => 'active', 'label' => 'Активен'],
    ]
);

Для preload/async фильтров route задаётся явно:

SelectFilterRegistryDefinition::preload(
    queryKey: 'filter_koap',
    columnKey: 'koapCode',
    title: 'КоАП',
    route: '/api/table/options/nsi/koap'
);

Поиск И Сортировка

Колонка участвует в global search только если вызван withSearchable():

$column = $column->withSearchable();

Сортировка включается через withSort():

$column = $column->withSort(RegistrySortDefinition::byColumn('createdAt'));

Для SQL expression:

$column = $column->withSort(
    RegistrySortDefinition::byExpression('fullName', 'last_name, first_name')
);

Default sort хранится на уровне таблицы:

$definition = $definition->withDefaultSort(RegistryDefaultSortDefinition::desc('id'));

SQL Compiler

PostgresRegistrySqlCompiler собирает SQL и params, но не выполняет запрос.

use Wolfcharaa\TableRegistry\Sql\PostgresRegistrySqlCompiler;

$compiled = (new PostgresRegistrySqlCompiler())->compileRows($definition, [
    'search' => 'иван',
    'filter_status' => ['active', 'blocked'],
    'sort_col' => 'fullName',
    'sort_dir' => 'desc',
    'page' => 1,
    'per_page' => 25,
]);

$sql = $compiled->sql();
$params = $compiled->params();

Доступные методы:

  • compileRows() - rows query с pagination;
  • compileCount() - count query;
  • compileExportRows() - rows query без pagination для export.

Выполнение SQL остаётся ответственностью приложения.

Query Validation

RegistryQueryValidator проверяет raw query параметры относительно TableRegistryDefinition, но не меняет поведение compiler-а сам по себе. Это позволяет приложению включить strict validation перед выполнением SQL и самостоятельно решить, как превратить ошибки в HTTP response.

use Wolfcharaa\TableRegistry\Query\RegistryQueryValidator;

$validation = (new RegistryQueryValidator())->validate($definition, [
    'sort_col' => 'fullName',
    'sort_dir' => 'desc',
    'filter_status' => ['active', 'blocked'],
    'page' => '1',
    'per_page' => '25',
]);

if (!$validation->isValid()) {
    $issues = $validation->jsonSerialize()['issues'];
}

Проверяются:

  • неизвестные query keys;
  • неизвестные или несортируемые sort columns;
  • sort direction;
  • page/per-page значения и max per-page;
  • filter values после нормализации;
  • date/time range format;
  • отключенный search или search без searchable columns.

Можно использовать assertValid(), если приложению удобнее работать с exception:

$validation->assertValid();

Runtime Constraints

Runtime constraints позволяют добавить условия, зависящие от пользователя, tenant, подразделения или другого контекста приложения.

use Wolfcharaa\TableRegistry\Sql\RegistrySqlCondition;
use Wolfcharaa\TableRegistry\Sql\RegistrySqlRuntimeContext;

$definition = $definition->withRuntimeConstraint(
    static function (RegistrySqlRuntimeContext $context): RegistrySqlCondition {
        $param = $context->param(42);

        return RegistrySqlCondition::raw('u.department_id = :' . $param);
    }
);

Для интеграции с DI используйте PsrContainerRegistryResolverInvoker. Он получает class-string resolver из Psr\Container\ContainerInterface, а для callable и обычных invokable classes использует native fallback.

use Wolfcharaa\TableRegistry\Runtime\PsrContainerRegistryResolverInvoker;

$projector = new RegistryRowsProjector(
    new PsrContainerRegistryResolverInvoker($container)
);

Rows Payload

RegistryRowsProjector преобразует строки из БД в стабильный payload:

use Wolfcharaa\TableRegistry\Row\RegistryRowsProjector;

$rows = (new RegistryRowsProjector())->project($definition, [
    ['id' => 1, 'status' => 'active'],
]);

Результат:

[
  {
    "primaryKey": "1",
    "values": {
      "status": {
        "value": "active",
        "label": "active"
      }
    }
  }
]

label всегда строка. Если label resolver не задан, label строится из hydrated value. Для DateTimeValue используется формат d.m.Y H:i.

Если приложению нужен стабильный wrapper для страницы таблицы, используйте RegistryRowsResult. Он не выполняет SQL, а только сериализует уже спроецированные rows, total и нормализованный query input:

use Wolfcharaa\TableRegistry\Row\RegistryRowsResult;
use Wolfcharaa\TableRegistry\Sql\RegistrySqlQueryInput;

$query = RegistrySqlQueryInput::fromArray($definition, $_GET);
$projectedRows = (new RegistryRowsProjector())->project($definition, $sqlRows);

$payload = new RegistryRowsResult(
    definition: $definition,
    query: $query,
    total: $total,
    rows: $projectedRows
);

JSON contract:

{
  "schemaVersion": 2,
  "registryKey": "users",
  "rows": [],
  "total": 123,
  "query": {
    "page": 1,
    "perPage": 25,
    "search": "",
    "sort": {
      "column": "",
      "direction": "asc"
    }
  }
}

Value Objects И Labels

Колонка может нормализовать raw value через PrimitiveValueObjectInterface:

use Wolfcharaa\TableRegistry\Value\DateTimeValue;

$column = (new ColumnRegistryDefinition('createdAt', 'Дата создания'))
    ->withRowTypeObject(DateTimeValue::class);

Label можно задать строкой, callback или class-string resolver-а:

$column = $column->withLabel(
    static fn ($value, $row, $column): string => $value->getValue() === 'active' ? 'Активен' : 'Неактивен'
);

Если строка является существующим классом, класс должен реализовать ColumnValueLabelResolverInterface.

Export

Сначала выберите запрошенные колонки. Resolver сам ограничит их export policy из TableRegistryDefinition:

use Wolfcharaa\TableRegistry\Export\RegistryExportColumnResolver;

$columns = (new RegistryExportColumnResolver())->resolve(
    definition: $definition,
    requestedColumnKeys: ['fullName', 'status']
);

Если requestedColumnKeys пустой, resolver вернёт все колонки, для которых exportable() === true.

Для плоских строк из projected rows:

use Wolfcharaa\TableRegistry\Row\RegistryProjectedRowsFlattener;

$flatRows = RegistryProjectedRowsFlattener::flattenLabels($projectedRows);

XLSX:

use Wolfcharaa\TableRegistry\Export\RegistryXlsxExporter;

$result = (new RegistryXlsxExporter())->export(
    definition: $definition,
    columns: $columns,
    rows: $flatRows,
    fileNamePrefix: 'users'
);

$filePath = $result->filePath();
$fileName = $result->fileName();

Тесты

composer test

Unit-тесты покрывают:

  • JSON-контракт definition;
  • валидацию primary key и дубликатов колонок;
  • PostgreSQL SQL compiler;
  • runtime constraint callback;
  • projection rows в {primaryKey, values};
  • label resolver для строк;
  • export column resolver;
  • flatten projected rows;
  • PostgreSQL identifier/literal value objects.