diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 3426360316..227a3b02de 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -443,6 +443,44 @@ COMMENT = '测试';`; expect(rangeSqlTexts(executableStatementRanges(sql, "mysql"))).toEqual([sql.slice(0, -1)]); }); + it("keeps issue #4045 ClickHouse ALTER TABLE UPDATE mutation together", () => { + const sql = `ALTER TABLE m2_db.history_data5 +UPDATE value = 14.06 +WHERE uuid IN ( + SELECT uuid + FROM m2_db.pt_uuid_mapping + WHERE point_id = 'TEST_Location_1_L2_METER_2_AE_DIFF_H' +) +AND ts = toDateTime('2026-07-15 12:00:00');`; + + expect(statementRangeAtCursor(sql, indexOf(sql, "ALTER"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1)); + expect(statementRangeAtCursor(sql, indexOf(sql, "UPDATE"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1)); + expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([sql.slice(0, -1)]); + }); + + it("keeps a simple ClickHouse ALTER TABLE UPDATE mutation together", () => { + const sql = "ALTER TABLE `events` ON CLUSTER 'analytics'\nUPDATE value = 1\nWHERE id = 42;"; + + expect(statementRangeAtCursor(sql, indexOf(sql, "UPDATE"), "clickhouse")?.sql.trim()).toBe(sql.slice(0, -1)); + expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([sql.slice(0, -1)]); + }); + + it("separates statements after a ClickHouse ALTER TABLE UPDATE mutation", () => { + const mutation = "ALTER TABLE events\nUPDATE value = 1\nWHERE id = 42"; + const sql = `${mutation};\nSELECT * FROM events;\nUPDATE audit SET processed = 1;`; + + expect(rangeSqlTexts(executableStatementRanges(sql, "clickhouse"))).toEqual([mutation, "SELECT * FROM events", "UPDATE audit SET processed = 1"]); + }); + + it("does not merge independent UPDATE statements into other ALTER statements", () => { + const otherDatabaseSql = "ALTER TABLE events\nUPDATE audit SET processed = 1;"; + const clickHouseSql = "ALTER TABLE events ADD COLUMN source String\nUPDATE audit SET processed = 1;"; + + expect(rangeSqlTexts(executableStatementRanges(otherDatabaseSql, "postgres"))).toEqual(["ALTER TABLE events", "UPDATE audit SET processed = 1"]); + expect(rangeSqlTexts(executableStatementRanges(otherDatabaseSql, "mysql"))).toEqual(["ALTER TABLE events", "UPDATE audit SET processed = 1"]); + expect(rangeSqlTexts(executableStatementRanges(clickHouseSql, "clickhouse"))).toEqual(["ALTER TABLE events ADD COLUMN source String", "UPDATE audit SET processed = 1"]); + }); + it("keeps insert-select with the INSERT statement", () => { const sql = "INSERT INTO archived_users (id, name)\nSELECT id, name FROM users\nUPDATE users SET archived = 1;"; const range = statementRangeAtCursor(sql, indexOf(sql, "archived_users")); diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 7a314f5b07..a8bc59b6ce 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -246,6 +246,7 @@ const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE" const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]); const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]); const ALTER_BODY_KEYWORDS = new Set(["ADD", "ALTER", "COMMENT", "DROP", "MODIFY", "RENAME", "SET"]); +const CLICKHOUSE_ALTER_TABLE_HEADER = /^ALTER\s+TABLE\s+(?:(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")\s*\.\s*)?(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")(?:\s+ON\s+CLUSTER\s+(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+"|'(?:''|[^'])+'))?\s*$/i; const SET_OPERATION_KEYWORDS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]); const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]); const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle"]); @@ -682,6 +683,12 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d continue; } + if (currentBodyKeyword === "ALTER" && isClickHouseAlterTableUpdateContinuation(sql, boundaries[boundaries.length - 1].from, lineStart.from, lineStart.keyword, databaseType)) { + // ClickHouse mutations use UPDATE as the first ALTER TABLE action, not as + // a standalone statement. Keep this dialect-specific to preserve soft boundaries elsewhere. + continue; + } + if (currentBodyKeyword === "ALTER" && ALTER_BODY_KEYWORDS.has(lineStart.keyword)) { continue; } @@ -917,6 +924,11 @@ function isMysqlCreateTableOptionContinuation(sql: string, statementFrom: number return next === "=" || next === "'" || next === '"'; } +function isClickHouseAlterTableUpdateContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean { + if (databaseType !== "clickhouse" || keyword !== "UPDATE") return false; + return CLICKHOUSE_ALTER_TABLE_HEADER.test(sql.slice(statementFrom, lineStartFrom)); +} + function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean { const text = sql.slice(statementFrom, statementFrom + 256); return /^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(text);