perf(directories): SQL-фильтрация/пагинация справочников, N+1, лимиты column-values

Шаги 1.1 и 1.2 плана production-готовности:
- getDataTableRowsPaged: фильтры/поиск/сортировка/пагинация в SQL (values->>N)
- getDataTablesWithAccess и list_directories без N+1
- /column-values: limit (дефолт 20, кап 1000), убраны debug-логи с горячего пути
- tree-режим и legacy-путь TableEditor сохранены 1:1
- 10 новых тестов (88/88)
This commit is contained in:
2026-09-07 23:09:15 +03:00
parent 701af06290
commit 9528a8123a
8 changed files with 348 additions and 99 deletions

View File

@@ -5,6 +5,7 @@ import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth
import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit";
import { buildDataTableTree } from "../utils/data-table-tree";
import { clampDirectoryRowsLimit, clampDirectoryRowsOffset } from "../utils/data-table-rows-query";
import {
getDirectoryFolders,
createDirectoryFolder,
@@ -445,6 +446,45 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return res.status(403).json({ error: 'Нет доступа к таблице' });
}
const sortColumn = req.query.sortColumn as string;
const sortDir = req.query.sortDirection as string;
// SQL-путь: при limit/offset/search фильтрация, сортировка и пагинация
// выполняются на уровне БД (без полной выгрузки строк).
// Без этих параметров — прежнее поведение (все строки + JS-фильтры/сортировка),
// чтобы не менять семантику существующих клиентов.
const useSqlPaging =
req.query.limit !== undefined ||
req.query.offset !== undefined ||
req.query.search !== undefined;
if (useSqlPaging) {
let filters: Record<number, string> | undefined;
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const parsed: Record<string, string> = JSON.parse(filtersStr);
filters = {};
for (const [col, val] of Object.entries(parsed)) {
const ci = parseInt(col);
if (!isNaN(ci) && val) filters[ci] = String(val);
}
} catch {}
}
const ci = parseInt(sortColumn);
const { rows, total } = await storage.getDataTableRowsPaged(tableId, req.organizationId!, {
filters,
search: req.query.search ? String(req.query.search) : undefined,
sortColumn: !isNaN(ci) ? ci : undefined,
sortDirection: sortDir === 'asc' || sortDir === 'desc' ? sortDir : undefined,
limit: clampDirectoryRowsLimit(req.query.limit),
offset: clampDirectoryRowsOffset(req.query.offset),
});
return res.json({ rows, total });
}
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
const filtersStr = req.query.filters as string;
@@ -463,8 +503,6 @@ export function registerDataTableRoutes(app: import("express").Express): void {
} catch {}
}
const sortColumn = req.query.sortColumn as string;
const sortDir = req.query.sortDirection as string;
if (sortColumn !== undefined && (sortDir === 'asc' || sortDir === 'desc')) {
const ci = parseInt(sortColumn);
if (!isNaN(ci)) {

View File

@@ -7,6 +7,11 @@ import { db } from "../db";
import { eq, inArray } from "drizzle-orm";
import { tasksMinimalCache } from "../utils/cache";
import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination";
import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query";
// Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров)
const COLUMN_VALUES_DEFAULT_LIMIT = 20;
const COLUMN_VALUES_MAX_LIMIT = 1000;
function safeParseOptions(opts: unknown): unknown {
if (Array.isArray(opts)) return opts;
@@ -478,9 +483,6 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
const num = parsed?.value ? String(parsed.value) : str;
const entityTitle = parsed?.legalEntityTaskId ? legalEntityMap?.get(Number(parsed.legalEntityTaskId)) : undefined;
const result = entityTitle ? `${num} — ${entityTitle}` : num;
if (process.env.NODE_ENV !== 'production') {
console.log('[getTaskColumnValue] contract-number:', 'fieldId:', fieldId, 'parsed:', JSON.stringify(parsed), 'entityTitle:', entityTitle, 'result:', result);
}
return result;
} catch {
return str;
@@ -640,17 +642,11 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
// Загружаем юрлица для полей типа 'contract-number'
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
console.log('[column-values] contractNumberFields count:', contractNumberFields.length, 'formId:', formId);
for (const f of contractNumberFields) {
console.log('[column-values] contract-number field:', f.id, f.name, 'options:', JSON.stringify(f.options));
}
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
console.log('[column-values] loading legal entities for formId:', leFormId, 'org:', req.organizationId);
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
console.log('[column-values] loaded legal entities:', leTasks.length);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
@@ -658,7 +654,6 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
console.error('[column-values] error loading legal entities for formId:', leFormId, err);
}
}
console.log('[column-values] legalEntityMap size:', legalEntityMap.size);
// Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия
const tableRowMap = new Map<number, Map<number, string>>();
@@ -721,21 +716,8 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: SafeUser) => [u.id, u]));
// Отладка: логируем select/radio-group/table поля и их options
if (column.startsWith('field_')) {
const dbgFieldId = parseInt(column.replace('field_', ''));
const dbgField = formFields.find(f => f.id === dbgFieldId);
if (dbgField && (dbgField.type === 'select' || dbgField.type === 'radio-group' || dbgField.type === 'table')) {
console.log('[column-values] field:', dbgField.id, 'type:', dbgField.type, 'options:', JSON.stringify(dbgField.options), 'optionsType:', typeof dbgField.options);
// Логируем первую задачу с ненулевым значением
const firstTask = taskList.find(t => t.fieldValues && t.fieldValues[dbgField.id] !== undefined && t.fieldValues[dbgField.id] !== null);
if (firstTask) {
const rawV = firstTask.fieldValues![dbgField.id];
const resolved = getTaskColumnValue(firstTask, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap);
console.log('[column-values] firstTaskId:', firstTask.id, 'rawV:', JSON.stringify(rawV), 'rawVType:', typeof rawV, 'resolved:', resolved);
}
}
}
// Кап на количество уникальных значений: параметр limit (дефолт 20, максимум 1000)
const limit = clampDirectoryRowsLimit(req.query.limit, COLUMN_VALUES_DEFAULT_LIMIT, COLUMN_VALUES_MAX_LIMIT);
const seen = new Set<string>();
const values: string[] = [];
@@ -746,7 +728,7 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= 20) break;
if (values.length >= limit) break;
}
res.json({ values });