diff --git a/apps/desktop/src/lib/__tests__/sql/sqlMetadataRefresh.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlMetadataRefresh.spec.ts new file mode 100644 index 000000000..4f2515dfd --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlMetadataRefresh.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh"; + +describe("sqlMetadataRefreshTarget", () => { + it.each(["CREATE TEMP TABLE session_rows (id int)", "CREATE GLOBAL TEMPORARY TABLE session_rows (id int)", "DROP TABLE #temp_list", "DROP TABLE IF EXISTS ##global_temp", "ALTER TABLE [#temp_list] ADD note nvarchar(50)"])( + "does not refresh persistent metadata for temporary table DDL: %s", + (sql) => { + expect(sqlMetadataRefreshTarget(sql, "dbo")).toEqual({ scope: "none" }); + }, + ); + + it("still refreshes the active schema for persistent table DDL", () => { + expect(sqlMetadataRefreshTarget("DROP TABLE orders", "dbo")).toEqual({ scope: "database", schema: "dbo" }); + }); + + it("does not treat MySQL hash comments as metadata changes", () => { + expect(sqlMetadataRefreshTarget("SELECT 1; # DROP TABLE orders", "app")).toEqual({ scope: "none" }); + }); + + it("refreshes persistent DDL while ignoring temporary statements in the same batch", () => { + expect(sqlMetadataRefreshTarget("CREATE TABLE #stage (id int); ALTER TABLE sales.orders ADD note varchar(50)", "dbo")).toEqual({ scope: "database", schema: "sales" }); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlMetadataRefresh.ts b/apps/desktop/src/lib/sql/sqlMetadataRefresh.ts index 81b43e09c..0aedad149 100644 --- a/apps/desktop/src/lib/sql/sqlMetadataRefresh.ts +++ b/apps/desktop/src/lib/sql/sqlMetadataRefresh.ts @@ -4,15 +4,23 @@ export type SqlMetadataRefreshTarget = { scope: "none" } | { scope: "connection" const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+DATABASE\b/i; const SCHEMA_DDL_RE = /\b(CREATE|DROP)\s+SCHEMA\b/i; const OBJECT_DDL_RE = /\b(CREATE|ALTER|DROP|RENAME)\s+(OR\s+REPLACE\s+)?(((GLOBAL|LOCAL)\s+)?TEMP(ORARY)?\s+)?(MATERIALIZED\s+)?(TABLE|VIEW|INDEX|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\b/i; +const EXPLICIT_TEMP_OBJECT_DDL_RE = /\b(?:CREATE|ALTER|DROP|RENAME)\s+(?:OR\s+REPLACE\s+)?(?:(?:GLOBAL|LOCAL)\s+)?TEMP(?:ORARY)?\s+(?:TABLE|VIEW|INDEX)\b/i; +const SQLSERVER_TEMP_TABLE_DDL_RE = /\b(?:CREATE|ALTER|DROP)\s+TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:\[##?[^\]]+\]|##?[A-Za-z_][\w$#]*)/i; const OBJECT_NAME_DDL_RE = /\b(?:CREATE|ALTER|DROP|RENAME)\s+(?:OR\s+REPLACE\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?TEMP(?:ORARY)?\s+)?(?:MATERIALIZED\s+)?(?:TABLE|VIEW|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i; const INDEX_TABLE_DDL_RE = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s+ON\s+((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i; +const SQLSERVER_TEMP_TABLE_TOKEN_RE = /(\b(?:CREATE|ALTER|DROP)\s+TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?)(\[?)(##?)([A-Za-z_][\w$#]*)(\]?)/gi; +const TEMP_HASH_PLACEHOLDER = "\u0000dbx-temp-hash\u0000"; function stripSqlMetadataComments(sql: string): string { return sql + .replace(SQLSERVER_TEMP_TABLE_TOKEN_RE, (_match, prefix: string, openBracket: string, hashes: string, name: string, closeBracket: string) => { + return `${prefix}${openBracket}${hashes.replaceAll("#", TEMP_HASH_PLACEHOLDER)}${name}${closeBracket}`; + }) .replace(/\/\*[\s\S]*?\*\//g, " ") .replace(/--.*$/gm, " ") - .replace(/#.*$/gm, " "); + .replace(/#.*$/gm, " ") + .replaceAll(TEMP_HASH_PLACEHOLDER, "#"); } export function sqlMetadataRefreshScope(sql: string): SqlMetadataRefreshScope { @@ -44,6 +52,10 @@ function schemaFromObjectDdl(statement: string): string | undefined { return match?.[1] ? schemaFromQualifiedName(match[1]) : undefined; } +function isTemporaryObjectDdl(statement: string): boolean { + return EXPLICIT_TEMP_OBJECT_DDL_RE.test(statement) || SQLSERVER_TEMP_TABLE_DDL_RE.test(statement); +} + export function sqlMetadataRefreshTarget(sql: string, activeSchema?: string): SqlMetadataRefreshTarget { const statements = splitSqlMetadataStatements(sql); if (statements.some((stmt) => DATABASE_DDL_RE.test(stmt))) return { scope: "connection" }; @@ -57,6 +69,7 @@ export function sqlMetadataRefreshTarget(sql: string, activeSchema?: string): Sq continue; } if (!OBJECT_DDL_RE.test(statement)) continue; + if (isTemporaryObjectDdl(statement)) continue; hasDatabaseRefresh = true; const schema = schemaFromObjectDdl(statement) || activeSchema; if (schema) schemaTargets.add(schema); diff --git a/packages/app-tests/sqlMetadataRefresh.test.ts b/packages/app-tests/sqlMetadataRefresh.test.ts index 72275d2ee..f828fbc64 100644 --- a/packages/app-tests/sqlMetadataRefresh.test.ts +++ b/packages/app-tests/sqlMetadataRefresh.test.ts @@ -14,13 +14,17 @@ test("schema DDL refreshes the selected database tree", () => { test("object DDL refreshes the selected database tree", () => { assert.equal(sqlMetadataRefreshScope("CREATE TABLE users (id int);"), "database"); - assert.equal(sqlMetadataRefreshScope("CREATE TEMP TABLE scratch (id int);"), "database"); assert.equal(sqlMetadataRefreshScope("CREATE MATERIALIZED VIEW daily_users AS SELECT 1;"), "database"); assert.equal(sqlMetadataRefreshScope("ALTER TABLE users ADD COLUMN name text;"), "database"); assert.equal(sqlMetadataRefreshScope("DROP VIEW active_users;"), "database"); assert.equal(sqlMetadataRefreshScope("CREATE OR REPLACE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE SQL;"), "database"); }); +test("temporary table DDL does not refresh persistent metadata trees", () => { + assert.equal(sqlMetadataRefreshScope("CREATE TEMP TABLE scratch (id int);"), "none"); + assert.deepEqual(sqlMetadataRefreshTarget("CREATE TEMPORARY TABLE scratch (id int);"), { scope: "none" }); +}); + test("qualified object DDL refreshes the matching schema node", () => { assert.deepEqual(sqlMetadataRefreshTarget("CREATE TABLE public.users (id int);"), { scope: "database",