From 2bfd03cca4cb7361d8c07507f88fa2e3686d476e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 6 Jul 2026 01:59:02 +0800 Subject: [PATCH] feat(database): add database creation and property editing --- .../src/components/sidebar/TreeItem.vue | 307 ++++++++++-- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../database/databaseFeatureSupport.spec.ts | 67 +++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 6 +- apps/desktop/src/lib/backend/tauri.ts | 6 +- .../src/lib/database/createDatabaseSql.ts | 2 + .../src/lib/database/databaseCapabilities.ts | 2 + .../lib/database/databaseNamespaceCreation.ts | 102 ++++ .../lib/database/databasePropertyEditing.ts | 110 +++++ apps/desktop/src/lib/database/dbAdminSql.ts | 23 +- .../assets/database-drivers.manifest.json | 16 +- crates/dbx-core/src/db_admin_sql.rs | 439 +++++++++++++++++- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/query.rs | 22 +- src-tauri/src/commands/query.rs | 11 +- src-tauri/src/lib.rs | 1 + 23 files changed, 1101 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/lib/database/databaseNamespaceCreation.ts create mode 100644 apps/desktop/src/lib/database/databasePropertyEditing.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index cd349d980f..1a3d1fbc5d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -80,7 +80,23 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { getCachedTableMetadata, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS, tableMetadataToDataTabMeta } from "@/lib/metadata/tableMetadataCache"; -import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; +import { + canCreateConnectionNamespace, + canCreateDatabaseNodeNamespace, + canEditDatabaseProperties as canEditDatabasePropertiesForNode, + connectionNamespaceCreationTarget, + editableDatabasePropertyGroups, + supportsDatabaseCreation, + supportsDatabaseSearch, + supportsFieldLineage, + supportsObjectBrowserTreeNode, + supportsSchemaDiagram, + supportsSqlFileExecution, + supportsTableImport, + supportsTableTruncate, + supportsTableStructureEditing, + usesTreeSchemaMode, +} from "@/lib/database/databaseCapabilities"; import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts"; import { formatSqlInsert } from "@/lib/export/exportFormats"; @@ -93,13 +109,14 @@ import { buildDropDatabaseSql, buildDropObjectSql, buildDropSchemaSql, + buildGetDatabaseCommentSql, buildGetSchemaCommentSql, + buildUpdateDatabasePropertiesSql, buildDropTableSql, buildDropTableChildObjectSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, - buildSetSchemaCommentSql, buildTruncateTableSql, supportsSchemaComment, type DropTableChildObjectSqlOptions, @@ -1753,6 +1770,12 @@ const showFlushRedisDbConfirm = ref(false); const showCreateSchemaDialog = ref(false); const createSchemaName = ref(""); const showDropSchemaConfirm = ref(false); +const showEditDatabasePropertiesDialog = ref(false); +const editDatabasePropertiesLoading = ref(false); +const editDatabasePropertiesPreviewSql = ref(""); +const editDatabaseCharset = ref("utf8mb4"); +const editDatabaseCollation = ref("utf8mb4_unicode_ci"); +const editDatabaseCommentText = ref(""); const showEditSchemaCommentDialog = ref(false); const schemaCommentText = ref(""); const schemaCommentLoading = ref(false); @@ -2326,7 +2349,7 @@ const canCreateTable = computed(() => { const canCreateDatabase = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "connection" && (supportsDatabaseCreation(config?.db_type) || config?.db_type === "duckdb" || (config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy")); + return props.node.type === "connection" && canCreateConnectionNamespace(config); }); const canCreateNacosNamespace = computed(() => { @@ -2342,12 +2365,12 @@ const canEditNacosNamespace = computed(() => { const isDuckDbConnection = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "connection" && config?.db_type === "duckdb"; + return props.node.type === "connection" && connectionNamespaceCreationTarget(config) === "attach"; }); const canSetCreateDatabaseCharset = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile); + return connectionNamespaceCreationTarget(config) === "database" && supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile); }); const canDropDatabase = computed(() => { @@ -2355,6 +2378,19 @@ const canDropDatabase = computed(() => { return props.node.type === "database" && !isSqlServerLinkedNode(props.node) && supportsDatabaseCreation(config?.db_type); }); +const databasePropertyGroups = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return editableDatabasePropertyGroups(config, props.node); +}); + +const canEditDatabaseProperties = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return canEditDatabasePropertiesForNode(config, props.node) && !isSqlServerLinkedNode(props.node); +}); + +const canEditDatabaseCharsetCollation = computed(() => databasePropertyGroups.value.includes("charsetCollation")); +const canEditDatabaseComment = computed(() => databasePropertyGroups.value.includes("databaseComment")); + const canDropMongoDatabase = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return props.node.type === "mongo-db" && !!props.node.database && config?.driver_profile !== "mongodb-legacy"; @@ -2393,7 +2429,7 @@ function mongoDropAllIndexesPreview(node: Pick): string { const canCreateSchema = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return props.node.type === "database" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); + return canCreateDatabaseNodeNamespace(config, props.node) && !isSqlServerLinkedNode(props.node) && !connectionUsesDatabaseObjectTreeMode(config); }); const canDropSchema = computed(() => { @@ -2510,32 +2546,151 @@ async function refreshDropSchemaPreviewSql() { }).catch(() => ""); } -const schemaCommentPreviewSql = computed(() => { - if (!canEditSchemaComment.value) return ""; - try { - return buildSetSchemaCommentSql({ - databaseType: currentDatabaseType(), - name: props.node.schema || props.node.label, - comment: schemaCommentText.value, - }); - } catch { - return ""; - } -}); +function databasePropertyName(): string { + return props.node.database || props.node.label; +} -function schemaCommentFromResult(result: { columns?: string[]; rows?: unknown[] }): string { +function resultColumnValue(result: { columns?: string[]; rows?: unknown[] }, names: string[]): string { const firstRow = result.rows?.[0]; if (Array.isArray(firstRow)) { - const index = Math.max(0, result.columns?.findIndex((column) => column === "comment") ?? 0); + const lowerNames = names.map((name) => name.toLowerCase()); + const index = Math.max(0, result.columns?.findIndex((column) => lowerNames.includes(column.toLowerCase())) ?? 0); return firstRow[index] == null ? "" : String(firstRow[index]); } - if (firstRow && typeof firstRow === "object" && "comment" in firstRow) { - const value = (firstRow as { comment?: unknown }).comment; + if (firstRow && typeof firstRow === "object") { + const record = firstRow as Record; + const key = Object.keys(record).find((column) => names.some((name) => name.toLowerCase() === column.toLowerCase())); + const value = key ? record[key] : undefined; return value == null ? "" : String(value); } return ""; } +function databasePropertyEditOptions() { + if (!canEditDatabaseProperties.value) return null; + const base = { + databaseType: currentDatabaseType(), + driverProfile: props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.driver_profile : undefined, + target: "database" as const, + name: databasePropertyName(), + }; + if (canEditDatabaseCharsetCollation.value) { + return { + ...base, + charset: editDatabaseCharset.value, + collation: editDatabaseCollation.value, + }; + } + if (canEditDatabaseComment.value) { + return { + ...base, + comment: editDatabaseCommentText.value, + }; + } + return null; +} + +async function refreshEditDatabasePropertiesPreviewSql() { + editDatabasePropertiesPreviewSql.value = ""; + const options = databasePropertyEditOptions(); + if (!options) return; + editDatabasePropertiesPreviewSql.value = await buildUpdateDatabasePropertiesSql(options).catch(() => ""); +} + +async function loadDatabaseCharsetProperties() { + const node = props.node; + if (!node.connectionId) return; + const sql = `SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${databasePropertyName().replace(/'/g, "''")}';`; + const result = await api.executeQuery(node.connectionId, databasePropertyName(), sql, undefined, undefined, { maxRows: 1 }); + const charset = resultColumnValue(result, ["charset", "DEFAULT_CHARACTER_SET_NAME"]); + const collation = resultColumnValue(result, ["collation", "DEFAULT_COLLATION_NAME"]); + if (charset) editDatabaseCharset.value = charset; + if (collation) editDatabaseCollation.value = collation; +} + +async function loadDatabaseCommentProperty() { + const node = props.node; + if (!node.connectionId) return; + const sql = buildGetDatabaseCommentSql({ + databaseType: currentDatabaseType(), + name: databasePropertyName(), + }); + const result = await api.executeQuery(node.connectionId, databasePropertyName(), sql, undefined, undefined, { maxRows: 1 }); + editDatabaseCommentText.value = resultColumnValue(result, ["comment"]); +} + +async function openEditDatabasePropertiesDialog() { + const node = props.node; + if (!canEditDatabaseProperties.value || !node.connectionId) return; + editDatabasePropertiesLoading.value = true; + editDatabaseCharset.value = "utf8mb4"; + editDatabaseCollation.value = "utf8mb4_unicode_ci"; + editDatabaseCommentText.value = ""; + editDatabasePropertiesPreviewSql.value = ""; + createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets; + createDatabaseCollationsByCharset.value = fallbackCreateDatabaseCharset.collationsByCharset; + showEditDatabasePropertiesDialog.value = true; + try { + await connectionStore.ensureConnected(node.connectionId); + if (canEditDatabaseCharsetCollation.value) { + await loadCreateDatabaseCharsetMetadata("edit"); + await loadDatabaseCharsetProperties().catch(() => undefined); + } else if (canEditDatabaseComment.value) { + await loadDatabaseCommentProperty().catch(() => undefined); + } + await refreshEditDatabasePropertiesPreviewSql(); + } finally { + editDatabasePropertiesLoading.value = false; + } +} + +async function confirmEditDatabaseProperties() { + const node = props.node; + if (!canEditDatabaseProperties.value || !node.connectionId || editDatabasePropertiesLoading.value) return; + editDatabasePropertiesLoading.value = true; + try { + await connectionStore.ensureConnected(node.connectionId); + const options = databasePropertyEditOptions(); + if (!options) return; + const sql = await buildUpdateDatabasePropertiesSql(options); + await api.executeQuery(node.connectionId, databasePropertyName(), sql); + toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000); + showEditDatabasePropertiesDialog.value = false; + await connectionStore.loadDatabases(node.connectionId, { force: true }); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } finally { + editDatabasePropertiesLoading.value = false; + } +} + +watch([editDatabaseCharset, editDatabaseCollation, editDatabaseCommentText], () => { + if (showEditDatabasePropertiesDialog.value) void refreshEditDatabasePropertiesPreviewSql(); +}); + +const schemaCommentPreviewSql = ref(""); + +async function refreshSchemaCommentPreviewSql() { + if (!canEditSchemaComment.value) { + schemaCommentPreviewSql.value = ""; + return; + } + schemaCommentPreviewSql.value = await buildUpdateDatabasePropertiesSql({ + databaseType: currentDatabaseType(), + target: "schema", + name: props.node.schema || props.node.label, + comment: schemaCommentText.value, + }).catch(() => ""); +} + +watch(schemaCommentText, () => { + if (showEditSchemaCommentDialog.value) void refreshSchemaCommentPreviewSql(); +}); + +function schemaCommentFromResult(result: { columns?: string[]; rows?: unknown[] }): string { + return resultColumnValue(result, ["comment"]); +} + async function openEditSchemaCommentDialog() { const node = props.node; if (!canEditSchemaComment.value || !node.connectionId || !node.database) return; @@ -2550,8 +2705,10 @@ async function openEditSchemaCommentDialog() { }); const result = await api.executeQuery(node.connectionId, node.database, sql, node.schema, undefined, { maxRows: 1 }); schemaCommentText.value = schemaCommentFromResult(result); + await refreshSchemaCommentPreviewSql(); } catch { schemaCommentText.value = ""; + await refreshSchemaCommentPreviewSql(); } finally { schemaCommentLoading.value = false; } @@ -2563,8 +2720,9 @@ async function confirmEditSchemaComment() { schemaCommentLoading.value = true; try { await connectionStore.ensureConnected(node.connectionId); - const sql = buildSetSchemaCommentSql({ + const sql = await buildUpdateDatabasePropertiesSql({ databaseType: currentDatabaseType(), + target: "schema", name: node.schema || node.label, comment: schemaCommentText.value, }); @@ -2594,7 +2752,9 @@ function openCreateDatabaseDialog() { createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets; createDatabaseCollationsByCharset.value = fallbackCreateDatabaseCharset.collationsByCharset; showCreateDatabaseDialog.value = true; - void loadCreateDatabaseCharsetMetadata(); + if (canSetCreateDatabaseCharset.value) { + void loadCreateDatabaseCharsetMetadata(); + } } function updateCreateDatabaseCharset(value: string) { @@ -2603,21 +2763,33 @@ function updateCreateDatabaseCharset(value: string) { createDatabaseCollation.value = nextCreateDatabaseCollation(value, previousCharset, createDatabaseCollation.value, createDatabaseCollationsByCharset.value); } -async function loadCreateDatabaseCharsetMetadata() { +function updateEditDatabaseCharset(value: string) { + const previousCharset = editDatabaseCharset.value; + editDatabaseCharset.value = value; + editDatabaseCollation.value = nextCreateDatabaseCollation(value, previousCharset, editDatabaseCollation.value, createDatabaseCollationsByCharset.value); +} + +async function loadCreateDatabaseCharsetMetadata(target: "create" | "edit" = "create") { const node = props.node; if (!node.connectionId || createDatabaseCharsetLoading.value) return; createDatabaseCharsetLoading.value = true; try { await connectionStore.ensureConnected(node.connectionId); const [charsetResult, collationResult] = await Promise.all([api.executeQuery(node.connectionId, "", "SHOW CHARACTER SET", undefined, undefined, { maxRows: 1000 }), api.executeQuery(node.connectionId, "", "SHOW COLLATION", undefined, undefined, { maxRows: 10000 })]); - if (!showCreateDatabaseDialog.value) return; + if (target === "create" && !showCreateDatabaseDialog.value) return; + if (target === "edit" && !showEditDatabasePropertiesDialog.value) return; const metadata = parseCreateDatabaseCharsetMetadata(charsetResult, collationResult); createDatabaseCharsetOptions.value = metadata.charsets; createDatabaseCollationsByCharset.value = metadata.collationsByCharset; - if (!createDatabaseCharsetOptions.value.includes(createDatabaseCharset.value) && createDatabaseCharsetOptions.value.length) { - updateCreateDatabaseCharset(createDatabaseCharsetOptions.value[0]); + const selectedCharset = target === "create" ? createDatabaseCharset.value : editDatabaseCharset.value; + if (!createDatabaseCharsetOptions.value.includes(selectedCharset) && createDatabaseCharsetOptions.value.length) { + target === "create" ? updateCreateDatabaseCharset(createDatabaseCharsetOptions.value[0]) : updateEditDatabaseCharset(createDatabaseCharsetOptions.value[0]); } else { - createDatabaseCollation.value = nextCreateDatabaseCollation(createDatabaseCharset.value, createDatabaseCharset.value, createDatabaseCollation.value, createDatabaseCollationsByCharset.value); + if (target === "create") { + createDatabaseCollation.value = nextCreateDatabaseCollation(createDatabaseCharset.value, createDatabaseCharset.value, createDatabaseCollation.value, createDatabaseCollationsByCharset.value); + } else { + editDatabaseCollation.value = nextCreateDatabaseCollation(editDatabaseCharset.value, editDatabaseCharset.value, editDatabaseCollation.value, createDatabaseCollationsByCharset.value); + } } } catch { createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets; @@ -2745,6 +2917,7 @@ async function confirmCreateDatabase() { const sql = await buildCreateDatabaseSql({ databaseType: config?.db_type, driverProfile: config?.driver_profile, + target: "database", name, charset: createDatabaseCharset.value, collation: createDatabaseCollation.value, @@ -4315,6 +4488,9 @@ function treeItemMenuItems(): ContextMenuItem[] { items.push({ label: t("contextMenu.clearDefaultDatabase"), action: clearNodeDefaultDatabase, icon: Database }); } } + if (canEditDatabaseProperties.value) { + items.push({ label: t("contextMenu.editDatabaseProperties"), action: openEditDatabasePropertiesDialog, icon: SquarePen }); + } if (canCreateTable.value) { items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus }); } @@ -5112,6 +5288,79 @@ function treeItemMenuItems(): ContextMenuItem[] { + + + + {{ t("contextMenu.editDatabasePropertiesTitle", { name: node.label }) }} + +
+
+
+ + + + +
+
+ + + + +
+
+
+ + +
+

+      
+ + + + +
+
+ diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b4123cee64..d6cdcfe22c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1480,6 +1480,12 @@ export default { confirmDropDatabaseTitle: "Drop Database", confirmDropDatabaseMessage: 'Are you sure you want to drop database "{name}"? This will permanently delete the database and all its data.', createDatabaseSuccess: 'Database "{name}" created', + editDatabaseProperties: "Edit Database Properties", + editDatabasePropertiesTitle: 'Edit Database Properties: "{name}"', + editDatabasePropertiesSaving: "Saving...", + editDatabasePropertiesSuccess: 'Database "{name}" properties updated', + editDatabaseComment: "Database comment", + editDatabaseCommentPlaceholder: "Enter database comment...", createDuckDbFileSuccess: 'DuckDB database file "{name}" created and attached', createDuckDbFileDesktopOnly: "Creating DuckDB database files is only available in the desktop app", dropDatabaseSuccess: 'Database "{name}" dropped', diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 07ac06aebb..5ae7508d9c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1436,6 +1436,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "Eliminar base de datos", confirmDropDatabaseMessage: '¿Estás seguro de que deseas eliminar la base de datos "{name}"? Esto borrará permanentemente la base de datos y todos sus datos.', createDatabaseSuccess: 'Base de datos "{name}" creada', + editDatabaseProperties: "Editar propiedades de base de datos", + editDatabasePropertiesTitle: 'Editar propiedades de base de datos: "{name}"', + editDatabasePropertiesSaving: "Guardando...", + editDatabasePropertiesSuccess: 'Propiedades de la base de datos "{name}" actualizadas', + editDatabaseComment: "Comentario de base de datos", + editDatabaseCommentPlaceholder: "Introduce el comentario de la base de datos...", createDuckDbFileSuccess: 'Archivo de base de datos DuckDB "{name}" creado y adjuntado', createDuckDbFileDesktopOnly: "Crear archivos de base de datos DuckDB solo está disponible en la app de escritorio", dropDatabaseSuccess: 'Base de datos "{name}" eliminada', diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 1273465530..438a5057a0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1434,6 +1434,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "Elimina Database", confirmDropDatabaseMessage: 'Sei sicuro di voler eliminare il database "{name}"? Questa operazione eliminerà permanentemente il database e tutti i suoi dati.', createDatabaseSuccess: 'Database "{name}" creato', + editDatabaseProperties: "Modifica proprietà database", + editDatabasePropertiesTitle: 'Modifica proprietà database: "{name}"', + editDatabasePropertiesSaving: "Salvataggio...", + editDatabasePropertiesSuccess: 'Proprietà del database "{name}" aggiornate', + editDatabaseComment: "Commento database", + editDatabaseCommentPlaceholder: "Inserisci commento database...", createDuckDbFileSuccess: 'File di database DuckDB "{name}" creato e collegato', createDuckDbFileDesktopOnly: "La creazione di file di database DuckDB è disponibile solo nell'applicazione desktop", dropDatabaseSuccess: 'Database "{name}" eliminato', diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 57754e7f3d..5703d9e6ee 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1430,6 +1430,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "データベースを削除", confirmDropDatabaseMessage: "本当にデータベース「{name}」をドロップ(削除)しますか?データベースとすべてのデータが永久に削除されます。", createDatabaseSuccess: "データベース「{name}」を作成しました", + editDatabaseProperties: "データベースプロパティを編集", + editDatabasePropertiesTitle: "データベースプロパティを編集: 「{name}」", + editDatabasePropertiesSaving: "保存中...", + editDatabasePropertiesSuccess: "データベース「{name}」のプロパティを更新しました", + editDatabaseComment: "データベースコメント", + editDatabaseCommentPlaceholder: "データベースコメントを入力...", createDuckDbFileSuccess: "DuckDBデータベースファイル「{name}」を作成し、アタッチしました", createDuckDbFileDesktopOnly: "DuckDBデータベースファイルの作成はデスクトップアプリでのみ利用可能です", dropDatabaseSuccess: "データベース「{name}」を削除しました", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 8c03693ce7..75443af803 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1435,6 +1435,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "Remover Banco de Dados", confirmDropDatabaseMessage: 'Tem certeza de que deseja remover o banco de dados "{name}"? Isso excluirá permanentemente o banco de dados e todos os seus dados.', createDatabaseSuccess: 'Banco de dados "{name}" criado', + editDatabaseProperties: "Editar propriedades do banco de dados", + editDatabasePropertiesTitle: 'Editar propriedades do banco de dados: "{name}"', + editDatabasePropertiesSaving: "Salvando...", + editDatabasePropertiesSuccess: 'Propriedades do banco de dados "{name}" atualizadas', + editDatabaseComment: "Comentário do banco de dados", + editDatabaseCommentPlaceholder: "Insira o comentário do banco de dados...", createDuckDbFileSuccess: 'Arquivo de banco de dados DuckDB "{name}" criado e anexado', createDuckDbFileDesktopOnly: "A criação de arquivos de banco de dados DuckDB só está disponível no aplicativo desktop", dropDatabaseSuccess: 'Banco de dados "{name}" removido', diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bbf7daf4f7..64346c2808 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1482,6 +1482,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "删除数据库", confirmDropDatabaseMessage: "确定要删除数据库「{name}」吗?这将永久删除该数据库及其所有数据。", createDatabaseSuccess: "数据库「{name}」已创建", + editDatabaseProperties: "编辑数据库属性", + editDatabasePropertiesTitle: "编辑数据库属性:「{name}」", + editDatabasePropertiesSaving: "正在保存...", + editDatabasePropertiesSuccess: "数据库「{name}」属性已更新", + editDatabaseComment: "数据库注释", + editDatabaseCommentPlaceholder: "输入数据库注释...", createDuckDbFileSuccess: "DuckDB 数据库文件「{name}」已创建并附加", createDuckDbFileDesktopOnly: "新建 DuckDB 数据库文件仅支持桌面端", dropDatabaseSuccess: "数据库「{name}」已删除", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 314706c783..f48d073d1b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1435,6 +1435,12 @@ export default withEnglishFallback({ confirmDropDatabaseTitle: "刪除資料庫", confirmDropDatabaseMessage: "確定要刪除資料庫「{name}」嗎?這將永久刪除該資料庫及其所有資料。", createDatabaseSuccess: "資料庫「{name}」已建立", + editDatabaseProperties: "編輯資料庫屬性", + editDatabasePropertiesTitle: "編輯資料庫屬性:「{name}」", + editDatabasePropertiesSaving: "正在儲存...", + editDatabasePropertiesSuccess: "資料庫「{name}」屬性已更新", + editDatabaseComment: "資料庫註解", + editDatabaseCommentPlaceholder: "輸入資料庫註解...", createDuckDbFileSuccess: "DuckDB 資料庫檔案「{name}」已建立並附加", createDuckDbFileDesktopOnly: "建立 DuckDB 資料庫檔案僅支援桌面端", dropDatabaseSuccess: "資料庫「{name}」已刪除", diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index 72eb197418..570dccbd14 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation"; +import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing"; +import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql"; import { supportsTransaction } from "@/lib/database/databaseFeatureSupport"; describe("supportsTransaction", () => { @@ -26,3 +29,67 @@ describe("supportsTransaction", () => { expect(supportsTransaction(undefined)).toBe(false); }); }); + +describe("database property editing", () => { + it("allows MySQL-compatible charset and collation edits on database nodes", () => { + expect(editableDatabasePropertyGroups({ db_type: "mysql" }, { type: "database", database: "app" })).toEqual(["charsetCollation"]); + expect(editableDatabasePropertyGroups({ db_type: "goldendb" }, { type: "database", database: "app" })).toEqual(["charsetCollation"]); + expect(editableDatabasePropertyGroups({ db_type: "jdbc", driver_profile: "mysql" }, { type: "database", database: "app" })).toEqual([]); + }); + + it("allows PostgreSQL-style comment edits on supported database and schema nodes", () => { + expect(editableDatabasePropertyGroups({ db_type: "postgres" }, { type: "database", database: "postgres" })).toEqual(["databaseComment"]); + expect(editableDatabasePropertyGroups({ db_type: "kingbase" }, { type: "database", database: "TEST" })).toEqual(["databaseComment"]); + expect(editableSchemaPropertyGroups({ db_type: "postgres" }, { type: "schema", database: "postgres", schema: "public" })).toEqual(["schemaComment"]); + expect(editableSchemaPropertyGroups({ db_type: "highgo" }, { type: "schema", database: "postgres", schema: "public" })).toEqual(["schemaComment"]); + }); + + it("hides property editing for read-only, unsupported, and wrong tree nodes", () => { + expect(editableDatabasePropertyGroups({ db_type: "mysql", read_only: true }, { type: "database", database: "app" })).toEqual([]); + expect(editableDatabasePropertyGroups({ db_type: "sqlite" }, { type: "database", database: "main" })).toEqual([]); + expect(editableDatabasePropertyGroups({ db_type: "sqlserver" }, { type: "database", database: "master" })).toEqual([]); + expect(editableDatabasePropertyGroups({ db_type: "postgres" }, { type: "connection" })).toEqual([]); + expect(editableSchemaPropertyGroups({ db_type: "postgres", read_only: true }, { type: "schema", database: "postgres", schema: "public" })).toEqual([]); + expect(editableSchemaPropertyGroups({ db_type: "postgres" }, { type: "database", database: "postgres" })).toEqual([]); + }); + + it("queries PostgreSQL database comments from shared object descriptions", () => { + expect(buildGetDatabaseCommentSql({ databaseType: "postgres", name: "app" })).toContain("shobj_description(db.oid, 'pg_database')"); + }); +}); + +describe("database namespace creation", () => { + it("allows connection-level database creation for verified database targets", () => { + expect(connectionNamespaceCreationTarget({ db_type: "mysql" })).toBe("database"); + expect(connectionNamespaceCreationTarget({ db_type: "sqlserver" })).toBe("database"); + expect(connectionNamespaceCreationTarget({ db_type: "clickhouse" })).toBe("database"); + expect(connectionNamespaceCreationTarget({ db_type: "snowflake" })).toBe("database"); + expect(connectionNamespaceCreationTarget({ db_type: "databend" })).toBe("database"); + expect(connectionNamespaceCreationTarget({ db_type: "tdengine" })).toBe("database"); + }); + + it("keeps special creation flows explicit", () => { + expect(connectionNamespaceCreationTarget({ db_type: "duckdb" })).toBe("attach"); + expect(connectionNamespaceCreationTarget({ db_type: "mongodb" })).toBe("special"); + expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBeNull(); + }); + + it("hides creation for read-only, file-only, and unknown generic targets", () => { + expect(connectionNamespaceCreationTarget({ db_type: "mysql", read_only: true })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "sqlite" })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "jdbc" })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "oracle" })).toBeNull(); + }); + + it("allows schema creation only on writable database nodes with schema targets", () => { + expect(databaseNodeNamespaceCreationTarget({ db_type: "postgres" }, { type: "database", database: "postgres" })).toBe("schema"); + expect(databaseNodeNamespaceCreationTarget({ db_type: "sqlserver" }, { type: "database", database: "master" })).toBe("schema"); + expect(databaseNodeNamespaceCreationTarget({ db_type: "db2" }, { type: "database", database: "SAMPLE" })).toBe("schema"); + expect(databaseNodeNamespaceCreationTarget({ db_type: "postgres", read_only: true }, { type: "database", database: "postgres" })).toBeNull(); + expect(databaseNodeNamespaceCreationTarget({ db_type: "postgres" }, { type: "connection" })).toBeNull(); + expect(databaseNodeNamespaceCreationTarget({ db_type: "mysql" }, { type: "database", database: "app" })).toBeNull(); + expect(databaseNodeNamespaceCreationTarget({ db_type: "goldendb" }, { type: "database", database: "app" })).toBeNull(); + expect(databaseNodeNamespaceCreationTarget({ db_type: "duckdb" }, { type: "database", database: "main" })).toBeNull(); + expect(databaseNodeNamespaceCreationTarget({ db_type: "jdbc" }, { type: "database", database: "main" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index a8b7c4f0a1..3e98290f32 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -171,6 +171,7 @@ export const buildEmptyTableSql = forward("buildEmptyTableSql"); export const buildTruncateTableSql = forward("buildTruncateTableSql"); export const buildDropDatabaseSql = forward("buildDropDatabaseSql"); export const buildCreateSchemaSql = forward("buildCreateSchemaSql"); +export const buildUpdateDatabasePropertiesSql = forward("buildUpdateDatabasePropertiesSql"); export const buildDropSchemaSql = forward("buildDropSchemaSql"); export const buildDuplicateTableStructureSql = forward("buildDuplicateTableStructureSql"); export const buildCopyTableDataSql = forward("buildCopyTableDataSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 1b0efa14a6..6d0cd013b8 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -114,7 +114,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI import type { BuildViewDdlInput } from "@/lib/table/viewDdl"; import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql"; import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"; -import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport"; import { loadBrowserAppState, saveBrowserAppState } from "@/lib/backend/browserAppStateStorage"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare"; @@ -792,6 +792,10 @@ export async function buildCreateSchemaSql(options: SchemaNameSqlOptions): Promi return post("/api/query/build-create-schema-sql", { options }); } +export async function buildUpdateDatabasePropertiesSql(options: DatabasePropertyEditSqlOptions): Promise { + return post("/api/query/build-update-database-properties-sql", { options }); +} + export async function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise { return post("/api/query/build-drop-schema-sql", { options }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 95eeb221b4..35a83c1f58 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -57,7 +57,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI import type { BuildViewDdlInput } from "@/lib/table/viewDdl"; import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql"; import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"; -import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport"; export interface AgentDriverInfo { @@ -825,6 +825,10 @@ export async function buildCreateSchemaSql(options: SchemaNameSqlOptions): Promi return invoke("build_create_schema_sql", { options }); } +export async function buildUpdateDatabasePropertiesSql(options: DatabasePropertyEditSqlOptions): Promise { + return invoke("build_update_database_properties_sql", { options }); +} + export async function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise { return invoke("build_drop_schema_sql", { options }); } diff --git a/apps/desktop/src/lib/database/createDatabaseSql.ts b/apps/desktop/src/lib/database/createDatabaseSql.ts index 133bf08c06..6051ce5db2 100644 --- a/apps/desktop/src/lib/database/createDatabaseSql.ts +++ b/apps/desktop/src/lib/database/createDatabaseSql.ts @@ -7,6 +7,8 @@ const MYSQL_COMPATIBLE_TYPES = new Set(["mysql", "doris", "starroc export interface CreateDatabaseSqlOptions { databaseType?: DatabaseType; driverProfile?: string | null; + target?: "database" | "schema" | "catalog" | "namespace"; + parent?: string | null; name: string; charset?: string; collation?: string; diff --git a/apps/desktop/src/lib/database/databaseCapabilities.ts b/apps/desktop/src/lib/database/databaseCapabilities.ts index 5f7a5f6fa5..ce99af9997 100644 --- a/apps/desktop/src/lib/database/databaseCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseCapabilities.ts @@ -1,5 +1,7 @@ export * from "@/lib/database/databaseCapabilitySets"; export * from "@/lib/database/databaseDriverManifest"; export * from "@/lib/database/databaseFeatureSupport"; +export * from "@/lib/database/databaseNamespaceCreation"; +export * from "@/lib/database/databasePropertyEditing"; export * from "@/lib/database/databaseObjectCapabilities"; export * from "@/lib/database/databaseTableDataCapabilities"; diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts new file mode 100644 index 0000000000..462e4b4404 --- /dev/null +++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts @@ -0,0 +1,102 @@ +import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database"; + +export type DatabaseNamespaceCreationTarget = "database" | "schema" | "attach" | "special"; + +type ConnectionCreationTarget = Extract; +type DatabaseNodeCreationTarget = Extract; + +export interface DatabaseNamespaceCreationMatrixEntry { + connection?: ConnectionCreationTarget; + database?: DatabaseNodeCreationTarget; + deferred?: string; +} + +type CreationConnection = Pick | undefined; + +// Keep creation target-specific: many products expose schemas, files, or provider-managed namespaces instead of a top-level database. +export const DATABASE_NAMESPACE_CREATION_MATRIX = { + mysql: { connection: "database" }, + postgres: { connection: "database", database: "schema" }, + sqlite: { deferred: "file-backed; create a new connection/file instead" }, + rqlite: { deferred: "single SQLite-compatible database per node" }, + turso: { deferred: "remote libSQL database lifecycle is provider-managed" }, + redis: { deferred: "numbered logical databases are server-configured" }, + duckdb: { connection: "attach" }, + clickhouse: { connection: "database" }, + sqlserver: { connection: "database", database: "schema" }, + mongodb: { connection: "special" }, + oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" }, + elasticsearch: { deferred: "index creation is not modeled as database creation" }, + qdrant: { deferred: "collection creation is separate from database creation" }, + milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" }, + weaviate: { deferred: "collection creation is separate from database creation" }, + chromadb: { deferred: "collection creation is separate from database creation" }, + doris: { connection: "database" }, + starrocks: { connection: "database" }, + manticoresearch: { deferred: "index/table creation is not database creation" }, + databend: { connection: "database" }, + redshift: { connection: "database", database: "schema" }, + dameng: { deferred: "schema/user creation requires a dedicated user workflow" }, + gaussdb: { connection: "database", database: "schema" }, + kingbase: { connection: "database", database: "schema" }, + highgo: { connection: "database", database: "schema" }, + vastbase: { connection: "database", database: "schema" }, + goldendb: { connection: "database" }, + kwdb: { connection: "database", database: "schema" }, + yashandb: { connection: "database", database: "schema" }, + databricks: { database: "schema" }, + saphana: { database: "schema" }, + teradata: { database: "schema" }, + vertica: { database: "schema" }, + firebird: { deferred: "database files are created through connection provisioning" }, + exasol: { database: "schema" }, + opengauss: { connection: "database", database: "schema" }, + "oceanbase-oracle": { deferred: "Oracle-mode schemas are users; use a dedicated user workflow" }, + questdb: { deferred: "single database model in DBX" }, + gbase: { database: "schema" }, + access: { deferred: "file-backed; create a new connection/file instead" }, + h2: { database: "schema" }, + snowflake: { connection: "database", database: "schema" }, + trino: { database: "schema" }, + prestosql: { database: "schema" }, + hive: { deferred: "Hive database creation needs agent metadata validation first" }, + db2: { database: "schema" }, + informix: { database: "schema" }, + neo4j: { deferred: "database creation depends on edition/admin privileges" }, + cassandra: { deferred: "keyspace creation requires replication options" }, + bigquery: { deferred: "dataset creation needs project/location options" }, + kylin: { deferred: "project/model lifecycle is not SQL database creation" }, + sundb: { deferred: "creation semantics not verified for first pass" }, + oscar: { database: "schema" }, + tdengine: { connection: "database" }, + xugu: { database: "schema" }, + iotdb: { deferred: "storage group/database semantics need dedicated IoTDB handling" }, + etcd: { deferred: "key-value namespaces are not databases" }, + zookeeper: { deferred: "key-value namespaces are not databases" }, + iris: { database: "schema" }, + influxdb: { connection: "database" }, + jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" }, + mq: { deferred: "message queue namespaces are handled by MQ admin panels" }, + nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" }, +} satisfies Record; + +export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null { + if (!connection || connection.read_only) return null; + if (connection.db_type === "mongodb" && connection.driver_profile === "mongodb-legacy") return null; + const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; + return entry.connection ?? null; +} + +export function databaseNodeNamespaceCreationTarget(connection: CreationConnection, node: Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">): DatabaseNodeCreationTarget | null { + if (!connection || connection.read_only || node.type !== "database" || !node.database) return null; + const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; + return entry.database ?? null; +} + +export function canCreateConnectionNamespace(connection: CreationConnection): boolean { + return connectionNamespaceCreationTarget(connection) !== null; +} + +export function canCreateDatabaseNodeNamespace(connection: CreationConnection, node: Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">): boolean { + return databaseNodeNamespaceCreationTarget(connection, node) !== null; +} diff --git a/apps/desktop/src/lib/database/databasePropertyEditing.ts b/apps/desktop/src/lib/database/databasePropertyEditing.ts new file mode 100644 index 0000000000..f7f1947e7c --- /dev/null +++ b/apps/desktop/src/lib/database/databasePropertyEditing.ts @@ -0,0 +1,110 @@ +import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database"; +import { supportsCreateDatabaseCharset } from "@/lib/database/createDatabaseSql"; + +export type DatabasePropertyEditGroup = "charsetCollation" | "databaseComment" | "schemaComment"; + +export interface DatabasePropertyEditingEntry { + database?: DatabasePropertyEditGroup[]; + schema?: DatabasePropertyEditGroup[]; + deferred?: string; +} + +type PropertyEditConnection = Pick | undefined; +type DatabaseNode = Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">; +type SchemaNode = Pick<{ type: TreeNodeType; database?: string | null; schema?: string | null }, "type" | "database" | "schema">; + +const POSTGRES_COMMENT_TYPES = new Set(["postgres", "gaussdb", "kwdb", "kingbase", "highgo", "vastbase", "opengauss", "yashandb"]); + +export const DATABASE_PROPERTY_EDITING_MATRIX = { + mysql: { database: ["charsetCollation"] }, + postgres: { database: ["databaseComment"], schema: ["schemaComment"] }, + sqlite: { deferred: "file-backed database properties are not edited in-place" }, + rqlite: { deferred: "single SQLite-compatible database per node" }, + turso: { deferred: "remote libSQL database lifecycle is provider-managed" }, + redis: { deferred: "numbered logical databases are server-configured" }, + duckdb: { deferred: "attached database file properties need a dedicated DuckDB workflow" }, + clickhouse: { deferred: "database property editing not verified for first pass" }, + sqlserver: { deferred: "database options are broad administrative settings and need a dedicated editor" }, + mongodb: { deferred: "database options are not modeled as SQL database properties" }, + oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" }, + elasticsearch: { deferred: "index settings are not database properties" }, + qdrant: { deferred: "collection settings are not database properties" }, + milvus: { deferred: "collection/database settings need a dedicated vector workflow" }, + weaviate: { deferred: "collection settings are not database properties" }, + chromadb: { deferred: "collection settings are not database properties" }, + doris: { deferred: "database collation metadata is MySQL-compatible only and not an editable database default" }, + starrocks: { deferred: "ALTER DATABASE edits quota/rename/storage volume, not charset/collation defaults" }, + manticoresearch: { deferred: "index/table settings are not database properties" }, + databend: { deferred: "database property editing not verified for first pass" }, + redshift: { deferred: "comment catalog compatibility not verified for first pass" }, + dameng: { deferred: "schema/user settings need a dedicated workflow" }, + gaussdb: { database: ["databaseComment"], schema: ["schemaComment"] }, + kingbase: { database: ["databaseComment"], schema: ["schemaComment"] }, + highgo: { database: ["databaseComment"], schema: ["schemaComment"] }, + vastbase: { database: ["databaseComment"], schema: ["schemaComment"] }, + goldendb: { database: ["charsetCollation"] }, + kwdb: { database: ["databaseComment"], schema: ["schemaComment"] }, + yashandb: { database: ["databaseComment"], schema: ["schemaComment"] }, + databricks: { deferred: "catalog/schema properties need warehouse-specific handling" }, + saphana: { deferred: "schema properties need product-specific handling" }, + teradata: { deferred: "database properties need product-specific handling" }, + vertica: { deferred: "schema properties need product-specific handling" }, + firebird: { deferred: "database files are managed through connection provisioning" }, + exasol: { deferred: "schema properties need product-specific handling" }, + opengauss: { database: ["databaseComment"], schema: ["schemaComment"] }, + "oceanbase-oracle": { deferred: "Oracle-mode schemas are users; use a dedicated user workflow" }, + questdb: { deferred: "single database model in DBX" }, + gbase: { deferred: "schema properties need product-specific handling" }, + access: { deferred: "file-backed database properties are not edited in-place" }, + h2: { deferred: "schema properties need product-specific handling" }, + snowflake: { deferred: "database/schema properties need Snowflake-specific options" }, + trino: { deferred: "catalog properties are connector-managed" }, + prestosql: { deferred: "catalog properties are connector-managed" }, + hive: { deferred: "database properties need agent metadata validation first" }, + db2: { deferred: "schema properties need product-specific handling" }, + informix: { deferred: "schema properties need product-specific handling" }, + neo4j: { deferred: "database properties depend on edition/admin privileges" }, + cassandra: { deferred: "keyspace properties require replication option handling" }, + bigquery: { deferred: "dataset properties need project/location-specific handling" }, + kylin: { deferred: "project/model lifecycle is not SQL database property editing" }, + sundb: { deferred: "property semantics not verified for first pass" }, + oscar: { deferred: "schema properties need product-specific handling" }, + tdengine: { deferred: "database property editing not verified for first pass" }, + xugu: { deferred: "schema properties need product-specific handling" }, + iotdb: { deferred: "storage group/database semantics need dedicated IoTDB handling" }, + etcd: { deferred: "key-value namespaces are not databases" }, + zookeeper: { deferred: "key-value namespaces are not databases" }, + iris: { deferred: "schema properties need product-specific handling" }, + influxdb: { deferred: "database retention policies need a dedicated workflow" }, + jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" }, + mq: { deferred: "message queue namespaces are handled by MQ admin panels" }, + nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" }, +} satisfies Record; + +function entryFor(connection: PropertyEditConnection): DatabasePropertyEditingEntry | null { + if (!connection || connection.read_only) return null; + return DATABASE_PROPERTY_EDITING_MATRIX[connection.db_type] ?? null; +} + +export function editableDatabasePropertyGroups(connection: PropertyEditConnection, node: DatabaseNode): DatabasePropertyEditGroup[] { + if (node.type !== "database" || !node.database) return []; + const groups = entryFor(connection)?.database ?? []; + return groups.filter((group) => group !== "charsetCollation" || supportsCreateDatabaseCharset(connection?.db_type, connection?.driver_profile)); +} + +export function editableSchemaPropertyGroups(connection: PropertyEditConnection, node: SchemaNode): DatabasePropertyEditGroup[] { + if (node.type !== "schema" || !node.database) return []; + return entryFor(connection)?.schema ?? []; +} + +export function canEditDatabaseProperties(connection: PropertyEditConnection, node: DatabaseNode): boolean { + return editableDatabasePropertyGroups(connection, node).length > 0; +} + +export function canEditSchemaProperties(connection: PropertyEditConnection, node: SchemaNode): boolean { + return editableSchemaPropertyGroups(connection, node).length > 0; +} + +export function supportsPostgresStyleComments(databaseType?: DatabaseType): boolean { + return !!databaseType && POSTGRES_COMMENT_TYPES.has(databaseType); +} diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index 641d2e028c..150fe7e748 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -38,6 +38,16 @@ export interface SchemaCommentSqlOptions extends SchemaNameSqlOptions { comment: string; } +export interface DatabasePropertyEditSqlOptions { + databaseType?: DatabaseType; + driverProfile?: string | null; + target: "database" | "schema"; + name: string; + charset?: string; + collation?: string; + comment?: string; +} + export interface DuplicateTableStructureSqlOptions { databaseType?: DatabaseType; schema?: string | null; @@ -88,7 +98,18 @@ export function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise { + return api.buildUpdateDatabasePropertiesSql(options); +} + +export function buildGetDatabaseCommentSql(options: DatabaseNameSqlOptions): string { + if (!supportsSchemaComment(options.databaseType)) { + throw new Error("Database comments are not supported by this database"); + } + return ["SELECT pg_catalog.shobj_description(db.oid, 'pg_database') AS comment", "FROM pg_catalog.pg_database db", `WHERE db.datname = ${quoteSqlLiteral(options.name)};`].join("\n"); } export function buildGetSchemaCommentSql(options: SchemaNameSqlOptions): string { diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 5e62c0d607..20c0d6336c 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -284,7 +284,7 @@ "tableImport": true, "dataTransfer": true, "sqlFileExecution": true, - "databaseCreate": true, + "databaseCreate": false, "fieldLineage": true, "sqlExplain": false, "userAdmin": false, @@ -605,7 +605,7 @@ "tableImport": true, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": true, @@ -635,7 +635,7 @@ "tableImport": true, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": true, @@ -725,7 +725,7 @@ "tableImport": false, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": false, @@ -813,7 +813,7 @@ "tableImport": true, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": false, @@ -1081,7 +1081,7 @@ "tableImport": false, "dataTransfer": true, "sqlFileExecution": true, - "databaseCreate": true, + "databaseCreate": false, "fieldLineage": true, "sqlExplain": false, "userAdmin": false, @@ -1212,7 +1212,7 @@ "tableImport": false, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": false, @@ -1570,7 +1570,7 @@ "tableImport": false, "dataTransfer": false, "sqlFileExecution": true, - "databaseCreate": false, + "databaseCreate": true, "fieldLineage": false, "sqlExplain": false, "userAdmin": false, diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 41598a6172..408b57f93e 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -41,6 +41,10 @@ pub struct CreateDatabaseSqlOptions { pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub driver_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub charset: Option, @@ -48,6 +52,15 @@ pub struct CreateDatabaseSqlOptions { pub collation: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DatabaseCreationTarget { + Database, + Schema, + Catalog, + Namespace, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg(feature = "duckdb-bundled")] @@ -105,6 +118,30 @@ pub struct SchemaNameSqlOptions { pub name: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DatabasePropertyEditSqlOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver_profile: Option, + pub target: DatabasePropertyTarget, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub charset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DatabasePropertyTarget { + Database, + Schema, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DuplicateTableStructureSqlOptions { @@ -137,22 +174,119 @@ const MYSQL_COMPATIBLE_PROFILES: &[&str] = &["mysql", "mariadb", "tidb", "oceanbase", "doris", "starrocks", "custom_mysql"]; pub fn supports_create_database_charset(database_type: Option, driver_profile: Option<&str>) -> bool { + let normalized_profile = driver_profile.map(str::to_ascii_lowercase); matches!( database_type, Some(DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb) - ) || driver_profile.is_some_and(|profile| MYSQL_COMPATIBLE_PROFILES.contains(&profile)) + ) || normalized_profile.as_deref().is_some_and(|profile| MYSQL_COMPATIBLE_PROFILES.contains(&profile)) } -pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> String { +pub fn build_create_database_sql(options: CreateDatabaseSqlOptions) -> Result { + match options.target.unwrap_or(DatabaseCreationTarget::Database) { + DatabaseCreationTarget::Database => build_create_database_statement(&options), + // Schema creation is exposed through the same frontend dialog contract when the tree target is a database node. + DatabaseCreationTarget::Schema => { + build_create_schema_sql(SchemaNameSqlOptions { database_type: options.database_type, name: options.name }) + } + DatabaseCreationTarget::Catalog => Err("Creating catalogs is not supported yet.".to_string()), + DatabaseCreationTarget::Namespace => Err("Creating namespaces is not supported yet.".to_string()), + } +} + +fn build_create_database_statement(options: &CreateDatabaseSqlOptions) -> Result { + if !supports_create_database_target(options.database_type) { + return Err(format!("Creating databases is not supported for {}.", database_label(options.database_type))); + } let name = quote_table_identifier(options.database_type, &options.name); let charset = clean_sql_option(options.charset.as_deref()); let collation = clean_sql_option(options.collation.as_deref()); if !supports_create_database_charset(options.database_type, options.driver_profile.as_deref()) || charset.is_empty() { - return format!("CREATE DATABASE {name};"); + return Ok(format!("CREATE DATABASE {name};")); } let collate_clause = if collation.is_empty() { String::new() } else { format!(" COLLATE {collation}") }; - format!("CREATE DATABASE {name} CHARACTER SET {charset}{collate_clause};") + Ok(format!("CREATE DATABASE {name} CHARACTER SET {charset}{collate_clause};")) +} + +pub fn supports_create_database_target(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Mysql + | DatabaseType::Doris + | DatabaseType::StarRocks + | DatabaseType::Goldendb + | DatabaseType::ClickHouse + | DatabaseType::SqlServer + | DatabaseType::InfluxDb + | DatabaseType::Databend + | DatabaseType::Snowflake + | DatabaseType::Tdengine + | DatabaseType::Postgres + | DatabaseType::Redshift + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::OpenGauss + | DatabaseType::Vastbase + | DatabaseType::Highgo + | DatabaseType::Kingbase + | DatabaseType::Yashandb + ) + ) +} + +pub fn supports_create_schema_target(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Postgres + | DatabaseType::Redshift + | DatabaseType::SqlServer + | DatabaseType::Db2 + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Vastbase + | DatabaseType::Yashandb + | DatabaseType::Databricks + | DatabaseType::SapHana + | DatabaseType::Teradata + | DatabaseType::Vertica + | DatabaseType::Exasol + | DatabaseType::OpenGauss + | DatabaseType::Gbase + | DatabaseType::Trino + | DatabaseType::PrestoSql + | DatabaseType::H2 + | DatabaseType::Informix + | DatabaseType::Xugu + | DatabaseType::Oscar + | DatabaseType::Iris + | DatabaseType::Snowflake + ) + ) +} + +pub fn supports_database_property_charset(database_type: Option, driver_profile: Option<&str>) -> bool { + supports_create_database_charset(database_type, driver_profile) + && matches!(database_type, Some(DatabaseType::Mysql | DatabaseType::Goldendb)) +} + +pub fn supports_database_property_comment(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Postgres + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Vastbase + | DatabaseType::OpenGauss + | DatabaseType::Yashandb + ) + ) } #[cfg(feature = "duckdb-bundled")] @@ -292,8 +426,70 @@ pub fn build_drop_database_sql(options: DatabaseNameSqlOptions) -> String { format!("DROP DATABASE {};", quote_table_identifier(options.database_type, &options.name)) } -pub fn build_create_schema_sql(options: SchemaNameSqlOptions) -> String { - format!("CREATE SCHEMA {};", quote_table_identifier(options.database_type, &options.name)) +pub fn build_update_database_properties_sql(options: DatabasePropertyEditSqlOptions) -> Result { + match options.target { + DatabasePropertyTarget::Database => { + if options.comment.is_some() { + return build_database_comment_sql(options.database_type, &options.name, options.comment.as_deref()); + } + build_database_charset_sql(&options) + } + DatabasePropertyTarget::Schema => { + build_schema_comment_sql(options.database_type, &options.name, options.comment.as_deref()) + } + } +} + +fn build_database_charset_sql(options: &DatabasePropertyEditSqlOptions) -> Result { + if !supports_database_property_charset(options.database_type, options.driver_profile.as_deref()) { + return Err(format!( + "Editing database charset/collation is not supported for {}.", + database_label(options.database_type) + )); + } + let charset = clean_sql_option(options.charset.as_deref()); + let collation = clean_sql_option(options.collation.as_deref()); + if charset.is_empty() && collation.is_empty() { + return Err("At least one charset or collation value is required.".to_string()); + } + let mut sql = format!("ALTER DATABASE {}", quote_table_identifier(options.database_type, &options.name)); + if !charset.is_empty() { + sql.push_str(&format!(" DEFAULT CHARACTER SET {charset}")); + } + if !collation.is_empty() { + sql.push_str(&format!(" DEFAULT COLLATE {collation}")); + } + sql.push(';'); + Ok(sql) +} + +fn build_database_comment_sql( + database_type: Option, + name: &str, + comment: Option<&str>, +) -> Result { + if !supports_database_property_comment(database_type) { + return Err(format!("Editing database comments is not supported for {}.", database_label(database_type))); + } + Ok(format!("COMMENT ON DATABASE {} IS {};", quote_table_identifier(database_type, name), comment_literal(comment))) +} + +fn build_schema_comment_sql( + database_type: Option, + name: &str, + comment: Option<&str>, +) -> Result { + if !supports_database_property_comment(database_type) { + return Err(format!("Editing schema comments is not supported for {}.", database_label(database_type))); + } + Ok(format!("COMMENT ON SCHEMA {} IS {};", quote_table_identifier(database_type, name), comment_literal(comment))) +} + +pub fn build_create_schema_sql(options: SchemaNameSqlOptions) -> Result { + if !supports_create_schema_target(options.database_type) { + return Err(format!("Creating schemas is not supported for {}.", database_label(options.database_type))); + } + Ok(format!("CREATE SCHEMA {};", quote_table_identifier(options.database_type, &options.name))) } pub fn build_drop_schema_sql(options: SchemaNameSqlOptions) -> String { @@ -523,6 +719,13 @@ fn quote_sql_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } +fn comment_literal(value: Option<&str>) -> String { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => quote_sql_string(value), + None => "NULL".to_string(), + } +} + fn database_label(database_type: Option) -> String { database_type .and_then(|database_type| serde_json::to_value(database_type).ok()) @@ -540,10 +743,13 @@ mod tests { build_create_database_sql(CreateDatabaseSqlOptions { database_type: Some(DatabaseType::Mysql), driver_profile: Some("mysql".to_string()), + target: None, + parent: None, name: "app db".to_string(), charset: Some("utf8mb4".to_string()), collation: Some("utf8mb4_unicode_ci".to_string()), - }), + }) + .unwrap(), "CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" ); } @@ -554,10 +760,13 @@ mod tests { build_create_database_sql(CreateDatabaseSqlOptions { database_type: Some(DatabaseType::Goldendb), driver_profile: Some("goldendb".to_string()), + target: None, + parent: None, name: "app_db".to_string(), charset: Some("utf8mb4".to_string()), collation: Some("utf8mb4_unicode_ci".to_string()), - }), + }) + .unwrap(), "CREATE DATABASE `app_db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" ); } @@ -568,10 +777,13 @@ mod tests { build_create_database_sql(CreateDatabaseSqlOptions { database_type: Some(DatabaseType::Postgres), driver_profile: None, + target: None, + parent: None, name: "analytics".to_string(), charset: Some("utf8mb4".to_string()), collation: Some("utf8mb4_unicode_ci".to_string()), - }), + }) + .unwrap(), "CREATE DATABASE \"analytics\";" ); } @@ -582,14 +794,196 @@ mod tests { build_create_database_sql(CreateDatabaseSqlOptions { database_type: Some(DatabaseType::Vastbase), driver_profile: Some("vastbase".to_string()), + target: None, + parent: None, name: "app_db".to_string(), charset: Some("utf8mb4".to_string()), collation: Some("utf8mb4_unicode_ci".to_string()), - }), + }) + .unwrap(), "CREATE DATABASE \"app_db\";" ); } + #[test] + fn builds_additional_verified_create_database_targets() { + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::SqlServer), + driver_profile: None, + target: None, + parent: None, + name: "analytics db".to_string(), + charset: None, + collation: None, + }) + .unwrap(), + "CREATE DATABASE [analytics db];" + ); + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Snowflake), + driver_profile: None, + target: None, + parent: None, + name: "analytics db".to_string(), + charset: None, + collation: None, + }) + .unwrap(), + "CREATE DATABASE \"analytics db\";" + ); + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Databend), + driver_profile: None, + target: None, + parent: None, + name: "analytics db".to_string(), + charset: None, + collation: None, + }) + .unwrap(), + "CREATE DATABASE `analytics db`;" + ); + assert_eq!( + build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Tdengine), + driver_profile: None, + target: None, + parent: None, + name: "analytics db".to_string(), + charset: None, + collation: None, + }) + .unwrap(), + "CREATE DATABASE `analytics db`;" + ); + } + + #[test] + fn rejects_unsupported_create_database_targets() { + assert!(build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Oracle), + driver_profile: None, + target: None, + parent: None, + name: "analytics".to_string(), + charset: None, + collation: None, + }) + .unwrap_err() + .contains("Creating databases is not supported")); + assert!(build_create_database_sql(CreateDatabaseSqlOptions { + database_type: Some(DatabaseType::Jdbc), + driver_profile: None, + target: None, + parent: None, + name: "analytics".to_string(), + charset: None, + collation: None, + }) + .unwrap_err() + .contains("Creating databases is not supported")); + } + + #[test] + fn builds_mysql_database_property_charset_sql() { + assert_eq!( + build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: Some("mysql".to_string()), + target: DatabasePropertyTarget::Database, + name: "app db".to_string(), + charset: Some("utf8mb4".to_string()), + collation: Some("utf8mb4_unicode_ci".to_string()), + comment: None, + }) + .unwrap(), + "ALTER DATABASE `app db` DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;" + ); + assert_eq!( + build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::Goldendb), + driver_profile: None, + target: DatabasePropertyTarget::Database, + name: "app".to_string(), + charset: Some("utf8mb4".to_string()), + collation: None, + comment: None, + }) + .unwrap(), + "ALTER DATABASE `app` DEFAULT CHARACTER SET utf8mb4;" + ); + } + + #[test] + fn builds_postgres_style_database_and_schema_comment_sql() { + assert_eq!( + build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::Postgres), + driver_profile: None, + target: DatabasePropertyTarget::Database, + name: "app db".to_string(), + charset: None, + collation: None, + comment: Some("owner's app".to_string()), + }) + .unwrap(), + "COMMENT ON DATABASE \"app db\" IS 'owner''s app';" + ); + assert_eq!( + build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::Kingbase), + driver_profile: None, + target: DatabasePropertyTarget::Schema, + name: "public".to_string(), + charset: None, + collation: None, + comment: Some("".to_string()), + }) + .unwrap(), + "COMMENT ON SCHEMA \"public\" IS NULL;" + ); + } + + #[test] + fn rejects_unsupported_database_property_sql() { + assert!(build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::SqlServer), + driver_profile: None, + target: DatabasePropertyTarget::Database, + name: "master".to_string(), + charset: Some("utf8mb4".to_string()), + collation: None, + comment: None, + }) + .unwrap_err() + .contains("charset/collation is not supported")); + assert!(build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + target: DatabasePropertyTarget::Database, + name: "app".to_string(), + charset: None, + collation: None, + comment: Some("comment".to_string()), + }) + .unwrap_err() + .contains("database comments is not supported")); + assert!(build_update_database_properties_sql(DatabasePropertyEditSqlOptions { + database_type: Some(DatabaseType::DuckDb), + driver_profile: None, + target: DatabasePropertyTarget::Schema, + name: "main".to_string(), + charset: None, + collation: None, + comment: Some("comment".to_string()), + }) + .unwrap_err() + .contains("schema comments is not supported")); + } + #[test] fn recognizes_mysql_compatible_create_database_profiles() { assert!(supports_create_database_charset(Some(DatabaseType::Mysql), Some("oceanbase"))); @@ -733,9 +1127,32 @@ mod tests { build_create_schema_sql(SchemaNameSqlOptions { database_type: Some(DatabaseType::Postgres), name: "analytics".to_string(), - }), + }) + .unwrap(), "CREATE SCHEMA \"analytics\";" ); + assert_eq!( + build_create_schema_sql(SchemaNameSqlOptions { + database_type: Some(DatabaseType::SqlServer), + name: "analytics".to_string(), + }) + .unwrap(), + "CREATE SCHEMA [analytics];" + ); + assert_eq!( + build_create_schema_sql(SchemaNameSqlOptions { + database_type: Some(DatabaseType::Db2), + name: "analytics".to_string(), + }) + .unwrap(), + "CREATE SCHEMA \"analytics\";" + ); + assert!(build_create_schema_sql(SchemaNameSqlOptions { + database_type: Some(DatabaseType::DuckDb), + name: "analytics".to_string(), + }) + .unwrap_err() + .contains("Creating schemas is not supported")); assert_eq!( build_drop_schema_sql(SchemaNameSqlOptions { database_type: Some(DatabaseType::Kwdb), diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 1ce0f50380..34711c9bae 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -332,6 +332,7 @@ async fn main() { .route("/query/build-truncate-table-sql", post(routes::query::build_truncate_table_sql)) .route("/query/build-drop-database-sql", post(routes::query::build_drop_database_sql)) .route("/query/build-create-schema-sql", post(routes::query::build_create_schema_sql)) + .route("/query/build-update-database-properties-sql", post(routes::query::build_update_database_properties_sql)) .route("/query/build-drop-schema-sql", post(routes::query::build_drop_schema_sql)) .route("/query/build-duplicate-table-structure-sql", post(routes::query::build_duplicate_table_structure_sql)) .route("/query/build-copy-table-data-sql", post(routes::query::build_copy_table_data_sql)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index b4f67eda75..d05a3989e1 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -168,6 +168,12 @@ pub struct BuildSchemaNameSqlRequest { pub options: dbx_core::db_admin_sql::SchemaNameSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildDatabasePropertyEditSqlRequest { + pub options: dbx_core::db_admin_sql::DatabasePropertyEditSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildDuplicateTableStructureSqlRequest { @@ -582,8 +588,10 @@ pub async fn build_rename_object_sql(Json(req): Json) -> Json { - Json(dbx_core::db_admin_sql::build_create_database_sql(req.options)) +pub async fn build_create_database_sql( + Json(req): Json, +) -> Result, AppError> { + dbx_core::db_admin_sql::build_create_database_sql(req.options).map(Json).map_err(AppError) } pub async fn build_duckdb_attach_database_sql(Json(req): Json) -> Json { @@ -616,8 +624,14 @@ pub async fn build_drop_database_sql(Json(req): Json) -> Json { - Json(dbx_core::db_admin_sql::build_create_schema_sql(req.options)) +pub async fn build_create_schema_sql(Json(req): Json) -> Result, AppError> { + dbx_core::db_admin_sql::build_create_schema_sql(req.options).map(Json).map_err(AppError) +} + +pub async fn build_update_database_properties_sql( + Json(req): Json, +) -> Result, AppError> { + dbx_core::db_admin_sql::build_update_database_properties_sql(req.options).map(Json).map_err(AppError) } pub async fn build_drop_schema_sql(Json(req): Json) -> Json { diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 5edc5eb6e3..b123d8ddd1 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -333,7 +333,7 @@ pub fn build_rename_object_sql(options: dbx_core::db_admin_sql::RenameObjectSqlO #[tauri::command] pub fn build_create_database_sql(options: dbx_core::db_admin_sql::CreateDatabaseSqlOptions) -> Result { - Ok(dbx_core::db_admin_sql::build_create_database_sql(options)) + dbx_core::db_admin_sql::build_create_database_sql(options) } #[cfg(feature = "duckdb-bundled")] @@ -378,7 +378,14 @@ pub fn build_drop_database_sql(options: dbx_core::db_admin_sql::DatabaseNameSqlO #[tauri::command] pub fn build_create_schema_sql(options: dbx_core::db_admin_sql::SchemaNameSqlOptions) -> Result { - Ok(dbx_core::db_admin_sql::build_create_schema_sql(options)) + dbx_core::db_admin_sql::build_create_schema_sql(options) +} + +#[tauri::command] +pub fn build_update_database_properties_sql( + options: dbx_core::db_admin_sql::DatabasePropertyEditSqlOptions, +) -> Result { + dbx_core::db_admin_sql::build_update_database_properties_sql(options) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d92b7531b6..35a4b4ffcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -844,6 +844,7 @@ pub fn run() { commands::query::build_truncate_table_sql, commands::query::build_drop_database_sql, commands::query::build_create_schema_sql, + commands::query::build_update_database_properties_sql, commands::query::build_drop_schema_sql, commands::query::build_duplicate_table_structure_sql, commands::query::build_copy_table_data_sql,