mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-18 03:12:16 +08:00
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
import { splitQualifiedNameSegments, stripIdentifierQuotes } from './qualifiedName';
|
||
|
||
export type FilterCondition = {
|
||
id?: number;
|
||
enabled?: boolean;
|
||
logic?: 'AND' | 'OR';
|
||
column?: string;
|
||
op?: string;
|
||
value?: string;
|
||
value2?: string;
|
||
};
|
||
|
||
const normalizeIdentPart = (ident: string) => stripIdentifierQuotes(ident);
|
||
|
||
// 检查标识符是否需要引号(包含特殊字符或是保留字)
|
||
const needsQuote = (ident: string): boolean => {
|
||
if (!ident) return false;
|
||
// 如果包含特殊字符(非字母、数字、下划线)则需要引号
|
||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ident)) return true;
|
||
// PostgreSQL 会将未加引号的标识符折叠为小写,含大写字母时必须加引号
|
||
if (/[A-Z]/.test(ident)) return true;
|
||
// 常见 SQL 保留字列表(简化版)
|
||
const reserved = ['select', 'from', 'where', 'table', 'index', 'user', 'order', 'group', 'by', 'limit', 'offset', 'and', 'or', 'not', 'null', 'true', 'false', 'key', 'primary', 'foreign', 'references', 'default', 'constraint', 'create', 'drop', 'alter', 'insert', 'update', 'delete', 'set', 'values', 'into', 'join', 'left', 'right', 'inner', 'outer', 'on', 'as', 'is', 'in', 'like', 'between', 'case', 'when', 'then', 'else', 'end', 'having', 'distinct', 'all', 'any', 'exists', 'union', 'except', 'intersect'];
|
||
return reserved.includes(ident.toLowerCase());
|
||
};
|
||
|
||
export const quoteIdentPart = (dbType: string, ident: string) => {
|
||
const raw = normalizeIdentPart(ident);
|
||
if (!raw) return raw;
|
||
const dbTypeLower = (dbType || '').toLowerCase();
|
||
|
||
if (dbTypeLower === 'mysql' || dbTypeLower === 'goldendb' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'iotdb' || dbTypeLower === 'clickhouse') {
|
||
return `\`${raw.replace(/`/g, '``')}\``;
|
||
}
|
||
|
||
// 对于 KingBase/PostgreSQL,只在必要时加引号
|
||
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres' || dbTypeLower === 'opengauss' || dbTypeLower === 'gaussdb') {
|
||
if (needsQuote(raw)) {
|
||
return `"${raw.replace(/"/g, '""')}"`;
|
||
}
|
||
// 不加引号,保持原样(数据库会自动转小写处理)
|
||
return raw;
|
||
}
|
||
|
||
// SQL Server 使用 [bracket] 标识符
|
||
if (dbTypeLower === 'sqlserver' || dbTypeLower === 'mssql') {
|
||
return `[${raw.replace(/]/g, ']]')}]`;
|
||
}
|
||
|
||
// 其他数据库默认加双引号
|
||
return `"${raw.replace(/"/g, '""')}"`;
|
||
};
|
||
|
||
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
|
||
const raw = (ident || '').trim();
|
||
if (!raw) return raw;
|
||
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
|
||
return quoteIdentPart(dbType, raw);
|
||
}
|
||
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
|
||
if (parts.length === 0) return quoteIdentPart(dbType, raw);
|
||
if (parts.length === 1 && parts[0] === normalizeIdentPart(raw)) return quoteIdentPart(dbType, raw);
|
||
return parts.map((part) => quoteIdentPart(dbType, part)).join('.');
|
||
};
|
||
|
||
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
|
||
|
||
type SortInfoItem = {
|
||
columnKey?: string;
|
||
order?: string;
|
||
enabled?: boolean;
|
||
};
|
||
|
||
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
|
||
|
||
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
|
||
// MySQL: 使用 Optimizer Hint `SET_VAR`。
|
||
// MariaDB: 使用 `SET STATEMENT ... FOR` 包装当前查询。
|
||
export const withSortBufferTuningSQL = (
|
||
dbType: string,
|
||
sql: string,
|
||
sortBufferBytes: number,
|
||
) => {
|
||
const rawSql = String(sql || '');
|
||
const trimmed = rawSql.trim();
|
||
if (!trimmed) return rawSql;
|
||
if (!/^select\b/i.test(trimmed)) return rawSql;
|
||
|
||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||
const bytes = Math.max(256 * 1024, Math.floor(Number(sortBufferBytes) || 0));
|
||
if (normalizedType === 'mysql') {
|
||
return rawSql.replace(
|
||
/^\s*select\b/i,
|
||
(matched) => `${matched} /*+ SET_VAR(sort_buffer_size=${bytes}) */`,
|
||
);
|
||
}
|
||
if (normalizedType === 'mariadb') {
|
||
return `SET STATEMENT sort_buffer_size=${bytes} FOR ${rawSql}`;
|
||
}
|
||
return rawSql;
|
||
};
|
||
|
||
/** 将 SortInfo(单字段或多字段)标准化为 SortInfoItem 数组 */
|
||
const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => {
|
||
if (!sortInfo) return [];
|
||
if (Array.isArray(sortInfo)) return sortInfo;
|
||
return [sortInfo];
|
||
};
|
||
|
||
/** 判断 SortInfo 中是否存在至少一个有效排序 */
|
||
export const hasExplicitSort = (sortInfo: SortInfo): boolean => {
|
||
const items = normalizeSortInfoItems(sortInfo);
|
||
return items.some(item => {
|
||
if (item?.enabled === false) return false;
|
||
const col = String(item?.columnKey || '').trim();
|
||
const order = String(item?.order || '');
|
||
return !!col && (order === 'ascend' || order === 'descend');
|
||
});
|
||
};
|
||
|
||
export const buildOrderBySQL = (
|
||
dbType: string,
|
||
sortInfo: SortInfo,
|
||
fallbackColumns: string[] = [],
|
||
) => {
|
||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||
const items = normalizeSortInfoItems(sortInfo);
|
||
const seen = new Set<string>();
|
||
const sortParts: string[] = [];
|
||
|
||
for (const item of items) {
|
||
if (item?.enabled === false) continue;
|
||
const sortColumn = normalizeIdentPart(String(item?.columnKey || ''));
|
||
const sortOrder = String(item?.order || '');
|
||
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
|
||
if (sortColumn && direction) {
|
||
const key = sortColumn.toLowerCase();
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (sortParts.length > 0) {
|
||
return ` ORDER BY ${sortParts.join(', ')}`;
|
||
}
|
||
|
||
// 部分数据源在无显式排序需求时强制 ORDER BY(即使按主键)会显著放大大表预览成本:
|
||
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误,DuckDB 大文件可能被排序拖到连接超时。
|
||
// 因此仅在用户主动点击排序时下发 ORDER BY,默认分页查询不加兜底排序。
|
||
if (dbTypeLower === 'mysql' || dbTypeLower === 'goldendb' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'duckdb') {
|
||
return '';
|
||
}
|
||
|
||
const stableColumns = (fallbackColumns || [])
|
||
.map((col) => normalizeIdentPart(String(col || '')))
|
||
.filter((col) => {
|
||
if (!col) return false;
|
||
const key = col.toLowerCase();
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
if (stableColumns.length > 0) {
|
||
const parts = stableColumns.map((col) => `${quoteIdentPart(dbType, col)} ASC`);
|
||
return ` ORDER BY ${parts.join(', ')}`;
|
||
}
|
||
|
||
return '';
|
||
};
|
||
|
||
const splitOrderByParts = (body: string): string[] => {
|
||
const text = String(body || '');
|
||
const parts: string[] = [];
|
||
let start = 0;
|
||
let parenDepth = 0;
|
||
let inSingle = false;
|
||
let inDouble = false;
|
||
let inBracket = false;
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
const next = i + 1 < text.length ? text[i + 1] : '';
|
||
|
||
if (inSingle) {
|
||
if (ch === "'" && next === "'") {
|
||
i++;
|
||
} else if (ch === "'") {
|
||
inSingle = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (inDouble) {
|
||
if (ch === '"' && next === '"') {
|
||
i++;
|
||
} else if (ch === '"') {
|
||
inDouble = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (inBracket) {
|
||
if (ch === ']' && next === ']') {
|
||
i++;
|
||
} else if (ch === ']') {
|
||
inBracket = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === "'") {
|
||
inSingle = true;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inDouble = true;
|
||
continue;
|
||
}
|
||
if (ch === '[') {
|
||
inBracket = true;
|
||
continue;
|
||
}
|
||
if (ch === '(') {
|
||
parenDepth++;
|
||
continue;
|
||
}
|
||
if (ch === ')') {
|
||
if (parenDepth > 0) parenDepth--;
|
||
continue;
|
||
}
|
||
if (ch === ',' && parenDepth === 0) {
|
||
const part = text.slice(start, i).trim();
|
||
if (part) parts.push(part);
|
||
start = i + 1;
|
||
}
|
||
}
|
||
|
||
const tail = text.slice(start).trim();
|
||
if (tail) parts.push(tail);
|
||
return parts;
|
||
};
|
||
|
||
export const reverseOrderBySQL = (orderBySQL: string): string => {
|
||
const raw = String(orderBySQL || '').trim();
|
||
if (!raw) return '';
|
||
const body = raw.replace(/^order\s+by\s+/i, '').trim();
|
||
if (!body) return '';
|
||
|
||
const parts = splitOrderByParts(body)
|
||
.map((part) => {
|
||
if (/\s+asc$/i.test(part)) return part.replace(/\s+asc$/i, ' DESC');
|
||
if (/\s+desc$/i.test(part)) return part.replace(/\s+desc$/i, ' ASC');
|
||
return `${part} DESC`;
|
||
})
|
||
.filter(Boolean);
|
||
if (parts.length === 0) return '';
|
||
return ` ORDER BY ${parts.join(', ')}`;
|
||
};
|
||
|
||
const addSqlServerTopLimit = (sql: string, limit: number): string => {
|
||
const text = String(sql || '').trim();
|
||
if (!text) return text;
|
||
if (/^\s*select\s+(?:distinct\s+)?top\b/i.test(text)) {
|
||
return text;
|
||
}
|
||
return text.replace(
|
||
/^(\s*select\b)(\s+distinct\b)?/i,
|
||
(_match, selectKeyword: string, distinctKeyword = '') => `${selectKeyword}${distinctKeyword} TOP ${limit}`,
|
||
);
|
||
};
|
||
|
||
const buildSqlServerPaginatedSelectSQL = (
|
||
base: string,
|
||
orderBy: string,
|
||
limit: number,
|
||
offset: number,
|
||
): string => {
|
||
if (offset <= 0) {
|
||
return `${addSqlServerTopLimit(base, limit)}${orderBy}`;
|
||
}
|
||
|
||
const effectiveOrderBy = orderBy.trim();
|
||
if (effectiveOrderBy) {
|
||
const reverseOrderBy = reverseOrderBySQL(effectiveOrderBy);
|
||
if (reverseOrderBy) {
|
||
const upperBound = offset + limit;
|
||
return `SELECT * FROM (SELECT TOP ${limit} * FROM (SELECT TOP ${upperBound} * FROM (${base}) AS [__gonavi_page_base__] ${effectiveOrderBy}) AS [__gonavi_page_window__] ${reverseOrderBy}) AS [__gonavi_page_slice__] ${effectiveOrderBy}`;
|
||
}
|
||
}
|
||
|
||
const rowNumberOrderBy = effectiveOrderBy || 'ORDER BY (SELECT NULL)';
|
||
const upperBound = offset + limit;
|
||
return `SELECT * FROM (SELECT [__gonavi_page__].*, ROW_NUMBER() OVER (${rowNumberOrderBy}) AS [__gonavi_rn__] FROM (${base}) AS [__gonavi_page__]) AS [__gonavi_page_result__] WHERE [__gonavi_rn__] > ${offset} AND [__gonavi_rn__] <= ${upperBound} ORDER BY [__gonavi_rn__]`;
|
||
};
|
||
|
||
export const buildPaginatedSelectSQL = (
|
||
dbType: string,
|
||
baseSql: string,
|
||
orderBySQL: string,
|
||
limit: number,
|
||
offset: number,
|
||
) => {
|
||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||
const safeOffset = Math.max(0, Math.floor(Number(offset) || 0));
|
||
const base = String(baseSql || '').trim();
|
||
const orderBy = String(orderBySQL || '');
|
||
|
||
if (!base || safeLimit <= 0) {
|
||
return `${base}${orderBy}`;
|
||
}
|
||
|
||
switch (normalizedType) {
|
||
case 'oracle':
|
||
case 'dameng': {
|
||
const orderedSql = `${base}${orderBy}`;
|
||
const upperBound = safeOffset + safeLimit;
|
||
if (safeOffset <= 0) {
|
||
return `SELECT * FROM (${orderedSql}) WHERE ROWNUM <= ${upperBound}`;
|
||
}
|
||
return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`;
|
||
}
|
||
case 'sqlserver':
|
||
case 'mssql': {
|
||
return buildSqlServerPaginatedSelectSQL(base, orderBy, safeLimit, safeOffset);
|
||
}
|
||
default:
|
||
return `${base}${orderBy} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
||
}
|
||
};
|
||
|
||
export const parseListValues = (val: string) => {
|
||
const raw = (val || '').trim();
|
||
if (!raw) return [];
|
||
return raw
|
||
.split(/[\n,,]+/)
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const normalizeConditionLogic = (logic: unknown): 'AND' | 'OR' => {
|
||
return String(logic || '').trim().toUpperCase() === 'OR' ? 'OR' : 'AND';
|
||
};
|
||
|
||
export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => {
|
||
const whereParts: Array<{ expr: string; logic: 'AND' | 'OR' }> = [];
|
||
|
||
(conditions || []).forEach((cond) => {
|
||
if (cond?.enabled === false) return;
|
||
|
||
const op = (cond?.op || '').trim();
|
||
const column = (cond?.column || '').trim();
|
||
const value = (cond?.value ?? '').toString();
|
||
const value2 = (cond?.value2 ?? '').toString();
|
||
const logic = normalizeConditionLogic(cond?.logic);
|
||
|
||
const appendWherePart = (expr: string) => {
|
||
const normalizedExpr = String(expr || '').trim();
|
||
if (!normalizedExpr) return;
|
||
whereParts.push({ expr: normalizedExpr, logic });
|
||
};
|
||
|
||
if (op === 'CUSTOM') {
|
||
const expr = value.trim();
|
||
if (expr) appendWherePart(`(${expr})`);
|
||
return;
|
||
}
|
||
|
||
if (!column) return;
|
||
|
||
const col = quoteIdentPart(dbType, column);
|
||
|
||
switch (op) {
|
||
case 'IS_NULL':
|
||
appendWherePart(`${col} IS NULL`);
|
||
return;
|
||
case 'IS_NOT_NULL':
|
||
appendWherePart(`${col} IS NOT NULL`);
|
||
return;
|
||
case 'IS_EMPTY':
|
||
// 兼容:空值通常理解为 NULL 或空字符串
|
||
appendWherePart(`(${col} IS NULL OR ${col} = '')`);
|
||
return;
|
||
case 'IS_NOT_EMPTY':
|
||
appendWherePart(`(${col} IS NOT NULL AND ${col} <> '')`);
|
||
return;
|
||
case 'BETWEEN': {
|
||
const v1 = value.trim();
|
||
const v2 = value2.trim();
|
||
if (!v1 || !v2) return;
|
||
appendWherePart(`${col} BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||
return;
|
||
}
|
||
case 'NOT_BETWEEN': {
|
||
const v1 = value.trim();
|
||
const v2 = value2.trim();
|
||
if (!v1 || !v2) return;
|
||
appendWherePart(`${col} NOT BETWEEN '${escapeLiteral(v1)}' AND '${escapeLiteral(v2)}'`);
|
||
return;
|
||
}
|
||
case 'IN': {
|
||
const items = parseListValues(value);
|
||
if (items.length === 0) return;
|
||
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||
appendWherePart(`${col} IN (${list})`);
|
||
return;
|
||
}
|
||
case 'NOT_IN': {
|
||
const items = parseListValues(value);
|
||
if (items.length === 0) return;
|
||
const list = items.map(v => `'${escapeLiteral(v)}'`).join(', ');
|
||
appendWherePart(`${col} NOT IN (${list})`);
|
||
return;
|
||
}
|
||
case 'CONTAINS': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||
return;
|
||
}
|
||
case 'NOT_CONTAINS': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}%'`);
|
||
return;
|
||
}
|
||
case 'STARTS_WITH': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} LIKE '${escapeLiteral(v)}%'`);
|
||
return;
|
||
}
|
||
case 'NOT_STARTS_WITH': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} NOT LIKE '${escapeLiteral(v)}%'`);
|
||
return;
|
||
}
|
||
case 'ENDS_WITH': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}'`);
|
||
return;
|
||
}
|
||
case 'NOT_ENDS_WITH': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} NOT LIKE '%${escapeLiteral(v)}'`);
|
||
return;
|
||
}
|
||
case '=':
|
||
case '!=':
|
||
case '<':
|
||
case '<=':
|
||
case '>':
|
||
case '>=': {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
|
||
return;
|
||
}
|
||
default: {
|
||
// 兼容旧值:LIKE
|
||
if (op.toUpperCase() === 'LIKE') {
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} LIKE '%${escapeLiteral(v)}%'`);
|
||
return;
|
||
}
|
||
|
||
const v = value.trim();
|
||
if (!v) return;
|
||
appendWherePart(`${col} ${op} '${escapeLiteral(v)}'`);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (whereParts.length === 0) return '';
|
||
|
||
let whereExpr = `(${whereParts[0].expr})`;
|
||
for (let i = 1; i < whereParts.length; i++) {
|
||
const part = whereParts[i];
|
||
whereExpr = `(${whereExpr} ${part.logic} (${part.expr}))`;
|
||
}
|
||
return `WHERE ${whereExpr}`;
|
||
};
|