mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-20 15:50:51 +08:00
🐛 fix(query-editor): 修复多数据源大查询限流失效
- SQL限流:抽取查询自动限流工具,修复 SELECT 判断大小写不一致导致限制未生效 - 方言适配:按 Oracle/Dameng、SQL Server、MySQL/PostgreSQL 等方言分别注入行数限制 - 自定义驱动:支持 custom 连接根据 driver 解析 Oracle、PostgreSQL、SQL Server 等方言 - MongoDB修复:修正 db.collection.find() 解析边界,并对 find/只读 aggregate 下推 limit - Oracle优化:DSN 增加 PREFETCH_ROWS 和 LOB FETCH 参数,减少大结果集拉取开销 - 测试覆盖:补充 SQL 方言矩阵、MongoDB 限流和 Oracle DSN 参数测试 Refs #424
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { convertMongoShellToJsonCommand } from './mongodb';
|
||||
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from './mongodb';
|
||||
|
||||
const parseCommand = (command: string | undefined) => JSON.parse(command || '{}');
|
||||
|
||||
describe('convertMongoShellToJsonCommand', () => {
|
||||
it('converts show dbs shell shortcut to listDatabases command', () => {
|
||||
@@ -16,4 +18,105 @@ describe('convertMongoShellToJsonCommand', () => {
|
||||
command: JSON.stringify({ listCollections: 1, filter: {}, nameOnly: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('converts find shell commands without adding implicit limit', () => {
|
||||
const result = convertMongoShellToJsonCommand('db.users.find({ active: true })');
|
||||
|
||||
expect(result.recognized).toBe(true);
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: { active: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit find limit values from shell commands', () => {
|
||||
const result = convertMongoShellToJsonCommand('db.users.find({}).limit(10)');
|
||||
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: {},
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps explicit zero limit values from shell commands', () => {
|
||||
const result = convertMongoShellToJsonCommand('db.users.find({}).limit(0)');
|
||||
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: {},
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMongoQueryAutoLimit', () => {
|
||||
it('adds limit to raw Mongo find commands', () => {
|
||||
const result = applyMongoQueryAutoLimit('{"find":"users","filter":{}}', 500);
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: {},
|
||||
limit: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds limit after shell find conversion', () => {
|
||||
const shell = convertMongoShellToJsonCommand('db.users.find({ active: true })');
|
||||
const result = applyMongoQueryAutoLimit(shell.command || '', 500);
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: { active: true },
|
||||
limit: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not replace explicit find limits', () => {
|
||||
const result = applyMongoQueryAutoLimit('{"find":"users","filter":{},"limit":10}', 500);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
find: 'users',
|
||||
filter: {},
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds $limit to read-only aggregate pipelines', () => {
|
||||
const result = applyMongoQueryAutoLimit('{"aggregate":"users","pipeline":[{"$match":{"active":true}}],"cursor":{}}', 500);
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(parseCommand(result.command)).toEqual({
|
||||
aggregate: 'users',
|
||||
pipeline: [
|
||||
{ $match: { active: true } },
|
||||
{ $limit: 500 },
|
||||
],
|
||||
cursor: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add another aggregate $limit', () => {
|
||||
const command = '{"aggregate":"users","pipeline":[{"$limit":10}],"cursor":{}}';
|
||||
const result = applyMongoQueryAutoLimit(command, 500);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.command).toBe(command);
|
||||
});
|
||||
|
||||
it('does not alter aggregate write pipelines', () => {
|
||||
const command = '{"aggregate":"users","pipeline":[{"$match":{}},{"$out":"tmp_users"}],"cursor":{}}';
|
||||
const result = applyMongoQueryAutoLimit(command, 500);
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.command).toBe(command);
|
||||
});
|
||||
|
||||
it('does not limit non-read or invalid commands', () => {
|
||||
expect(applyMongoQueryAutoLimit('{"count":"users","query":{}}', 500).applied).toBe(false);
|
||||
expect(applyMongoQueryAutoLimit('db.users.find({})', 500).applied).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,7 +321,7 @@ const parseCollectionAndMethod = (raw: string): {
|
||||
pos = nextPos;
|
||||
} else {
|
||||
let end = pos;
|
||||
while (end < input.length && /[A-Za-z0-9_$.-]/.test(input[end])) end++;
|
||||
while (end < input.length && /[A-Za-z0-9_$-]/.test(input[end])) end++;
|
||||
collection = input.slice(pos, end).trim();
|
||||
pos = end;
|
||||
}
|
||||
@@ -662,7 +662,7 @@ export const buildMongoFindCommand = (params: {
|
||||
if (params.sort && Object.keys(params.sort).length > 0) {
|
||||
command.sort = params.sort;
|
||||
}
|
||||
if (Number.isFinite(params.limit) && Number(params.limit) > 0) {
|
||||
if (Number.isFinite(params.limit) && Number(params.limit) >= 0) {
|
||||
command.limit = Math.floor(Number(params.limit));
|
||||
}
|
||||
if (Number.isFinite(params.skip) && Number(params.skip) > 0) {
|
||||
@@ -678,6 +678,45 @@ export const buildMongoCountCommand = (collection: string, filter: Record<string
|
||||
});
|
||||
};
|
||||
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string) => Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
const isMongoCommandObject = (value: unknown): value is Record<string, unknown> => (
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
|
||||
export const applyMongoQueryAutoLimit = (
|
||||
command: string,
|
||||
maxRows: number,
|
||||
): { command: string; applied: boolean; maxRows: number } => {
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { command, applied: false, maxRows };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(String(command || '').trim());
|
||||
} catch {
|
||||
return { command, applied: false, maxRows };
|
||||
}
|
||||
if (!isMongoCommandObject(parsed)) return { command, applied: false, maxRows };
|
||||
|
||||
const nextMaxRows = Math.floor(Number(maxRows));
|
||||
if (hasOwn(parsed, 'find')) {
|
||||
if (hasOwn(parsed, 'limit')) return { command, applied: false, maxRows };
|
||||
parsed.limit = nextMaxRows;
|
||||
return { command: JSON.stringify(parsed), applied: true, maxRows };
|
||||
}
|
||||
|
||||
if (hasOwn(parsed, 'aggregate') && Array.isArray(parsed.pipeline)) {
|
||||
const pipeline = parsed.pipeline as unknown[];
|
||||
const hasExplicitLimit = pipeline.some((stage) => isMongoCommandObject(stage) && hasOwn(stage, '$limit'));
|
||||
const hasWriteStage = pipeline.some((stage) => isMongoCommandObject(stage) && (hasOwn(stage, '$out') || hasOwn(stage, '$merge')));
|
||||
if (hasExplicitLimit || hasWriteStage) return { command, applied: false, maxRows };
|
||||
pipeline.push({ $limit: nextMaxRows });
|
||||
return { command: JSON.stringify(parsed), applied: true, maxRows };
|
||||
}
|
||||
|
||||
return { command, applied: false, maxRows };
|
||||
};
|
||||
|
||||
const buildMongoInsertCommand = (
|
||||
collection: string,
|
||||
documents: Record<string, unknown>[],
|
||||
|
||||
110
frontend/src/utils/queryAutoLimit.test.ts
Normal file
110
frontend/src/utils/queryAutoLimit.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyQueryAutoLimit } from './queryAutoLimit';
|
||||
|
||||
describe('applyQueryAutoLimit', () => {
|
||||
const limitDialects = [
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'doris',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'kingbase',
|
||||
'kingbase8',
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlite',
|
||||
'sqlite3',
|
||||
'duckdb',
|
||||
'clickhouse',
|
||||
'tdengine',
|
||||
];
|
||||
|
||||
it.each(limitDialects)('adds generic LIMIT for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users', dbType, 500).sql)
|
||||
.toBe('SELECT * FROM users LIMIT 500');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['oracle'],
|
||||
['dameng'],
|
||||
['dm'],
|
||||
['dm8'],
|
||||
])('adds FETCH FIRST limit for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG', dbType, 500).sql)
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['sqlserver'],
|
||||
['mssql'],
|
||||
['sql_server'],
|
||||
['sql-server'],
|
||||
])('adds TOP limit for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users', dbType, 500).sql)
|
||||
.toBe('SELECT TOP 500 * FROM users');
|
||||
});
|
||||
|
||||
it('adds SQL Server TOP after DISTINCT', () => {
|
||||
expect(applyQueryAutoLimit('SELECT DISTINCT name FROM users', 'sqlserver', 500).sql)
|
||||
.toBe('SELECT DISTINCT TOP 500 name FROM users');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['oracle', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['dm8', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['mssql', 'SELECT TOP 500 * FROM users'],
|
||||
['postgresql', 'SELECT * FROM users LIMIT 500'],
|
||||
['doris', 'SELECT * FROM users LIMIT 500'],
|
||||
['sqlite3', 'SELECT * FROM users LIMIT 500'],
|
||||
])('uses custom driver dialect %s', (driver, expected) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users', 'custom', 500, driver).sql)
|
||||
.toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps trailing semicolon and comments after injected Oracle limit', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG; -- preview', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY; -- preview');
|
||||
});
|
||||
|
||||
it('does not add another generic limit when SQL already limits rows', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users LIMIT 10', 'mysql', 500).applied)
|
||||
.toBe(false);
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users OFFSET 10 LIMIT 10', 'postgres', 500).applied)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat nested LIMIT as the outer query limit', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM (SELECT * FROM users LIMIT 10) t', 'postgres', 500).sql)
|
||||
.toBe('SELECT * FROM (SELECT * FROM users LIMIT 10) t LIMIT 500');
|
||||
});
|
||||
|
||||
it('does not add another Oracle limit when Oracle SQL already limits rows', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users WHERE ROWNUM <= 10', 'oracle', 500).applied)
|
||||
.toBe(false);
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users FETCH FIRST 10 ROWS ONLY', 'oracle', 500).applied)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not add another SQL Server limit when SQL already uses TOP', () => {
|
||||
expect(applyQueryAutoLimit('SELECT TOP 10 * FROM users', 'sqlserver', 500).applied)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('adds generic LIMIT before locking clauses', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users FOR UPDATE', 'mysql', 500).sql)
|
||||
.toBe('SELECT * FROM users LIMIT 500 FOR UPDATE');
|
||||
});
|
||||
|
||||
it('adds generic LIMIT before OFFSET clauses', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users OFFSET 10', 'postgres', 500).sql)
|
||||
.toBe('SELECT * FROM users LIMIT 500 OFFSET 10');
|
||||
});
|
||||
|
||||
it('does not limit non-select statements', () => {
|
||||
expect(applyQueryAutoLimit('UPDATE users SET name = \'a\'', 'mysql', 500).applied)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
336
frontend/src/utils/queryAutoLimit.ts
Normal file
336
frontend/src/utils/queryAutoLimit.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { resolveSqlDialect } from './sqlDialect';
|
||||
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
|
||||
|
||||
const getLeadingKeyword = (sql: string): string => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let dollarTag: string | null = null;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = i + 1 < text.length ? text[i + 1] : '';
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
const next2 = i + 2 < text.length ? text[i + 2] : '';
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick) {
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
i++;
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
i++;
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#') {
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
|
||||
i++;
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (dollarTag) {
|
||||
if (text.startsWith(dollarTag, i)) {
|
||||
i += dollarTag.length - 1;
|
||||
dollarTag = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '$') {
|
||||
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
|
||||
if (m && m[0]) {
|
||||
dollarTag = m[0];
|
||||
i += dollarTag.length - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ((inSingle || inDouble) && ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (!inDouble && !inBacktick && ch === "'") {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inBacktick && ch === '"') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && ch === '`') {
|
||||
inBacktick = !inBacktick;
|
||||
continue;
|
||||
}
|
||||
if (inSingle || inDouble || inBacktick || dollarTag) continue;
|
||||
if (isWS(ch)) continue;
|
||||
if (isWord(ch)) {
|
||||
let j = i;
|
||||
while (j < text.length && isWord(text[j])) j++;
|
||||
return text.slice(i, j).toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const splitSqlTail = (sql: string): { main: string; tail: string } => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let dollarTag: string | null = null;
|
||||
let lastMeaningful = -1;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = i + 1 < text.length ? text[i + 1] : '';
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
const next2 = i + 2 < text.length ? text[i + 2] : '';
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick) {
|
||||
if (dollarTag) {
|
||||
if (text.startsWith(dollarTag, i)) {
|
||||
lastMeaningful = i + dollarTag.length - 1;
|
||||
i += dollarTag.length - 1;
|
||||
dollarTag = null;
|
||||
} else if (!isWS(ch)) {
|
||||
lastMeaningful = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
i++;
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
i++;
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#') {
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
|
||||
i++;
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '$') {
|
||||
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
|
||||
if (m && m[0]) {
|
||||
dollarTag = m[0];
|
||||
lastMeaningful = i + dollarTag.length - 1;
|
||||
i += dollarTag.length - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if ((inSingle || inDouble) && ch === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
if (!inDouble && !inBacktick && ch === "'") inSingle = !inSingle;
|
||||
else if (!inSingle && !inBacktick && ch === '"') inDouble = !inDouble;
|
||||
else if (!inSingle && !inDouble && ch === '`') inBacktick = !inBacktick;
|
||||
}
|
||||
|
||||
if (!inLineComment && !inBlockComment && !isWS(ch)) {
|
||||
lastMeaningful = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMeaningful < 0) return { main: '', tail: text };
|
||||
let mainEnd = lastMeaningful + 1;
|
||||
while (mainEnd > 0 && (isWS(text[mainEnd - 1]) || text[mainEnd - 1] === ';' || text[mainEnd - 1] === ';')) {
|
||||
mainEnd--;
|
||||
}
|
||||
return { main: text.slice(0, mainEnd), tail: text.slice(mainEnd) };
|
||||
};
|
||||
|
||||
const findTopLevelKeyword = (sql: string, keyword: string): number => {
|
||||
const text = sql;
|
||||
const kw = keyword.toLowerCase();
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBacktick = false;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let dollarTag: string | null = null;
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = i + 1 < text.length ? text[i + 1] : '';
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
const next2 = i + 2 < text.length ? text[i + 2] : '';
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick) {
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
i++;
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
i++;
|
||||
inBlockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#') {
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
|
||||
i++;
|
||||
inLineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (dollarTag) {
|
||||
if (text.startsWith(dollarTag, i)) {
|
||||
i += dollarTag.length - 1;
|
||||
dollarTag = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '$') {
|
||||
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
|
||||
if (m && m[0]) {
|
||||
dollarTag = m[0];
|
||||
i += dollarTag.length - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ((inSingle || inDouble) && ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (!inDouble && !inBacktick && ch === "'") {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inBacktick && ch === '"') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && ch === '`') {
|
||||
inBacktick = !inBacktick;
|
||||
continue;
|
||||
}
|
||||
if (inSingle || inDouble || inBacktick || dollarTag) continue;
|
||||
if (ch === '(') {
|
||||
parenDepth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
if (parenDepth > 0) parenDepth--;
|
||||
continue;
|
||||
}
|
||||
if (parenDepth !== 0) continue;
|
||||
if (!isWord(ch)) continue;
|
||||
if (text.slice(i, i + kw.length).toLowerCase() !== kw) continue;
|
||||
const before = i - 1 >= 0 ? text[i - 1] : '';
|
||||
const after = i + kw.length < text.length ? text[i + kw.length] : '';
|
||||
if ((before && isWord(before)) || (after && isWord(after))) continue;
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const applyQueryAutoLimit = (
|
||||
sql: string,
|
||||
dbType: string,
|
||||
maxRows: number,
|
||||
driver = '',
|
||||
): { sql: string; applied: boolean; maxRows: number } => {
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||
const normalizedType = String(resolveSqlDialect(dbType || 'mysql', driver)).toLowerCase();
|
||||
const keyword = getLeadingKeyword(sql);
|
||||
if (keyword !== 'select') return { sql, applied: false, maxRows };
|
||||
|
||||
const { main, tail } = splitSqlTail(sql);
|
||||
if (!main.trim()) return { sql, applied: false, maxRows };
|
||||
|
||||
const fromPos = findTopLevelKeyword(main, 'from');
|
||||
const limitPos = findTopLevelKeyword(main, 'limit');
|
||||
if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const fetchPos = findTopLevelKeyword(main, 'fetch');
|
||||
if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
|
||||
if (normalizedType === 'sqlserver' || normalizedType === 'mssql') {
|
||||
const topPos = findTopLevelKeyword(main, 'top');
|
||||
if (topPos >= 0) return { sql, applied: false, maxRows };
|
||||
const selectPos = findTopLevelKeyword(main, 'select');
|
||||
if (selectPos < 0) return { sql, applied: false, maxRows };
|
||||
const afterSelect = selectPos + 'SELECT'.length;
|
||||
const restAfterSelect = main.slice(afterSelect);
|
||||
const distinctMatch = restAfterSelect.match(/^(\s+DISTINCT\b)/i);
|
||||
const insertOffset = distinctMatch ? afterSelect + distinctMatch[1].length : afterSelect;
|
||||
const nextMain = main.slice(0, insertOffset) + ` TOP ${maxRows}` + main.slice(insertOffset);
|
||||
return { sql: nextMain + tail, applied: true, maxRows };
|
||||
}
|
||||
|
||||
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
|
||||
const rownumPos = findTopLevelKeyword(main, 'rownum');
|
||||
if (rownumPos >= 0) return { sql, applied: false, maxRows };
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
return { sql: `${main.trimEnd()} FETCH FIRST ${maxRows} ROWS ONLY${tail}`, applied: true, maxRows };
|
||||
}
|
||||
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
const forPos = findTopLevelKeyword(main, 'for');
|
||||
const lockPos = findTopLevelKeyword(main, 'lock');
|
||||
const candidates = [offsetPos, forPos, lockPos]
|
||||
.filter(pos => pos >= 0 && (fromPos < 0 || pos > fromPos));
|
||||
const insertAt = candidates.length > 0 ? Math.min(...candidates) : main.length;
|
||||
const before = main.slice(0, insertAt).trimEnd();
|
||||
const after = main.slice(insertAt).trimStart();
|
||||
const nextMain = [before, `LIMIT ${maxRows}`, after].filter(Boolean).join(' ').trim();
|
||||
return { sql: nextMain + tail, applied: true, maxRows };
|
||||
};
|
||||
Reference in New Issue
Block a user