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

@@ -1,7 +1,18 @@
import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, dataTableLinks, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule, type DataTableLink, type DataTableSubdirectory } from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { eq, and, or, desc, sql, inArray, isNull, ne } from "drizzle-orm";
import { BotsStorage } from "./bots.storage";
import { escapeIlikePattern } from "../utils/data-table-rows-query";
// Параметры SQL-пагинации строк справочника (см. getDataTableRowsPaged)
export interface DirectoryRowsPageOptions {
filters?: Record<number, string>;
search?: string;
sortColumn?: number;
sortDirection?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export class DataTablesCoreStorage extends BotsStorage {
async getDataTable(id: number, organizationId: number): Promise<DataTable | undefined> {
const [table] = await db
@@ -27,33 +38,26 @@ export class DataTablesCoreStorage extends BotsStorage {
}
async getDataTablesWithAccess(userId: number, organizationId: number): Promise<DataTable[]> {
const allTables = await db
// Права пользователя собираем одним запросом, чтобы не делать N+1 по каждой таблице
const permissions = await db
.select({ tableId: dataTablePermissions.tableId })
.from(dataTablePermissions)
.where(eq(dataTablePermissions.userId, userId));
const permittedIds = permissions.map((p) => p.tableId);
const accessCondition = permittedIds.length > 0
? or(eq(dataTables.createdBy, userId), inArray(dataTables.id, permittedIds))
: eq(dataTables.createdBy, userId);
return await db
.select()
.from(dataTables)
.where(and(
eq(dataTables.organizationId, organizationId),
eq(dataTables.isDeleted, false)
eq(dataTables.isDeleted, false),
accessCondition
))
.orderBy(desc(dataTables.createdAt));
const tablesWithAccess: DataTable[] = [];
for (const table of allTables) {
if (table.createdBy === userId) {
tablesWithAccess.push(table);
} else {
const [permission] = await db
.select()
.from(dataTablePermissions)
.where(and(
eq(dataTablePermissions.tableId, table.id),
eq(dataTablePermissions.userId, userId)
));
if (permission) {
tablesWithAccess.push(table);
}
}
}
return tablesWithAccess;
}
// =====================================================
@@ -160,6 +164,100 @@ export class DataTablesCoreStorage extends BotsStorage {
.orderBy(dataTableRows.position, dataTableRows.id);
}
/**
* SQL-пагинация строк справочника: фильтры/поиск/сортировка/лимит выполняются
* на уровне БД, без выгрузки всех строк в память.
* Фильтры и поиск — подстрочное совпадение без учёта регистра (ILIKE),
* семантика совпадает с прежней JS-фильтрацией (String(...).toLowerCase().includes).
* Сортировка — текстовая по lower(values->>N), пустые значения идут первыми (как '' в JS).
*/
async getDataTableRowsPaged(
tableId: number,
organizationId: number,
opts: DirectoryRowsPageOptions = {}
): Promise<{ rows: DataTableRow[]; total: number }> {
const [table] = await db
.select()
.from(dataTables)
.where(and(
eq(dataTables.id, tableId),
eq(dataTables.organizationId, organizationId)
));
if (!table) return { rows: [], total: 0 };
const conditions = [
eq(dataTableRows.tableId, tableId),
eq(dataTableRows.isDeleted, false),
];
if (opts.filters) {
for (const [colKey, val] of Object.entries(opts.filters)) {
const ci = Number(colKey);
if (!Number.isInteger(ci) || ci < 0 || !val) continue;
conditions.push(
sql`${dataTableRows.values}->>${ci} ILIKE ${'%' + escapeIlikePattern(val) + '%'} ESCAPE '\\'`
);
}
}
if (opts.search) {
conditions.push(
sql`${dataTableRows.values}::text ILIKE ${'%' + escapeIlikePattern(opts.search) + '%'} ESCAPE '\\'`
);
}
const where = and(...conditions);
const [countRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(dataTableRows)
.where(where);
const total = Number(countRow?.count ?? 0);
const query = db.select().from(dataTableRows).where(where);
if (opts.sortColumn !== undefined && (opts.sortDirection === 'asc' || opts.sortDirection === 'desc')) {
const ci = opts.sortColumn;
const sortExpr = opts.sortDirection === 'desc'
? sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) DESC`
: sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) ASC`;
// Вторичная сортировка по position/id — стабильный порядок при равных значениях
query.orderBy(sortExpr, dataTableRows.position, dataTableRows.id);
} else {
query.orderBy(dataTableRows.position, dataTableRows.id);
}
if (opts.limit !== undefined && opts.limit > 0) {
query.limit(opts.limit);
}
if (opts.offset !== undefined && opts.offset > 0) {
query.offset(opts.offset);
}
const rows = await query;
return { rows, total };
}
/**
* Количество неудалённых строк по каждому справочнику организации одним запросом
* (используется MCP list_directories вместо N+1 выборок строк).
*/
async getDataTableRowCounts(organizationId: number): Promise<Map<number, number>> {
const result = await db
.select({
tableId: dataTableRows.tableId,
count: sql<number>`count(*)::int`,
})
.from(dataTableRows)
.innerJoin(dataTables, eq(dataTableRows.tableId, dataTables.id))
.where(and(
eq(dataTables.organizationId, organizationId),
eq(dataTableRows.isDeleted, false)
))
.groupBy(dataTableRows.tableId);
return new Map(result.map((r) => [r.tableId, Number(r.count)]));
}
async getDataTableRow(id: number, tableId: number, organizationId: number): Promise<DataTableRow | undefined> {
const [table] = await db
.select()
@@ -288,10 +386,19 @@ export class DataTablesCoreStorage extends BotsStorage {
}
}
const rows = await this.getDataTableRows(tableId, organizationId);
const siblings = rows
.filter((r) => r.id !== id && (r.parentId ?? null) === targetParentId)
.sort((a, b) => a.position - b.position);
// Только siblings нужного родителя — без полной выборки строк таблицы
const siblings = await db
.select()
.from(dataTableRows)
.where(and(
eq(dataTableRows.tableId, tableId),
eq(dataTableRows.isDeleted, false),
ne(dataTableRows.id, id),
targetParentId === null
? isNull(dataTableRows.parentId)
: eq(dataTableRows.parentId, targetParentId)
))
.orderBy(dataTableRows.position, dataTableRows.id);
const clampedPosition = Math.max(0, Math.min(position, siblings.length));
const reordered = [