mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(postgres): support editing table owners
This commit is contained in:
@@ -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"
|
||||
|
||||
+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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "インデックス",
|
||||
|
||||
@@ -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: "인덱스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "索引名",
|
||||
|
||||
@@ -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: "索引",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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, ¶ms).await.map_err(pg_error_to_string)?;
|
||||
|
||||
Ok(rows.first().map(|row| pg_row_try_string(row, 0)).filter(|owner| !owner.is_empty()))
|
||||
}
|
||||
|
||||
pub async fn get_table_access(pool: &Pool, schema: &str, table: &str) -> Result<PostgresTableAccessInfo, String> {
|
||||
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
|
||||
let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user