mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +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:
@@ -9,11 +9,12 @@ import { useStore } from '../store';
|
||||
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
|
||||
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
|
||||
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
|
||||
@@ -1184,359 +1185,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return statements;
|
||||
};
|
||||
|
||||
const getLeadingKeyword = (sql: string): string => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
|
||||
|
||||
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');
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Start comments
|
||||
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 };
|
||||
return { main: text.slice(0, lastMeaningful + 1), tail: text.slice(lastMeaningful + 1) };
|
||||
};
|
||||
|
||||
const findTopLevelKeyword = (sql: string, keyword: string): number => {
|
||||
const text = sql;
|
||||
const kw = keyword.toLowerCase();
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||
|
||||
// 只对 SELECT 语句自动加限制
|
||||
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');
|
||||
// 已有 LIMIT → 不注入
|
||||
if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const fetchPos = findTopLevelKeyword(main, 'fetch');
|
||||
// 已有 FETCH → 不注入
|
||||
if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
|
||||
// SQL Server / mssql: 检查是否已有 TOP,未有则注入 SELECT TOP N
|
||||
if (normalizedType === 'sqlserver' || normalizedType === 'mssql') {
|
||||
const topPos = findTopLevelKeyword(main, 'top');
|
||||
if (topPos >= 0) return { sql, applied: false, maxRows }; // 已有 TOP
|
||||
// 在 SELECT 关键字之后插入 TOP N
|
||||
const selectPos = findTopLevelKeyword(main, 'select');
|
||||
if (selectPos < 0) return { sql, applied: false, maxRows };
|
||||
const afterSelect = selectPos + 'SELECT'.length;
|
||||
// 处理 SELECT DISTINCT 的情况
|
||||
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 };
|
||||
}
|
||||
|
||||
// Oracle / Dameng: 使用 FETCH FIRST N ROWS ONLY(Oracle 12c+ 标准语法)
|
||||
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
|
||||
// 检查是否已有 ROWNUM 限制
|
||||
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 };
|
||||
const nextMain = main.trimEnd() + ` FETCH FIRST ${maxRows} ROWS ONLY`;
|
||||
return { sql: nextMain + tail, applied: true, maxRows };
|
||||
}
|
||||
|
||||
// 通用 LIMIT 语法(MySQL, PostgreSQL, SQLite, ClickHouse, DuckDB 等)
|
||||
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 };
|
||||
};
|
||||
|
||||
const getSelectedSQL = (): string => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return '';
|
||||
@@ -1662,8 +1310,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
try {
|
||||
const rawSQL = getSelectedSQL() || currentQuery;
|
||||
const dbType = String((buildRpcConnectionConfig(config) as any).type || 'mysql');
|
||||
const normalizedDbType = dbType.trim().toLowerCase();
|
||||
const rpcConfig = buildRpcConnectionConfig(config) as any;
|
||||
const dbType = String(rpcConfig.type || 'mysql');
|
||||
const driver = String((config as any).driver || '');
|
||||
const normalizedDbType = String(resolveSqlDialect(dbType, driver)).trim().toLowerCase();
|
||||
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
||||
|
||||
// MongoDB 仍走逐条执行的旧路径
|
||||
@@ -1703,6 +1353,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
executedSql = shellConvert.command;
|
||||
}
|
||||
}
|
||||
if (wantsLimitProbe) {
|
||||
const limitResult = applyMongoQueryAutoLimit(executedSql, maxRows);
|
||||
if (limitResult.applied) {
|
||||
executedSql = limitResult.command;
|
||||
}
|
||||
}
|
||||
const startTime = Date.now();
|
||||
let queryId: string;
|
||||
try {
|
||||
@@ -1797,7 +1453,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (Number.isFinite(maxRowsForLimit) && maxRowsForLimit > 0) {
|
||||
const stmts = splitSQLStatements(fullSQL);
|
||||
const limitedStmts = stmts.map(s => {
|
||||
const result = applyAutoLimit(s, normalizedDbType, maxRowsForLimit);
|
||||
const result = applyQueryAutoLimit(s, normalizedDbType, maxRowsForLimit, driver);
|
||||
if (result.applied) anyLimitApplied = true;
|
||||
return result.sql;
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
32
internal/db/oracle_dsn_test.go
Normal file
32
internal/db/oracle_dsn_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestOracleGetDSNIncludesQueryPerformanceOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dsn := (&OracleDB{}).getDSN(connection.ConnectionConfig{
|
||||
Host: "db.example.com",
|
||||
Port: 1521,
|
||||
User: "scott",
|
||||
Password: "tiger",
|
||||
Database: "ORCLPDB1",
|
||||
})
|
||||
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Oracle DSN 失败: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if got := query.Get("PREFETCH_ROWS"); got != "10000" {
|
||||
t.Fatalf("PREFETCH_ROWS = %q, want 10000", got)
|
||||
}
|
||||
if got := query.Get("LOB FETCH"); got != "POST" {
|
||||
t.Fatalf("LOB FETCH = %q, want POST", got)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
|
||||
q.Set("SSL", "TRUE")
|
||||
q.Set("SSL VERIFY", "FALSE")
|
||||
}
|
||||
// 提高 prefetch 行数,减少大结果集的网络往返次数(默认仅 25 行/次)
|
||||
q.Set("PREFETCH_ROWS", "10000")
|
||||
// LOB 数据延迟加载,避免大 LOB 列影响普通查询性能
|
||||
q.Set("LOB FETCH", "POST")
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
u.RawQuery = encoded
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user