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:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user