diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 7200a533c8..e3c04b445a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -369,6 +369,20 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(["SELECT 'it''s; ok'", "SELECT 2"]); }); + it("treats backslash as a literal inside single quotes for standard SQL (issue #8189)", () => { + // ESCAPE '\' is a complete one-char string in standard SQL; the closing quote must not be + // swallowed, so the following statement keeps its own range (and thus its run button). + const sql = "SELECT * FROM MAXRELATIONSHIP m WHERE m.PARENT LIKE 'A\\_%' ESCAPE '\\';\nSELECT * FROM USER;"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "postgres"))).toEqual(["SELECT * FROM MAXRELATIONSHIP m WHERE m.PARENT LIKE 'A\\_%' ESCAPE '\\'", "SELECT * FROM USER"]); + // No databaseType (generic) must behave the same as a standard-SQL dialect. + expect(rangeSqlTexts(splitSqlStatementRanges(sql)).length).toBe(2); + }); + + it("keeps backslash-escaped quotes as one string for MySQL (no regression)", () => { + const sql = "SELECT 'a\\'b; still string';\nSELECT 2"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["SELECT 'a\\'b; still string'", "SELECT 2"]); + }); + it("ignores semicolons inside double-quoted identifiers", () => { const sql = 'SELECT "a;b";\nSELECT 2'; expect(rangeSqlTexts(splitSqlStatementRanges(sql))).toEqual(['SELECT "a;b"', "SELECT 2"]); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 63d497860c..2b6efdfabc 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -12,6 +12,7 @@ import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints"; import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types"; import { isEditorStatePlausibleFor, resolveLexicalLeafFromSyntaxTree, resolveSqlStatementWindow } from "@/lib/sql/sqlSyntaxTreeWindow"; import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; +import { BACKSLASH_ESCAPE_STRING_DIALECTS } from "@/lib/sql/sqlStatementRanges"; import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; @@ -2887,7 +2888,8 @@ const MYSQL_DASH_COMMENT_DIALECTS = new Set(["mysql", "doris", "st // why that's unsafe (a trailing backslash before a closing quote in a dialect that doesn't escape // it, e.g. a Postgres Windows-path string literal, would misread the real closing quote as // escaped and swallow the rest of the query). -const BACKSLASH_ESCAPE_STRING_DIALECTS = new Set(["mysql", "doris", "starrocks", "hive", "argo", "impala", "spark"]); +// The authoritative set lives in sqlStatementRanges.ts (the statement splitter depends on it too); +// import it here to keep a single source of truth. // Table/schema/db unquoted-identifier continue class needs @ and # in addition to what // SQL_IDENTIFIER_CONTINUE_CHAR covers, so splice its inner class body into a locally-built class diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 9d63136fe2..97f511cc36 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -273,6 +273,15 @@ const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]); // ranges must stay whole instead of splitting at every body semicolon. const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle", "xugu", "argo"]); const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]); +// Backslash escaping inside '...'/"..." strings is a MySQL-family extension; in standard SQL '\' +// is a complete one-char string and quotes are escaped by doubling (''). Treating backslash as an +// escape unconditionally makes ESCAPE '\' swallow its closing quote and the following statement +// boundary, so the next statement loses its run button (#8189). Gate it by dialect, matching the +// tokenizer/completion side. +export const BACKSLASH_ESCAPE_STRING_DIALECTS: ReadonlySet = new Set(["mysql", "doris", "starrocks", "hive", "argo", "impala", "spark", "databend"]); +function allowsBackslashStringEscape(databaseType?: DatabaseType): boolean { + return !!databaseType && BACKSLASH_ESCAPE_STRING_DIALECTS.has(databaseType); +} const MYSQL_CREATE_TABLE_OPTION_DATABASES: ReadonlySet = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb", "gbase"]); const MYSQL_ROUTINE_OBJECT_TYPES = new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]); const MYSQL_NON_ROUTINE_CREATE_TYPES = new Set(["DATABASE", "INDEX", "LOGFILE", "ROLE", "SCHEMA", "SERVER", "SPATIAL", "TABLE", "TEMPORARY", "UNIQUE", "USER", "VIEW"]); @@ -303,6 +312,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType const statements: RawStatement[] = []; const len = sql.length; const supportsDelimiterCommands = databaseType === "mysql"; + const backslashEscapes = allowsBackslashStringEscape(databaseType); let statementStart = -1; let statementEnd = -1; @@ -419,8 +429,10 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType if (state === "single") { markContent(i); - // Backslash escapes the next char (e.g. PostgreSQL standard_conforming_strings=off style). - if (ch === "\\" && next) { + // Only MySQL-family dialects treat backslash as an escape inside '...' (see + // BACKSLASH_ESCAPE_STRING_DIALECTS); in standard SQL '\' is a literal char and must not + // consume the next char, otherwise the closing quote is swallowed (#8189). + if (ch === "\\" && next && backslashEscapes) { i += 2; continue; } @@ -836,6 +848,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d // Recover soft statement boundaries while the user is still typing an // EXPLAIN option list; otherwise its unmatched opener hides every later line. const unclosedExplainOptionsStart = explainOptionsStart !== null && skipBalancedParens(sql, explainOptionsStart, databaseType, parameterOptions) === null ? explainOptionsStart : null; + const backslashEscapes = allowsBackslashStringEscape(databaseType); let state: QuoteState | "lineComment" | "blockComment" = "none"; let dollarTag = ""; let parenDepth = 0; @@ -895,7 +908,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d } if (state === "single") { - if (ch === "\\" && next) { + if (ch === "\\" && next && backslashEscapes) { i += 2; continue; } @@ -1058,6 +1071,7 @@ function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean function topLevelWordsBefore(sql: string, from: number, to: number, limit: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): string[] { const words: string[] = []; + const backslashEscapes = allowsBackslashStringEscape(databaseType); let state: QuoteState | "lineComment" | "blockComment" = "none"; let dollarTag = ""; let parenDepth = 0; @@ -1098,7 +1112,7 @@ function topLevelWordsBefore(sql: string, from: number, to: number, limit: numbe } if (state === "single") { - if (ch === "\\" && next) { + if (ch === "\\" && next && backslashEscapes) { i += 2; continue; } @@ -1398,6 +1412,7 @@ function trimRangeEnd(sql: string, from: number, to: number): number { } function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): number { + const backslashEscapes = allowsBackslashStringEscape(databaseType); let state: QuoteState | "lineComment" | "blockComment" = "none"; let dollarTag = ""; let lastContentEnd = from; @@ -1441,7 +1456,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF if (state === "single") { lastContentEnd = i + 1; - if (ch === "\\" && next) { + if (ch === "\\" && next && backslashEscapes) { i += 2; lastContentEnd = i; continue; @@ -1724,6 +1739,8 @@ function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions, continue; } if (state === "single") { + // mysqlRoutineTokens runs only for MYSQL_ROUTINE_BLOCK_DATABASES (all MySQL-family), so + // backslash escaping here (and in the double branch below) is unconditionally correct. if (ch === "\\" && next) { i += 2; continue;