From 5309170bf7e3d110637525de50e220e832f0b4ac Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 29 Jul 2026 20:29:13 +0800 Subject: [PATCH] fix(editor): validate MyBatis statement placeholders --- .../src/components/editor/QueryEditor.vue | 15 ++- .../sql/executableStatementRangeCache.spec.ts | 19 ++++ .../lib/__tests__/sql/sqlParameters.spec.ts | 9 +- .../__tests__/sql/sqlStatementRanges.spec.ts | 10 ++ .../lib/sql/executableStatementRangeCache.ts | 26 ++++-- apps/desktop/src/lib/sql/sqlParameters.ts | 44 +++++---- .../desktop/src/lib/sql/sqlStatementRanges.ts | 92 ++++++++++--------- 7 files changed, 141 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index a921446b3..f45a41a1a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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); } diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index 3a0f9d7da..8e021b1e2 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 50d42cfb1..e65cb55a5 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -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"]); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index ded9db00c..b5d61ac7a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -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"]); diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index 401187943..39fe003e9 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -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; byExecutableLineStart: Map; 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(); const byExecutableLineStart = new Map(); - 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]; diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index c7d433e85..47b0b49b0 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -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)) { diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 184c7746b..af7cc3420 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -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 { 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) {