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

@@ -38,6 +38,7 @@ import {
PanelBottom,
PanelRight,
TableProperties,
UserRound,
Database,
Eraser,
Columns3,
@@ -79,6 +80,7 @@ import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoColumnNavigation";
import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import * as api from "@/lib/backend/api";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
@@ -10060,6 +10062,37 @@ const ddlContent = ref("");
const ddlPreRef = ref<HTMLPreElement | null>(null);
const ddlSearchMatchCount = ref(0);
const ddlSearchMatchIndex = ref(0);
const tableOwner = ref<string | null>(null);
const tableOwnerLoading = ref(false);
const tableOwnerError = ref("");
let tableOwnerRequestGeneration = 0;
const canShowTableOwner = computed(() => resolvedDatabaseType.value === "postgres" && !!props.connectionId && !!props.database && !!props.tableMeta?.schema && !!props.tableMeta?.tableName);
async function fetchTableOwner(force = false) {
if (!canShowTableOwner.value || !props.connectionId || !props.database || !props.tableMeta?.schema || !props.tableMeta.tableName) return;
const requestGeneration = ++tableOwnerRequestGeneration;
const request = {
connectionId: props.connectionId,
database: props.database,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
catalog: props.tableMeta.catalog,
objectType: tableObjectSourceKind(props.tableMeta.tableType),
};
tableOwnerLoading.value = true;
tableOwnerError.value = "";
try {
const result = await loadObjectMetadataFacet(request, "owner", () => api.getTableOwner(request.connectionId, request.database, request.schema, request.tableName), { force });
if (requestGeneration !== tableOwnerRequestGeneration) return;
tableOwner.value = result.value;
} catch (error: any) {
if (requestGeneration !== tableOwnerRequestGeneration) return;
tableOwner.value = null;
tableOwnerError.value = error?.message || String(error);
} finally {
if (requestGeneration === tableOwnerRequestGeneration) tableOwnerLoading.value = false;
}
}
function scrollDdlSearchMatchIntoView(match: HTMLElement) {
const pre = ddlPreRef.value;
@@ -10485,6 +10518,10 @@ async function fetchTriggers() {
watch(
() => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName],
() => {
tableOwner.value = null;
tableOwnerLoading.value = false;
tableOwnerError.value = "";
tableOwnerRequestGeneration += 1;
ddlContent.value = "";
indexes.value = [];
indexesLoaded.value = false;
@@ -10504,9 +10541,14 @@ watch(
void fetchIndexes();
}
if (showTableInfo.value) selectTableInfoTab(activeTableInfoTab.value);
if (showTableInfo.value) void fetchTableOwner();
},
);
watch([showTableInfo, canShowTableOwner], ([shown, supported]) => {
if (shown && supported && !tableOwnerLoading.value && tableOwner.value === null && !tableOwnerError.value) void fetchTableOwner();
});
watch(
() => [showIndexIndicatorsInHeader.value, canShowTableIndexes.value, currentIndexTableIdentity.value] as const,
([showIndicators, canShowIndexes, tableIdentity]) => {
@@ -13001,6 +13043,14 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<X class="w-3 h-3" />
</Button>
</div>
<div v-if="canShowTableOwner" class="flex h-7 min-w-0 shrink-0 items-center gap-1.5 border-b bg-background px-3 text-[11px] text-muted-foreground">
<Loader2 v-if="tableOwnerLoading" class="h-3 w-3 shrink-0 animate-spin" />
<UserRound v-else class="h-3 w-3 shrink-0" />
<span class="shrink-0">{{ t("grid.tableOwner") }}</span>
<span v-if="tableOwner" class="min-w-0 truncate font-mono text-foreground" :title="tableOwner">{{ tableOwner }}</span>
<span v-else-if="tableOwnerError" class="min-w-0 truncate text-destructive" :title="tableOwnerError">{{ t("grid.tableOwnerUnavailable") }}</span>
<span v-else class="text-muted-foreground">-</span>
</div>
<div class="grid border-b bg-background shrink-0" :style="tableInfoTabListStyle">
<button
v-for="tab in tableInfoTabs"
@@ -11,12 +11,14 @@ const mocks = vi.hoisted(() => ({
driver_label: "MySQL",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
toast: vi.fn(),
}));
@@ -45,6 +47,7 @@ vi.mock("@lucide/vue", async () => {
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
@@ -213,9 +216,10 @@ vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet }));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
}));
@@ -11,14 +11,18 @@ const mocks = vi.hoisted(() => ({
driver_label: "Dameng",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
getTablePartitionStatus: vi.fn(),
getTableOwner: vi.fn(),
buildTableOwnerChangeSql: vi.fn(),
toast: vi.fn(),
}));
@@ -46,6 +50,7 @@ vi.mock("@lucide/vue", async () => {
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
@@ -163,6 +168,7 @@ vi.mock("@/components/ui/searchable-select", async () => {
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
allowCustom: { type: Boolean, default: false },
trimCustom: { type: Boolean, default: true },
},
emits: ["update:modelValue"],
setup:
@@ -175,6 +181,7 @@ vi.mock("@/components/ui/searchable-select", async () => {
"data-model-value": props.modelValue,
"data-options": JSON.stringify(props.options),
"data-allow-custom": String(props.allowCustom),
"data-trim-custom": String(props.trimCustom),
onClick: () => emit("update:modelValue", "custom_domain"),
}),
}),
@@ -214,12 +221,15 @@ vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet }));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
getTableOwner: mocks.getTableOwner,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
@@ -288,7 +298,7 @@ async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | "
return root;
}
async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl") {
async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl", owner = "app_user") {
mocks.connection.db_type = "postgres";
mocks.connection.name = "postgres";
mocks.connection.driver_label = "postgres";
@@ -296,7 +306,7 @@ async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKe
mocks.listDataTypes.mockResolvedValue([]);
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : [], cacheStatus: "remote" }));
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : facet === "owner" ? owner : [], cacheStatus: "remote" }));
const root = document.createElement("div");
document.body.append(root);
@@ -341,7 +351,17 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.invalidateObjectDdl.mockResolvedValue(undefined);
mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" });
mocks.invalidateObjectMetadataCache.mockResolvedValue(undefined);
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? "app_user" : [], cacheStatus: "remote" }));
mocks.getTableOwner.mockResolvedValue("app_user");
mocks.executeQuery.mockResolvedValue({
columns: ["user", "host", "plugin"],
rows: [
["app_user", "LOGIN", ""],
["reporting_role", "ROLE", ""],
],
});
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
// TableStructureEditor probes the partition status for PostgreSQL tables
// (PR #6361); a resolved non-partitioned result keeps metadata loads on the
// original facet expectations unchanged.
@@ -654,26 +674,55 @@ describe("TableStructureEditor horizontal scrolling", () => {
});
describe("TableStructureEditor metadata loading", () => {
it("opens the initial DDL tab without starting structure metadata loads", async () => {
it("opens the initial DDL tab while loading only the table owner", async () => {
await mountLoadingEditor("ddl");
await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet).not.toHaveBeenCalled();
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["owner"]);
});
it.each([
["columns", ["columns", "indexes", "comment"]],
["indexes", ["columns", "indexes", "comment"]],
["foreignKeys", ["columns", "indexes", "foreign-keys", "comment"]],
["triggers", ["triggers", "comment"]],
["columns", ["columns", "indexes", "comment", "owner"]],
["indexes", ["columns", "indexes", "comment", "owner"]],
["foreignKeys", ["columns", "indexes", "foreign-keys", "comment", "owner"]],
["triggers", ["triggers", "comment", "owner"]],
] as const)("loads only the required facets for the initial %s tab", async (initialTab, expectedFacets) => {
await mountLoadingEditor(initialTab);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(expectedFacets.length));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(expectedFacets);
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual([...expectedFacets].sort());
expect(mocks.loadObjectDdl).not.toHaveBeenCalled();
});
it("preserves exact PostgreSQL owner names and includes an owner change in the SQL preview", async () => {
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? " app_user " : [], cacheStatus: "remote" }));
mocks.executeQuery.mockResolvedValue({
columns: ["user", "host", "plugin"],
rows: [
[" app_user ", "LOGIN", ""],
["reporting_role", "ROLE", ""],
],
});
mocks.buildTableOwnerChangeSql.mockImplementation(async (options: { owner: string; originalOwner: string }) => ({
statements: options.owner === options.originalOwner ? [] : [`ALTER TABLE "public"."users" OWNER TO "${options.owner}";`],
warnings: [],
}));
const root = await mountLoadingEditor("columns", " app_user ");
const ownerSelect = await vi.waitFor(() => {
const select = root.querySelector<HTMLButtonElement>("[data-owner-select]");
expect(select?.dataset.modelValue).toBe(" app_user ");
expect(JSON.parse(select?.dataset.options ?? "[]")).toEqual([" app_user ", "reporting_role"]);
expect(select?.dataset.allowCustom).toBe("true");
expect(select?.dataset.trimCustom).toBe("false");
return select!;
});
ownerSelect.click();
await vi.waitFor(() => expect(mocks.buildTableOwnerChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ owner: "custom_domain", originalOwner: " app_user ", schema: "public", tableName: "users" })));
});
it("loads the PostgreSQL primary index name before showing a missing-name warning", async () => {
mocks.connection.db_type = "postgres";
mocks.connection.name = "postgres";
@@ -691,7 +740,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -708,7 +759,7 @@ describe("TableStructureEditor metadata loading", () => {
app.mount(root);
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull());
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["columns", "indexes", "comment"]);
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["columns", "indexes", "comment", "owner"].sort());
const primaryKey = columnCheckbox(root, "structureEditor.primaryKey");
primaryKey.checked = false;
@@ -739,7 +790,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -758,8 +811,8 @@ describe("TableStructureEditor metadata loading", () => {
mountedApps.push(app);
app.mount(root);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["indexes"]);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]);
const primaryKey = columnCheckbox(root, "structureEditor.primaryKey");
primaryKey.checked = false;
@@ -794,7 +847,9 @@ describe("TableStructureEditor metadata loading", () => {
]
: facet === "comment"
? ""
: [],
: facet === "owner"
? "app_user"
: [],
cacheStatus: "remote",
}));
@@ -817,8 +872,8 @@ describe("TableStructureEditor metadata loading", () => {
await Promise.resolve();
await nextTick();
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["indexes"]);
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2));
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]);
await vi.waitFor(() => expect(root.querySelector('[data-index-row-index="0"]')).not.toBeNull());
});
});
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -28,7 +28,7 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor
import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateObjectMetadataCache, loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
@@ -42,6 +42,7 @@ import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, norma
import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { postgresListRolesSql, usersFromPostgresRolesResult } from "@/lib/database/databaseUserAdmin";
import type { ColumnInfo, ConstraintInfo, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database";
import {
applyManticoreDdlColumnExtras,
@@ -894,6 +895,19 @@ const canAddColumn = computed(() => canAddTableStructureColumn(databaseType.valu
const newTableName = ref("");
const tableComment = ref("");
const originalTableComment = ref("");
const tableOwner = ref("");
const originalTableOwner = ref("");
const tableOwnerLoading = ref(false);
const tableOwnerLoadError = ref("");
const tableOwnerRoles = ref<string[]>([]);
const tableOwnerRolesLoading = ref(false);
const tableOwnerRolesLoadError = ref("");
const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.value === "postgres");
const tableOwnerOptions = computed(() => {
const owner = tableOwner.value;
if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value;
return [owner, ...tableOwnerRoles.value];
});
const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.name, props.database, props.schema, isCreateMode.value ? undefined : props.tableName));
function isManticoreTextColumn(column: EditableStructureColumn): boolean {
@@ -909,6 +923,8 @@ function isManticoreJsonColumn(column: EditableStructureColumn): boolean {
let sqlPreviewRequestId = 0;
let structureLoadRequestId = 0;
let tableOwnerLoadRequestId = 0;
let tableOwnerRolesLoadRequestId = 0;
let dataTypeOptionsRequestId = 0;
let sqlPreviewDebounceTimer: ReturnType<typeof setTimeout> | undefined;
let deferredSqlPreviewRefresh = false;
@@ -1094,6 +1110,8 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft {
newTableName: newTableName.value,
tableComment: tableComment.value,
originalTableComment: originalTableComment.value,
tableOwner: tableOwner.value,
originalTableOwner: originalTableOwner.value,
columns: cloneDraftValue(columns.value),
indexes: cloneDraftValue(indexes.value),
foreignKeys: cloneDraftValue(foreignKeys.value),
@@ -1122,6 +1140,8 @@ function restoreDraft(draft: TableStructureEditorDraft) {
newTableName.value = draft.newTableName || "";
tableComment.value = draft.tableComment || "";
originalTableComment.value = draft.originalTableComment || "";
tableOwner.value = draft.tableOwner || "";
originalTableOwner.value = draft.originalTableOwner || "";
columns.value = cloneDraftValue(draft.columns || []);
// Existing-index edits never support Concurrent (the checkbox is disabled and
// the core builder rejects the request), so a stale `concurrently: true`
@@ -1206,7 +1226,7 @@ function hasPendingStructureChanges(): boolean {
return !!newTableName.value.trim() || !!tableComment.value.trim() || columns.value.length > 0 || indexes.value.length > 0 || foreignKeys.value.length > 0 || triggers.value.length > 0;
}
const scope = captureStructureRefreshScope();
return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment;
return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment || (supportsTableOwner.value && tableOwner.value.trim() !== originalTableOwner.value.trim());
}
function clearSqlPreviewState() {
@@ -1390,9 +1410,18 @@ async function refreshSqlPreview() {
const options = structureChangeOptions();
try {
const result = isCreateMode.value ? await api.buildCreateTableSql(options) : hasSqliteTypeChange.value ? await api.previewSqliteTableStructureChange(props.connectionId, props.database, options) : await api.buildTableStructureChangeSql(options);
const ownerResult = supportsTableOwner.value
? await api.buildTableOwnerChangeSql({
databaseType: databaseType.value,
schema: metadataSchema.value,
tableName: props.tableName || "",
owner: tableOwner.value,
originalOwner: originalTableOwner.value,
})
: { statements: [], warnings: [] };
if (requestId !== sqlPreviewRequestId) return;
pendingStatements.value = result.statements;
warnings.value = result.warnings;
pendingStatements.value = [...result.statements, ...ownerResult.statements];
warnings.value = [...result.warnings, ...ownerResult.warnings];
sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined;
} catch (e: any) {
if (requestId !== sqlPreviewRequestId) return;
@@ -1459,6 +1488,15 @@ function resetState() {
newTableName.value = "";
tableComment.value = "";
originalTableComment.value = "";
tableOwner.value = "";
originalTableOwner.value = "";
tableOwnerLoadRequestId += 1;
tableOwnerLoading.value = false;
tableOwnerLoadError.value = "";
tableOwnerRoles.value = [];
tableOwnerRolesLoadRequestId += 1;
tableOwnerRolesLoading.value = false;
tableOwnerRolesLoadError.value = "";
columnSearchText.value = "";
highlightedColumnId.value = null;
indexSearchText.value = "";
@@ -1486,9 +1524,9 @@ async function reloadStructureFromDatabase() {
loadedMetadataFacets.clear();
if (refreshDdl) {
ddlFetched.value = false;
await fetchDdl(true);
await Promise.all([fetchDdl(true), loadTableOwner(true), loadTableOwnerRoles()]);
} else {
await loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true });
await Promise.all([loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true }), loadTableOwner(true), loadTableOwnerRoles()]);
}
}
@@ -1523,6 +1561,59 @@ function loadCachedTableComment(request: ReturnType<typeof ddlRequest>, force =
return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force });
}
async function loadTableOwner(force = false, preserveDraft = false) {
const connectionId = props.connectionId;
const database = props.database;
const schema = metadataSchema.value;
const tableName = props.tableName;
const catalog = props.catalog;
if (!supportsTableOwner.value || !connectionId || !database || !schema || !tableName) return;
const requestId = ++tableOwnerLoadRequestId;
tableOwnerLoading.value = true;
tableOwnerLoadError.value = "";
try {
await store.ensureConnected(connectionId);
const result = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog }, "owner", () => api.getTableOwner(connectionId, database, schema, tableName), { force });
if (requestId !== tableOwnerLoadRequestId) return;
const owner = result.value || "";
originalTableOwner.value = owner;
if (!preserveDraft) tableOwner.value = owner;
loadedMetadataFacets.add("owner");
} catch (error: any) {
if (requestId !== tableOwnerLoadRequestId) return;
tableOwnerLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableOwnerLoadRequestId) tableOwnerLoading.value = false;
}
}
async function loadTableOwnerRoles() {
const connectionId = props.connectionId;
const database = props.database;
if (!supportsTableOwner.value || !connectionId || !database) return;
const requestId = ++tableOwnerRolesLoadRequestId;
tableOwnerRolesLoading.value = true;
tableOwnerRolesLoadError.value = "";
try {
await store.ensureConnected(connectionId);
const result = await api.executeQuery(connectionId, database, postgresListRolesSql(), undefined, undefined, { maxRows: 5000 });
if (requestId !== tableOwnerRolesLoadRequestId) return;
tableOwnerRoles.value = [
...new Set(
usersFromPostgresRolesResult(result)
.map((role) => role.user)
.filter(Boolean),
),
];
} catch (error: any) {
if (requestId !== tableOwnerRolesLoadRequestId) return;
tableOwnerRoles.value = [];
tableOwnerRolesLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableOwnerRolesLoadRequestId) tableOwnerRolesLoading.value = false;
}
}
async function loadStructure(
silent = false,
scope: TableStructureRefreshScope = visibleTableStructureRefreshScope(activeTab.value),
@@ -1679,7 +1770,7 @@ async function loadStructure(
async function refreshStructureAfterSave(scope: TableStructureRefreshScope, characterLengthUnitsAfterSave: ReadonlyMap<string, string>) {
try {
await loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave });
await Promise.all([loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave }), loadTableOwner(true)]);
} catch (e) {
console.warn("[DBX][structure-editor:post-save-refresh-failed]", e);
} finally {
@@ -2844,7 +2935,9 @@ async function applyChanges() {
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
await recordStructureHistory(sql, startedAt, true, result);
if (!isCreateMode.value && props.tableName) {
invalidateTableMetadataCache({ connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName });
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
invalidateTableMetadataCache(metadataMatch);
await invalidateObjectMetadataCache(metadataMatch);
await invalidateObjectDdl(ddlRequest());
loadedMetadataFacets.clear();
}
@@ -2953,6 +3046,8 @@ onMounted(() => {
}
structureEditorReady = true;
observeStructureHorizontalScroller();
void loadTableOwner(false, props.draft?.tableOwner !== undefined);
void loadTableOwnerRoles();
if (props.draft?.initialized) {
void hydrateRestoredDraftFromDatabase().then(() => {
applyInitialStructureTarget();
@@ -2971,6 +3066,8 @@ onActivated(() => {
registerStructureEditorShortcuts();
observeStructureHorizontalScroller();
void loadDynamicDataTypeOptions();
if (supportsTableOwner.value && !loadedMetadataFacets.has("owner")) void loadTableOwner(false, props.draft?.tableOwner !== undefined);
if (supportsTableOwner.value && !tableOwnerRolesLoading.value && tableOwnerRoles.value.length === 0 && !tableOwnerRolesLoadError.value) void loadTableOwnerRoles();
if (props.draft?.initialized && !draftHydrated) {
restoreDraft(props.draft);
applyInitialStructureTarget();
@@ -3084,7 +3181,7 @@ watch([() => props.connectionId, () => props.database, databaseType], () => {
});
watch(
[isCreateMode, () => props.connectionId, () => props.database, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, columns, indexes, foreignKeys, triggers],
[isCreateMode, () => props.connectionId, () => props.database, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, tableOwner, columns, indexes, foreignKeys, triggers],
() => {
scheduleSqlPreviewRefresh();
syncDraftToParent();
@@ -3193,6 +3290,40 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</Tooltip>
</div>
<div v-if="supportsTableOwner" class="flex shrink-0 items-center gap-2">
<label class="flex shrink-0 items-center gap-1 font-medium text-muted-foreground">
<UserRound :class="structureIconClass" />
{{ t("structureEditor.owner") }}
</label>
<SearchableSelect
v-model="tableOwner"
:options="tableOwnerOptions"
:placeholder="t('structureEditor.ownerPlaceholder')"
:search-placeholder="t('structureEditor.ownerSearchPlaceholder')"
:empty-text="t('structureEditor.ownerRolesEmpty')"
:loading-text="t('common.loading')"
:loading="tableOwnerRolesLoading"
:allow-custom="true"
:trim-custom="false"
:disabled="tableOwnerLoading || !!tableOwnerLoadError"
:trigger-class="[structureMonoControlClass, 'w-[220px] max-w-[220px]']"
data-owner-select
/>
<Loader2 v-if="tableOwnerLoading" :class="[structureIconClass, 'animate-spin text-muted-foreground']" />
<Tooltip v-else-if="tableOwnerLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-destructive']" />
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.ownerLoadFailed", { message: tableOwnerLoadError }) }}</TooltipContent>
</Tooltip>
<Tooltip v-else-if="tableOwnerRolesLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-amber-500']" />
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.ownerRolesLoadFailed", { message: tableOwnerRolesLoadError }) }}</TooltipContent>
</Tooltip>
</div>
<div v-if="loading" class="flex min-h-0 flex-1 items-center justify-center gap-2 text-[length:var(--structure-font-size)] text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -11,14 +11,18 @@ const mocks = vi.hoisted(() => ({
driver_label: "PostgreSQL",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
getTablePartitionStatus: vi.fn(),
getTableOwner: vi.fn(),
buildTableOwnerChangeSql: vi.fn(),
toast: vi.fn(),
}));
@@ -46,6 +50,7 @@ vi.mock("@lucide/vue", async () => {
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
@@ -231,12 +236,15 @@ vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet }));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
getTableOwner: mocks.getTableOwner,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
@@ -248,11 +256,12 @@ async function mountIndexesEditor() {
mocks.connection.name = "postgres";
mocks.connection.driver_label = "postgres";
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValue({ columns: ["user", "host", "plugin"], rows: [["app_user", "LOGIN", ""]] });
mocks.listDataTypes.mockResolvedValue([]);
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({
value: facet === "comment" ? "" : [],
value: facet === "comment" ? "" : facet === "owner" ? "app_user" : [],
cacheStatus: "remote",
}));
// The SQL builder mirrors the Concurrent checkbox onto the generated
@@ -298,6 +307,8 @@ function concurrentCheckboxInRow(row: HTMLElement): HTMLInputElement {
beforeEach(() => {
vi.clearAllMocks();
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
mocks.getTableOwner.mockResolvedValue("app_user");
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
});
afterEach(() => {
@@ -32,6 +32,7 @@ const props = withDefaults(
displayName?: (option: string) => string;
optionTooltip?: (option: string) => string | undefined;
normalizeCustom?: (value: string) => string;
trimCustom?: boolean;
clearable?: boolean;
clearSelectedOption?: boolean;
}>(),
@@ -41,6 +42,7 @@ const props = withDefaults(
allowCustom: false,
clearable: false,
clearSelectedOption: false,
trimCustom: true,
loadingText: "Loading...",
triggerVariant: "outline",
triggerIconClass: "size-4 text-muted-foreground",
@@ -83,7 +85,7 @@ const triggerBaseClass = computed(() =>
);
const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName));
const customOptionValue = computed(() => props.normalizeCustom(searchText.value.trim()));
const customOptionValue = computed(() => props.normalizeCustom(props.trimCustom ? searchText.value.trim() : searchText.value));
const canSelectCustom = computed(() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value));
function highlightSelectedOption() {
+8
View File
@@ -2040,6 +2040,8 @@ export default {
tableInfoNullable: "Nullable",
tableInfoEmpty: "No metadata",
tableInfoSearch: "Search...",
tableOwner: "Owner",
tableOwnerUnavailable: "Unavailable",
tableInfoNoResults: "No results",
goToColumn: "Go to column",
columnWidth: "Column Width",
@@ -3771,6 +3773,12 @@ export default {
editComment: "Edit comment",
commentPlaceholder: "Enter column comment...",
tableCommentPlaceholder: "Enter table comment...",
owner: "Owner",
ownerPlaceholder: "Enter role name...",
ownerSearchPlaceholder: "Search or enter a role name...",
ownerRolesEmpty: "No roles found; enter a role name manually",
ownerLoadFailed: "Failed to load table owner: {message}",
ownerRolesLoadFailed: "Failed to load roles; you can still enter one manually: {message}",
tableCommentUnsupported: "Table comments are not supported by this database",
actions: "Actions",
indexName: "Index",
+8
View File
@@ -1891,6 +1891,8 @@ export default withEnglishFallback({
rowsPerPageShort: " filas",
columnDetails: "Detalles de columna",
tableInfoSearch: "Buscar...",
tableOwner: "Propietario",
tableOwnerUnavailable: "No disponible",
tableInfoNoResults: "Sin resultados",
goToColumn: "Ir a la columna",
columnWidth: "Ancho de columna",
@@ -3594,6 +3596,12 @@ export default withEnglishFallback({
editComment: "Editar comentario",
commentPlaceholder: "Ingresar comentario de columna...",
tableCommentPlaceholder: "Ingresar comentario de tabla...",
owner: "Propietario",
ownerPlaceholder: "Ingrese el nombre del rol...",
ownerSearchPlaceholder: "Buscar o ingresar un rol...",
ownerRolesEmpty: "No se encontraron roles; ingrese uno manualmente",
ownerLoadFailed: "No se pudo cargar el propietario de la tabla: {message}",
ownerRolesLoadFailed: "No se pudieron cargar los roles; aún puede ingresar uno manualmente: {message}",
tableCommentUnsupported: "La base de datos no admite comentarios de tabla",
actions: "Acciones",
indexName: "Índice",
+8
View File
@@ -1900,6 +1900,8 @@ export default withEnglishFallback({
tableInfoNullable: "Nullabile",
tableInfoEmpty: "Nessun metadato",
tableInfoSearch: "Cerca...",
tableOwner: "Proprietario",
tableOwnerUnavailable: "Non disponibile",
tableInfoNoResults: "Nessun risultato",
goToColumn: "Vai alla colonna",
columnWidth: "Larghezza colonna",
@@ -3592,6 +3594,12 @@ export default withEnglishFallback({
editComment: "Modifica commento",
commentPlaceholder: "Inserisci commento colonna...",
tableCommentPlaceholder: "Inserisci commento tabella...",
owner: "Proprietario",
ownerPlaceholder: "Inserisci il nome del ruolo...",
ownerSearchPlaceholder: "Cerca o inserisci un ruolo...",
ownerRolesEmpty: "Nessun ruolo trovato; inseriscine uno manualmente",
ownerLoadFailed: "Impossibile caricare il proprietario della tabella: {message}",
ownerRolesLoadFailed: "Impossibile caricare i ruoli; puoi comunque inserirne uno manualmente: {message}",
tableCommentUnsupported: "I commenti della tabella non sono supportati da questo database",
actions: "Azioni",
indexName: "Indice",
+8
View File
@@ -1918,6 +1918,8 @@ export default withEnglishFallback({
tableInfoNullable: "NULL許容",
tableInfoEmpty: "メタデータなし",
tableInfoSearch: "検索...",
tableOwner: "所有者",
tableOwnerUnavailable: "取得できません",
tableInfoNoResults: "結果なし",
goToColumn: "列へ移動",
columnWidth: "列幅",
@@ -3654,6 +3656,12 @@ export default withEnglishFallback({
editComment: "コメントを編集",
commentPlaceholder: "列コメントを入力...",
tableCommentPlaceholder: "テーブルコメントを入力...",
owner: "所有者",
ownerPlaceholder: "ロール名を入力...",
ownerSearchPlaceholder: "ロール名を検索または入力...",
ownerRolesEmpty: "ロールが見つかりません。ロール名を直接入力できます",
ownerLoadFailed: "テーブル所有者を読み込めませんでした: {message}",
ownerRolesLoadFailed: "ロール一覧を読み込めませんでした。手動入力は可能です: {message}",
tableCommentUnsupported: "このデータベースではテーブルコメントはサポートされていません",
actions: "操作",
indexName: "インデックス",
+8
View File
@@ -1876,6 +1876,8 @@ export default withEnglishFallback({
tableInfoNullable: "NULL 허용",
tableInfoEmpty: "메타데이터 없음",
tableInfoSearch: "검색...",
tableOwner: "소유자",
tableOwnerUnavailable: "사용할 수 없음",
tableInfoNoResults: "결과 없음",
goToColumn: "컬럼으로 이동",
columnWidth: "컬럼 너비",
@@ -3553,6 +3555,12 @@ export default withEnglishFallback({
editComment: "주석 편집",
commentPlaceholder: "컬럼 주석 입력...",
tableCommentPlaceholder: "테이블 주석 입력...",
owner: "소유자",
ownerPlaceholder: "역할 이름 입력...",
ownerSearchPlaceholder: "역할 이름 검색 또는 입력...",
ownerRolesEmpty: "역할을 찾을 수 없습니다. 역할 이름을 직접 입력할 수 있습니다",
ownerLoadFailed: "테이블 소유자를 불러오지 못했습니다: {message}",
ownerRolesLoadFailed: "역할 목록을 불러오지 못했습니다. 직접 입력할 수 있습니다: {message}",
tableCommentUnsupported: "이 데이터베이스는 테이블 주석을 지원하지 않습니다",
actions: "작업",
indexName: "인덱스",
+8
View File
@@ -1902,6 +1902,8 @@ export default withEnglishFallback({
tableInfoNullable: "Permite Nulo",
tableInfoEmpty: "Sem metadados",
tableInfoSearch: "Pesquisar...",
tableOwner: "Proprietário",
tableOwnerUnavailable: "Indisponível",
tableInfoNoResults: "Nenhum resultado",
goToColumn: "Ir para a coluna",
columnWidth: "Largura da coluna",
@@ -3594,6 +3596,12 @@ export default withEnglishFallback({
editComment: "Editar comentário",
commentPlaceholder: "Insira o comentário da coluna...",
tableCommentPlaceholder: "Insira o comentário da tabela...",
owner: "Proprietário",
ownerPlaceholder: "Insira o nome da função...",
ownerSearchPlaceholder: "Pesquise ou insira uma função...",
ownerRolesEmpty: "Nenhuma função encontrada; insira uma manualmente",
ownerLoadFailed: "Falha ao carregar o proprietário da tabela: {message}",
ownerRolesLoadFailed: "Falha ao carregar as funções; ainda é possível inserir uma manualmente: {message}",
tableCommentUnsupported: "Comentários de tabela não são suportados por este banco de dados",
actions: "Ações",
indexName: "Índice",
+8
View File
@@ -1965,6 +1965,8 @@ export default withEnglishFallback({
tableInfoNullable: "可空",
tableInfoEmpty: "暂无元数据",
tableInfoSearch: "搜索...",
tableOwner: "所有者",
tableOwnerUnavailable: "无法获取",
tableInfoNoResults: "无匹配结果",
goToColumn: "跳转列",
columnWidth: "列宽",
@@ -3759,6 +3761,12 @@ export default withEnglishFallback({
editComment: "编辑注释",
commentPlaceholder: "输入字段注释...",
tableCommentPlaceholder: "输入表注释...",
owner: "所有者",
ownerPlaceholder: "输入角色名...",
ownerSearchPlaceholder: "搜索或输入角色名...",
ownerRolesEmpty: "未找到角色,可直接输入角色名",
ownerLoadFailed: "加载表所有者失败:{message}",
ownerRolesLoadFailed: "加载角色列表失败,仍可手动输入:{message}",
tableCommentUnsupported: "当前数据库不支持表注释",
actions: "操作",
indexName: "索引名",
+8
View File
@@ -1906,6 +1906,8 @@ export default withEnglishFallback({
tableInfoNullable: "可為空",
tableInfoEmpty: "無",
tableInfoSearch: "搜尋……",
tableOwner: "擁有者",
tableOwnerUnavailable: "無法取得",
tableInfoNoResults: "沒有相符結果",
goToColumn: "跳轉欄",
columnWidth: "列寬",
@@ -3335,6 +3337,12 @@ export default withEnglishFallback({
editComment: "編輯註解",
commentPlaceholder: "輸入欄位註解……",
tableCommentPlaceholder: "輸入資料表註解……",
owner: "擁有者",
ownerPlaceholder: "輸入角色名稱……",
ownerSearchPlaceholder: "搜尋或輸入角色名稱……",
ownerRolesEmpty: "找不到角色,可直接輸入角色名稱",
ownerLoadFailed: "載入資料表擁有者失敗:{message}",
ownerRolesLoadFailed: "載入角色清單失敗,仍可手動輸入:{message}",
tableCommentUnsupported: "此資料庫不支援資料表註解",
actions: "操作",
indexName: "索引",
+2
View File
@@ -186,6 +186,7 @@ export const listFunctions = forward("listFunctions");
export const listSequences = forward("listSequences");
export const listRules = forward("listRules");
export const listOwners = forward("listOwners");
export const getTableOwner = forward("getTableOwner");
export const listExtensions = forward("listExtensions");
export const listAvailableExtensions = forward("listAvailableExtensions");
export const prepareSchemaDiff = forward("prepareSchemaDiff");
@@ -252,6 +253,7 @@ export const buildEditableObjectSource = forward("buildEditableObjectSource");
export const buildRoutineRenameObjectSourceStatements = forward("buildRoutineRenameObjectSourceStatements");
export const buildViewDdlSql = forward("buildViewDdlSql");
export const buildTableStructureChangeSql = forward("buildTableStructureChangeSql");
export const buildTableOwnerChangeSql = forward("buildTableOwnerChangeSql");
export const previewSqliteTableStructureChange = forward("previewSqliteTableStructureChange");
export const applySqliteTableStructureChange = forward("applySqliteTableStructureChange");
export const buildCreateTableSql = forward("buildCreateTableSql");
+9 -1
View File
@@ -159,7 +159,7 @@ import type {
HiveTablePropertiesSqlOptions,
} from "@/lib/dataGrid/dataGridSql";
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
@@ -969,6 +969,10 @@ export async function listOwners(connectionId: string, database: string, schema:
return get(`/api/schema/owners?${qs({ connection_id: connectionId, database, schema })}`);
}
export async function getTableOwner(connectionId: string, database: string, schema: string, table: string): Promise<string | null> {
return get(`/api/schema/table-owner?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
return get(`/api/schema/extensions?${qs({ connection_id: connectionId, database, schema })}`);
}
@@ -1425,6 +1429,10 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC
return post("/api/query/build-table-structure-change-sql", { options });
}
export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSqlOptions): Promise<TableStructureChangeSql> {
return post("/api/query/build-table-owner-change-sql", { options });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
return post("/api/query/preview-sqlite-table-structure-change", {
connectionId,
+9 -1
View File
@@ -83,7 +83,7 @@ import type {
import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
@@ -1603,6 +1603,10 @@ export async function buildTableStructureChangeSql(options: BuildTableStructureC
return invoke("build_table_structure_change_sql", { options });
}
export async function buildTableOwnerChangeSql(options: BuildTableOwnerChangeSqlOptions): Promise<TableStructureChangeSql> {
return invoke("build_table_owner_change_sql", { options });
}
export async function previewSqliteTableStructureChange(connectionId: string, database: string, options: BuildTableStructureChangeSqlOptions): Promise<SqliteTableStructureChangePreview> {
return invoke("preview_sqlite_table_structure_change", {
connectionId,
@@ -1876,6 +1880,10 @@ export async function listOwners(connectionId: string, database: string, schema:
return invoke("list_owners", { connectionId, database, schema });
}
export async function getTableOwner(connectionId: string, database: string, schema: string, table: string): Promise<string | null> {
return invoke("get_table_owner", { connectionId, database, schema, table });
}
export async function listExtensions(connectionId: string, database: string, schema?: string): Promise<ExtensionInfo[]> {
return invoke("list_extensions", { connectionId, database, schema });
}
@@ -12,6 +12,12 @@ describe("SearchableSelect layout", () => {
expect(labelBoundaries).toHaveLength(3);
});
it("can preserve custom option whitespace for exact database identifiers", () => {
expect(searchableSelectSource).toContain("trimCustom?: boolean");
expect(searchableSelectSource).toContain("trimCustom: true");
expect(searchableSelectSource).toContain("props.trimCustom ? searchText.value.trim() : searchText.value");
});
it("truncates long data transfer connection labels without shrinking their icons", () => {
const truncatedConnectionLabels = dataTransferDialogSource.match(/<span class="min-w-0 flex-1 truncate">\{\{ label \}\}<\/span>/g) ?? [];
const fixedConnectionIcons = dataTransferDialogSource.match(/class="h-3\.5 w-3\.5 shrink-0"/g) ?? [];
@@ -49,7 +49,7 @@ async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
}
}
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment";
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment" | "owner";
function cacheSegment(value: string | undefined): string {
return encodeURIComponent(value ?? "");
@@ -97,6 +97,14 @@ export interface TableStructureChangeSql {
warnings: string[];
}
export interface BuildTableOwnerChangeSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
owner: string;
originalOwner: string;
}
export interface SqliteTableStructureChangePreview extends TableStructureChangeSql {
schemaRevision: string;
}
+2
View File
@@ -1070,6 +1070,8 @@ export interface TableStructureEditorDraft {
newTableName: string;
tableComment: string;
originalTableComment: string;
tableOwner?: string;
originalTableOwner?: string;
columns: import("@/lib/table/tableStructureEditorSql").EditableStructureColumn[];
indexes: import("@/lib/table/tableStructureEditorSql").EditableStructureIndex[];
foreignKeys: import("@/lib/table/tableStructureEditorSql").EditableStructureForeignKey[];