From da00405e103281d49fc5247a0d8a2c6d75db02dd Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 18 May 2026 21:24:03 +0800 Subject: [PATCH] fix(db): support routine rename editing --- .../src/components/objects/ObjectBrowser.vue | 52 ++++++-- .../src/components/sidebar/TreeItem.vue | 43 ++++-- .../desktop/src/lib/databaseCapabilitySets.ts | 2 + apps/desktop/src/lib/objectRenameSql.ts | 6 +- apps/desktop/src/lib/objectSourceEditor.ts | 124 +++++++++++++++++- .../src/lib/tableStructureEditorSql.ts | 57 +++++++- .../app-tests/databaseCapabilities.test.ts | 3 +- packages/app-tests/objectRenameSql.test.ts | 16 +++ packages/app-tests/objectSourceEditor.test.ts | 61 +++++++++ .../app-tests/tableStructureEditorSql.test.ts | 119 +++++++++++++++++ 10 files changed, 453 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 8e7115aa0..322d08514 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -34,7 +34,12 @@ import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/dat import { isSchemaAware } from "@/lib/databaseCapabilities"; import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql"; import { useToast } from "@/composables/useToast"; -import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; +import { + buildExecutableObjectSourceStatements, + buildRoutineRenameObjectSourceStatements, + objectSourceSaveExecutionMode, + supportsSourceBackedRoutineRename, +} from "@/lib/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -169,7 +174,10 @@ function canOpenSource(row: ObjectBrowserRow) { } function canRename(row: ObjectBrowserRow) { - return supportsObjectRename(props.connection.db_type, row.type); + return ( + supportsObjectRename(props.connection.db_type, row.type) || + supportsSourceBackedRoutineRename(props.connection.db_type, row.type as ObjectSourceKind) + ); } function sourceTitle(row: ObjectBrowserRow | null) { @@ -250,6 +258,9 @@ function renamePreviewSql() { const row = renameTarget.value; const newName = renameInput.value.trim(); if (!row || !newName || newName === row.name) return ""; + if (supportsSourceBackedRoutineRename(props.connection.db_type, row.type as ObjectSourceKind)) { + return `-- Recreate ${row.type} from source, then drop the original object.`; + } try { return buildRenameObjectSql({ databaseType: props.connection.db_type, @@ -270,14 +281,35 @@ async function confirmRename() { renameError.value = ""; try { const schema = row.schema || selectedSchema.value || props.database; - const sql = buildRenameObjectSql({ - databaseType: props.connection.db_type, - objectType: row.type, - schema, - oldName: row.name, - newName, - }); - await api.executeQuery(props.connection.id, props.database, sql, schema); + if (supportsSourceBackedRoutineRename(props.connection.db_type, row.type as ObjectSourceKind)) { + const source = await api.getObjectSource( + props.connection.id, + props.database, + schema, + row.name, + row.type as ObjectSourceKind, + ); + const statements = buildRoutineRenameObjectSourceStatements({ + databaseType: props.connection.db_type, + objectType: row.type as ObjectSourceKind, + schema, + name: row.name, + newName, + source: source.source, + }); + for (const sql of statements) { + await api.executeQuery(props.connection.id, props.database, sql, schema); + } + } else { + const sql = buildRenameObjectSql({ + databaseType: props.connection.db_type, + objectType: row.type, + schema, + oldName: row.name, + newName, + }); + await api.executeQuery(props.connection.id, props.database, sql, schema); + } toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName })); showRenameDialog.value = false; if (sourceRow.value?.id === row.id) closeSource(); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 33ed525b3..652c78aee 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -102,6 +102,7 @@ import { uniqueDuckDbAttachedDatabaseName, } from "@/lib/createDatabaseSql"; import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/objectRenameSql"; +import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/objectSourceEditor"; import { hexToRgba } from "@/lib/color"; import { focusSidebarRenameInput, shouldPreventRenameCloseAutoFocus } from "@/lib/sidebarRenameFocus"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; @@ -687,7 +688,11 @@ function nodeRenameObjectType(): RenameableObjectType | null { const canRenameObject = computed(() => { const objectType = nodeRenameObjectType(); - return !!objectType && supportsObjectRename(currentDatabaseType(), objectType); + return ( + !!objectType && + (supportsObjectRename(currentDatabaseType(), objectType) || + supportsSourceBackedRoutineRename(currentDatabaseType(), objectType as any)) + ); }); function openRenameObjectDialog() { @@ -700,6 +705,9 @@ function buildRenameObjectPreviewSql(): string { const objectType = nodeRenameObjectType(); const newName = renameObjectName.value.trim(); if (!objectType || !newName || newName === props.node.label) return ""; + if (supportsSourceBackedRoutineRename(currentDatabaseType(), objectType as any)) { + return `-- Recreate ${objectType} from source, then drop the original object.`; + } try { return buildRenameObjectSql({ databaseType: currentDatabaseType(), @@ -720,15 +728,32 @@ async function confirmRenameObject() { if (!objectType || !newName || newName === node.label || !node.connectionId || !node.database) return; renameObjectError.value = ""; try { - const sql = buildRenameObjectSql({ - databaseType: currentDatabaseType(), - objectType, - schema: node.schema, - oldName: node.label, - newName, - }); + const dbType = currentDatabaseType(); await connectionStore.ensureConnected(node.connectionId); - await api.executeQuery(node.connectionId, node.database, sql, node.schema); + if (supportsSourceBackedRoutineRename(dbType, objectType as any)) { + const schema = node.schema || node.database; + const source = await api.getObjectSource(node.connectionId, node.database, schema, node.label, objectType as any); + const statements = buildRoutineRenameObjectSourceStatements({ + databaseType: dbType!, + objectType: objectType as any, + schema, + name: node.label, + newName, + source: source.source, + }); + for (const sql of statements) { + await api.executeQuery(node.connectionId, node.database, sql, schema); + } + } else { + const sql = buildRenameObjectSql({ + databaseType: dbType, + objectType, + schema: node.schema, + oldName: node.label, + newName, + }); + await api.executeQuery(node.connectionId, node.database, sql, node.schema); + } toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000); showRenameObjectDialog.value = false; await refreshTableList(node); diff --git a/apps/desktop/src/lib/databaseCapabilitySets.ts b/apps/desktop/src/lib/databaseCapabilitySets.ts index 1711bfa50..3ff57e3e2 100644 --- a/apps/desktop/src/lib/databaseCapabilitySets.ts +++ b/apps/desktop/src/lib/databaseCapabilitySets.ts @@ -98,6 +98,8 @@ export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set([ "sqlite", "duckdb", "sqlserver", + "oracle", + "dameng", ]); export const CREATE_DATABASE_SUPPORTED_TYPES = new Set([ diff --git a/apps/desktop/src/lib/objectRenameSql.ts b/apps/desktop/src/lib/objectRenameSql.ts index 16530fcf2..c6cc9e65c 100644 --- a/apps/desktop/src/lib/objectRenameSql.ts +++ b/apps/desktop/src/lib/objectRenameSql.ts @@ -49,7 +49,9 @@ export function supportsObjectRename( ): boolean { if (!databaseType) return false; if (databaseType === "sqlserver") return true; - if (objectType === "PROCEDURE" || objectType === "FUNCTION") return false; + if (objectType === "PROCEDURE" || objectType === "FUNCTION") { + return false; + } if (databaseType === "sqlite") return objectType === "TABLE"; if (databaseType === "mysql" || databaseType === "goldendb") return objectType === "TABLE" || objectType === "VIEW"; if (postgresLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW"; @@ -79,7 +81,7 @@ export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): stri postgresLikeRenameTypes.has(databaseType as DatabaseType) || oracleLikeRenameTypes.has(databaseType as DatabaseType) ) { - const keyword = objectType === "VIEW" ? "VIEW" : "TABLE"; + const keyword = objectType; return `ALTER ${keyword} ${qualifiedName(databaseType, schema, oldName)} RENAME TO ${quoteRenameIdentifier(databaseType, newName)};`; } diff --git a/apps/desktop/src/lib/objectSourceEditor.ts b/apps/desktop/src/lib/objectSourceEditor.ts index f95c1040d..ece474476 100644 --- a/apps/desktop/src/lib/objectSourceEditor.ts +++ b/apps/desktop/src/lib/objectSourceEditor.ts @@ -8,6 +8,10 @@ type BuildEditableObjectSourceSqlInput = { source: string; }; +type BuildRoutineRenameObjectSourceInput = BuildEditableObjectSourceSqlInput & { + newName: string; +}; + export type ObjectSourceSaveExecutionMode = "single" | "script"; const postgresLikeRoutineRenameTypes = new Set([ @@ -18,11 +22,17 @@ const postgresLikeRoutineRenameTypes = new Set([ "highgo", "vastbase", ]); +const mysqlLikeRoutineRenameTypes = new Set(["mysql", "goldendb"]); +const oracleLikeRoutineRenameTypes = new Set(["oracle", "dameng"]); function quotePostgresIdentifier(value: string) { return `"${value.replaceAll('"', '""')}"`; } +function quoteMysqlIdentifier(value: string) { + return `\`${value.replaceAll("`", "``")}\``; +} + function ensureSemicolon(sql: string) { const trimmed = sql.trim(); return trimmed.endsWith(";") ? trimmed : `${trimmed};`; @@ -35,6 +45,13 @@ function postgresQualifiedName(schema: string | null | undefined, name: string) .join("."); } +function mysqlQualifiedName(schema: string | null | undefined, name: string) { + return [schema, name] + .filter(Boolean) + .map((part) => quoteMysqlIdentifier(part as string)) + .join("."); +} + function unquotePostgresIdentifier(value: string) { const trimmed = value.trim(); if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1).replaceAll('""', '"'); @@ -46,9 +63,20 @@ function splitQualifiedRoutineName(value: string) { return parts.map(unquotePostgresIdentifier); } +function unquoteMysqlIdentifier(value: string) { + const trimmed = value.trim(); + if (trimmed.startsWith("`") && trimmed.endsWith("`")) return trimmed.slice(1, -1).replaceAll("``", "`"); + return trimmed; +} + +function splitMysqlQualifiedRoutineName(value: string) { + const parts = value.match(/`(?:``|[^`])+`|[A-Za-z_][\w$]*/g) ?? []; + return parts.map(unquoteMysqlIdentifier); +} + 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, + /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\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]); @@ -61,14 +89,57 @@ function routineDeclaration(source: string) { }; } +function replaceSqlRoutineDeclarationName(source: string, schema: string | null | undefined, newName: string) { + const match = source.match( + /^(\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:"(?:""|[^"])+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"(?:""|[^"])+"|[A-Za-z_][\w$]*))?)/i, + ); + if (!match) return null; + const existingParts = splitQualifiedRoutineName(match[2]); + const schemaName = schema || (existingParts.length > 1 ? existingParts[0] : null); + const replacement = schemaName + ? `${quotePostgresIdentifier(schemaName)}.${quotePostgresIdentifier(newName)}` + : quotePostgresIdentifier(newName); + return `${source.slice(0, match.index)}${match[1]}${replacement}${source.slice((match.index ?? 0) + match[0].length)}`; +} + +function mysqlRoutineDeclaration(source: string) { + const match = source.match( + /^\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(FUNCTION|PROCEDURE)\s+((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)/i, + ); + if (!match) return null; + const nameParts = splitMysqlQualifiedRoutineName(match[2]); + const name = nameParts[nameParts.length - 1]; + if (!name) return null; + return { + kind: match[1].toUpperCase() as "FUNCTION" | "PROCEDURE", + name, + }; +} + +function replaceMysqlRoutineDeclarationName(source: string, newName: string) { + const match = source.match( + /^(\s*CREATE\s+(?:DEFINER\s*=\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s*@\s*(?:`(?:``|[^`])+`|'(?:''|[^'])+'|[^\s]+)\s+)?(?:FUNCTION|PROCEDURE)\s+)((?:`(?:``|[^`])+`|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:`(?:``|[^`])+`|[A-Za-z_][\w$]*))?)/i, + ); + if (!match) return null; + return `${source.slice(0, match.index)}${match[1]}${quoteMysqlIdentifier(newName)}${source.slice((match.index ?? 0) + match[0].length)}`; +} + 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; + if (mysqlLikeRoutineRenameTypes.has(input.databaseType)) { + const declaration = mysqlRoutineDeclaration(source); + if (!declaration || declaration.kind !== input.objectType) return null; + if (!routineNameChanged(declaration.name, input.name)) return null; + return `DROP ${input.objectType} IF EXISTS ${mysqlQualifiedName(input.schema, input.name)};`; + } + + if (!postgresLikeRoutineRenameTypes.has(input.databaseType)) return null; + const declaration = routineDeclaration(source); if (!declaration || declaration.kind !== input.objectType) return null; if (!routineNameChanged(declaration.name, input.name)) return null; @@ -76,6 +147,55 @@ function buildRoutineRenameCleanup(input: BuildEditableObjectSourceSqlInput, sou return `DROP ${input.objectType} IF EXISTS ${postgresQualifiedName(input.schema, input.name)}${declaration.signature};`; } +export function supportsSourceBackedRoutineRename( + databaseType: DatabaseType | undefined, + objectType: ObjectSourceKind, +): boolean { + if (objectType !== "FUNCTION" && objectType !== "PROCEDURE") return false; + if (!databaseType || databaseType === "sqlserver") return false; + return ( + mysqlLikeRoutineRenameTypes.has(databaseType) || + postgresLikeRoutineRenameTypes.has(databaseType) || + oracleLikeRoutineRenameTypes.has(databaseType) + ); +} + +export function buildRoutineRenameObjectSourceStatements(input: BuildRoutineRenameObjectSourceInput) { + if (!supportsSourceBackedRoutineRename(input.databaseType, input.objectType)) { + throw new Error(`Renaming ${input.objectType} from source is not supported for ${input.databaseType}.`); + } + + const source = input.source.trim(); + const declaration = mysqlLikeRoutineRenameTypes.has(input.databaseType) + ? mysqlRoutineDeclaration(source) + : routineDeclaration(source); + if (!declaration || declaration.kind !== input.objectType) { + throw new Error(`Cannot find a CREATE ${input.objectType} declaration in the object source.`); + } + + const renamedSource = mysqlLikeRoutineRenameTypes.has(input.databaseType) + ? replaceMysqlRoutineDeclarationName(source, input.newName) + : replaceSqlRoutineDeclarationName(source, input.schema, input.newName); + if (!renamedSource) { + throw new Error(`Cannot rewrite the ${input.objectType} name in the object source.`); + } + + if (oracleLikeRoutineRenameTypes.has(input.databaseType)) { + return [ + ensureSemicolon(renamedSource), + `DROP ${input.objectType} ${postgresQualifiedName(input.schema, input.name)};`, + ]; + } + + return buildExecutableObjectSourceStatements({ + databaseType: input.databaseType, + objectType: input.objectType, + schema: input.schema, + name: input.name, + source: renamedSource, + }); +} + export function buildExecutableObjectSourceStatements(input: BuildEditableObjectSourceSqlInput) { const source = input.source.trim(); if (input.databaseType === "sqlserver") { diff --git a/apps/desktop/src/lib/tableStructureEditorSql.ts b/apps/desktop/src/lib/tableStructureEditorSql.ts index faea367c3..5b1d8b776 100644 --- a/apps/desktop/src/lib/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/tableStructureEditorSql.ts @@ -45,8 +45,12 @@ function quoteIdent(databaseType: DatabaseType | undefined, name: string): strin return `"${name.replace(/"/g, '""')}"`; } +function isOracleLike(databaseType: DatabaseType | undefined): databaseType is "oracle" | "dameng" { + return databaseType === "oracle" || databaseType === "dameng"; +} + function qualifiedTable(databaseType: DatabaseType | undefined, schema: string | undefined, tableName: string): string { - if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) { + if ((databaseType === "postgres" || isOracleLike(databaseType) || databaseType === "sqlserver") && schema) { return `${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, tableName)}`; } return quoteIdent(databaseType, tableName); @@ -67,7 +71,7 @@ function normalizeDefault(value: string | null | undefined): string { function columnDefinition(databaseType: DatabaseType | undefined, column: EditableStructureColumn): string { const parts = [quoteIdent(databaseType, column.name), column.dataType.trim()]; - if (!column.isNullable) parts.push("NOT NULL"); + if (!column.isNullable && !isOracleLike(databaseType)) parts.push("NOT NULL"); const defaultValue = normalizeDefault(column.defaultValue); if (defaultValue) parts.push(`DEFAULT ${defaultValue}`); if (databaseType === "mysql" && clean(column.comment)) { @@ -102,8 +106,11 @@ function buildAddColumnSql( column: EditableStructureColumn, ): string[] { const addKeyword = databaseType === "sqlserver" ? "ADD" : "ADD COLUMN"; - const statements = [`ALTER TABLE ${table} ${addKeyword} ${columnDefinition(databaseType, column)};`]; - if (databaseType === "postgres" && clean(column.comment)) { + const definition = columnDefinition(databaseType, column); + const statements = isOracleLike(databaseType) + ? [`ALTER TABLE ${table} ADD (${definition});`] + : [`ALTER TABLE ${table} ${addKeyword} ${definition};`]; + if ((databaseType === "postgres" || isOracleLike(databaseType)) && clean(column.comment)) { statements.push( `COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`, ); @@ -111,6 +118,42 @@ function buildAddColumnSql( return statements; } +function buildOracleLikeExistingColumnSql( + databaseType: DatabaseType, + table: string, + column: EditableStructureColumn, +): string[] { + const original = column.original; + if (!original) return []; + + const statements: string[] = []; + let currentName = original.name; + if (column.name !== original.name) { + statements.push( + `ALTER TABLE ${table} RENAME COLUMN ${quoteIdent(databaseType, original.name)} TO ${quoteIdent(databaseType, column.name)};`, + ); + currentName = column.name; + } + if (column.dataType.trim() !== original.data_type.trim()) { + statements.push( + `ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} ${column.dataType.trim()});`, + ); + } + if (column.isNullable !== original.is_nullable) { + const nullability = column.isNullable ? "NULL" : "NOT NULL"; + statements.push(`ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} ${nullability});`); + } + if (normalizeDefault(column.defaultValue) !== originalDefault(column)) { + const defaultValue = normalizeDefault(column.defaultValue) || "NULL"; + statements.push(`ALTER TABLE ${table} MODIFY (${quoteIdent(databaseType, currentName)} DEFAULT ${defaultValue});`); + } + if (clean(column.comment) !== originalComment(column)) { + const commentValue = clean(column.comment) ? quoteString(clean(column.comment)) : "NULL"; + statements.push(`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, currentName)} IS ${commentValue};`); + } + return statements; +} + function buildMysqlExistingColumnSql(table: string, column: EditableStructureColumn): string[] { const originalName = column.original?.name ?? column.name; const operation = @@ -199,6 +242,8 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings: statements.push(...buildMysqlExistingColumnSql(table, column)); } else if (databaseType === "postgres") { statements.push(...buildPostgresExistingColumnSql(table, column)); + } else if (isOracleLike(databaseType)) { + statements.push(...buildOracleLikeExistingColumnSql(databaseType, table, column)); } else if (databaseType === "sqlite") { statements.push(...buildSqliteExistingColumnSql(table, column, warnings)); } else { @@ -217,7 +262,7 @@ function buildDropIndexSql( ): string { if (databaseType === "mysql") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`; if (databaseType === "sqlserver") return `DROP INDEX ${quoteIdent(databaseType, indexName)} ON ${table};`; - if ((databaseType === "postgres" || databaseType === "oracle") && schema) { + if ((databaseType === "postgres" || isOracleLike(databaseType)) && schema) { return `DROP INDEX ${quoteIdent(databaseType, schema)}.${quoteIdent(databaseType, indexName)};`; } return `DROP INDEX ${quoteIdent(databaseType, indexName)};`; @@ -344,7 +389,7 @@ export function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions statements.push(`CREATE TABLE ${table} (\n ${colDefs.join(",\n ")}\n);`); - if (databaseType === "postgres") { + if (databaseType === "postgres" || isOracleLike(databaseType)) { for (const col of activeColumns) { if (clean(col.comment)) { statements.push( diff --git a/packages/app-tests/databaseCapabilities.test.ts b/packages/app-tests/databaseCapabilities.test.ts index 07c6fcb6d..f5573a7f4 100644 --- a/packages/app-tests/databaseCapabilities.test.ts +++ b/packages/app-tests/databaseCapabilities.test.ts @@ -113,7 +113,8 @@ test("describes feature support through capability helpers", () => { assert.equal(supportsTableImport("hive"), false); assert.equal(supportsTableStructureEditing("postgres"), true); assert.equal(supportsTableStructureEditing("duckdb"), true); - assert.equal(supportsTableStructureEditing("oracle"), false); + assert.equal(supportsTableStructureEditing("oracle"), true); + assert.equal(supportsTableStructureEditing("dameng"), true); assert.equal(supportsDatabaseCreation("clickhouse"), true); assert.equal(supportsDatabaseCreation("sqlite"), false); assert.equal(supportsFieldLineage("gaussdb"), true); diff --git a/packages/app-tests/objectRenameSql.test.ts b/packages/app-tests/objectRenameSql.test.ts index 870b70abd..c6e658020 100644 --- a/packages/app-tests/objectRenameSql.test.ts +++ b/packages/app-tests/objectRenameSql.test.ts @@ -83,6 +83,22 @@ test("builds Oracle-family table and view rename statements", () => { ); }); +test("does not build direct Oracle-family routine rename statements", () => { + assert.equal(supportsObjectRename("oracle", "FUNCTION"), false); + assert.equal(supportsObjectRename("dameng", "PROCEDURE"), false); + assert.throws( + () => + buildRenameObjectSql({ + databaseType: "dameng", + objectType: "PROCEDURE", + schema: "SYSDBA", + oldName: "REFRESH_CACHE", + newName: "REFRESH_CACHE_V2", + }), + /Renaming PROCEDURE is not supported/, + ); +}); + test("reports unsupported routine rename cases", () => { assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false); assert.equal(supportsObjectRename("postgres", "FUNCTION"), false); diff --git a/packages/app-tests/objectSourceEditor.test.ts b/packages/app-tests/objectSourceEditor.test.ts index f9bbc98ef..f5c14b5b4 100644 --- a/packages/app-tests/objectSourceEditor.test.ts +++ b/packages/app-tests/objectSourceEditor.test.ts @@ -3,7 +3,9 @@ import test from "node:test"; import { buildExecutableObjectSourceSql, buildExecutableObjectSourceStatements, + buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, + supportsSourceBackedRoutineRename, } from "../../apps/desktop/src/lib/objectSourceEditor.ts"; test("SQL Server edited source saves as ALTER", () => { @@ -104,3 +106,62 @@ test("object source SQL joins generated save statements for previews", () => { 'CREATE OR REPLACE PROCEDURE "public"."refresh_cache_v2"(mode text)\nLANGUAGE SQL\nAS $$ SELECT 1 $$;\nDROP PROCEDURE IF EXISTS "public"."refresh_cache"(mode text);', ); }); + +test("Oracle and Dameng object source saves as semicolon-terminated source", () => { + assert.equal( + buildExecutableObjectSourceSql({ + databaseType: "oracle", + objectType: "VIEW", + schema: "HR", + name: "ACTIVE_USERS", + source: "CREATE OR REPLACE VIEW HR.ACTIVE_USERS AS SELECT ID FROM USERS", + }), + "CREATE OR REPLACE VIEW HR.ACTIVE_USERS AS SELECT ID FROM USERS;", + ); + + assert.equal( + buildExecutableObjectSourceSql({ + databaseType: "dameng", + objectType: "PROCEDURE", + schema: "SYSDBA", + name: "REFRESH_CACHE", + source: "CREATE OR REPLACE PROCEDURE SYSDBA.REFRESH_CACHE AS BEGIN SELECT 1; END;", + }), + "CREATE OR REPLACE PROCEDURE SYSDBA.REFRESH_CACHE AS BEGIN SELECT 1; END;", + ); +}); + +test("MySQL routine rename creates the renamed routine and drops the original routine", () => { + const statements = buildExecutableObjectSourceStatements({ + databaseType: "mysql", + objectType: "PROCEDURE", + schema: "app", + name: "refresh_cache", + source: "CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END", + }); + + assert.deepEqual(statements, [ + "CREATE DEFINER=`root`@`%` PROCEDURE `refresh_cache_v2`(IN mode_name varchar(20)) BEGIN SELECT 1; END;", + "DROP PROCEDURE IF EXISTS `app`.`refresh_cache`;", + ]); +}); + +test("Oracle-family routine rename rewrites source and drops the original routine", () => { + assert.equal(supportsSourceBackedRoutineRename("dameng", "PROCEDURE"), true); + assert.equal(supportsSourceBackedRoutineRename("oracle", "FUNCTION"), true); + + const statements = buildRoutineRenameObjectSourceStatements({ + databaseType: "dameng", + objectType: "PROCEDURE", + schema: "SYSDBA", + name: "SP_TAB_BAKSET_REMOVE_BATCH", + newName: "SP_TAB_BAKSET_REMOVE_BATCH_2", + source: + 'CREATE OR REPLACE PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH" AS\nBEGIN\n SELECT 1;\nEND;', + }); + + assert.deepEqual(statements, [ + 'CREATE OR REPLACE PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH_2" AS\nBEGIN\n SELECT 1;\nEND;', + 'DROP PROCEDURE "SYSDBA"."SP_TAB_BAKSET_REMOVE_BATCH";', + ]); +}); diff --git a/packages/app-tests/tableStructureEditorSql.test.ts b/packages/app-tests/tableStructureEditorSql.test.ts index ea691e91b..f8b4f0bbf 100644 --- a/packages/app-tests/tableStructureEditorSql.test.ts +++ b/packages/app-tests/tableStructureEditorSql.test.ts @@ -473,3 +473,122 @@ test("index with empty name and columns produces warnings and no statements", () ]); assert.deepEqual(result.statements, []); }); + +test("builds Oracle column, comment, and index change statements", () => { + const result = buildTableStructureChangeSql({ + databaseType: "oracle", + schema: "HR", + tableName: "EMPLOYEES", + columns: [ + column({ + id: "status", + name: "EMP_STATUS", + dataType: "VARCHAR2(20)", + isNullable: false, + defaultValue: "'ACTIVE'", + comment: "Employment status", + original: { + name: "STATUS", + data_type: "VARCHAR2(10)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: "", + }, + }), + column({ id: "email", name: "EMAIL", dataType: "VARCHAR2(255)", isNullable: true, comment: "Work email" }), + column({ + id: "legacy", + name: "LEGACY_CODE", + markedForDrop: true, + original: { + name: "LEGACY_CODE", + data_type: "VARCHAR2(20)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }), + ], + indexes: [ + index({ + id: "old", + name: "IDX_EMP_OLD", + markedForDrop: true, + original: { name: "IDX_EMP_OLD", columns: ["STATUS"], is_unique: false, is_primary: false }, + }), + index({ id: "new", name: "IDX_EMP_STATUS", columns: ["EMP_STATUS"], isUnique: true }), + ], + }); + + assert.deepEqual(result.warnings, []); + assert.deepEqual(result.statements, [ + 'ALTER TABLE "HR"."EMPLOYEES" RENAME COLUMN "STATUS" TO "EMP_STATUS";', + 'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" VARCHAR2(20));', + 'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" NOT NULL);', + 'ALTER TABLE "HR"."EMPLOYEES" MODIFY ("EMP_STATUS" DEFAULT \'ACTIVE\');', + 'COMMENT ON COLUMN "HR"."EMPLOYEES"."EMP_STATUS" IS \'Employment status\';', + 'ALTER TABLE "HR"."EMPLOYEES" ADD ("EMAIL" VARCHAR2(255));', + 'COMMENT ON COLUMN "HR"."EMPLOYEES"."EMAIL" IS \'Work email\';', + 'ALTER TABLE "HR"."EMPLOYEES" DROP COLUMN "LEGACY_CODE";', + 'DROP INDEX "HR"."IDX_EMP_OLD";', + 'CREATE UNIQUE INDEX "IDX_EMP_STATUS" ON "HR"."EMPLOYEES" ("EMP_STATUS");', + ]); +}); + +test("builds Dameng existing column and create table statements", () => { + const change = buildTableStructureChangeSql({ + databaseType: "dameng", + schema: "SYSDBA", + tableName: "USERS", + columns: [ + column({ + id: "name", + name: "DISPLAY_NAME", + dataType: "VARCHAR(120)", + isNullable: true, + defaultValue: "", + comment: "", + original: { + name: "NAME", + data_type: "VARCHAR(80)", + is_nullable: false, + column_default: "'guest'", + is_primary_key: false, + extra: null, + comment: "Old name", + }, + }), + ], + indexes: [], + }); + + assert.deepEqual(change.warnings, []); + assert.deepEqual(change.statements, [ + 'ALTER TABLE "SYSDBA"."USERS" RENAME COLUMN "NAME" TO "DISPLAY_NAME";', + 'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" VARCHAR(120));', + 'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" NULL);', + 'ALTER TABLE "SYSDBA"."USERS" MODIFY ("DISPLAY_NAME" DEFAULT NULL);', + 'COMMENT ON COLUMN "SYSDBA"."USERS"."DISPLAY_NAME" IS NULL;', + ]); + + const create = buildCreateTableSql({ + databaseType: "dameng", + schema: "SYSDBA", + tableName: "USERS", + columns: [ + column({ id: "id", name: "ID", dataType: "NUMBER", isNullable: false, isPrimaryKey: true }), + column({ id: "name", name: "NAME", dataType: "VARCHAR(120)", isNullable: false, comment: "Display name" }), + ], + indexes: [index({ id: "idx", name: "IDX_USERS_NAME", columns: ["NAME"] })], + }); + + assert.deepEqual(create.warnings, []); + assert.deepEqual(create.statements, [ + 'CREATE TABLE "SYSDBA"."USERS" (\n "ID" NUMBER,\n "NAME" VARCHAR(120) NOT NULL,\n PRIMARY KEY ("ID")\n);', + 'COMMENT ON COLUMN "SYSDBA"."USERS"."NAME" IS \'Display name\';', + 'CREATE INDEX "IDX_USERS_NAME" ON "SYSDBA"."USERS" ("NAME");', + ]); +});