feat(postgres): support editing table owners

This commit is contained in:
zipg authored and GitHub committed 2026-08-23 01:39:23 +08:00
1 parent 4a1f3e7141
commit acc2137cde
32 files changed
+556 -36

No files matched your search

@@ -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());
});
});