diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 88bf3b5cb4..dc32e0a4c9 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -526,6 +526,16 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(sql)).length).toBe(2); }); + it("keeps DuckDB Windows path literals separate from following statements", () => { + const sql = "SET VARIABLE path = 'C:\\path\\';\nSET VARIABLE next = 'value';\nSELECT 1;"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "duckdb"))).toEqual(["SET VARIABLE path = 'C:\\path\\'", "SET VARIABLE next = 'value'", "SELECT 1"]); + }); + + it("keeps DuckDB escape string literals separate from following statements", () => { + const sql = "SELECT E'it\\'';\nSELECT 1;"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "duckdb"))).toEqual(["SELECT E'it\\''", "SELECT 1"]); + }); + 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"]); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 235c34a80b..9b8db910e7 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -311,6 +311,14 @@ export const BACKSLASH_ESCAPE_STRING_DIALECTS: ReadonlySet = new S function allowsBackslashStringEscape(databaseType?: DatabaseType): boolean { return !!databaseType && BACKSLASH_ESCAPE_STRING_DIALECTS.has(databaseType); } +const POSTGRES_ESCAPE_STRING_DIALECTS: ReadonlySet = new Set(["postgres", "opengauss", "gaussdb", "vastbase", "kingbase", "highgo", "uxdb", "kwdb", "duckdb"]); +function startsPostgresEscapeString(sql: string, quoteIndex: number, databaseType?: DatabaseType): boolean { + if (!databaseType || !POSTGRES_ESCAPE_STRING_DIALECTS.has(databaseType)) return false; + const prefixIndex = quoteIndex - 1; + if (sql[prefixIndex] !== "E" && sql[prefixIndex] !== "e") return false; + const beforePrefix = sql[prefixIndex - 1]; + return beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix); +} 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"]); @@ -376,6 +384,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType let pendingMysqlDirectiveLineEnd = -1; let customDelimiter: string | null = null; let state: QuoteState = "none"; + let singleQuoteBackslashEscapes = false; let dollarTag = ""; let postgresDollarQuotedRoutine = false; let oraclePlSqlStatementEnd: number | null | undefined; @@ -478,7 +487,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType // 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) { + if (ch === "\\" && next && singleQuoteBackslashEscapes) { i += 2; continue; } @@ -489,6 +498,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } state = "none"; + singleQuoteBackslashEscapes = false; } i += 1; continue; @@ -595,6 +605,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType if (ch === "'") { markContent(i); state = "single"; + singleQuoteBackslashEscapes = backslashEscapes || startsPostgresEscapeString(sql, i, databaseType); i += 1; continue; } @@ -1089,6 +1100,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d const unclosedExplainOptionsStart = explainOptionsStart !== null && skipBalancedParens(sql, explainOptionsStart, databaseType, parameterOptions) === null ? explainOptionsStart : null; const backslashEscapes = allowsBackslashStringEscape(databaseType); let state: QuoteState | "lineComment" | "blockComment" = "none"; + let singleQuoteBackslashEscapes = false; let dollarTag = ""; let parenDepth = 0; let lineStart = statement.from; @@ -1147,7 +1159,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d } if (state === "single") { - if (ch === "\\" && next && backslashEscapes) { + if (ch === "\\" && next && singleQuoteBackslashEscapes) { i += 2; continue; } @@ -1157,6 +1169,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d continue; } state = "none"; + singleQuoteBackslashEscapes = false; } i += 1; continue; @@ -1210,6 +1223,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d } if (ch === "'") { state = "single"; + singleQuoteBackslashEscapes = backslashEscapes || startsPostgresEscapeString(sql, i, databaseType); i += 1; continue; } @@ -1339,6 +1353,7 @@ function topLevelWordsInRange(sql: string, from: number, to: number, databaseTyp const ends: number[] = []; const backslashEscapes = allowsBackslashStringEscape(databaseType); let state: QuoteState | "lineComment" | "blockComment" = "none"; + let singleQuoteBackslashEscapes = false; let dollarTag = ""; let parenDepth = 0; let i = from; @@ -1378,7 +1393,7 @@ function topLevelWordsInRange(sql: string, from: number, to: number, databaseTyp } if (state === "single") { - if (ch === "\\" && next && backslashEscapes) { + if (ch === "\\" && next && singleQuoteBackslashEscapes) { i += 2; continue; } @@ -1388,6 +1403,7 @@ function topLevelWordsInRange(sql: string, from: number, to: number, databaseTyp continue; } state = "none"; + singleQuoteBackslashEscapes = false; } i += 1; continue; @@ -1440,6 +1456,7 @@ function topLevelWordsInRange(sql: string, from: number, to: number, databaseTyp } if (ch === "'") { state = "single"; + singleQuoteBackslashEscapes = backslashEscapes || startsPostgresEscapeString(sql, i, databaseType); i += 1; continue; } @@ -1584,6 +1601,7 @@ function explainLikeTargetKeywordAt(sql: string, pos: number, databaseType?: Dat function skipBalancedParens(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): number | null { let state: "none" | "single" | "double" | "lineComment" | "blockComment" = "none"; + let singleQuoteBackslashEscapes = false; let depth = 0; let i = pos; @@ -1606,11 +1624,18 @@ function skipBalancedParens(sql: string, pos: number, databaseType?: DatabaseTyp continue; } if (state === "single") { + if (ch === "\\" && next && singleQuoteBackslashEscapes) { + i += 2; + continue; + } if (ch === "'" && next === "'") { i += 2; continue; } - if (ch === "'") state = "none"; + if (ch === "'") { + state = "none"; + singleQuoteBackslashEscapes = false; + } i += 1; continue; } @@ -1641,6 +1666,7 @@ function skipBalancedParens(sql: string, pos: number, databaseType?: DatabaseTyp } if (ch === "'") { state = "single"; + singleQuoteBackslashEscapes = startsPostgresEscapeString(sql, i, databaseType); i += 1; continue; } @@ -1686,6 +1712,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 singleQuoteBackslashEscapes = false; let state: QuoteState | "lineComment" | "blockComment" = "none"; let dollarTag = ""; let lastContentEnd = from; @@ -1729,7 +1756,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF if (state === "single") { lastContentEnd = i + 1; - if (ch === "\\" && next && backslashEscapes) { + if (ch === "\\" && next && singleQuoteBackslashEscapes) { i += 2; lastContentEnd = i; continue; @@ -1741,6 +1768,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF continue; } state = "none"; + singleQuoteBackslashEscapes = false; } i += 1; continue; @@ -1798,6 +1826,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF } if (ch === "'") { state = "single"; + singleQuoteBackslashEscapes = backslashEscapes || startsPostgresEscapeString(sql, i, databaseType); lastContentEnd = i + 1; i += 1; continue; diff --git a/crates/dbx-sql-core/src/sql.rs b/crates/dbx-sql-core/src/sql.rs index d0d80ae29d..0d950284d6 100644 --- a/crates/dbx-sql-core/src/sql.rs +++ b/crates/dbx-sql-core/src/sql.rs @@ -255,6 +255,14 @@ impl SqlDialectProfile { return Self::sap_hana(); } + if matches!(db_type, DatabaseType::DuckDb) { + return Self { + supports_backslash_escaped_quotes: false, + supports_postgres_escape_strings: true, + ..Self::default() + }; + } + if Self::is_postgres_string_lexer_database(db_type) { return Self { supports_backslash_escaped_quotes: false, @@ -3877,6 +3885,16 @@ mod tests { ); } + #[test] + fn duckdb_plain_strings_keep_windows_paths_and_following_statements_separate() { + let sql = r#"SET VARIABLE path = 'C:\path\'; SET VARIABLE next = 'value'; SELECT 1;"#; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::DuckDb), + vec![r#"SET VARIABLE path = 'C:\path\'"#, "SET VARIABLE next = 'value'", "SELECT 1"] + ); + } + #[test] fn postgres_escape_string_keeps_a_backslash_escaped_quote() { let sql = r#"SELECT E'it\'s; still one value'; SELECT 1;"#; @@ -4670,9 +4688,9 @@ SELECT 2;"; assert!(profile.supports_postgres_escape_strings); } - // Engines without PostgreSQL string literals keep the historical escape - // rule until their own evidence is recorded. - assert!(SqlDialectProfile::for_database_type(DatabaseType::DuckDb).supports_backslash_escaped_quotes); + let duckdb = SqlDialectProfile::for_database_type(DatabaseType::DuckDb); + assert!(!duckdb.supports_backslash_escaped_quotes); + assert!(duckdb.supports_postgres_escape_strings); assert!(SqlDialectProfile::for_database_type(DatabaseType::ClickHouse).supports_backslash_escaped_quotes); for db_type in [