romanfedorskij / table-registry
PHP-first table registry core for explicit column contracts, PostgreSQL query compilation, row projection and XLSX export.
Requires
- php: ^8.1
- ext-json: *
- ext-mbstring: *
- openspout/openspout: ^4.28 || ^5.0
- psr/container: ^1.1 || ^2.0
Requires (Dev)
- phpunit/phpunit: ^10.5
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.