fix(editor): validate MyBatis statement placeholders

This commit is contained in:
zipg 2026-07-29 20:29:13 +08:00 committed by GitHub
parent 9e5dfa3396
commit 5309170bf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 141 additions and 74 deletions

View File

@ -18,6 +18,7 @@ import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { formatSqlText, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
@ -167,6 +168,11 @@ let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined =
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
function sqlStatementParameterOptions() {
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, props.databaseType);
return { databaseType: props.databaseType, enabledSyntaxes: enabledSqlParameterSyntaxes(toggles) };
}
const { isDark, themePalette } = useTheme();
const { t } = useI18n();
const { toast } = useToast();
@ -620,11 +626,12 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
}
// No selection resolve the execution target, optionally via the picker.
const doc = currentView.state.doc.toString();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType);
const parameterOptions = sqlStatementParameterOptions();
const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType, parameterOptions);
if (candidates.length === 0) return false;
// The execution shortcut keeps executing the configured target (cursor/all) directly:
// it stays keyboard-driven and never pops the picker, which is reserved for click entry points.
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType)) {
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all";
const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0];
emitExecutionRequest(candidate.sql, options.openInNewResultTab);
@ -1231,13 +1238,13 @@ function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuIte
}
function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType);
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom);
}
function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null {
if (!supportsExecutionTargetPicker(props.databaseType)) return null;
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType);
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head);
}

View File

