fix(sql): align DuckDB multi-statement string splitting

This commit is contained in:
onenewcode
2026-10-11 20:52:06 +08:00
committed by GitHub
parent 06c3bbaa7c
commit 69a1a4f2d4
3 changed files with 65 additions and 8 deletions
@@ -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"]);
+34 -5
View File
@@ -311,6 +311,14 @@ export const BACKSLASH_ESCAPE_STRING_DIALECTS: ReadonlySet<DatabaseType> = new S
function allowsBackslashStringEscape(databaseType?: DatabaseType): boolean {
return !!databaseType && BACKSLASH_ESCAPE_STRING_DIALECTS.has(databaseType);
}
const POSTGRES_ESCAPE_STRING_DIALECTS: ReadonlySet<DatabaseType> = 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<DatabaseType> = 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;
+21 -3
View File
@@ -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 [