diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5997e9797..d6576c2ba 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -34,7 +34,7 @@ import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; -import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; +import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; import { resolveExecutableSql } from "@/lib/sqlExecutionTarget"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen"; @@ -284,17 +284,19 @@ async function saveActiveObjectSource(tab: NonNullable) if (!connection || !source) return; try { - const sql = buildExecutableObjectSourceSql({ + const statements = buildExecutableObjectSourceStatements({ databaseType: connection.db_type, objectType: source.objectType, schema: source.schema || tab.schema || tab.database, name: source.name, source: tab.sql, }); - if (objectSourceSaveExecutionMode(connection.db_type) === "single") { - await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema); - } else { - await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema); + for (const sql of statements) { + if (objectSourceSaveExecutionMode(connection.db_type) === "single") { + await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema); + } else { + await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema); + } } toast(t("objects.sourceSaved"), 2000); } catch (e: any) { diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 14f5b8aa3..692a466d3 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -35,7 +35,7 @@ import { isSchemaAware } from "@/lib/databaseCapabilities"; import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql"; import { normalizeDatabaseObjectName } from "@/lib/tableTree"; import { useToast } from "@/composables/useToast"; -import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; +import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -220,6 +220,8 @@ async function openSource(row: ObjectRow) { row.type as ObjectSourceKind, ); sourceContent.value = result.source; + sourceDraft.value = result.source; + sourceEditing.value = true; } catch (e: any) { sourceError.value = e?.message || String(e); } finally { @@ -395,17 +397,19 @@ async function saveSource() { sourceSaving.value = true; sourceSaveError.value = ""; try { - const sql = buildExecutableObjectSourceSql({ + const statements = buildExecutableObjectSourceStatements({ databaseType: props.connection.db_type, objectType: row.type as ObjectSourceKind, schema, name: row.name, source: sourceDraft.value, }); - if (objectSourceSaveExecutionMode(props.connection.db_type) === "single") { - await api.executeQuery(props.connection.id, props.database, sql, schema); - } else { - await api.executeScript(props.connection.id, props.database, sql, schema); + for (const sql of statements) { + if (objectSourceSaveExecutionMode(props.connection.db_type) === "single") { + await api.executeQuery(props.connection.id, props.database, sql, schema); + } else { + await api.executeScript(props.connection.id, props.database, sql, schema); + } } toast(t("objects.sourceSaved")); sourceEditing.value = false; diff --git a/apps/desktop/src/lib/objectSourceEditor.ts b/apps/desktop/src/lib/objectSourceEditor.ts index b03ed0fd6..f95c1040d 100644 --- a/apps/desktop/src/lib/objectSourceEditor.ts +++ b/apps/desktop/src/lib/objectSourceEditor.ts @@ -10,6 +10,15 @@ type BuildEditableObjectSourceSqlInput = { export type ObjectSourceSaveExecutionMode = "single" | "script"; +const postgresLikeRoutineRenameTypes = new Set([ + "postgres", + "redshift", + "gaussdb", + "kingbase", + "highgo", + "vastbase", +]); + function quotePostgresIdentifier(value: string) { return `"${value.replaceAll('"', '""')}"`; } @@ -26,17 +35,64 @@ function postgresQualifiedName(schema: string | null | undefined, name: string) .join("."); } -export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) { +function unquotePostgresIdentifier(value: string) { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1).replaceAll('""', '"'); + return trimmed; +} + +function splitQualifiedRoutineName(value: string) { + const parts = value.match(/"(?:""|[^"])+"|[A-Za-z_][\w$]*/g) ?? []; + return parts.map(unquotePostgresIdentifier); +} + +function routineDeclaration(source: string) { + const match = source.match( + /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\s+((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)\s*(\([^]*?\))?/i, + ); + if (!match) return null; + const nameParts = splitQualifiedRoutineName(match[2]); + const name = nameParts[nameParts.length - 1]; + if (!name) return null; + return { + kind: match[1].toUpperCase() as "FUNCTION" | "PROCEDURE", + name, + signature: match[3]?.trim() ?? "", + }; +} + +function routineNameChanged(sourceName: string, savedName: string) { + return sourceName.toLowerCase() !== savedName.toLowerCase(); +} + +function buildRoutineRenameCleanup(input: BuildEditableObjectSourceSqlInput, source: string) { + if (!postgresLikeRoutineRenameTypes.has(input.databaseType)) return null; + if (input.objectType !== "FUNCTION" && input.objectType !== "PROCEDURE") return null; + + const declaration = routineDeclaration(source); + if (!declaration || declaration.kind !== input.objectType) return null; + if (!routineNameChanged(declaration.name, input.name)) return null; + + return `DROP ${input.objectType} IF EXISTS ${postgresQualifiedName(input.schema, input.name)}${declaration.signature};`; +} + +export function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput) { const source = input.source.trim(); if (input.databaseType === "sqlserver") { - return source.replace(/^CREATE\s+(?:OR\s+ALTER\s+)?/i, "ALTER "); + return [source.replace(/^CREATE\s+(?:OR\s+ALTER\s+)?/i, "ALTER ")]; } if ((input.databaseType === "postgres" || input.databaseType === "gaussdb") && input.objectType === "VIEW") { - return `CREATE OR REPLACE VIEW ${postgresQualifiedName(input.schema, input.name)} AS\n${ensureSemicolon(source)}`; + return [`CREATE OR REPLACE VIEW ${postgresQualifiedName(input.schema, input.name)} AS\n${ensureSemicolon(source)}`]; } - return ensureSemicolon(source); + const createStatement = ensureSemicolon(source); + const cleanup = buildRoutineRenameCleanup(input, source); + return cleanup ? [createStatement, cleanup] : [createStatement]; +} + +export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) { + return buildExecutableObjectSourceStatements(input).join("\n"); } export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode { diff --git a/packages/app-tests/objectSourceEditor.test.ts b/packages/app-tests/objectSourceEditor.test.ts index e27ad14c5..f9bbc98ef 100644 --- a/packages/app-tests/objectSourceEditor.test.ts +++ b/packages/app-tests/objectSourceEditor.test.ts @@ -1,6 +1,10 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "../../apps/desktop/src/lib/objectSourceEditor.ts"; +import { + buildExecutableObjectSourceSql, + buildExecutableObjectSourceStatements, + objectSourceSaveExecutionMode, +} from "../../apps/desktop/src/lib/objectSourceEditor.ts"; test("SQL Server edited source saves as ALTER", () => { const sql = buildExecutableObjectSourceSql({ @@ -54,3 +58,49 @@ test("Postgres view body opens as CREATE OR REPLACE VIEW", () => { assert.equal(sql, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;'); }); + +test("Kingbase function rename creates the renamed routine and then drops the original routine", () => { + const statements = buildExecutableObjectSourceStatements({ + databaseType: "kingbase", + objectType: "FUNCTION", + schema: "DLJPM", + name: "CONVERTSPECIALNAME", + source: + 'CREATE OR REPLACE function "DLJPM"."CONVERTSPECIALNAME1" (SpName varchar2)\nRETURN VARCHAR2\nas\nbegin\nreturn SpName;\nend;', + }); + + assert.deepEqual(statements, [ + 'CREATE OR REPLACE function "DLJPM"."CONVERTSPECIALNAME1" (SpName varchar2)\nRETURN VARCHAR2\nas\nbegin\nreturn SpName;\nend;', + 'DROP FUNCTION IF EXISTS "DLJPM"."CONVERTSPECIALNAME"(SpName varchar2);', + ]); +}); + +test("Postgres procedure rename creates the renamed routine and then drops the original routine", () => { + const statements = buildExecutableObjectSourceStatements({ + databaseType: "postgres", + objectType: "PROCEDURE", + schema: "public", + name: "refresh_cache", + source: 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;', + }); + + assert.deepEqual(statements, [ + 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;', + 'DROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);', + ]); +}); + +test("object source SQL joins generated save statements for previews", () => { + const sql = buildExecutableObjectSourceSql({ + databaseType: "postgres", + objectType: "PROCEDURE", + schema: "public", + name: "refresh_cache", + source: 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;', + }); + + assert.equal( + sql, + 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;\nDROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);', + ); +});