feat(postgres): support editing table owners
This commit is contained in:
32 files changed
+556
-36
No files matched your search
+5
-1
@@ -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,
|
||||
}));
|
||||
|
||||
+74
-19
@@ -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") }}
|
||||
|
||||
Vendored
+13
-2
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user