mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 05:53:46 +08:00
- Oracle/Dameng 自动限行改为 ROWNUM 外层包裹 - 避免旧版本 Oracle 不支持 FETCH FIRST 导致 ORA-00933 - 保留尾部分号与注释,避免执行语句结构丢失 - 跳过 FOR UPDATE 语句自动包裹,避免改变锁语义 - 补充 Oracle/Dameng 自动限行回归测试 Refs #429
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
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 };
|
||
const forPos = findTopLevelKeyword(main, 'for');
|
||
if (forPos >= 0 && (fromPos < 0 || forPos > fromPos)) return { sql, applied: false, maxRows };
|
||
return { sql: `SELECT * FROM (${main.trimEnd()}) WHERE ROWNUM <= ${maxRows}${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 };
|
||
};
|