Files
MyGoNavi/frontend/src/utils/sqlStatementSelection.ts

340 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 nextSqlSignificantTokenSpan = (text: string, position: number): { token: string; end: number } => {
const index = skipSqlWhitespaceAndComments(text, position);
if (index >= text.length || !isSqlIdentifierStart(text[index])) {
return { token: '', end: index };
}
let end = index + 1;
while (end < text.length && isSqlIdentifierPart(text[end])) end += 1;
return { token: text.slice(index, end).toLowerCase(), end };
};
const isCreateRoutineHeaderPrefix = (text: string): boolean => {
let current = nextSqlSignificantTokenSpan(text, 0);
if (current.token !== 'create') return false;
current = nextSqlSignificantTokenSpan(text, current.end);
if (current.token === 'or') {
current = nextSqlSignificantTokenSpan(text, current.end);
if (current.token !== 'replace') return false;
current = nextSqlSignificantTokenSpan(text, current.end);
}
while (['editionable', 'noneditionable'].includes(current.token)) {
current = nextSqlSignificantTokenSpan(text, current.end);
}
return current.token === 'procedure' || current.token === 'function';
};
const shouldEnterPlsqlCreateRoutineBlock = (
text: string,
statementStart: number,
token: string,
tokenEnd: number,
): boolean => {
if (token !== 'is' && token !== 'as') return false;
const nextChar = nextSqlSignificantChar(text, tokenEnd);
if (!nextChar) return false;
if (token === 'as' && (nextChar === '$' || nextChar === "'" || nextChar === '"')) {
return false;
}
return isCreateRoutineHeaderPrefix(text.slice(statementStart, tokenEnd - token.length));
};
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 (plsqlDepth === 0 && shouldEnterPlsqlCreateRoutineBlock(text, statementStart, token, 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;
};