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

@@ -3331,18 +3331,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
const all = await storage.getDataTablesByOrganization(organizationId);
// Фильтруем выдачу по scopes.tableIds (null = все справочники)
const allowed = all.filter((t) => isTableAllowed(t.id));
const result = await Promise.all(
allowed.map(async (t) => {
const rows = await storage.getDataTableRows(t.id, organizationId);
return {
id: t.id,
name: t.name,
description: t.description,
columns: t.columns,
rowCount: rows.length,
};
})
);
// Количество строк по всем справочникам — одним GROUP BY запросом (без N+1)
const rowCounts = await storage.getDataTableRowCounts(organizationId);
const result = allowed.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
columns: t.columns,
rowCount: rowCounts.get(t.id) ?? 0,
}));
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
};
@@ -3421,55 +3418,40 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
const table = await storage.getDataTable(tableId, organizationId);
if (!table) return directoryError(`Справочник ${tableId} не найден`);
let rows = await storage.getDataTableRows(tableId, organizationId);
// tree-режим: дерево строится из всех строк (иерархия parent_id / treeColumns),
// поэтому здесь осознанно полная выборка — пагинация в дереве неприменима.
if (mode === "tree") {
const rows = await storage.getDataTableRows(tableId, organizationId);
const tree = buildDataTableTree(table, rows, { filters, search });
return {
content: [{ type: "text" as const, text: JSON.stringify(tree, null, 2) }],
};
}
// flat: фильтры по подстроке (includes), без учёта регистра
// flat-режим: фильтры/поиск/сортировка/пагинация на уровне SQL
let numericFilters: Record<number, string> | undefined;
if (filters) {
Object.entries(filters).forEach(([col, val]) => {
numericFilters = {};
for (const [col, val] of Object.entries(filters)) {
const ci = parseInt(col);
if (!isNaN(ci) && val) {
rows = rows.filter((r) => {
const v = Array.isArray(r.values) ? r.values : [];
return String(v[ci] || '').toLowerCase().includes(val.toLowerCase());
});
}
});
if (!isNaN(ci) && val) numericFilters[ci] = val;
}
}
// Поиск по всем значениям строки
if (search) {
const q = search.toLowerCase();
rows = rows.filter((r) => {
const v = Array.isArray(r.values) ? r.values : [];
return v.some((val) => String(val ?? '').toLowerCase().includes(q));
});
}
const { rows, total } = await storage.getDataTableRowsPaged(tableId, organizationId, {
filters: numericFilters,
search,
sortColumn,
sortDirection,
limit: limit ?? 100,
offset: offset ?? 0,
});
// Сортировка по колонке
if (sortColumn !== undefined && (sortDirection === 'asc' || sortDirection === 'desc')) {
const ci = sortColumn;
rows = [...rows].sort((a, b) => {
const av = Array.isArray(a.values) ? a.values : [];
const bv = Array.isArray(b.values) ? b.values : [];
const cmp = String(av[ci] || '').toLowerCase().localeCompare(String(bv[ci] || '').toLowerCase(), 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
const total = rows.length;
const paged = rows.slice(offset ?? 0, (offset ?? 0) + (limit ?? 100));
return {
content: [{
type: "text" as const,
text: JSON.stringify({
rows: paged.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })),
rows: rows.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })),
total,
}, null, 2),
}],