diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 84867a7783..b36d53a34b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -38,6 +38,7 @@ import { PanelBottom, PanelRight, TableProperties, + UserRound, Database, Eraser, Columns3, @@ -79,6 +80,7 @@ import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind"; import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation"; import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoColumnNavigation"; import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference"; +import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import * as api from "@/lib/backend/api"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; @@ -10060,6 +10062,37 @@ const ddlContent = ref(""); const ddlPreRef = ref(null); const ddlSearchMatchCount = ref(0); const ddlSearchMatchIndex = ref(0); +const tableOwner = ref(null); +const tableOwnerLoading = ref(false); +const tableOwnerError = ref(""); +let tableOwnerRequestGeneration = 0; +const canShowTableOwner = computed(() => resolvedDatabaseType.value === "postgres" && !!props.connectionId && !!props.database && !!props.tableMeta?.schema && !!props.tableMeta?.tableName); + +async function fetchTableOwner(force = false) { + if (!canShowTableOwner.value || !props.connectionId || !props.database || !props.tableMeta?.schema || !props.tableMeta.tableName) return; + const requestGeneration = ++tableOwnerRequestGeneration; + const request = { + connectionId: props.connectionId, + database: props.database, + schema: props.tableMeta.schema, + tableName: props.tableMeta.tableName, + catalog: props.tableMeta.catalog, + objectType: tableObjectSourceKind(props.tableMeta.tableType), + }; + tableOwnerLoading.value = true; + tableOwnerError.value = ""; + try { + const result = await loadObjectMetadataFacet(request, "owner", () => api.getTableOwner(request.connectionId, request.database, request.schema, request.tableName), { force }); + if (requestGeneration !== tableOwnerRequestGeneration) return; + tableOwner.value = result.value; + } catch (error: any) { + if (requestGeneration !== tableOwnerRequestGeneration) return; + tableOwner.value = null; + tableOwnerError.value = error?.message || String(error); + } finally { + if (requestGeneration === tableOwnerRequestGeneration) tableOwnerLoading.value = false; + } +} function scrollDdlSearchMatchIntoView(match: HTMLElement) { const pre = ddlPreRef.value; @@ -10485,6 +10518,10 @@ async function fetchTriggers() { watch( () => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName], () => { + tableOwner.value = null; + tableOwnerLoading.value = false; + tableOwnerError.value = ""; + tableOwnerRequestGeneration += 1; ddlContent.value = ""; indexes.value = []; indexesLoaded.value = false; @@ -10504,9 +10541,14 @@ watch( void fetchIndexes(); } if (showTableInfo.value) selectTableInfoTab(activeTableInfoTab.value); + if (showTableInfo.value) void fetchTableOwner(); }, ); +watch([showTableInfo, canShowTableOwner], ([shown, supported]) => { + if (shown && supported && !tableOwnerLoading.value && tableOwner.value === null && !tableOwnerError.value) void fetchTableOwner(); +}); + watch( () => [showIndexIndicatorsInHeader.value, canShowTableIndexes.value, currentIndexTableIdentity.value] as const, ([showIndicators, canShowIndexes, tableIdentity]) => { @@ -13001,6 +13043,14 @@ function currentGridContextMenuItems(): ContextMenuItem[] { +
+ + + {{ t("grid.tableOwner") }} + {{ tableOwner }} + {{ t("grid.tableOwnerUnavailable") }} + - +
+
+ + + + + + + + {{ t("structureEditor.ownerLoadFailed", { message: tableOwnerLoadError }) }} + + + + + + {{ t("structureEditor.ownerRolesLoadFailed", { message: tableOwnerRolesLoadError }) }} + +
+
{{ t("common.loading") }} diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrentTransition.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrentTransition.spec.ts index 03f5a83506..23be36b37f 100644 --- a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrentTransition.spec.ts +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.concurrentTransition.spec.ts @@ -11,14 +11,18 @@ const mocks = vi.hoisted(() => ({ driver_label: "PostgreSQL", }, ensureConnected: vi.fn(), + executeQuery: vi.fn(), listDataTypes: vi.fn(), buildTableStructureChangeSql: vi.fn(), updateEditorSettings: vi.fn(), loadObjectDdl: vi.fn(), invalidateObjectDdl: vi.fn(), loadObjectMetadataFacet: vi.fn(), + invalidateObjectMetadataCache: vi.fn(), invalidateTableMetadataCache: vi.fn(), getTablePartitionStatus: vi.fn(), + getTableOwner: vi.fn(), + buildTableOwnerChangeSql: vi.fn(), toast: vi.fn(), })); @@ -46,6 +50,7 @@ vi.mock("@lucide/vue", async () => { Settings: Icon, SlidersHorizontal: Icon, Trash2: Icon, + UserRound: Icon, X: Icon, }; }); @@ -231,12 +236,15 @@ vi.mock("@/lib/metadata/objectDdlCache", () => ({ loadObjectDdl: mocks.loadObjectDdl, invalidateObjectDdl: mocks.invalidateObjectDdl, })); -vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet })); +vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache })); vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, listDataTypes: mocks.listDataTypes, buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, + buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql, getTablePartitionStatus: mocks.getTablePartitionStatus, + getTableOwner: mocks.getTableOwner, })); import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; @@ -248,11 +256,12 @@ async function mountIndexesEditor() { mocks.connection.name = "postgres"; mocks.connection.driver_label = "postgres"; mocks.ensureConnected.mockResolvedValue(undefined); + mocks.executeQuery.mockResolvedValue({ columns: ["user", "host", "plugin"], rows: [["app_user", "LOGIN", ""]] }); mocks.listDataTypes.mockResolvedValue([]); mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false }); mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ - value: facet === "comment" ? "" : [], + value: facet === "comment" ? "" : facet === "owner" ? "app_user" : [], cacheStatus: "remote", })); // The SQL builder mirrors the Concurrent checkbox onto the generated @@ -298,6 +307,8 @@ function concurrentCheckboxInRow(row: HTMLElement): HTMLInputElement { beforeEach(() => { vi.clearAllMocks(); mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false }); + mocks.getTableOwner.mockResolvedValue("app_user"); + mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] }); }); afterEach(() => { diff --git a/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue b/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue index dd5fed807e..c3b5896d76 100644 --- a/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue +++ b/apps/desktop/src/components/ui/searchable-select/SearchableSelect.vue @@ -32,6 +32,7 @@ const props = withDefaults( displayName?: (option: string) => string; optionTooltip?: (option: string) => string | undefined; normalizeCustom?: (value: string) => string; + trimCustom?: boolean; clearable?: boolean; clearSelectedOption?: boolean; }>(), @@ -41,6 +42,7 @@ const props = withDefaults( allowCustom: false, clearable: false, clearSelectedOption: false, + trimCustom: true, loadingText: "Loading...", triggerVariant: "outline", triggerIconClass: "size-4 text-muted-foreground", @@ -83,7 +85,7 @@ const triggerBaseClass = computed(() => ); const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName)); -const customOptionValue = computed(() => props.normalizeCustom(searchText.value.trim())); +const customOptionValue = computed(() => props.normalizeCustom(props.trimCustom ? searchText.value.trim() : searchText.value)); const canSelectCustom = computed(() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value)); function highlightSelectedOption() { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 15c2175dcf..4d3c8ed12b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2040,6 +2040,8 @@ export default { tableInfoNullable: "Nullable", tableInfoEmpty: "No metadata", tableInfoSearch: "Search...", + tableOwner: "Owner", + tableOwnerUnavailable: "Unavailable", tableInfoNoResults: "No results", goToColumn: "Go to column", columnWidth: "Column Width", @@ -3771,6 +3773,12 @@ export default { editComment: "Edit comment", commentPlaceholder: "Enter column comment...", tableCommentPlaceholder: "Enter table comment...", + owner: "Owner", + ownerPlaceholder: "Enter role name...", + ownerSearchPlaceholder: "Search or enter a role name...", + ownerRolesEmpty: "No roles found; enter a role name manually", + ownerLoadFailed: "Failed to load table owner: {message}", + ownerRolesLoadFailed: "Failed to load roles; you can still enter one manually: {message}", tableCommentUnsupported: "Table comments are not supported by this database", actions: "Actions", indexName: "Index", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ea0355445b..5e293f04ab 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1891,6 +1891,8 @@ export default withEnglishFallback({ rowsPerPageShort: " filas", columnDetails: "Detalles de columna", tableInfoSearch: "Buscar...", + tableOwner: "Propietario", + tableOwnerUnavailable: "No disponible", tableInfoNoResults: "Sin resultados", goToColumn: "Ir a la columna", columnWidth: "Ancho de columna", @@ -3594,6 +3596,12 @@ export default withEnglishFallback({ editComment: "Editar comentario", commentPlaceholder: "Ingresar comentario de columna...", tableCommentPlaceholder: "Ingresar comentario de tabla...", + owner: "Propietario", + ownerPlaceholder: "Ingrese el nombre del rol...", + ownerSearchPlaceholder: "Buscar o ingresar un rol...", + ownerRolesEmpty: "No se encontraron roles; ingrese uno manualmente", + ownerLoadFailed: "No se pudo cargar el propietario de la tabla: {message}", + ownerRolesLoadFailed: "No se pudieron cargar los roles; aún puede ingresar uno manualmente: {message}", tableCommentUnsupported: "La base de datos no admite comentarios de tabla", actions: "Acciones", indexName: "Índice", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 8256e2417a..7ad944d185 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1900,6 +1900,8 @@ export default withEnglishFallback({ tableInfoNullable: "Nullabile", tableInfoEmpty: "Nessun metadato", tableInfoSearch: "Cerca...", + tableOwner: "Proprietario", + tableOwnerUnavailable: "Non disponibile", tableInfoNoResults: "Nessun risultato", goToColumn: "Vai alla colonna", columnWidth: "Larghezza colonna", @@ -3592,6 +3594,12 @@ export default withEnglishFallback({ editComment: "Modifica commento", commentPlaceholder: "Inserisci commento colonna...", tableCommentPlaceholder: "Inserisci commento tabella...", + owner: "Proprietario", + ownerPlaceholder: "Inserisci il nome del ruolo...", + ownerSearchPlaceholder: "Cerca o inserisci un ruolo...", + ownerRolesEmpty: "Nessun ruolo trovato; inseriscine uno manualmente", + ownerLoadFailed: "Impossibile caricare il proprietario della tabella: {message}", + ownerRolesLoadFailed: "Impossibile caricare i ruoli; puoi comunque inserirne uno manualmente: {message}", tableCommentUnsupported: "I commenti della tabella non sono supportati da questo database", actions: "Azioni", indexName: "Indice", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 943e1295e9..8d2c27b64d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1918,6 +1918,8 @@ export default withEnglishFallback({ tableInfoNullable: "NULL許容", tableInfoEmpty: "メタデータなし", tableInfoSearch: "検索...", + tableOwner: "所有者", + tableOwnerUnavailable: "取得できません", tableInfoNoResults: "結果なし", goToColumn: "列へ移動", columnWidth: "列幅", @@ -3654,6 +3656,12 @@ export default withEnglishFallback({ editComment: "コメントを編集", commentPlaceholder: "列コメントを入力...", tableCommentPlaceholder: "テーブルコメントを入力...", + owner: "所有者", + ownerPlaceholder: "ロール名を入力...", + ownerSearchPlaceholder: "ロール名を検索または入力...", + ownerRolesEmpty: "ロールが見つかりません。ロール名を直接入力できます", + ownerLoadFailed: "テーブル所有者を読み込めませんでした: {message}", + ownerRolesLoadFailed: "ロール一覧を読み込めませんでした。手動入力は可能です: {message}", tableCommentUnsupported: "このデータベースではテーブルコメントはサポートされていません", actions: "操作", indexName: "インデックス", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f6a7b7b7a0..8a40c79e6d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1876,6 +1876,8 @@ export default withEnglishFallback({ tableInfoNullable: "NULL 허용", tableInfoEmpty: "메타데이터 없음", tableInfoSearch: "검색...", + tableOwner: "소유자", + tableOwnerUnavailable: "사용할 수 없음", tableInfoNoResults: "결과 없음", goToColumn: "컬럼으로 이동", columnWidth: "컬럼 너비", @@ -3553,6 +3555,12 @@ export default withEnglishFallback({ editComment: "주석 편집", commentPlaceholder: "컬럼 주석 입력...", tableCommentPlaceholder: "테이블 주석 입력...", + owner: "소유자", + ownerPlaceholder: "역할 이름 입력...", + ownerSearchPlaceholder: "역할 이름 검색 또는 입력...", + ownerRolesEmpty: "역할을 찾을 수 없습니다. 역할 이름을 직접 입력할 수 있습니다", + ownerLoadFailed: "테이블 소유자를 불러오지 못했습니다: {message}", + ownerRolesLoadFailed: "역할 목록을 불러오지 못했습니다. 직접 입력할 수 있습니다: {message}", tableCommentUnsupported: "이 데이터베이스는 테이블 주석을 지원하지 않습니다", actions: "작업", indexName: "인덱스", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 87a9e57607..a33ef74bc2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1902,6 +1902,8 @@ export default withEnglishFallback({ tableInfoNullable: "Permite Nulo", tableInfoEmpty: "Sem metadados", tableInfoSearch: "Pesquisar...", + tableOwner: "Proprietário", + tableOwnerUnavailable: "Indisponível", tableInfoNoResults: "Nenhum resultado", goToColumn: "Ir para a coluna", columnWidth: "Largura da coluna", @@ -3594,6 +3596,12 @@ export default withEnglishFallback({ editComment: "Editar comentário", commentPlaceholder: "Insira o comentário da coluna...", tableCommentPlaceholder: "Insira o comentário da tabela...", + owner: "Proprietário", + ownerPlaceholder: "Insira o nome da função...", + ownerSearchPlaceholder: "Pesquise ou insira uma função...", + ownerRolesEmpty: "Nenhuma função encontrada; insira uma manualmente", + ownerLoadFailed: "Falha ao carregar o proprietário da tabela: {message}", + ownerRolesLoadFailed: "Falha ao carregar as funções; ainda é possível inserir uma manualmente: {message}", tableCommentUnsupported: "Comentários de tabela não são suportados por este banco de dados", actions: "Ações", indexName: "Índice", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 52c2af116f..a5da4a627e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1965,6 +1965,8 @@ export default withEnglishFallback({ tableInfoNullable: "可空", tableInfoEmpty: "暂无元数据", tableInfoSearch: "搜索...", + tableOwner: "所有者", + tableOwnerUnavailable: "无法获取", tableInfoNoResults: "无匹配结果", goToColumn: "跳转列", columnWidth: "列宽", @@ -3759,6 +3761,12 @@ export default withEnglishFallback({ editComment: "编辑注释", commentPlaceholder: "输入字段注释...", tableCommentPlaceholder: "输入表注释...", + owner: "所有者", + ownerPlaceholder: "输入角色名...", + ownerSearchPlaceholder: "搜索或输入角色名...", + ownerRolesEmpty: "未找到角色,可直接输入角色名", + ownerLoadFailed: "加载表所有者失败:{message}", + ownerRolesLoadFailed: "加载角色列表失败,仍可手动输入:{message}", tableCommentUnsupported: "当前数据库不支持表注释", actions: "操作", indexName: "索引名", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 18e0e8dbb1..1c33015b6c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1906,6 +1906,8 @@ export default withEnglishFallback({ tableInfoNullable: "可為空", tableInfoEmpty: "無", tableInfoSearch: "搜尋……", + tableOwner: "擁有者", + tableOwnerUnavailable: "無法取得", tableInfoNoResults: "沒有相符結果", goToColumn: "跳轉欄", columnWidth: "列寬", @@ -3335,6 +3337,12 @@ export default withEnglishFallback({ editComment: "編輯註解", commentPlaceholder: "輸入欄位註解……", tableCommentPlaceholder: "輸入資料表註解……", + owner: "擁有者", + ownerPlaceholder: "輸入角色名稱……", + ownerSearchPlaceholder: "搜尋或輸入角色名稱……", + ownerRolesEmpty: "找不到角色,可直接輸入角色名稱", + ownerLoadFailed: "載入資料表擁有者失敗:{message}", + ownerRolesLoadFailed: "載入角色清單失敗,仍可手動輸入:{message}", tableCommentUnsupported: "此資料庫不支援資料表註解", actions: "操作", indexName: "索引", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 9cea127c25..ae3f7a0093 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -186,6 +186,7 @@ export const listFunctions = forward("listFunctions"); export const listSequences = forward("listSequences"); export const listRules = forward("listRules"); export const listOwners = forward("listOwners"); +export const getTableOwner = forward("getTableOwner"); export const listExtensions = forward("listExtensions"); export const listAvailableExtensions = forward("listAvailableExtensions"); export const prepareSchemaDiff = forward("prepareSchemaDiff"); @@ -252,6 +253,7 @@ export const buildEditableObjectSource = forward("buildEditableObjectSource"); export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRenameObjectSourceStatements"); export const buildViewDdlSql = forward("buildViewDdlSql"); export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql"); +export const buildTableOwnerChangeSql = forward("buildTableOwnerChangeSql"); export const previewSqliteTableStructureChange = forward("previewSqliteTableStructureChange"); export const applySqliteTableStructureChange = forward("applySqliteTableStructureChange"); export const buildCreateTableSql = forward("buildCreateTableSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 5ea9922623..f6c7557f64 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -159,7 +159,7 @@ import type { HiveTablePropertiesSqlOptions, } from "@/lib/dataGrid/dataGridSql"; import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor"; -import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql"; +import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql"; import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql"; import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch"; import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor"; @@ -969,6 +969,10 @@ export async function listOwners(connectionId: string, database: string, schema: return get(`/api/schema/owners?${qs({ connection_id: connectionId, database, schema })}`); } +export async function getTableOwner(connectionId: string, database: string, schema: string, table: string): Promise { + return get(`/api/schema/table-owner?${qs({ connection_id: connectionId, database, schema, table })}`); +} + export async function listExtensions(connectionId: string, database: string, schema?: string): Promise { return get(`/api/schema/extensions?${qs({ connection_id: connectionId, database, schema })}`); } @@ -1425,6 +1429,10 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC return post("/api/query/build-table-structure-change-sql", { options }); } +export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSqlOptions): Promise { + return post("/api/query/build-table-owner-change-sql", { options }); +} + export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise { return post("/api/query/preview-sqlite-table-structure-change", { connectionId, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 20601bf5a2..f45af2966d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -83,7 +83,7 @@ import type { import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare"; import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff"; -import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql"; +import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql"; import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql"; import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch"; import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor"; @@ -1603,6 +1603,10 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC return invoke("build_table_structure_change_sql", { options }); } +export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSqlOptions): Promise { + return invoke("build_table_owner_change_sql", { options }); +} + export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise { return invoke("preview_sqlite_table_structure_change", { connectionId, @@ -1876,6 +1880,10 @@ export async function listOwners(connectionId: string, database: string, schema: return invoke("list_owners", { connectionId, database, schema }); } +export async function getTableOwner(connectionId: string, database: string, schema: string, table: string): Promise { + return invoke("get_table_owner", { connectionId, database, schema, table }); +} + export async function listExtensions(connectionId: string, database: string, schema?: string): Promise { return invoke("list_extensions", { connectionId, database, schema }); } diff --git a/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts b/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts index 3bc1bfa2de..c3fd0816fe 100644 --- a/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts +++ b/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts @@ -12,6 +12,12 @@ describe("SearchableSelect layout", () => { expect(labelBoundaries).toHaveLength(3); }); + it("can preserve custom option whitespace for exact database identifiers", () => { + expect(searchableSelectSource).toContain("trimCustom?: boolean"); + expect(searchableSelectSource).toContain("trimCustom: true"); + expect(searchableSelectSource).toContain("props.trimCustom ? searchText.value.trim() : searchText.value"); + }); + it("truncates long data transfer connection labels without shrinking their icons", () => { const truncatedConnectionLabels = dataTransferDialogSource.match(/\{\{ label \}\}<\/span>/g) ?? []; const fixedConnectionIcons = dataTransferDialogSource.match(/class="h-3\.5 w-3\.5 shrink-0"/g) ?? []; diff --git a/apps/desktop/src/lib/metadata/objectMetadataCache.ts b/apps/desktop/src/lib/metadata/objectMetadataCache.ts index 83a3a60c37..140f2e72f3 100644 --- a/apps/desktop/src/lib/metadata/objectMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/objectMetadataCache.ts @@ -49,7 +49,7 @@ async function deleteSchemaCachePrefixSafe(prefix: string): Promise { } } -export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment"; +export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment" | "owner"; function cacheSegment(value: string | undefined): string { return encodeURIComponent(value ?? ""); diff --git a/apps/desktop/src/lib/table/tableStructureEditorSql.ts b/apps/desktop/src/lib/table/tableStructureEditorSql.ts index 5e7c7d411d..8be04231b3 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorSql.ts @@ -97,6 +97,14 @@ export interface TableStructureChangeSql { warnings: string[]; } +export interface BuildTableOwnerChangeSqlOptions { + databaseType?: DatabaseType; + schema?: string; + tableName: string; + owner: string; + originalOwner: string; +} + export interface SqliteTableStructureChangePreview extends TableStructureChangeSql { schemaRevision: string; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index c9d4f52122..bc2e39fefc 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1070,6 +1070,8 @@ export interface TableStructureEditorDraft { newTableName: string; tableComment: string; originalTableComment: string; + tableOwner?: string; + originalTableOwner?: string; columns: import("@/lib/table/tableStructureEditorSql").EditableStructureColumn[]; indexes: import("@/lib/table/tableStructureEditorSql").EditableStructureIndex[]; foreignKeys: import("@/lib/table/tableStructureEditorSql").EditableStructureForeignKey[]; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 4bbaf08550..d4f8038d68 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -7190,6 +7190,14 @@ pub async fn list_owners(pool: &Pool, schema: &str) -> Result, St .collect()) } +pub async fn get_table_owner(pool: &Pool, schema: &str, table: &str) -> Result, String> { + let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table]; + let rows = postgres_query_cached(&client, POSTGRES_TABLE_OWNER_SQL, ¶ms).await.map_err(pg_error_to_string)?; + + Ok(rows.first().map(|row| pg_row_try_string(row, 0)).filter(|owner| !owner.is_empty())) +} + pub async fn get_table_access(pool: &Pool, schema: &str, table: &str) -> Result { let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table]; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 4f80b3e323..0393e5ee90 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -7049,6 +7049,25 @@ pub async fn list_owners_core( .await } +pub async fn get_table_owner_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || async { + let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; + let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; + + match &pool { + PoolKind::Postgres(p) => db::postgres::get_table_owner(p, schema, table).await, + _ => Ok(None), + } + }) + .await +} + /// Whether to widen or normalize a single-table DDL fetch for its caller. /// /// Database export and table transfer render one relation at a time because diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs index eac4b3d574..878a1132bf 100644 --- a/crates/dbx-core/src/table_structure_sql.rs +++ b/crates/dbx-core/src/table_structure_sql.rs @@ -6,6 +6,7 @@ mod create_table; mod dialect; mod foreign_keys; mod indexes; +mod owner; mod sqlite_rebuild; mod triggers; mod types; @@ -17,6 +18,7 @@ mod tests; pub use column_alter::build_single_column_alter_sql; pub use create_table::build_create_table_sql; +pub use owner::build_table_owner_change_sql; pub use sqlite_rebuild::{apply_sqlite_table_structure_change, preview_sqlite_table_structure_change}; pub use types::*; diff --git a/crates/dbx-core/src/table_structure_sql/owner.rs b/crates/dbx-core/src/table_structure_sql/owner.rs new file mode 100644 index 0000000000..f2e4df22db --- /dev/null +++ b/crates/dbx-core/src/table_structure_sql/owner.rs @@ -0,0 +1,79 @@ +use super::dialect::StructureDialect; +use super::types::{TableOwnerChangeSqlOptions, TableStructureSqlResult}; +use super::util::{qualified_table, quote_ident}; +use crate::models::connection::DatabaseType; + +pub fn build_table_owner_change_sql(options: TableOwnerChangeSqlOptions) -> TableStructureSqlResult { + let owner = &options.owner; + if owner == &options.original_owner { + return TableStructureSqlResult { statements: Vec::new(), warnings: Vec::new() }; + } + if owner.is_empty() { + return TableStructureSqlResult { + statements: Vec::new(), + warnings: vec!["Table owner cannot be empty.".to_string()], + }; + } + if options.database_type != Some(DatabaseType::Postgres) { + return TableStructureSqlResult { + statements: Vec::new(), + warnings: vec!["Changing the table owner is currently supported only for PostgreSQL.".to_string()], + }; + } + + let table = qualified_table(StructureDialect::Postgres, options.schema.as_deref(), &options.table_name); + let owner = quote_ident(StructureDialect::Postgres, owner); + TableStructureSqlResult { statements: vec![format!("ALTER TABLE {table} OWNER TO {owner};")], warnings: Vec::new() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(owner: &str, original_owner: &str) -> TableOwnerChangeSqlOptions { + TableOwnerChangeSqlOptions { + database_type: Some(DatabaseType::Postgres), + schema: Some("app schema".to_string()), + table_name: "order\"items".to_string(), + owner: owner.to_string(), + original_owner: original_owner.to_string(), + } + } + + #[test] + fn builds_quoted_postgres_owner_change() { + let result = build_table_owner_change_sql(options("reporting\"role", "app_user")); + assert!(result.warnings.is_empty()); + assert_eq!( + result.statements, + vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \"reporting\"\"role\";".to_string()] + ); + } + + #[test] + fn preserves_whitespace_in_postgres_role_names() { + let changed = build_table_owner_change_sql(options(" app_user ", "app_user")); + assert!(changed.warnings.is_empty()); + assert_eq!( + changed.statements, + vec!["ALTER TABLE \"app schema\".\"order\"\"items\" OWNER TO \" app_user \";".to_string()] + ); + + let unchanged = build_table_owner_change_sql(options(" app_user ", " app_user ")); + assert!(unchanged.statements.is_empty()); + assert!(unchanged.warnings.is_empty()); + } + + #[test] + fn rejects_empty_or_unsupported_owner_changes() { + let empty = build_table_owner_change_sql(options("", "app_user")); + assert!(empty.statements.is_empty()); + assert_eq!(empty.warnings, vec!["Table owner cannot be empty."]); + + let mut unsupported = options("reporting", "app_user"); + unsupported.database_type = Some(DatabaseType::Mysql); + let unsupported = build_table_owner_change_sql(unsupported); + assert!(unsupported.statements.is_empty()); + assert_eq!(unsupported.warnings, vec!["Changing the table owner is currently supported only for PostgreSQL."]); + } +} diff --git a/crates/dbx-core/src/table_structure_sql/types.rs b/crates/dbx-core/src/table_structure_sql/types.rs index 7da77515c0..e83097b0ca 100644 --- a/crates/dbx-core/src/table_structure_sql/types.rs +++ b/crates/dbx-core/src/table_structure_sql/types.rs @@ -231,6 +231,20 @@ pub struct TableStructureSqlResult { pub warnings: Vec, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableOwnerChangeSqlOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + #[serde(default)] + pub owner: String, + #[serde(default)] + pub original_owner: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqliteTableStructurePreview { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 2d3d13ba78..4b739ae558 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -435,6 +435,7 @@ async fn main() { .route("/schema/sequences", get(routes::schema::list_sequences)) .route("/schema/rules", get(routes::schema::list_rules)) .route("/schema/owners", get(routes::schema::list_owners)) + .route("/schema/table-owner", get(routes::schema::get_table_owner)) .route("/schema/extensions", get(routes::schema::list_extensions)) .route("/schema/available-extensions", get(routes::schema::list_available_extensions)) .route("/schema/ddl", get(routes::schema::get_ddl)) @@ -509,6 +510,7 @@ async fn main() { ) .route("/query/build-view-ddl-sql", post(routes::query::build_view_ddl_sql)) .route("/query/build-table-structure-change-sql", post(routes::query::build_table_structure_change_sql)) + .route("/query/build-table-owner-change-sql", post(routes::query::build_table_owner_change_sql)) .route( "/query/preview-sqlite-table-structure-change", post(routes::query::preview_sqlite_table_structure_change), diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 9996042fb7..4bb6f227a2 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -267,6 +267,12 @@ pub struct BuildTableStructureSqlRequest { pub options: dbx_core::table_structure_sql::TableStructureSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildTableOwnerChangeSqlRequest { + pub options: dbx_core::table_structure_sql::TableOwnerChangeSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreviewSqliteTableStructureChangeRequest { @@ -947,6 +953,12 @@ pub async fn build_table_structure_change_sql( Json(dbx_core::table_structure_sql::build_table_structure_change_sql(req.options)) } +pub async fn build_table_owner_change_sql( + Json(req): Json, +) -> Json { + Json(dbx_core::table_structure_sql::build_table_owner_change_sql(req.options)) +} + pub async fn preview_sqlite_table_structure_change( State(state): State>, Json(req): Json, diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 9e30fb220f..113057d378 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -790,6 +790,19 @@ pub async fn list_owners( Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } +pub async fn get_table_owner( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let database = q.database.as_deref().unwrap_or(""); + let schema = q.schema.as_deref().unwrap_or(""); + let table = q.table.as_deref().unwrap_or(""); + let result = dbx_core::schema::get_table_owner_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) +} + pub async fn list_extensions( State(state): State>, Query(q): Query, diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 558595eb8a..16d6496e98 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -713,6 +713,13 @@ pub fn build_table_structure_change_sql( Ok(dbx_core::table_structure_sql::build_table_structure_change_sql(options)) } +#[tauri::command] +pub fn build_table_owner_change_sql( + options: dbx_core::table_structure_sql::TableOwnerChangeSqlOptions, +) -> Result { + Ok(dbx_core::table_structure_sql::build_table_owner_change_sql(options)) +} + #[tauri::command] pub async fn preview_sqlite_table_structure_change( state: State<'_, Arc>, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 91394f948a..aaf1593fb8 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -574,6 +574,17 @@ pub async fn list_owners( dbx_core::schema::list_owners_core(&state, &connection_id, &database, &schema).await } +#[tauri::command] +pub async fn get_table_owner( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + table: String, +) -> Result, String> { + dbx_core::schema::get_table_owner_core(&state, &connection_id, &database, &schema, &table).await +} + #[tauri::command] pub async fn list_extensions( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8f57df2b0..8370d16256 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1690,6 +1690,7 @@ pub fn run() { commands::schema::list_sequences, commands::schema::list_rules, commands::schema::list_owners, + commands::schema::get_table_owner, commands::schema::list_extensions, commands::schema::list_available_extensions, commands::schema_diff::prepare_schema_diff, @@ -1756,6 +1757,7 @@ pub fn run() { commands::query::build_routine_rename_object_source_statements, commands::query::build_view_ddl_sql, commands::query::build_table_structure_change_sql, + commands::query::build_table_owner_change_sql, commands::query::preview_sqlite_table_structure_change, commands::query::apply_sqlite_table_structure_change, commands::query::build_create_table_sql,