У таблиці Posts в ICanUp сортування виглядало звичайною UI-функцією: натиснути на колонку, передати sort у backend і змінити ORDER BY.
Але саме колонки дат показали, що між назвою колонки у frontend і реальною колонкою SQL має існувати чіткий контракт. Frontend працював із createdAt та publishedAt, а backend sorting map використовував created_at та published_at.
Результатом був не просто неправильний порядок. Сортування могло завершитися серверною помилкою, а сама generic sorting логіка дозволяла зовнішньому sort key підходити надто близько до SQL.
Frontend надсилає ID колонки, а не назву поля бази
У TanStack Table sortable column має свій ID. Для Posts це, зокрема, publishedAt і createdAt.
Коли користувач змінює sorting, frontend формує значення на кшталт publishedAt:asc. Backend отримує саме цей public UI contract. Він не отримує posts.published_at і не повинен вимагати від браузера знати структуру бази.
const initialSortParts = typeof props.filters?.sort === "string" ? props.filters.sort.split(":") : []; const sortableIds = [ "id", "title", "status", "slug", "publishedAt", "createdAt",]; const sorting = ref( initialSortParts.length === 2col.accessor("publishedAt", { header: () => t("admin.posts.published_at"), cell: (info) => formatAdminDateTime( info.getValue(), locale.value, ) || "-", enableSorting: true,}); col.accessor("createdAt", { header: () => t("admin.common.created_at"), cell: (info) => formatAdminDateTime( info.getValue(), locale.value,Проблема була в контракті createdAt проти created_at
До виправлення backend map для дат мав snake_case keys. Тобто frontend надсилав createdAt, а allowlist знав created_at. Те саме відбувалося з publishedAt.
Це хороший приклад того, чому назва в UI, назва JSON field і назва SQL column - не одне й те саме.
protected array $baseOrderColumns = [ 'id' => 'posts.id', 'title' => 'pt.value', 'slug' => 'posts.slug', 'status' => 'posts.status', 'created_at' => 'posts.created_at', 'published_at' => 'posts.published_at',];Allowlist є перекладачем між UI та SQL
Після виправлення mapping описує рівно ті keys, які має право надсилати Admin UI.
| Client sort key | SQL expression |
|---|---|
id |
posts.id |
title |
pt.value |
slug |
posts.slug |
status |
posts.status |
createdAt |
posts.created_at |
publishedAt |
posts.published_at |
Тепер request визначає лише логічний ключ. Реальне SQL field або expression завжди вибирає сервер.
protected array $baseOrderColumns = [ 'id' => 'posts.id', 'title' => 'pt.value', 'slug' => 'posts.slug', 'status' => 'posts.status', 'createdAt' => 'posts.created_at', 'publishedAt' => 'posts.published_at',];Перевіряти потрібно і key, і direction
Allowlist колонок вирішує лише половину задачі. Частина після двокрапки теж приходить із request.
Тому direction допускає лише два значення: asc або desc. Невідомий key, неправильний direction, array замість string або malformed value не повинні доходити до SQL.
У такому випадку таблиця просто повертається до передбачуваного default order.
protected function order(): void{ $sort = $this->request->input('sort'); [$key, $direction] = array_pad( explode( ':', is_string($sort) ? $sort : '', 2, ), 2, null, ); $column = $this->baseOrderColumns[$key]Request може вибрати один із дозволених способів сортування. Він не повинен конструювати SQL sorting expression.
Навіщо тут reorder
builderInit() одразу задає default ORDER BY posts.id DESC. Це добре для звичайного відкриття списку, але створює пастку для server-side sorting.
Якщо потім просто додати orderBy(posts.published_at), початкове сортування не зникає. posts.id DESC залишається першим, а дата стає лише другим критерієм.
reorder() очищає попередній ORDER BY. Після цього можна застосувати саме той порядок, який попросив користувач.
protected function builderInit(): void{ $this->builder = Post::query() ->select($this->columns) ->leftJoin( 'post_translations as pt', function ($join): void { $join ->on( 'pt.post_id', '=', 'posts.id', ) ->where( 'pt.name', '=',Title показує, чому client field не може дорівнювати database field
З датами різниця виглядає просто: publishedAt мапиться на posts.published_at. Але title показує справжню цінність allowlist.
У таблиці Posts title не є колонкою posts.title. Локалізоване значення приходить із post_translations, який приєднаний як pt. Тому public sort key title мапиться на pt.value.
UI не повинен знати ні назву таблиці translations, ні alias join, ні спосіб зберігання локалізації.
Дати потрібно сортувати як raw datetime
Admin показує дату у локалізованому форматі через formatAdminDateTime(). Але presentation value не бере участі у sorting.
Backend сортує безпосередньо posts.created_at або posts.published_at. Це важливо, бо форматований текст залежить від locale і призначений для людини, а не для визначення хронологічного порядку.
it( 'sorts posts by raw publication datetime', function (string $direction): void { $later = Post::factory()->create([ 'published_at' => '2026-09-30 17:45:00', ]); $earlier = Post::factory()->create([ 'published_at' => '2026-09-30 08:00:00', ]); [$first, $second] = $direction === 'asc' ? [$earlier, $later]Однакові значення потребують tie-breaker
Два Posts можуть мати однаковий published_at. Якщо SQL знає тільки цей критерій, порядок між ними не є частиною нашого контракту.
Тому після основного поля додається posts.id DESC. Це робить результат стабільним і передбачуваним навіть для однакових timestamps.
it( 'uses newest ID to break publication datetime ties', function (): void { $older = Post::factory()->create([ 'published_at' => '2026-09-30 12:00:00', ]); $newer = Post::factory()->create([ 'published_at' => '2026-09-30 12:00:00', ]); $this->get( route( 'admin.posts.index',Найважливіший тест передає в sort те, чого там не повинно бути
Happy path із publishedAt:asc перевіряє функціональність. Але безпеку й надійність контракту краще видно на неправильних значеннях.
У тестах я передаю невідому колонку, реальне ім’я database column, SQL-looking expression, неправильний direction, кілька direction одразу та array замість string.
У кожному випадку endpoint має залишитися доступним, повернути default sorting і не використати request input як SQL expression.
it( 'falls back to newest post for unsupported sorting without using request input in SQL', function (string|array $sort): void { $older = Post::factory()->create(); $newer = Post::factory()->create(); $queries = []; DB::listen( static function ( QueryExecuted $query, ) use (&$queries): void { $queries[] =Sort також потрібно зберігати між сторінками й фільтрами
Server-side sorting не закінчується на правильному ORDER BY. Якщо після search, status filter або переходу на іншу сторінку параметр sort загубиться, користувач отримає інший порядок без явної причини.
Тому regressions перевіряють і сам SQL order, і те, що filters.sort повертається в Inertia payload та зберігається під час pagination.
$params = [ 'sort' => 'slug:desc', 'search' => 'sorting', 'status' => PostStatusEnum::PUBLISHED->value, 'per_page' => 1,]; $this->get( route(Що я забрала з цього бага
Спочатку це виглядало як невеликий баг двох колонок дат. Насправді він показав слабке місце в контракті всієї Admin DataTable.
Frontend sort ID не є database column. Видиме поле може мапитися на snake_case column, join alias, aggregate або інше server-side expression. Саме backend має володіти цим mapping.
Тому після цього кейсу для мене хороший sorting contract має п’ять властивостей:
- кожен client key є в явному allowlist;
- direction окремо обмежений
ascіdesc; - невідомий input не потрапляє в SQL;
- попередній default order очищається перед custom sorting;
- для рівних значень є deterministic tie-breaker.
Тоді таблиця не тільки правильно сортується. Межа між UI і SQL стає явною, тестованою та значно безпечнішою.



