feat(postgres): support editing table owners

This commit is contained in:
zipg
2026-08-23 01:39:23 +08:00
committed by GitHub
parent 4a1f3e7141
commit acc2137cde
32 changed files with 556 additions and 36 deletions
@@ -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<HTMLPreElement | null>(null);
const ddlSearchMatchCount = ref(0);
const ddlSearchMatchIndex = ref(0);
const tableOwner = ref<string | null>(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[] {
<X class="w-3 h-3" />
</Button>
</div>
<div v-if="canShowTableOwner" class="flex h-7 min-w-0 shrink-0 items-center gap-1.5 border-b bg-background px-3 text-[11px] text-muted-foreground">
<Loader2 v-if="tableOwnerLoading" class="h-3 w-3 shrink-0 animate-spin" />
<UserRound v-else class="h-3 w-3 shrink-0" />
<span class="shrink-0">{{ t("grid.tableOwner") }}</span>
<span v-if="tableOwner" class="min-w-0 truncate font-mono text-foreground" :title="tableOwner">{{ tableOwner }}</span>
<span v-else-if="tableOwnerError" class="min-w-0 truncate text-destructive" :title="tableOwnerError">{{ t("grid.tableOwnerUnavailable") }}</span>
<span v-else class="text-muted-foreground">-</span>
</div>
<div class="grid border-b bg-background shrink-0" :style="tableInfoTabListStyle">
<button
v-for="tab in tableInfoTabs"
@@ -11,12 +11,14 @@ const mocks = vi.hoisted(() => ({
driver_label: "MySQL",
},
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(),
toast: vi.fn(),
}));
@@ -45,6 +47,7 @@ vi.mock("@lucide/vue", async () => {
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
@@ -213,9 +216,10 @@ 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,
}));
@@ -11,14 +11,18 @@ const mocks = vi.hoisted(() => ({
driver_label: "Dameng",
},
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,
};
});
@@ -163,6 +168,7 @@ vi.mock("@/components/ui/searchable-select", async () => {
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
allowCustom: { type: Boolean, default: false },
trimCustom: { type: Boolean, default: true },
},
emits: ["update:modelValue"],
setup:
@@ -175,6 +181,7 @@ vi.mock("@/components/ui/searchable-select", async () => {
"data-model-value": props.modelValue,
"data-options": JSON.stringify(props.options),
"data-allow-custom": String(props.allowCustom),
"data-trim-custom": String(props.trimCustom),
onClick: () => emit("update:modelValue", "custom_domain"),
}),
}),
@@ -214,12 +221,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";
@@ -288,7 +298,7 @@ async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | "
return root;
}
async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl") {
async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl", owner = "app_user") {
mocks.connection.db_type = "postgres";
mocks.connection.name = "postgres";
mocks.connection.driver_label = "postgres";
@@ -296,7 +306,7 @@ async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKe
mocks.listDataTypes.mockResolvedValue([]);
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : [], cacheStatus: "remote" }));
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : facet === "owner" ? owner : [], cacheStatus: "remote" }));
const root = document.createElement("div");
document.body.append(root);
@@ -341,7 +351,17 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.invalidateObjectDdl.mockResolvedValue(undefined);
mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" });
mocks.invalidateObjectMetadataCache.mockResolvedValue(undefined);
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? "app_user" : [], cacheStatus: "remote" }));
mocks.getTableOwner.mockResolvedValue("app_user");
mocks.executeQuery.mockResolvedValue({
columns: ["user", "host", "plugin"],
rows: [
["app_user", "LOGIN", ""],
["reporting_role", "ROLE", ""],
],
});
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
// TableStructureEditor probes the partition status for PostgreSQL tables
// (PR #6361); a resolved non-partitioned result keeps metadata loads on the
// original facet expectations unchanged.
@@ -654,26 +674,55 @@ describe("TableStructureEditor horizontal scrolling", () => {
});
describe("TableStructureEditor metadata loading", () => {
it("opens the initial DDL tab without starting structure metadata loads", async () => {
it("opens the initial DDL tab while loading only the table owner", async () => {
await mountLoadingEditor("ddl");
await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet).not.toHaveBeenCalled();
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["owner"]);
});
it.each([
["columns", ["columns", "indexes", "comment"]],
["indexes", ["columns", "indexes", "comment"]],
["foreignKeys", ["columns", "indexes", "foreign-keys", "comment"]],
["triggers", ["triggers", "comment"]],
["columns", ["columns", "indexes", "comment", "owner"]],
["indexes", ["columns", "indexes", "comment", "owner"]],
["foreignKeys", ["columns", "indexes", "foreign-keys", "comment", "owner"]],
["triggers", ["triggers", "comment", "owner"]],
] as const)("loads only the required facets for the initial %s tab", async (initialTab, expectedFacets) => {
await mountLoadingEditor(initialTab);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(expectedFacets.length));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(expectedFacets);
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual([...expectedFacets].sort());
expect(mocks.loadObjectDdl).not.toHaveBeenCalled();
});
it("preserves exact PostgreSQL owner names and includes an owner change in the SQL preview", async () => {
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? " app_user " : [], cacheStatus: "remote" }));
mocks.executeQuery.mockResolvedValue({
columns: ["user", "host", "plugin"],
rows: [
[" app_user ", "LOGIN", ""],
["reporting_role", "ROLE", ""],
],
});
mocks.buildTableOwnerChangeSql.mockImplementation(async (options: { owner: string; originalOwner: string }) => ({
statements: options.owner === options.originalOwner ? [] : [`ALTER TABLE "public"."users" OWNER TO "${options.owner}";`],
warnings: [],
}));
const root = await mountLoadingEditor("columns", " app_user ");
const ownerSelect = await vi.waitFor(() => {
const select = root.querySelector<HTMLButtonElement>("[data-owner-select]");
expect(select?.dataset.modelValue).toBe(" app_user ");
expect(JSON.parse(select?.dataset.options ?? "[]")).toEqual([" app_user ", "reporting_role"]);
expect(select?.dataset.allowCustom).toBe("true");
expect(select?.dataset.trimCustom).toBe("false");
return select!;
});
ownerSelect.click();
await vi.waitFor(() => expect(mocks.buildTableOwnerChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ owner: "custom_domain", originalOwner: " app_user ", schema: "public", tableName: "users" })));
});
it("loads the PostgreSQL primary index name before showing a missing-name warning", async () => {
mocks.connection.db_type = "postgres";
mocks.connection.name = "postgres";
@@ -691,7 +740,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -708,7 +759,7 @@ describe("TableStructureEditor metadata loading", () => {
app.mount(root);
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull());
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["columns", "indexes", "comment"]);
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["columns", "indexes", "comment", "owner"].sort());
const primaryKey = columnCheckbox(root, "structureEditor.primaryKey");
primaryKey.checked = false;
@@ -739,7 +790,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -758,8 +811,8 @@ describe("TableStructureEditor metadata loading", () => {
mountedApps.push(app);
app.mount(root);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["indexes"]);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]);
const primaryKey = columnCheckbox(root, "structureEditor.primaryKey");
primaryKey.checked = false;
@@ -794,7 +847,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -817,8 +872,8 @@ describe("TableStructureEditor metadata loading", () => {
await Promise.resolve();
await nextTick();
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["indexes"]);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]);
await vi.waitFor(() => expect(root.querySelector('[data-index-row-index="0"]')).not.toBeNull());
});
});
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -28,7 +28,7 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor
import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateObjectMetadataCache, loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
@@ -42,6 +42,7 @@ import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, norma
import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { postgresListRolesSql, usersFromPostgresRolesResult } from "@/lib/database/databaseUserAdmin";
import type { ColumnInfo, ConstraintInfo, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database";
import {
applyManticoreDdlColumnExtras,
@@ -894,6 +895,19 @@ const canAddColumn = computed(() => canAddTableStructureColumn(databaseType.valu
const newTableName = ref("");
const tableComment = ref("");
const originalTableComment = ref("");
const tableOwner = ref("");
const originalTableOwner = ref("");
const tableOwnerLoading = ref(false);
const tableOwnerLoadError = ref("");
const tableOwnerRoles = ref<string[]>([]);
const tableOwnerRolesLoading = ref(false);
const tableOwnerRolesLoadError = ref("");
const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.value === "postgres");
const tableOwnerOptions = computed(() => {
const owner = tableOwner.value;
if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value;
return [owner, ...tableOwnerRoles.value];
});
const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.name, props.database, props.schema, isCreateMode.value ? undefined : props.tableName));
function isManticoreTextColumn(column: EditableStructureColumn): boolean {
@@ -909,6 +923,8 @@ function isManticoreJsonColumn(column: EditableStructureColumn): boolean {
let sqlPreviewRequestId = 0;
let structureLoadRequestId = 0;
let tableOwnerLoadRequestId = 0;
let tableOwnerRolesLoadRequestId = 0;
let dataTypeOptionsRequestId = 0;
let sqlPreviewDebounceTimer: ReturnType<typeof setTimeout> | undefined;
let deferredSqlPreviewRefresh = false;
@@ -1094,6 +1110,8 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft {
newTableName: newTableName.value,
tableComment: tableComment.value,
originalTableComment: originalTableComment.value,
tableOwner: tableOwner.value,
originalTableOwner: originalTableOwner.value,
columns: cloneDraftValue(columns.value),
indexes: cloneDraftValue(indexes.value),
foreignKeys: cloneDraftValue(foreignKeys.value),
@@ -1122,6 +1140,8 @@ function restoreDraft(draft: TableStructureEditorDraft) {
newTableName.value = draft.newTableName || "";
tableComment.value = draft.tableComment || "";
originalTableComment.value = draft.originalTableComment || "";
tableOwner.value = draft.tableOwner || "";
originalTableOwner.value = draft.originalTableOwner || "";
columns.value = cloneDraftValue(draft.columns || []);
// Existing-index edits never support Concurrent (the checkbox is disabled and
// the core builder rejects the request), so a stale `concurrently: true`
@@ -1206,7 +1226,7 @@ function hasPendingStructureChanges(): boolean {
return !!newTableName.value.trim() || !!tableComment.value.trim() || columns.value.length > 0 || indexes.value.length > 0 || foreignKeys.value.length > 0 || triggers.value.length > 0;
}
const scope = captureStructureRefreshScope();
return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment;
return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment || (supportsTableOwner.value && tableOwner.value.trim() !== originalTableOwner.value.trim());
}
function clearSqlPreviewState() {
@@ -1390,9 +1410,18 @@ async function refreshSqlPreview() {
const options = structureChangeOptions();
try {
const result = isCreateMode.value ? await api.buildCreateTableSql(options) : hasSqliteTypeChange.value ? await api.previewSqliteTableStructureChange(props.connectionId, props.database, options) : await api.buildTableStructureChangeSql(options);
const ownerResult = supportsTableOwner.value
? await api.buildTableOwnerChangeSql({
databaseType: databaseType.value,
schema: metadataSchema.value,
tableName: props.tableName || "",
owner: tableOwner.value,
originalOwner: originalTableOwner.value,
})
: { statements: [], warnings: [] };
if (requestId !== sqlPreviewRequestId) return;
pendingStatements.value = result.statements;
warnings.value = result.warnings;
pendingStatements.value = [...result.statements, ...ownerResult.statements];
warnings.value = [...result.warnings, ...ownerResult.warnings];
sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined;
} catch (e: any) {
if (requestId !== sqlPreviewRequestId) return;
@@ -1459,6 +1488,15 @@ function resetState() {
newTableName.value = "";
tableComment.value = "";
originalTableComment.value = "";
tableOwner.value = "";
originalTableOwner.value = "";
tableOwnerLoadRequestId += 1;
tableOwnerLoading.value = false;
tableOwnerLoadError.value = "";
tableOwnerRoles.value = [];
tableOwnerRolesLoadRequestId += 1;
tableOwnerRolesLoading.value = false;
tableOwnerRolesLoadError.value = "";
columnSearchText.value = "";
highlightedColumnId.value = null;
indexSearchText.value = "";
@@ -1486,9 +1524,9 @@ async function reloadStructureFromDatabase() {
loadedMetadataFacets.clear();
if (refreshDdl) {
ddlFetched.value = false;
await fetchDdl(true);
await Promise.all([fetchDdl(true), loadTableOwner(true), loadTableOwnerRoles()]);
} else {
await loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true });
await Promise.all([loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true }), loadTableOwner(true), loadTableOwnerRoles()]);
}
}
@@ -1523,6 +1561,59 @@ function loadCachedTableComment(request: ReturnType<typeof ddlRequest>, force =
return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force });
}
async function loadTableOwner(force = false, preserveDraft = false) {
const connectionId = props.connectionId;
const database = props.database;
const schema = metadataSchema.value;
const tableName = props.tableName;
const catalog = props.catalog;
if (!supportsTableOwner.value || !connectionId || !database || !schema || !tableName) return;
const requestId = ++tableOwnerLoadRequestId;
tableOwnerLoading.value = true;
tableOwnerLoadError.value = "";
try {
await store.ensureConnected(connectionId);
const result = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog }, "owner", () => api.getTableOwner(connectionId, database, schema, tableName), { force });
if (requestId !== tableOwnerLoadRequestId) return;
const owner = result.value || "";
originalTableOwner.value = owner;
if (!preserveDraft) tableOwner.value = owner;
loadedMetadataFacets.add("owner");
} catch (error: any) {
if (requestId !== tableOwnerLoadRequestId) return;
tableOwnerLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableOwnerLoadRequestId) tableOwnerLoading.value = false;
}
}
async function loadTableOwnerRoles() {
const connectionId = props.connectionId;
const database = props.database;
if (!supportsTableOwner.value || !connectionId || !database) return;
const requestId = ++tableOwnerRolesLoadRequestId;
tableOwnerRolesLoading.value = true;
tableOwnerRolesLoadError.value = "";
try {
await store.ensureConnected(connectionId);
const result = await api.executeQuery(connectionId, database, postgresListRolesSql(), undefined, undefined, { maxRows: 5000 });
if (requestId !== tableOwnerRolesLoadRequestId) return;
tableOwnerRoles.value = [
...new Set(
usersFromPostgresRolesResult(result)
.map((role) => role.user)
.filter(Boolean),
),
];
} catch (error: any) {
if (requestId !== tableOwnerRolesLoadRequestId) return;
tableOwnerRoles.value = [];
tableOwnerRolesLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableOwnerRolesLoadRequestId) tableOwnerRolesLoading.value = false;
}
}
async function loadStructure(
silent = false,
scope: TableStructureRefreshScope = visibleTableStructureRefreshScope(activeTab.value),
@@ -1679,7 +1770,7 @@ async function loadStructure(
async function refreshStructureAfterSave(scope: TableStructureRefreshScope, characterLengthUnitsAfterSave: ReadonlyMap<string, string>) {
try {
await loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave });
await Promise.all([loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave }), loadTableOwner(true)]);
} catch (e) {
console.warn("[DBX][structure-editor:post-save-refresh-failed]", e);
} finally {
@@ -2844,7 +2935,9 @@ async function applyChanges() {
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
await recordStructureHistory(sql, startedAt, true, result);
if (!isCreateMode.value && props.tableName) {
invalidateTableMetadataCache({ connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName });
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
invalidateTableMetadataCache(metadataMatch);
await invalidateObjectMetadataCache(metadataMatch);
await invalidateObjectDdl(ddlRequest());
loadedMetadataFacets.clear();
}
@@ -2953,6 +3046,8 @@ onMounted(() => {
}
structureEditorReady = true;
observeStructureHorizontalScroller();
void loadTableOwner(false, props.draft?.tableOwner !== undefined);
void loadTableOwnerRoles();
if (props.draft?.initialized) {
void hydrateRestoredDraftFromDatabase().then(() => {
applyInitialStructureTarget();
@@ -2971,6 +3066,8 @@ onActivated(() => {
registerStructureEditorShortcuts();
observeStructureHorizontalScroller();
void loadDynamicDataTypeOptions();
if (supportsTableOwner.value && !loadedMetadataFacets.has("owner")) void loadTableOwner(false, props.draft?.tableOwner !== undefined);
if (supportsTableOwner.value && !tableOwnerRolesLoading.value && tableOwnerRoles.value.length === 0 && !tableOwnerRolesLoadError.value) void loadTableOwnerRoles();
if (props.draft?.initialized && !draftHydrated) {
restoreDraft(props.draft);
applyInitialStructureTarget();
@@ -3084,7 +3181,7 @@ watch([() => props.connectionId, () => props.database, databaseType], () => {
});
watch(
[isCreateMode, () => props.connectionId, () => props.database, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, columns, indexes, foreignKeys, triggers],
[isCreateMode, () => props.connectionId, () => props.database, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, tableOwner, columns, indexes, foreignKeys, triggers],
() => {
scheduleSqlPreviewRefresh();
syncDraftToParent();
@@ -3193,6 +3290,40 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</Tooltip>
</div>
<div v-if="supportsTableOwner" class="flex shrink-0 items-center gap-2">
<label class="flex shrink-0 items-center gap-1 font-medium text-muted-foreground">
<UserRound :class="structureIconClass" />
{{ t("structureEditor.owner") }}
</label>
<SearchableSelect
v-model="tableOwner"
:options="tableOwnerOptions"
:placeholder="t('structureEditor.ownerPlaceholder')"
:search-placeholder="t('structureEditor.ownerSearchPlaceholder')"
:empty-text="t('structureEditor.ownerRolesEmpty')"
:loading-text="t('common.loading')"
:loading="tableOwnerRolesLoading"
:allow-custom="true"
:trim-custom="false"
:disabled="tableOwnerLoading || !!tableOwnerLoadError"
:trigger-class="[structureMonoControlClass, 'w-[220px] max-w-[220px]']"
data-owner-select
/>
<Loader2 v-if="tableOwnerLoading" :class="[structureIconClass, 'animate-spin text-muted-foreground']" />
<Tooltip v-else-if="tableOwnerLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-destructive']" />
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.ownerLoadFailed", { message: tableOwnerLoadError }) }}</TooltipContent>
</Tooltip>
<Tooltip v-else-if="tableOwnerRolesLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-amber-500']" />
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.ownerRolesLoadFailed", { message: tableOwnerRolesLoadError }) }}</TooltipContent>
</Tooltip>
</div>
<div v-if="loading" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-[length:var(--structure-font-size)] text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -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(() => {
@@ -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() {
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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: "インデックス",
+8
View File
@@ -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: "인덱스",
+8
View File
@@ -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",
+8
View File
@@ -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: "索引名",
+8
View File
@@ -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: "索引",
+2
View File
@@ -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");
+9 -1
View File
@@ -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<string | null> {
return get(`/api/schema/table-owner?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
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<TableStructureChangeSql> {
return post("/api/query/build-table-owner-change-sql", { options });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
return post("/api/query/preview-sqlite-table-structure-change", {
connectionId,
+9 -1
View File
@@ -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<TableStructureChangeSql> {
return invoke("build_table_owner_change_sql", { options });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
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<string | null> {
return invoke("get_table_owner", { connectionId, database, schema, table });
}
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
return invoke("list_extensions", { connectionId, database, schema });
}
@@ -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(/<span class="min-w-0 flex-1 truncate">\{\{ label \}\}<\/span>/g) ?? [];
const fixedConnectionIcons = dataTransferDialogSource.match(/class="h-3\.5 w-3\.5 shrink-0"/g) ?? [];
@@ -49,7 +49,7 @@ async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
}
}
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 ?? "");
@@ -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;
}
+2
View File
@@ -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[];
+8
View File
@@ -7190,6 +7190,14 @@ pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, St
.collect())
}
pub async fn get_table_owner(pool: &Pool, schema: &str, table: &str) -> Result<Option<String>, 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, &params).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<PostgresTableAccessInfo, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table];
+19
View File
@@ -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<Option<String>, 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
@@ -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::*;
@@ -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."]);
}
}
@@ -231,6 +231,20 @@ pub struct TableStructureSqlResult {
pub warnings: Vec<String>,
}
#[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<DatabaseType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
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 {
+2
View File
@@ -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),
+12
View File
@@ -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<BuildTableOwnerChangeSqlRequest>,
) -> Json<dbx_core::table_structure_sql::TableStructureSqlResult> {
Json(dbx_core::table_structure_sql::build_table_owner_change_sql(req.options))
}
pub async fn preview_sqlite_table_structure_change(
State(state): State<Arc<WebState>>,
Json(req): Json<PreviewSqliteTableStructureChangeRequest>,
+13
View File
@@ -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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
+7
View File
@@ -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<dbx_core::table_structure_sql::TableStructureSqlResult, String> {
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<AppState>>,
+11
View File
@@ -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<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Option<String>, 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<AppState>>,
+2
View File
@@ -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,