mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-22 22:43:46 +08:00
- Oracle 匿名块:识别 BEGIN/DECLARE...END 块,避免按内部分号错误拆分 - 执行路径:PL/SQL 块跳过批量写入路径,保持单条语句语义 - SQL 文件:同步修复流式 SQL 文件拆分逻辑 - 查询结果:系统元数据表保持只读但不再弹业务表主键提示 - 测试覆盖:补充前后端拆分、执行和 information_schema 回归用例
293 lines
8.5 KiB
TypeScript
293 lines
8.5 KiB
TypeScript
export interface SqlStatementRange {
|
||
start: number;
|
||
end: number;
|
||
text: string;
|
||
}
|
||
|
||
export type SqlExecutionSelectionSource = 'selection' | 'statement' | 'line';
|
||
|
||
export interface SqlExecutionSelection {
|
||
sql: string;
|
||
source: SqlExecutionSelectionSource;
|
||
}
|
||
|
||
const isWhitespace = (ch: string): boolean => (
|
||
ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'
|
||
);
|
||
|
||
const isSqlIdentifierStart = (ch: string): boolean => /^[A-Za-z_]$/.test(ch);
|
||
|
||
const isSqlIdentifierPart = (ch: string): boolean => /^[A-Za-z0-9_$#]$/.test(ch);
|
||
|
||
const skipSqlWhitespaceAndComments = (text: string, position: number): number => {
|
||
let index = position;
|
||
while (index < text.length) {
|
||
const ch = text[index];
|
||
const next = index + 1 < text.length ? text[index + 1] : '';
|
||
if (isWhitespace(ch)) {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if (ch === '-' && next === '-') {
|
||
index += 2;
|
||
while (index < text.length && text[index] !== '\n') index += 1;
|
||
continue;
|
||
}
|
||
if (ch === '/' && next === '*') {
|
||
index += 2;
|
||
while (index + 1 < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
||
index += 1;
|
||
}
|
||
if (index + 1 < text.length) index += 2;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
return index;
|
||
};
|
||
|
||
const nextSqlSignificantToken = (text: string, position: number): string => {
|
||
const index = skipSqlWhitespaceAndComments(text, position);
|
||
if (index >= text.length || !isSqlIdentifierStart(text[index])) return '';
|
||
let end = index + 1;
|
||
while (end < text.length && isSqlIdentifierPart(text[end])) end += 1;
|
||
return text.slice(index, end).toLowerCase();
|
||
};
|
||
|
||
const nextSqlSignificantChar = (text: string, position: number): string => {
|
||
const index = skipSqlWhitespaceAndComments(text, position);
|
||
return index >= text.length ? '' : text[index];
|
||
};
|
||
|
||
const shouldEnterPlsqlBeginBlock = (text: string, tokenEnd: number): boolean => {
|
||
const nextChar = nextSqlSignificantChar(text, tokenEnd);
|
||
if (!nextChar || nextChar === ';') return false;
|
||
return !['transaction', 'work', 'isolation', 'read', 'write'].includes(nextSqlSignificantToken(text, tokenEnd));
|
||
};
|
||
|
||
const shouldEnterPlsqlDeclareBlock = (text: string, tokenEnd: number): boolean => Boolean(nextSqlSignificantToken(text, tokenEnd));
|
||
|
||
const isPlsqlControlEnd = (text: string, tokenEnd: number): boolean => (
|
||
['if', 'loop', 'case'].includes(nextSqlSignificantToken(text, tokenEnd))
|
||
);
|
||
|
||
const trimStatementRange = (sql: string, start: number, end: number): SqlStatementRange | null => {
|
||
let nextStart = Math.max(0, start);
|
||
let nextEnd = Math.min(sql.length, Math.max(start, end));
|
||
|
||
while (nextStart < nextEnd && isWhitespace(sql[nextStart])) {
|
||
nextStart++;
|
||
}
|
||
while (nextEnd > nextStart && isWhitespace(sql[nextEnd - 1])) {
|
||
nextEnd--;
|
||
}
|
||
|
||
if (nextStart >= nextEnd) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
start: nextStart,
|
||
end: nextEnd,
|
||
text: sql.slice(nextStart, nextEnd),
|
||
};
|
||
};
|
||
|
||
export const findSqlStatementRanges = (sql: string): SqlStatementRange[] => {
|
||
const text = String(sql || '').replace(/\r\n/g, '\n');
|
||
const ranges: SqlStatementRange[] = [];
|
||
|
||
let statementStart = 0;
|
||
let inSingle = false;
|
||
let inDouble = false;
|
||
let inBacktick = false;
|
||
let escaped = false;
|
||
let inLineComment = false;
|
||
let inBlockComment = false;
|
||
let dollarTag: string | null = null;
|
||
let plsqlDepth = 0;
|
||
let plsqlDeclareBeginSkips = 0;
|
||
let justClosedPLSQLBlock = false;
|
||
|
||
const push = (end: number) => {
|
||
const range = trimStatementRange(text, statementStart, end);
|
||
if (range) {
|
||
ranges.push(range);
|
||
}
|
||
};
|
||
|
||
for (let index = 0; index < text.length; index++) {
|
||
const ch = text[index];
|
||
const next = index + 1 < text.length ? text[index + 1] : '';
|
||
const prev = index > 0 ? text[index - 1] : '';
|
||
const next2 = index + 2 < text.length ? text[index + 2] : '';
|
||
|
||
if (dollarTag) {
|
||
if (text.startsWith(dollarTag, index)) {
|
||
index += dollarTag.length - 1;
|
||
dollarTag = null;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (inLineComment) {
|
||
if (ch === '\n') {
|
||
inLineComment = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (inBlockComment) {
|
||
if (ch === '*' && next === '/') {
|
||
index++;
|
||
inBlockComment = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!inSingle && !inDouble && !inBacktick) {
|
||
if (ch === '/' && next === '*') {
|
||
index++;
|
||
inBlockComment = true;
|
||
continue;
|
||
}
|
||
if (ch === '#') {
|
||
inLineComment = true;
|
||
continue;
|
||
}
|
||
if (ch === '-' && next === '-' && (index === 0 || isWhitespace(prev)) && (next2 === '' || isWhitespace(next2))) {
|
||
index++;
|
||
inLineComment = true;
|
||
continue;
|
||
}
|
||
if (ch === '$') {
|
||
const match = text.slice(index).match(/^\$[A-Za-z0-9_]*\$/);
|
||
if (match?.[0]) {
|
||
dollarTag = match[0];
|
||
index += 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 && isSqlIdentifierStart(ch)) {
|
||
let tokenEnd = index + 1;
|
||
while (tokenEnd < text.length && isSqlIdentifierPart(text[tokenEnd])) {
|
||
tokenEnd++;
|
||
}
|
||
const token = text.slice(index, tokenEnd).toLowerCase();
|
||
if (token === 'begin' && plsqlDeclareBeginSkips > 0) {
|
||
plsqlDeclareBeginSkips--;
|
||
justClosedPLSQLBlock = false;
|
||
} else if (token === 'begin' && shouldEnterPlsqlBeginBlock(text, tokenEnd)) {
|
||
plsqlDepth++;
|
||
justClosedPLSQLBlock = false;
|
||
} else if (token === 'declare' && shouldEnterPlsqlDeclareBlock(text, tokenEnd)) {
|
||
plsqlDepth++;
|
||
plsqlDeclareBeginSkips++;
|
||
justClosedPLSQLBlock = false;
|
||
} else if (token === 'end' && plsqlDepth > 0 && !isPlsqlControlEnd(text, tokenEnd)) {
|
||
plsqlDepth--;
|
||
if (plsqlDeclareBeginSkips > plsqlDepth) {
|
||
plsqlDeclareBeginSkips = plsqlDepth;
|
||
}
|
||
justClosedPLSQLBlock = plsqlDepth === 0;
|
||
}
|
||
index = tokenEnd - 1;
|
||
continue;
|
||
}
|
||
|
||
if (!inSingle && !inDouble && !inBacktick && (ch === ';' || ch === ';')) {
|
||
if (plsqlDepth > 0) {
|
||
continue;
|
||
}
|
||
push(justClosedPLSQLBlock ? index + 1 : index);
|
||
statementStart = index + 1;
|
||
justClosedPLSQLBlock = false;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
push(text.length);
|
||
return ranges;
|
||
};
|
||
|
||
export const resolveCurrentSqlStatementRange = (sql: string, cursorOffset: number): SqlStatementRange | null => {
|
||
const text = String(sql || '').replace(/\r\n/g, '\n');
|
||
const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0));
|
||
const ranges = findSqlStatementRanges(text);
|
||
if (ranges.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const containingRange = ranges.find((range) => offset >= range.start && offset <= range.end);
|
||
if (containingRange) {
|
||
return containingRange;
|
||
}
|
||
|
||
const nextRange = ranges.find((range) => offset < range.start);
|
||
if (nextRange) {
|
||
return nextRange;
|
||
}
|
||
|
||
return ranges[ranges.length - 1];
|
||
};
|
||
|
||
export const resolveExecutableSql = (
|
||
sql: string,
|
||
cursorOffset: number,
|
||
selectedSql = '',
|
||
): SqlExecutionSelection | null => {
|
||
const selected = String(selectedSql || '').trim();
|
||
if (selected) {
|
||
return { sql: selectedSql, source: 'selection' };
|
||
}
|
||
|
||
const text = String(sql || '').replace(/\r\n/g, '\n');
|
||
const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0));
|
||
const ranges = findSqlStatementRanges(text);
|
||
const statement = ranges.find((range) => offset >= range.start && offset <= range.end);
|
||
if (statement?.text.trim()) {
|
||
return { sql: statement.text, source: 'statement' };
|
||
}
|
||
|
||
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
|
||
const nextLineBreak = text.indexOf('\n', offset);
|
||
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak;
|
||
const line = text.slice(lineStart, lineEnd).trim();
|
||
if (line) {
|
||
const lineStatement = [...ranges].reverse().find((range) => range.start < lineEnd && range.end >= lineStart);
|
||
if (lineStatement?.text.trim()) {
|
||
return { sql: lineStatement.text, source: 'statement' };
|
||
}
|
||
}
|
||
if (line) {
|
||
return { sql: line, source: 'line' };
|
||
}
|
||
|
||
return null;
|
||
};
|