From 23edbe391f6264377bef6b0cf17cca74a78b1759 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 24 Jun 2026 18:35:12 +0800 Subject: [PATCH] feat(postgres): support schema comments --- .../src/components/sidebar/TreeItem.vue | 125 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/es.ts | 5 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/i18n/locales/zh-TW.ts | 5 + apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/dbAdminSql.ts | 32 +++++ apps/desktop/src/lib/http.ts | 6 + apps/desktop/src/lib/tauri.ts | 5 + apps/desktop/src/stores/connectionStore.ts | 40 ++++-- apps/desktop/src/types/database.ts | 5 + crates/dbx-core/src/db/postgres.rs | 21 ++- crates/dbx-core/src/schema.rs | 28 ++++ crates/dbx-core/src/types.rs | 6 + packages/app-tests/dbAdminSql.test.ts | 23 ++++ src-tauri/src/commands/schema.rs | 9 ++ src-tauri/src/lib.rs | 1 + 20 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 packages/app-tests/dbAdminSql.test.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 82bf2294ab..3a41e0d946 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -83,11 +83,14 @@ import { buildDropDatabaseSql, buildDropObjectSql, buildDropSchemaSql, + buildGetSchemaCommentSql, buildDropTableSql, buildDropTableChildObjectSql, buildDuplicateTableStructureSql, buildEmptyTableSql, + buildSetSchemaCommentSql, buildTruncateTableSql, + supportsSchemaComment, type DropTableChildObjectSqlOptions, type DropObjectSqlOptions, type TableChildObjectType, @@ -407,17 +410,18 @@ const connectionInfoTooltip = computed(() => { return { rows }; }); -const tableInfoTooltip = computed(() => { +const objectCommentTooltip = computed(() => { const node = props.node; - if (node.type !== "table" && node.type !== "view") return null; + const comment = node.type === "column" && node.meta && "comment" in node.meta ? (node.meta as ColumnInfo).comment : node.comment; + if (!comment || (node.type !== "schema" && node.type !== "table" && node.type !== "view" && node.type !== "column")) return null; const rows: DetailTooltipRow[] = [ - { label: t("structureEditor.tableName"), value: visibleLabel(node) }, - { label: t("structureEditor.comment"), value: cleanTooltipValue(node.comment), multiline: true }, + { label: t("connection.name"), value: visibleLabel(node) }, + { label: t("structureEditor.comment"), value: cleanTooltipValue(comment), multiline: true }, ].filter((row) => row.value); return { rows }; }); -const detailTooltip = computed(() => connectionInfoTooltip.value ?? tableInfoTooltip.value); +const detailTooltip = computed(() => connectionInfoTooltip.value ?? objectCommentTooltip.value); function isTooltipDisabled(): boolean { if (detailTooltip.value?.rows.length) return isRenamingGroup.value; @@ -1421,6 +1425,9 @@ const showFlushRedisDbConfirm = ref(false); const showCreateSchemaDialog = ref(false); const createSchemaName = ref(""); const showDropSchemaConfirm = ref(false); +const showEditSchemaCommentDialog = ref(false); +const schemaCommentText = ref(""); +const schemaCommentLoading = ref(false); // --- Procedure / Function Management --- const showDropObjectConfirm = ref(false); @@ -1935,6 +1942,11 @@ const canDropSchema = computed(() => { return props.node.type === "schema" && !isSqlServerLinkedNode(props.node) && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config); }); +const canEditSchemaComment = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return props.node.type === "schema" && !!props.node.database && !config?.read_only && supportsSchemaComment(effectiveDatabaseTypeForConnection(config)); +}); + function tableAdminSqlOptions(): TableAdminSqlOptions { return { databaseType: currentDatabaseType(), @@ -2038,6 +2050,75 @@ 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 schemaCommentFromResult(result: { columns?: string[]; rows?: unknown[] }): string { + const firstRow = result.rows?.[0]; + if (Array.isArray(firstRow)) { + const index = Math.max(0, result.columns?.findIndex((column) => column === "comment") ?? 0); + return firstRow[index] == null ? "" : String(firstRow[index]); + } + if (firstRow && typeof firstRow === "object" && "comment" in firstRow) { + const value = (firstRow as { comment?: unknown }).comment; + return value == null ? "" : String(value); + } + return ""; +} + +async function openEditSchemaCommentDialog() { + const node = props.node; + if (!canEditSchemaComment.value || !node.connectionId || !node.database) return; + schemaCommentText.value = ""; + schemaCommentLoading.value = true; + showEditSchemaCommentDialog.value = true; + try { + await connectionStore.ensureConnected(node.connectionId); + const sql = buildGetSchemaCommentSql({ + databaseType: currentDatabaseType(), + name: node.schema || node.label, + }); + const result = await api.executeQuery(node.connectionId, node.database, sql, node.schema, undefined, { maxRows: 1 }); + schemaCommentText.value = schemaCommentFromResult(result); + } catch { + schemaCommentText.value = ""; + } finally { + schemaCommentLoading.value = false; + } +} + +async function confirmEditSchemaComment() { + const node = props.node; + if (!canEditSchemaComment.value || !node.connectionId || !node.database || schemaCommentLoading.value) return; + schemaCommentLoading.value = true; + try { + await connectionStore.ensureConnected(node.connectionId); + const sql = buildSetSchemaCommentSql({ + databaseType: currentDatabaseType(), + name: node.schema || node.label, + comment: schemaCommentText.value, + }); + await api.executeQuery(node.connectionId, node.database, sql, node.schema); + toast(t("contextMenu.editSchemaCommentSuccess", { name: node.label }), 3000); + showEditSchemaCommentDialog.value = false; + await connectionStore.loadSchemas(node.connectionId, node.database, { force: true }); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } finally { + schemaCommentLoading.value = false; + } +} + async function openCreateDatabase() { if (isDuckDbConnection.value) { await createDuckDbAttachedDatabaseFile(); @@ -2953,7 +3034,11 @@ const hasTypeMenu = computed(() => { }); const columnComment = computed(() => (!settingsStore.editorSettings.sidebarHideTableComments && props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null)); const tableComment = computed(() => - !settingsStore.editorSettings.sidebarHideTableComments && (props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection" || props.node.type === "vector-collection" || props.node.type === "elasticsearch-index") && props.node.comment ? props.node.comment : null, + !settingsStore.editorSettings.sidebarHideTableComments && + (props.node.type === "schema" || props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection" || props.node.type === "vector-collection" || props.node.type === "elasticsearch-index") && + props.node.comment + ? props.node.comment + : null, ); const paddingLeft = computed(() => treeItemPaddingLeft(props.depth)); const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)); @@ -3494,6 +3579,9 @@ function treeItemMenuItems(): ContextMenuItem[] { if (canCreateSchema.value) { items.push({ label: t("contextMenu.createSchema"), action: openCreateSchemaDialog, icon: Plus }); } + if (canEditSchemaComment.value) { + items.push({ label: t("contextMenu.editSchemaComment"), action: openEditSchemaCommentDialog, icon: SquarePen }); + } if (canOpenSqlFileExecution.value) { items.push({ label: t("sqlFile.title"), action: openSqlFileExecution, icon: FileCode }); } @@ -4224,6 +4312,31 @@ function treeItemMenuItems(): ContextMenuItem[] { + + + + {{ t("contextMenu.editSchemaCommentTitle", { name: node.label }) }} + +
+ +

+      
+ + + + +
+
+ diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 968ae8df8c..291aabe0b9 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1306,6 +1306,11 @@ export default { createSchemaSuccess: 'Schema "{name}" created', dropSchemaSuccess: 'Schema "{name}" dropped', createSchemaNamePlaceholder: "Schema name", + editSchemaComment: "Edit Comment", + editSchemaCommentTitle: 'Edit Comment: "{name}"', + schemaCommentPlaceholder: "Enter schema comment...", + schemaCommentSaving: "Saving...", + editSchemaCommentSuccess: 'Schema "{name}" comment updated', }, visibleDatabases: { title: "Visible Databases", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4214d99950..b1127124b3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1118,6 +1118,11 @@ export default { createSchemaSuccess: 'Esquema "{name}" creado', dropSchemaSuccess: 'Esquema "{name}" eliminado', createSchemaNamePlaceholder: "Nombre del esquema", + editSchemaComment: "Editar comentario", + editSchemaCommentTitle: 'Editar comentario: "{name}"', + schemaCommentPlaceholder: "Introduce un comentario del esquema...", + schemaCommentSaving: "Guardando...", + editSchemaCommentSuccess: 'Comentario del esquema "{name}" actualizado', }, visibleDatabases: { title: "Bases de datos visibles", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 24c01f50f6..29cba3dd83 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1242,6 +1242,11 @@ export default { createSchemaSuccess: 'Schema "{name}" creato', dropSchemaSuccess: 'Schema "{name}" eliminato', createSchemaNamePlaceholder: "Nome schema", + editSchemaComment: "Modifica commento", + editSchemaCommentTitle: 'Modifica commento: "{name}"', + schemaCommentPlaceholder: "Inserisci commento schema...", + schemaCommentSaving: "Salvataggio...", + editSchemaCommentSuccess: 'Commento dello schema "{name}" aggiornato', }, visibleDatabases: { title: "Database Visibili", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 5580166496..1d29088f4a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1294,6 +1294,11 @@ export default { createSchemaSuccess: "スキーマ「{name}」を作成しました", dropSchemaSuccess: "スキーマ「{name}」を削除しました", createSchemaNamePlaceholder: "スキーマ名", + editSchemaComment: "コメントを編集", + editSchemaCommentTitle: "コメントを編集: 「{name}」", + schemaCommentPlaceholder: "スキーマコメントを入力...", + schemaCommentSaving: "保存中...", + editSchemaCommentSuccess: "スキーマ「{name}」のコメントを更新しました", }, visibleDatabases: { title: "表示するデータベース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bcd2206a0e..61703633b2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1242,6 +1242,11 @@ export default { createSchemaSuccess: 'Schema "{name}" criado', dropSchemaSuccess: 'Schema "{name}" removido', createSchemaNamePlaceholder: "Nome do schema", + editSchemaComment: "Editar comentário", + editSchemaCommentTitle: 'Editar comentário: "{name}"', + schemaCommentPlaceholder: "Digite o comentário do schema...", + schemaCommentSaving: "Salvando...", + editSchemaCommentSuccess: 'Comentário do schema "{name}" atualizado', }, visibleDatabases: { title: "Bancos de dados visíveis", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 7359a38111..ba089ef93b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1305,6 +1305,11 @@ export default { createSchemaSuccess: "Schema「{name}」已创建", dropSchemaSuccess: "Schema「{name}」已删除", createSchemaNamePlaceholder: "Schema 名称", + editSchemaComment: "编辑注释", + editSchemaCommentTitle: "编辑注释:「{name}」", + schemaCommentPlaceholder: "输入 Schema 注释...", + schemaCommentSaving: "正在保存...", + editSchemaCommentSuccess: "Schema「{name}」注释已更新", }, visibleDatabases: { title: "显示数据库", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 43cc4a721a..36fa70e5ad 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1222,6 +1222,11 @@ export default { createSchemaSuccess: "Schema「{name}」已建立", dropSchemaSuccess: "Schema「{name}」已刪除", createSchemaNamePlaceholder: "Schema 名稱", + editSchemaComment: "編輯註解", + editSchemaCommentTitle: "編輯註解:「{name}」", + schemaCommentPlaceholder: "輸入 Schema 註解...", + schemaCommentSaving: "正在儲存...", + editSchemaCommentSuccess: "Schema「{name}」註解已更新", }, visibleDatabases: { title: "顯示資料庫", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 019ccc172a..f3977c0a13 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -113,6 +113,7 @@ export const saveSchemaCache = forward("saveSchemaCache"); export const loadSchemaCache = forward("loadSchemaCache"); export const deleteSchemaCachePrefix = forward("deleteSchemaCachePrefix"); export const listSchemas = forward("listSchemas"); +export const listSchemaInfos = forward("listSchemaInfos"); export const listTables = forward("listTables"); export const getTableComment = forward("getTableComment"); export const listObjects = forward("listObjects"); diff --git a/apps/desktop/src/lib/dbAdminSql.ts b/apps/desktop/src/lib/dbAdminSql.ts index b6cffda8f9..203d81cbad 100644 --- a/apps/desktop/src/lib/dbAdminSql.ts +++ b/apps/desktop/src/lib/dbAdminSql.ts @@ -34,6 +34,10 @@ export interface SchemaNameSqlOptions { name: string; } +export interface SchemaCommentSqlOptions extends SchemaNameSqlOptions { + comment: string; +} + export interface DuplicateTableStructureSqlOptions { databaseType?: DatabaseType; schema?: string | null; @@ -73,6 +77,34 @@ export function buildDropSchemaSql(options: SchemaNameSqlOptions): Promise { return api.buildDuplicateTableStructureSql(options); } + +function quotePostgresIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +function quoteSqlLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index caa47a7e1a..8aaed4dd93 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1,6 +1,7 @@ import type { ConnectionConfig, DatabaseInfo, + SchemaInfo, LinkedServerInfo, TableInfo, ObjectInfo, @@ -479,6 +480,11 @@ export async function listSchemas(connectionId: string, database: string): Promi return get(`/api/schema/schemas?${qs({ connection_id: connectionId, database })}`); } +export async function listSchemaInfos(connectionId: string, database: string): Promise { + const schemas = await listSchemas(connectionId, database); + return schemas.map((name) => ({ name, comment: null })); +} + export async function listTables(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: SidebarObjectKind[]): Promise { return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema, filter, limit, offset, object_types: objectTypes?.join(",") })}`); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index ffe0f72436..d01ca94635 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -3,6 +3,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { ConnectionConfig, DatabaseInfo, + SchemaInfo, LinkedServerInfo, TableInfo, ObjectInfo, @@ -547,6 +548,10 @@ export async function listSchemas(connectionId: string, database: string): Promi return invoke("list_schemas", { connectionId, database }); } +export async function listSchemaInfos(connectionId: string, database: string): Promise { + return invoke("list_schema_infos", { connectionId, database }); +} + export async function getColumns(connectionId: string, database: string, schema: string, table: string): Promise { return invoke("get_columns", { connectionId, database, schema, table }); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 56df06bbf4..22175943d7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/utils"; import { ref, computed, watch } from "vue"; -import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, ForeignKeyInfo, ObjectInfo, SidebarLayout, TableInfo, TreeNode } from "@/types/database"; +import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode } from "@/types/database"; import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/pinnedItems"; import { reconcileLayout, @@ -570,6 +570,16 @@ export const useConnectionStore = defineStore("connection", () => { return sidebarObjectKindsForDatabase(dbType); } + function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] { + const byName = new Map(); + for (const schema of schemas) { + const name = schema.name.trim(); + if (!name) continue; + byName.set(name, { name, comment: schema.comment ?? null }); + } + return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!); + } + function objectGroupCacheKey(node: TreeNode): string { return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, "objects-v3"); } @@ -1501,7 +1511,7 @@ export const useConnectionStore = defineStore("connection", () => { try { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; - const cacheKey = schemaCacheKey(connectionId, database, "schemas"); + const cacheKey = schemaCacheKey(connectionId, database, "schemas-v2"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey); if (cached.hit) { @@ -1510,17 +1520,21 @@ export const useConnectionStore = defineStore("connection", () => { } } - const schemas = sortSidebarNames(await api.listSchemas(connectionId, database)); - const children = schemas.map((s) => ({ - id: `${connectionId}:${database}:${s}`, - label: s, - type: "schema" as const, - connectionId, - database, - schema: s, - isExpanded: false, - children: [], - })); + const schemas = sortSidebarSchemaInfos(await api.listSchemaInfos(connectionId, database)); + const children = schemas.map((schema) => { + const s = schema.name; + return { + id: `${connectionId}:${database}:${s}`, + label: s, + type: "schema" as const, + connectionId, + database, + schema: s, + comment: schema.comment, + isExpanded: false, + children: [], + }; + }); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); node.isExpanded = true; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 62e87a1411..ae4f7b4db0 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -250,6 +250,11 @@ export interface DatabaseInfo { name: string; } +export interface SchemaInfo { + name: string; + comment?: string | null; +} + export interface LinkedServerInfo { name: string; product?: string | null; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 5dedfe838f..c115a73891 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -25,7 +25,7 @@ use crate::types::{ ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode, CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult, RuleInfo, - SequenceInfo, TableInfo, TriggerInfo, + SchemaInfo, SequenceInfo, TableInfo, TriggerInfo, }; fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option { @@ -1479,10 +1479,19 @@ pub async fn list_object_statistics(pool: &Pool, schema: &str) -> Result Result, String> { + Ok(list_schema_infos(pool).await?.into_iter().map(|schema| schema.name).collect()) +} + +pub async fn list_schema_infos(pool: &Pool) -> Result, String> { let client = pool.get().await.map_err(|e| e.to_string())?; let stmt = client .prepare_cached( - "SELECT n.nspname AS schema_name FROM pg_catalog.pg_namespace n \ + "SELECT n.nspname AS schema_name, d.description AS schema_comment \ + FROM pg_catalog.pg_namespace n \ + LEFT JOIN pg_catalog.pg_description d \ + ON d.objoid = n.oid \ + AND d.objsubid = 0 \ + AND d.classoid = 'pg_namespace'::regclass \ WHERE n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \ AND n.nspname NOT LIKE 'pg_toast_temp_%' \ AND n.nspname NOT LIKE 'pg_temp_%' \ @@ -1492,7 +1501,13 @@ pub async fn list_schemas(pool: &Pool) -> Result, String> { .map_err(|e| e.to_string())?; let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?; - Ok(rows.iter().map(|row| row.get::<_, String>(0)).collect()) + Ok(rows + .iter() + .map(|row| SchemaInfo { + name: row.get::<_, String>(0), + comment: row.try_get::<_, Option>(1).ok().flatten(), + }) + .collect()) } const POSTGRES_COLUMNS_SQL: &str = "SELECT a.attname AS column_name, \ diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 0411d821b6..dec35173f2 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -590,6 +590,34 @@ pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: .await } +pub async fn list_schema_infos_core( + state: &AppState, + connection_id: &str, + database: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || { + list_schema_infos_once(state, connection_id, database) + }) + .await +} + +async fn list_schema_infos_once( + state: &AppState, + connection_id: &str, + database: &str, +) -> Result, String> { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + { + let connections = state.connections.read().await; + if let Some(PoolKind::Postgres(pool)) = connections.get(&pool_key) { + return db::postgres::list_schema_infos(pool).await; + } + } + + let schemas = list_schemas_once(state, connection_id, database).await?; + Ok(schemas.into_iter().map(|name| db::SchemaInfo { name, comment: None }).collect()) +} + async fn list_schemas_once(state: &AppState, connection_id: &str, database: &str) -> Result, String> { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 2739cb85eb..030dce0738 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -5,6 +5,12 @@ pub struct DatabaseInfo { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaInfo { + pub name: String, + pub comment: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LinkedServerInfo { pub name: String, diff --git a/packages/app-tests/dbAdminSql.test.ts b/packages/app-tests/dbAdminSql.test.ts new file mode 100644 index 0000000000..0ea117eaeb --- /dev/null +++ b/packages/app-tests/dbAdminSql.test.ts @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; + +import { buildGetSchemaCommentSql, buildSetSchemaCommentSql, supportsSchemaComment } from "../../apps/desktop/src/lib/dbAdminSql.ts"; + +test("postgres schema comment SQL quotes identifiers and literals", () => { + assert.equal(supportsSchemaComment("postgres"), true); + assert.equal(supportsSchemaComment("mysql"), false); + assert.equal(buildSetSchemaCommentSql({ databaseType: "postgres", name: 'tenant"one', comment: "Owner's area" }), `COMMENT ON SCHEMA "tenant""one" IS 'Owner''s area';`); +}); + +test("postgres schema comment SQL clears blank comments", () => { + assert.equal(buildSetSchemaCommentSql({ databaseType: "postgres", name: "analytics", comment: " " }), `COMMENT ON SCHEMA "analytics" IS NULL;`); +}); + +test("postgres schema comment lookup uses pg_namespace description join", () => { + const sql = buildGetSchemaCommentSql({ databaseType: "postgres", name: "tenant's schema" }); + + assert.match(sql, /FROM pg_catalog\.pg_namespace n/); + assert.match(sql, /pg_catalog\.pg_description d/); + assert.match(sql, /d\.classoid = 'pg_namespace'::regclass/); + assert.match(sql, /WHERE n\.nspname = 'tenant''s schema';/); +}); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index d158ae8776..d04222e801 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -72,6 +72,15 @@ pub async fn list_schemas( dbx_core::schema::list_schemas_core(&state, &connection_id, &database).await } +#[tauri::command] +pub async fn list_schema_infos( + state: State<'_, Arc>, + connection_id: String, + database: String, +) -> Result, String> { + dbx_core::schema::list_schema_infos_core(&state, &connection_id, &database).await +} + #[tauri::command] pub async fn list_tables( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92cef2265c..a4f9622f8f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -457,6 +457,7 @@ pub fn run() { commands::schema::completion_assistant_search, commands::schema::get_object_source, commands::schema::list_schemas, + commands::schema::list_schema_infos, commands::schema::get_columns, commands::schema::list_indexes, commands::schema::list_foreign_keys,