@ -27,6 +27,25 @@ describe("executableStatementRangeCacheForDoc", () => {
expect(executableStatementRangeStartingAt(cache, secondStatementLine.from)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100");
});
it("keeps MyBatis parameters in a Kingbase gutter execution range", () => {
const sql = ["SELECT sum(nvl(a.medfee_sumamt, 0)) AS medfee_sumamt, a.insutype", "FROM yd_org_decla_detail a", "WHERE a.busin_type = '1' AND a.clr_ym = #{ym}", "GROUP BY a.clr_ym, a.insutype;"].join("\n");
const doc = Text.of(sql.split("\n"));
const cache = executableStatementRangeCacheForDoc(null, doc, "kingbase");
expect(executableStatementRangeStartingAt(cache, doc.line(1).from)?.sql).toBe(sql.slice(0, -1));
});
it("keeps a valid placeholder-only line executable and respects disabled MyBatis syntax", () => {
const sql = ["SELECT *", "FROM t", "#{where_clause};"].join("\n");
const doc = Text.of(sql.split("\n"));
const enabled = executableStatementRangeCacheForDoc(null, doc, "kingbase", { enabledSyntaxes: ["mybatis"] });
const disabled = executableStatementRangeCacheForDoc(enabled, doc, "kingbase", { enabledSyntaxes: ["shell"] });
expect(executableStatementRangeAtCursor(enabled, doc.line(3).from + 2)?.sql).toBe(sql.slice(0, -1));
expect(executableStatementRangeAtCursor(disabled, doc.line(3).from + 2)).toBeNull();
expect(disabled).not.toBe(enabled);
});
it("resolves statements with leading whitespace for gutter run buttons", () => {
const doc = Text.of([" SELECT 1;", " SELECT 2;", "\t SELECT 3;", "", " "]);
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");

View File

@ -1,7 +1,14 @@
import { describe, expect, it } from "vitest";
import { extractSqlParameterDescriptors, extractSqlParameters, sqlParameterLiteral, substituteSqlParameters } from "@/lib/sql/sqlParameters";
import { extractSqlParameterDescriptors, extractSqlParameters, readSqlBracedParameterAt, sqlParameterLiteral, substituteSqlParameters } from "@/lib/sql/sqlParameters";
describe("extractSqlParameters", () => {
it("shares strict braced-placeholder validation", () => {
expect(readSqlBracedParameterAt("#{month}", 0)?.name).toBe("month");
expect(readSqlBracedParameterAt("#{1month}", 0)).toBeNull();
expect(readSqlBracedParameterAt("#{month", 0)).toBeNull();
expect(readSqlBracedParameterAt("#{month}", 0, { enabledSyntaxes: ["shell"] })).toBeNull();
});
it("extracts unique template parameters in order", () => {
const sql = "select * from t where pt_dt between ${start_date} and ${end_date} or pt_dt = ${start_date}";
expect(extractSqlParameters(sql)).toEqual(["start_date", "end_date"]);

View File

@ -281,6 +281,16 @@ describe("splitSqlStatementRanges", () => {
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("keeps MyBatis placeholders instead of treating them as hash comments", () => {
const sql = "SELECT * FROM yd_org_decla_detail WHERE clr_ym = #{ym};\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql, "kingbase"))).toEqual(["SELECT * FROM yd_org_decla_detail WHERE clr_ym = #{ym}", "SELECT 2"]);
});
it("treats malformed or disabled MyBatis prefixes as hash comments", () => {
expect(rangeSqlTexts(splitSqlStatementRanges("SELECT 1; #{1ym};\nSELECT 2", "kingbase"))).toEqual(["SELECT 1", "SELECT 2"]);
expect(rangeSqlTexts(splitSqlStatementRanges("SELECT 1; #{ym};\nSELECT 2", "kingbase", { enabledSyntaxes: ["shell"] }))).toEqual(["SELECT 1", "SELECT 2"]);
});
it("ignores semicolons in block comments", () => {
const sql = "SELECT /* a; b */ 1;\nSELECT 2";
expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT /* a; b */ 1", "SELECT 2"]);

View File

@ -1,23 +1,35 @@
import type { Text } from "@codemirror/state";
import type { DatabaseType } from "@/types/database";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
export interface ExecutableStatementRangeCache {
doc: Text;
databaseType?: DatabaseType;
parameterOptions?: SqlParameterOptions;
parameterSyntaxKey: string;
byStart: Map<number, SqlTextRange>;
byExecutableLineStart: Map<number, SqlTextRange>;
ranges: SqlTextRange[];
}
export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType) => SqlTextRange[];
export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions) => SqlTextRange[];
export function executableStatementRangeCacheForDoc(cache: ExecutableStatementRangeCache | null, doc: Text, databaseType?: DatabaseType, parse: ExecutableStatementRangeParser = executableStatementRanges): ExecutableStatementRangeCache {
if (cache?.doc === doc && cache.databaseType === databaseType) return cache;
export function executableStatementRangeCacheForDoc(
cache: ExecutableStatementRangeCache | null,
doc: Text,
databaseType?: DatabaseType,
parameterOptionsOrParse?: SqlParameterOptions | ExecutableStatementRangeParser,
customParse: ExecutableStatementRangeParser = executableStatementRanges,
): ExecutableStatementRangeCache {
const parameterOptions = typeof parameterOptionsOrParse === "function" ? undefined : parameterOptionsOrParse;
const parse = typeof parameterOptionsOrParse === "function" ? parameterOptionsOrParse : customParse;
const parameterSyntaxKey = parameterOptions?.enabledSyntaxes ? parameterOptions.enabledSyntaxes.join(",") : "*";
if (cache?.doc === doc && cache.databaseType === databaseType && cache.parameterSyntaxKey === parameterSyntaxKey) return cache;
const byStart = new Map<number, SqlTextRange>();
const byExecutableLineStart = new Map<number, SqlTextRange>();
const ranges = parse(doc.toString(), databaseType);
const ranges = parse(doc.toString(), databaseType, parameterOptions);
for (const range of ranges) {
byStart.set(range.from, range);
const line = doc.lineAt(range.from);
@ -25,7 +37,7 @@ export function executableStatementRangeCacheForDoc(cache: ExecutableStatementRa
byExecutableLineStart.set(line.from, range);
}
}
return { doc, databaseType, byStart, byExecutableLineStart, ranges };
return { doc, databaseType, parameterOptions, parameterSyntaxKey, byStart, byExecutableLineStart, ranges };
}
export function executableStatementRangeStartingAt(cache: ExecutableStatementRangeCache, lineFrom: number): SqlTextRange | null {
@ -36,7 +48,9 @@ export function executableStatementRangeAtCursor(cache: ExecutableStatementRange
const pos = Math.max(0, Math.min(cursorPos, cache.doc.length));
const line = cache.doc.lineAt(pos);
const lineText = line.text.trim();
if (!lineText || lineText.startsWith("--") || lineText.startsWith("#") || isCursorOnLeadingBlockComment(line.text, pos - line.from)) return null;
const lineContentStart = line.from + line.text.search(/\S|$/);
const startsHashComment = lineText.startsWith("#") && readSqlBracedParameterAt(cache.doc.toString(), lineContentStart, cache.parameterOptions)?.syntax !== "mybatis";
if (!lineText || lineText.startsWith("--") || startsHashComment || isCursorOnLeadingBlockComment(line.text, pos - line.from)) return null;
for (let index = 0; index < cache.ranges.length; index += 1) {
const range = cache.ranges[index];

View File

@ -16,6 +16,11 @@ export interface SqlParameterDescriptor {
token: string;
}
export interface SqlBracedParameter extends SqlParameterDescriptor {
start: number;
end: number;
}
interface ParameterOccurrence extends SqlParameterDescriptor {
start: number;
end: number;
@ -36,6 +41,19 @@ const PARAMETER_NAME_START_RE = /[\p{L}_]/u;
const PARAMETER_NAME_CHAR_RE = /[\p{L}\p{N}_]/u;
const SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS = new Set(["table", "from", "join", "into", "update", "truncate"]);
export function readSqlBracedParameterAt(sql: string, start: number, options?: SqlParameterOptions): SqlBracedParameter | null {
const open = sql.slice(start, start + 2);
const syntax: SqlParameterSyntax | null = open === "${" ? "shell" : open === "#{" ? "mybatis" : null;
if (!syntax || (options?.enabledSyntaxes && !options.enabledSyntaxes.includes(syntax))) return null;
const closeBrace = sql.indexOf("}", start + 2);
if (closeBrace === -1) return null;
const name = sql.slice(start + 2, closeBrace).trim();
if (!PARAMETER_NAME_RE.test(name)) return null;
return { key: name, name, syntax, token: sql.slice(start, closeBrace + 1), start, end: closeBrace + 1 };
}
export function extractSqlParameters(sql: string, options?: SqlParameterOptions): string[] {
return extractSqlParameterDescriptors(sql, options).map((descriptor) => descriptor.key);
}
@ -163,26 +181,12 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions)
continue;
}
}
if (ch === "$" && next === "{" && isSyntaxEnabled("shell")) {
const end = sql.indexOf("}", i + 2);
if (end !== -1) {
const name = sql.slice(i + 2, end).trim();
if (PARAMETER_NAME_RE.test(name)) {
occurrences.push({ key: name, name, syntax: "shell", token: sql.slice(i, end + 1), start: i, end: end + 1 });
i = end + 1;
continue;
}
}
}
if (ch === "#" && next === "{" && isSyntaxEnabled("mybatis")) {
const end = sql.indexOf("}", i + 2);
if (end !== -1) {
const name = sql.slice(i + 2, end).trim();
if (PARAMETER_NAME_RE.test(name)) {
occurrences.push({ key: name, name, syntax: "mybatis", token: sql.slice(i, end + 1), start: i, end: end + 1 });
i = end + 1;
continue;
}
if ((ch === "$" || ch === "#") && next === "{") {
const parameter = readSqlBracedParameterAt(sql, i, options);
if (parameter) {
occurrences.push(parameter);
i = parameter.end;
continue;
}
}
if (isHashLineComment(sql, i)) {

View File

@ -1,5 +1,6 @@
import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { DatabaseType } from "@/types/database";
/**
@ -18,11 +19,11 @@ export function supportsExecutionTargetPicker(databaseType?: DatabaseType): bool
return !!databaseType && (databaseType === "redis" || databaseType === "elasticsearch" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType): boolean {
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
if (databaseType === "redis") {
return redisExecutableCommandCount(sql) > 1;
}
return splitSqlStatementRanges(sql, databaseType).length > 1;
return splitSqlStatementRanges(sql, databaseType, parameterOptions).length > 1;
}
interface RawStatement {
@ -272,7 +273,7 @@ const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
* only the statement text (the trailing semicolon and inter-statement
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType): RawStatement[] {
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
if (databaseType === "elasticsearch") {
const requests = splitElasticsearchRestRequestRanges(sql);
if (requests) return requests;
@ -423,7 +424,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
i = newline === -1 ? len : newline + 1;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
@ -491,9 +492,9 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
continue;
}
} else if (ch === ";") {
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i));
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i), parameterOptions);
if (isMysqlRoutineBlock) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1))) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1), parameterOptions)) {
markContent(i);
i += 1;
continue;
@ -544,14 +545,14 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
* The returned range covers only the statement's own text (no trailing `;`),
* which lets the editor highlight a tight preview range.
*/
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const statements = splitSqlStatementRanges(sql, databaseType);
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
for (let index = 0; index < statements.length; index += 1) {
const statement = statements[index];
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType);
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions);
// Cursor inside the statement body, including the exact start/end.
if (pos >= statement.from && pos <= statement.to) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
@ -569,7 +570,7 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (databaseType === "elasticsearch" && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType);
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previous, sql);
}
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
@ -631,13 +632,13 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu
return null;
}
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
if (isOraclePlSqlStatement(statement.sql, databaseType)) return [statement];
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
// Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements.
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql)) return [statement];
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql, parameterOptions)) return [statement];
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType, parameterOptions);
if (lineStarts.length <= 1) return [statement];
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
@ -661,7 +662,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
continue;
}
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword)) {
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword, parameterOptions)) {
continue;
}
@ -719,7 +720,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
for (let index = 0; index < boundaries.length; index += 1) {
const boundary = boundaries[index];
const next = boundaries[index + 1];
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from) : trimRangeEnd(sql, boundary.from, statement.to);
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from, parameterOptions) : trimRangeEnd(sql, boundary.from, statement.to);
if (to > boundary.from) {
ranges.push({
hitFrom: boundary.hitFrom,
@ -733,7 +734,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
return ranges.length > 0 ? ranges : [statement];
}
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): Array<{ hitFrom: number; from: number; keyword: string }> {
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): Array<{ hitFrom: number; from: number; keyword: string }> {
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
const len = statement.to;
const explainOptionsStart = explainOptionsParenAt(sql, statement.from);
@ -751,7 +752,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i) && !startsBlockComment(sql, i)) {
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i, parameterOptions) && !startsBlockComment(sql, i)) {
firstNonWhitespaceOnLine = i;
if (parenDepth === 0) {
const keyword = softStatementKeywordAt(sql, i, databaseType);
@ -850,7 +851,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@ -915,9 +916,9 @@ function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
}
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string): boolean {
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string, parameterOptions?: SqlParameterOptions): boolean {
if (keyword !== "SELECT" && keyword !== "WITH") return false;
const words = topLevelWordsBefore(sql, from, to, 3);
const words = topLevelWordsBefore(sql, from, to, 3, parameterOptions);
const last = words[words.length - 1];
if (last && SET_OPERATION_KEYWORDS.has(last)) return true;
if (last && SET_OPERATION_MODIFIER_KEYWORDS.has(last)) {
@ -952,7 +953,7 @@ function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean
return /^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(text);
}
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number): string[] {
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number, parameterOptions?: SqlParameterOptions): string[] {
const words: string[] = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
@ -1044,7 +1045,7 @@ function topLevelWordsBefore(sql: string, from: number, to: number, limit: numbe
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@ -1267,8 +1268,13 @@ function skipBalancedParens(sql: string, pos: number): number | null {
return null;
}
function startsLineComment(sql: string, pos: number): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || sql[pos] === "#";
function startsLineComment(sql: string, pos: number, parameterOptions?: SqlParameterOptions): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || startsHashLineComment(sql, pos, parameterOptions);
}
function startsHashLineComment(sql: string, pos: number, parameterOptions?: SqlParameterOptions): boolean {
if (sql[pos] !== "#") return false;
return readSqlBracedParameterAt(sql, pos, parameterOptions)?.syntax !== "mybatis";
}
function startsBlockComment(sql: string, pos: number): boolean {
@ -1283,7 +1289,7 @@ function trimRangeEnd(sql: string, from: number, to: number): number {
return end;
}
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number): number {
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number, parameterOptions?: SqlParameterOptions): number {
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let lastContentEnd = from;
@ -1384,7 +1390,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@ -1462,12 +1468,12 @@ function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType);
}
function startsWithMysqlRoutineBlock(sql: string): boolean {
return isMysqlRoutineDdlStart(sql) && mysqlRoutineTokens(sql).some((token) => token.kind === "word" && token.value === "BEGIN");
function startsWithMysqlRoutineBlock(sql: string, parameterOptions?: SqlParameterOptions): boolean {
return isMysqlRoutineDdlStart(sql, parameterOptions) && mysqlRoutineTokens(sql, parameterOptions).some((token) => token.kind === "word" && token.value === "BEGIN");
}
function isMysqlRoutineDdlStart(sql: string): boolean {
const words = mysqlRoutineWords(sql).slice(0, 16);
function isMysqlRoutineDdlStart(sql: string, parameterOptions?: SqlParameterOptions): boolean {
const words = mysqlRoutineWords(sql, parameterOptions).slice(0, 16);
if (words[0] !== "CREATE") return false;
for (const word of words.slice(1)) {
@ -1477,10 +1483,10 @@ function isMysqlRoutineDdlStart(sql: string): boolean {
return false;
}
function mysqlRoutineBlockIsComplete(sql: string): boolean {
if (!startsWithMysqlRoutineBlock(sql)) return false;
function mysqlRoutineBlockIsComplete(sql: string, parameterOptions?: SqlParameterOptions): boolean {
if (!startsWithMysqlRoutineBlock(sql, parameterOptions)) return false;
const tokens = mysqlRoutineTokens(sql);
const tokens = mysqlRoutineTokens(sql, parameterOptions);
let beginDepth = 0;
let sawBegin = false;
@ -1502,13 +1508,13 @@ function mysqlRoutineBlockIsComplete(sql: string): boolean {
return sawBegin && beginDepth === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
}
function mysqlRoutineWords(sql: string): string[] {
return mysqlRoutineTokens(sql)
function mysqlRoutineWords(sql: string, parameterOptions?: SqlParameterOptions): string[] {
return mysqlRoutineTokens(sql, parameterOptions)
.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.map((token) => token.value);
}
function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> {
function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions): Array<{ kind: "word" | "semicolon"; value: string }> {
const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let i = 0;
@ -1572,7 +1578,7 @@ function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; va
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@ -1975,21 +1981,21 @@ function normalizeSql(sql: string): string {
* cursor statement and the full document are effectively the same SQL in
* that case only a single candidate is returned to avoid duplicates.
*/
export function executableStatementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
export function executableStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange[] {
if (databaseType === "redis") return redisExecutableCommandRanges(sql);
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql)));
return splitSqlStatementRanges(sql, databaseType, parameterOptions).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions).map((range) => rangeFor(range, sql)));
}
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos);
if (databaseType === "mongodb") return null;
return statementRangeAtCursor(sql, cursorPos, databaseType);
return statementRangeAtCursor(sql, cursorPos, databaseType, parameterOptions);
}
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] {
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlExecutionCandidate[] {
const full = fullSqlRange(sql);
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType);
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType, parameterOptions);
if (!full && !cursorStatement) return [];
if (!full) {