feat(postgres): list user-defined types with details and DDL for PG-family databases

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-11 15:00:24 +08:00
1 parent 0c6c1fada2
commit 154c460657
59 files changed
+6538 -150

No files matched your search

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, RefreshCw, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ConnectionConfig, CustomTypeDetails, CustomTypeKind } from "@/types/database";
import * as api from "@/lib/backend/api";
const props = defineProps<{
connection: ConnectionConfig;
database: string;
schema: string;
name: string;
catalog?: string;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const toast = useToast();
const guard = createSidePanelRequestGuard();
const details = ref<CustomTypeDetails | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const tab = ref<"members" | "properties" | "ddl">("members");
const kindLabel = computed(() => (details.value ? t(`customType.kinds.${details.value.kind}`) : ""));
const hasMemberTab = computed(() => details.value?.kind === "composite" || details.value?.kind === "enum");
const tabs = computed(() => {
const items: Array<{ id: "members" | "properties" | "ddl"; label: string }> = [];
if (hasMemberTab.value) items.push({ id: "members", label: t("customType.tabs.members") });
items.push({ id: "properties", label: t("customType.tabs.properties") });
items.push({ id: "ddl", label: t("customType.tabs.ddl") });
return items;
});
async function load() {
// start() bumps the epoch so an in-flight request from a previously selected
// type can never overwrite the current panel state.
const epoch = guard.start();
loading.value = true;
error.value = null;
details.value = null;
tab.value = "members";
try {
const result = await api.getCustomTypeDetails(props.connection.id, props.database, props.schema, props.name);
if (guard.isStale(epoch)) return;
details.value = result;
if (tab.value === "members" && result.kind !== "composite" && result.kind !== "enum") {
tab.value = "properties";
}
} catch (e: any) {
if (guard.isStale(epoch)) return;
error.value = e?.message || String(e);
} finally {
if (guard.isFresh(epoch)) loading.value = false;
}
}
watch(
() => [props.connection.id, props.database, props.schema, props.name] as const,
() => load(),
{ immediate: true },
);
const ddlText = computed(() => details.value?.ddl?.sql ?? "");
async function copyDdl() {
if (!ddlText.value) return;
try {
await copyToClipboard(ddlText.value);
toast.toast(t("contextMenu.ddlCopied"));
} catch (error) {
toast.toast(t("grid.copyFailed", { message: translateBackendError(t, error) }));
}
}
const propertyRows = computed<Array<{ label: string; value: string }>>(() => {
const p = details.value?.properties;
if (!p) return [];
const rows: Array<{ label: string; value: string }> = [];
const push = (label: string, value: string | null | undefined) => {
if (value != null && value !== "") rows.push({ label, value: String(value) });
};
push(t("customType.properties.baseType"), p.baseType);
if (p.notNull) push(t("customType.properties.notNull"), "true");
push(t("customType.properties.default"), p.default);
push(t("customType.properties.collation"), p.collation);
for (const constraint of p.domainConstraints) {
rows.push({
label: constraint.name ? `${t("customType.properties.domainConstraint")} · ${constraint.name}` : t("customType.properties.domainConstraint"),
value: constraint.definition,
});
}
push(t("customType.properties.rangeSubtype"), p.rangeSubtype);
push(t("customType.properties.rangeMultirange"), p.rangeMultirangeName);
return rows;
});
function isFieldKind(kind: CustomTypeKind | undefined): boolean {
return kind === "composite";
}
function isEnumKind(kind: CustomTypeKind | undefined): boolean {
return kind === "enum";
}
function selectTab(next: "members" | "properties" | "ddl") {
tab.value = next;
}
defineExpose({ selectTab });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-2 border-b px-3 py-1.5 bg-muted/20 h-9 shrink-0">
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ name }}</span>
<span class="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground shrink-0">{{ kindLabel }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('close')">
<X class="w-3 h-3" />
</Button>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="error" class="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
<div class="text-xs text-destructive break-words max-w-full">{{ error }}</div>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="load">
<RefreshCw class="w-3 h-3" />
{{ t("common.retry") }}
</Button>
</div>
<template v-else-if="details">
<div class="flex items-center gap-1 border-b px-2 py-1 shrink-0">
<button v-for="item in tabs" :key="item.id" type="button" class="rounded px-2 py-1 text-xs" :class="tab === item.id ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'" @click="tab = item.id">
{{ item.label }}
</button>
<span class="flex-1" />
<Button v-if="tab === 'ddl' && ddlText" variant="ghost" size="sm" class="h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
<Copy class="w-3 h-3" />
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
</Button>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<!-- Members: empty state for composite/enum -->
<div v-if="tab === 'members' && details.members.length === 0" class="flex flex-col items-center justify-center gap-1 px-4 py-8 text-center">
<span class="text-xs text-muted-foreground">{{ t("customType.members.empty") }}</span>
</div>
<!-- Members: composite fields -->
<table v-else-if="tab === 'members' && isFieldKind(details.kind)" class="w-full text-[11px]">
<thead class="sticky top-0 bg-muted/40 text-left text-muted-foreground">
<tr>
<th class="px-3 py-1.5 font-medium">#</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.name") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.type") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.nullable") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.default") }}</th>
<th class="px-3 py-1.5 font-medium">{{ t("customType.members.comment") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in details.members" :key="member.ordinal" class="border-t border-border/60">
<td class="px-3 py-1.5 text-muted-foreground">{{ member.ordinal }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.name }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.dataType }}</td>
<td class="px-3 py-1.5">{{ member.nullable == null ? "—" : member.nullable ? "✓" : "✗" }}</td>
<td class="px-3 py-1.5 font-mono">{{ member.default ?? "—" }}</td>
<td class="px-3 py-1.5">{{ member.comment ?? "—" }}</td>
</tr>
</tbody>
</table>
<!-- Members: enum values -->
<ul v-else-if="tab === 'members' && isEnumKind(details.kind) && details.members.length > 0" class="divide-y divide-border/60">
<li v-for="member in details.members" :key="member.ordinal" class="flex items-center gap-3 px-3 py-1.5 text-[11px]">
<span class="w-6 text-muted-foreground text-right">{{ member.ordinal }}</span>
<span class="font-mono">{{ member.enumValue }}</span>
</li>
</ul>
<!-- Properties -->
<table v-else-if="tab === 'properties'" class="w-full text-[11px]">
<tbody>
<tr v-if="details.comment" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44">{{ t("customType.members.comment") }}</td>
<td class="px-3 py-1.5">{{ details.comment }}</td>
</tr>
<tr v-for="row in propertyRows" :key="row.label" class="border-b border-border/60">
<td class="px-3 py-1.5 text-muted-foreground w-44 align-top">{{ row.label }}</td>
<td class="px-3 py-1.5 font-mono whitespace-pre-wrap break-all">{{ row.value }}</td>
</tr>
<tr v-if="propertyRows.length === 0 && !details.comment" class="border-b border-border/60">
<td class="px-3 py-2 text-xs text-muted-foreground" colspan="2">{{ t("customType.properties.empty") }}</td>
</tr>
</tbody>
</table>
<!-- DDL -->
<div v-else class="flex h-full flex-col">
<div v-if="details.ddl && !details.ddl.complete" class="border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5">
<div v-for="warning in details.ddl.warnings" :key="warning" class="text-[11px] text-amber-600 dark:text-amber-400">{{ warning }}</div>
</div>
<pre class="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed">{{ ddlText || t("customType.ddl.empty") }}</pre>
</div>
</div>
</template>
</div>
</template>
@@ -18,6 +18,7 @@ import {
Eraser,
Eye,
FileCode,
Info,
GripVertical,
KeyRound,
LayoutGrid,
@@ -55,6 +56,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
@@ -118,6 +120,7 @@ import {
type ObjectBrowserSortKey,
} from "@/lib/table/objectBrowserRows";
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
@@ -173,7 +176,7 @@ const sourceCanEdit = ref(true);
// --- Right-side panel state ---
// Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.)
const sidePanelRow = ref<ObjectBrowserRow | null>(null);
const sidePanelMode = ref<"table-info" | "source">("source");
const sidePanelMode = ref<"table-info" | "source" | "type-info">("source");
// Table info panel state
const tableInfoTab = ref<TableInfoTab>("ddl");
const tableColumns = ref<ColumnInfo[]>([]);
@@ -196,6 +199,7 @@ let sidePanelResizeStartX = 0;
let sidePanelResizeStartWidth = 0;
const isResizingSidePanel = ref(false);
const sidePanelGuard = createSidePanelRequestGuard();
const sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
@@ -822,6 +826,9 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
case "table-info":
void openTableInfo(row);
break;
case "type-info":
void openTypeInfo(row);
break;
case "open-table":
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
break;
@@ -833,7 +840,7 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction)
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
const activation = settingsStore.editorSettings.sidebarActivation;
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation);
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value);
// Double click: cancel any pending single-click and fire immediately
if (isDouble) {
if (singleClickTimer) {
@@ -1107,6 +1114,28 @@ function closeSidePanel() {
sidePanelGuard.bump();
}
async function openTypeInfo(row: ObjectBrowserRow) {
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "type-info") {
closeSidePanel();
return;
}
sidePanelRow.value = row;
sidePanelMode.value = "type-info";
sidePanelGuard.bump();
}
function openTypeDdl(row: ObjectBrowserRow) {
sidePanelRow.value = row;
sidePanelMode.value = "type-info";
sidePanelGuard.bump();
// DDL tab is selected by the panel once details load; switching the tab is
// deferred via a dedicated request so the panel can focus it.
nextTick(() => {
const panel = sidePanelRef.value;
panel?.selectTab("ddl");
});
}
const canOpenTableStructureEditor = computed(() => sidePanelRow.value?.type === "TABLE" && canOpenStructureEditor.value);
function openTableStructureEditor() {
@@ -2737,9 +2766,32 @@ function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
];
}
function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
const capabilities = customTypeCapabilities(effectiveDatabaseType.value);
// Verified PG-family types open the read-only details panel; Xugu keeps its
// “view source” entry for TYPE/TYPE_BODY rows.
if (supportsTypeObjectSource(effectiveDatabaseType.value)) {
items.push({ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 });
}
if (capabilities.details) {
items.push({ label: t("contextMenu.viewDetails"), action: () => void openTypeInfo(item), icon: Info });
if (capabilities.ddl) {
items.push({ label: t("contextMenu.viewDdl"), action: () => openTypeDdl(item), icon: FileCode });
}
}
// Only separate when an action precedes copy-name.
if (items.length > 0) {
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy });
return items;
}
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(item);
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
return getProcFuncMenuItems(item);
}
@@ -3233,6 +3285,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</template>
<!-- Type info mode (read-only user-defined type details) -->
<template v-else-if="sidePanelMode === 'type-info'">
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
</template>
<!-- Source mode (views, procedures, functions, sequences) -->
<template v-else>
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
@@ -0,0 +1,246 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import type { ConnectionConfig, CustomTypeDetails } from "@/types/database";
const mocks = vi.hoisted(() => ({
getCustomTypeDetails: vi.fn(),
copyToClipboard: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
getCustomTypeDetails: (...args: unknown[]) => mocks.getCustomTypeDetails(...args),
}));
vi.mock("@/lib/common/clipboard", () => ({
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return {
Copy: Icon,
RefreshCw: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
const connection: ConnectionConfig = {
id: "pg-1",
name: "pg",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "test",
password: "secret",
database: Some("demo"),
visible_databases: null,
visible_schemas: null,
show_system_schemas: false,
attached_databases: [],
init_script: null,
color: null,
transport_layers: [],
connection_string: null,
driver_profile: null,
driver_label: null,
note: "",
url_params: null,
agent_java_options: [],
} as unknown as ConnectionConfig;
function Some<T>(value: T): T | null {
return value;
}
function enumDetails(overrides: Partial<CustomTypeDetails> = {}): CustomTypeDetails {
return {
name: "status",
schema: "app",
kind: "enum",
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
properties: { domainConstraints: [] },
ddl: { sql: "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');", complete: true, warnings: [] },
...overrides,
};
}
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
function mountPanel(props: Record<string, unknown>) {
root = document.createElement("div");
document.body.append(root);
app = createApp(CustomTypeInfoPanel, props);
app.mount(root);
}
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
vi.clearAllMocks();
});
async function flush() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function clickButton(text: string) {
const button = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent?.trim() === text);
button?.dispatchEvent(new MouseEvent("click"));
return button;
}
describe("CustomTypeInfoPanel", () => {
it("shows loading while details are being fetched", async () => {
let release!: () => void;
mocks.getCustomTypeDetails.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await nextTick();
expect(root!.textContent).toContain("common.loading");
release();
});
it("renders enum values and complete DDL", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("draft");
expect(root!.textContent).toContain("published");
});
it("shows the explanatory empty state for an enum with no values", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ members: [] }));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows the explanatory empty state for a composite with no fields", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails({ kind: "composite", members: [], ddl: { sql: "CREATE TYPE ...;", complete: true, warnings: [] } }));
mountPanel({ connection, database: "demo", schema: "app", name: "address" });
await flush();
expect(root!.textContent).toContain("customType.members.empty");
});
it("shows DDL warnings when the DDL is incomplete", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
ddl: { sql: "", complete: false, warnings: ["multirange companion; no standalone CREATE"] },
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "_price_range" });
await flush();
const ddlButton = Array.from(root!.querySelectorAll("button")).find((b) => b.textContent === "customType.tabs.ddl");
ddlButton?.dispatchEvent(new MouseEvent("click"));
await nextTick();
expect(root!.textContent).toContain("multirange companion; no standalone CREATE");
});
it("hides kernel-level type implementation attributes", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(
enumDetails({
kind: "base",
members: [],
properties: {
domainConstraints: [],
inputFunction: "base_in",
alignment: "i",
storage: "x",
},
}),
);
mountPanel({ connection, database: "demo", schema: "app", name: "base_type" });
await flush();
expect(clickButton("customType.tabs.members")).toBeUndefined();
expect(root!.textContent).toContain("customType.tabs.properties");
expect(root!.textContent).not.toContain("base_in");
expect(root!.textContent).not.toContain("customType.properties.alignment");
expect(root!.textContent).not.toContain("customType.properties.storage");
});
it("renders an error state with a working retry button", async () => {
mocks.getCustomTypeDetails.mockRejectedValueOnce(new Error("catalog unavailable"));
mocks.getCustomTypeDetails.mockResolvedValueOnce(enumDetails());
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
expect(root!.textContent).toContain("catalog unavailable");
clickButton("common.retry");
await vi.waitFor(() => expect(root!.textContent).toContain("draft"));
});
it("reports copy success through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockResolvedValue(undefined);
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("CREATE TYPE \"app\".\"status\" AS ENUM ('draft', 'published');"));
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.ddlCopied");
});
it("reports copy failure through the toast", async () => {
mocks.getCustomTypeDetails.mockResolvedValue(enumDetails());
mocks.copyToClipboard.mockRejectedValue(new Error("denied"));
mountPanel({ connection, database: "demo", schema: "app", name: "status" });
await flush();
clickButton("customType.tabs.ddl");
await flush();
clickButton("grid.copyDdl");
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed"));
});
it("ignores a stale response when the type is switched quickly on the same instance", async () => {
let releaseFirst!: () => void;
const first = new Promise<CustomTypeDetails>((resolve) => (releaseFirst = () => resolve(enumDetails({ name: "status" }))));
const second = Promise.resolve(enumDetails({ name: "email", kind: "domain", members: [], properties: { baseType: "text", domainConstraints: [] } }));
mocks.getCustomTypeDetails.mockReturnValueOnce(first).mockReturnValueOnce(second);
const hostName = ref("status");
const Host = defineComponent({
setup() {
return () => h(CustomTypeInfoPanel, { connection, database: "demo", schema: "app", name: hostName.value });
},
});
root = document.createElement("div");
document.body.append(root);
app = createApp(Host);
app.mount(root);
await nextTick();
hostName.value = "email";
await nextTick();
releaseFirst();
await flush();
// The stale "status" response must not overwrite the "email" details.
expect(root!.textContent).toContain("email");
expect(root!.textContent).not.toContain("draft");
});
});
@@ -13,6 +13,7 @@ import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
@@ -1338,6 +1339,9 @@ function openSidebarDdlForSelection(): boolean {
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction();
void store
@@ -97,6 +97,7 @@ import {
isSingleDatabase,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
@@ -565,6 +566,9 @@ async function toggle() {
}
return;
}
// PostgreSQL-family custom types without members (enum/domain/range/base) do
// not toggle; only composite types expand into their member list.
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.hasMembers === false) return;
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
@@ -593,6 +597,12 @@ async function toggle() {
return;
}
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details && node.children !== undefined) {
node.isExpanded = node.children.length > 0 ? !node.isExpanded : false;
emitNodeToggled(node, wasExpanded);
return;
}
// Keep the click path aligned with every object-group definition. In
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
@@ -625,6 +635,12 @@ async function toggle() {
}
try {
if (node.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
await connectionStore.loadCustomTypeChildren(node);
emitNodeToggled(node, wasExpanded);
return;
}
if (await loadSidebarObjectGroup(node, connectionStore)) {
emitNodeToggled(node, wasExpanded);
return;
@@ -803,7 +819,7 @@ function runRowClickAction(clickDetail: number) {
openMongoTreeData(node);
return;
}
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation);
const action = treeNodeRowAction(node.type, canExpand.value, settingsStore.editorSettings.sidebarActivation, currentDatabaseType());
if (!shouldRunTreeNodeRowAction(action, clickDetail, isGroupLabel(node))) return;
if (action === "open-data") {
scheduleOpenData(node);
@@ -1112,7 +1128,7 @@ function requestDeleteSelectedNode(): boolean {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(activeNode.value.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value);
const action = treeNodeRowDoubleClickAction(activeNode.value.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value, currentDatabaseType());
if (action === "open-object-browser") {
void openObjectBrowser();
} else if (action === "open-object-browser-and-expand") {
@@ -1506,6 +1522,28 @@ async function copyName() {
}
}
async function copyCustomTypeDdl() {
const node = createSidebarActionTarget(activeNode.value);
if (!node.connectionId || !node.database) return;
try {
await connectionStore.ensureConnected(node.connectionId);
const details = await api.getCustomTypeDetails(node.connectionId, node.database, node.schema || node.database, node.objectName || node.label);
const sql = details.ddl?.sql?.trim();
if (!sql) {
toast(t("customType.ddl.empty"), 3000);
return;
}
if (!details.ddl?.complete) {
toast(t("customType.ddl.incomplete"), 5000);
return;
}
await copyToClipboard(sql);
toast(t("contextMenu.ddlCopied"), 2000);
} catch (error: any) {
toast(t("grid.copyFailed", { message: translateBackendError(t, error) }), 5000);
}
}
async function copySelectedNames() {
const selectedNodes = selectedTreeNodesInVisibleOrder();
const nodes = selectedNodes.length > 1 && selectedNodes.some((node) => node.id === activeNode.value.id) ? selectedNodes : [activeNode.value];
@@ -1686,6 +1724,9 @@ async function refreshDropTableChildObjectPreviewSql() {
function openObjectSourceDialog(initialEditing: boolean) {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
// TYPE/TYPE_BODY only have a source implementation on Xugu; PostgreSQL-family
// connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(currentDatabaseType())) return;
const connectionId = node.connectionId;
const database = node.database;
const sourceTarget = objectSourceTargetForTreeNode(node);
@@ -3408,13 +3449,16 @@ function createMysqlObjectTemplate() {
queryStore.updateSql(tabId, template.sql);
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const canPin = computed(() => canTreeNodePin(activeNode.value.type));
@@ -4702,7 +4746,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "trigger" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") {
if (node.type === "trigger" || node.type === "package" || node.type === "package-body") {
if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) {
items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench });
}
@@ -4711,6 +4755,23 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
// PostgreSQL-family types expand in the tree and expose DDL as a lightweight
// copy action. Xugu keeps its source/change-open-mode entries.
if (node.type === "type" || node.type === "type-body") {
if (supportsTypeObjectSource(currentDatabaseType())) {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
return true;
}
if (customTypeCapabilities(currentDatabaseType()).details) {
items.push({ label: t("contextMenu.copyDdl"), action: copyCustomTypeDdl, icon: Copy });
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return true;
}
return false;
}
@@ -49,7 +49,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
@@ -72,6 +72,7 @@ import { useDragSort } from "@/composables/useDragSort";
import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime";
import { treeNodePinKey } from "@/lib/app/pinnedItems";
import { isTreeGroupNodeType } from "@/lib/sidebar/treeNodeGroup";
import { customTypeCapabilities } from "@/lib/database/databaseObjectCapabilities";
import { shouldActivateTreeNodeOnSingleClick } from "@/lib/sidebar/treeNodeClick";
const { t } = useI18n();
@@ -299,6 +300,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Braces, colorClass: "text-violet-500" };
case "type-body":
return { icon: FileCode, colorClass: "text-violet-400" };
case "type-member":
return { icon: Columns3, colorClass: "text-muted-foreground" };
case "group-tables":
return { icon: Table, colorClass: "text-green-500" };
case "group-views":
@@ -343,6 +346,12 @@ function displayLabel(node: TreeNode): string {
return isGroupLabel(node) ? t(node.label) : node.label;
}
function treeNodeSecondaryValue(node: TreeNode): string | undefined {
if (node.type === "type" && node.customTypeKind) return t(`customType.kinds.${node.customTypeKind}`);
if (node.type === "type-member") return (node.meta as CustomTypeTreeMemberMeta | undefined)?.displayValue;
return undefined;
}
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
@@ -572,13 +581,19 @@ async function cancelConnectionAttempt() {
}
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
const canExpand = computed(() => {
// PostgreSQL-family custom types: only show the expander when the type has
// loadable members; types without members (enum/domain/range/base) do not
// expand even if the catalog row lists child metadata.
if (activeNode.value.type === "type" && customTypeCapabilities(currentDatabaseType()).details) {
return activeNode.value.hasMembers === true;
}
return canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
}),
);
});
});
const isPinned = computed(() => activeNode.value.pinned || connectionStore.isTreeNodePinned(activeNode.value));
@@ -1205,6 +1220,7 @@ function onKeydown(event: KeyboardEvent) {
@click.stop
/>
<span v-else ref="labelRef" :class="[labelWidthClass, { 'flex-1': node.type === 'connection' && !trailingComment }]">{{ visibleLabel(node) }}</span>
<span v-if="treeNodeSecondaryValue(node)" class="min-w-0 max-w-[55%] shrink truncate text-xs text-muted-foreground" :title="treeNodeSecondaryValue(node)">{{ treeNodeSecondaryValue(node) }}</span>
<button
v-if="canDragPinnedOrder()"
type="button"
@@ -1226,9 +1242,11 @@ function onKeydown(event: KeyboardEvent) {
node.type === 'group-materialized-views' ||
node.type === 'group-procedures' ||
node.type === 'group-functions' ||
node.type === 'group-triggers' ||
node.type === 'group-sequences' ||
node.type === 'group-synonyms' ||
node.type === 'group-packages' ||
node.type === 'group-types' ||
node.type === 'group-partitions') &&
node.objectCount != null
"
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en";
import es from "@/i18n/locales/es";
import itLocale from "@/i18n/locales/it";
import ja from "@/i18n/locales/ja";
import ko from "@/i18n/locales/ko";
import ptBR from "@/i18n/locales/pt-BR";
import zhCN from "@/i18n/locales/zh-CN";
import zhTW from "@/i18n/locales/zh-TW";
type Messages = {
customType: typeof en.customType;
contextMenu: typeof en.contextMenu;
};
const locales = { es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW } as unknown as Record<string, Messages>;
describe("custom type translations", () => {
it("provides localized labels in every supported non-English locale", () => {
for (const [localeName, messages] of Object.entries(locales)) {
expect(messages.customType.kinds.composite, localeName).not.toBe(en.customType.kinds.composite);
expect(messages.customType.tabs.properties, localeName).not.toBe(en.customType.tabs.properties);
expect(messages.customType.members.empty, localeName).not.toBe(en.customType.members.empty);
expect(messages.customType.properties.empty, localeName).not.toBe(en.customType.properties.empty);
expect(messages.customType.ddl.empty, localeName).not.toBe(en.customType.ddl.empty);
expect(messages.customType.ddl.incomplete, localeName).not.toBe(en.customType.ddl.incomplete);
expect(messages.contextMenu.viewDetails, localeName).not.toBe(en.contextMenu.viewDetails);
}
});
});
+38
View File
@@ -77,6 +77,43 @@ const consul = {
};
export default {
customType: {
kinds: {
base: "Base",
composite: "Composite",
domain: "Domain",
enum: "Enum",
range: "Range",
multirange: "Multirange",
},
tabs: {
members: "Members",
properties: "Properties",
ddl: "DDL",
},
members: {
empty: "This type has no members.",
name: "Name",
type: "Type",
nullable: "Nullable",
default: "Default",
comment: "Comment",
},
properties: {
empty: "No attributes available.",
baseType: "Base type",
notNull: "Not null",
default: "Default",
collation: "Collation",
domainConstraint: "Constraint",
rangeSubtype: "Range subtype",
rangeMultirange: "Multirange type",
},
ddl: {
empty: "No DDL is available for this type.",
incomplete: "The generated DDL is incomplete and was not copied. Open the type details to review its warnings.",
},
},
app: {
name: "DBX",
},
@@ -2588,6 +2625,7 @@ export default {
editView: "Edit View",
viewSource: "View Source",
changeOpenMode: "Change open mode",
viewDetails: "View Details",
viewDdl: "View DDL",
viewDdlLoading: "Loading DDL...",
ddlCopied: "DDL copied",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Compuesto",
domain: "Dominio",
enum: "Enumeración",
range: "Rango",
multirange: "Multirrango",
},
tabs: {
members: "Miembros",
properties: "Propiedades",
ddl: "DDL",
},
members: {
empty: "Este tipo no tiene miembros.",
name: "Nombre",
type: "Tipo",
nullable: "Admite NULL",
default: "Valor predeterminado",
comment: "Comentario",
},
properties: {
empty: "No hay atributos disponibles.",
baseType: "Tipo base",
notNull: "No nulo",
default: "Valor predeterminado",
collation: "Intercalación",
domainConstraint: "Restricción",
rangeSubtype: "Subtipo del rango",
rangeMultirange: "Tipo multirrango",
},
ddl: {
empty: "No hay DDL disponible para este tipo.",
incomplete: "El DDL generado está incompleto y no se copió. Abra los detalles del tipo para revisar las advertencias.",
},
},
app: {
name: "DBX",
},
@@ -2522,6 +2559,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabla "{name}" eliminada',
editView: "Editar vista",
viewSource: "Ver código fuente",
viewDetails: "Ver detalles",
viewDdl: "Ver DDL",
viewDdlLoading: "Cargando DDL...",
ddlCopied: "DDL copiado",
+38
View File
@@ -77,6 +77,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Composito",
domain: "Dominio",
enum: "Enumerazione",
range: "Intervallo",
multirange: "Intervallo multiplo",
},
tabs: {
members: "Membri",
properties: "Proprietà",
ddl: "DDL",
},
members: {
empty: "Questo tipo non contiene membri.",
name: "Nome",
type: "Tipo",
nullable: "Ammette NULL",
default: "Valore predefinito",
comment: "Commento",
},
properties: {
empty: "Nessun attributo disponibile.",
baseType: "Tipo base",
notNull: "Non nullo",
default: "Valore predefinito",
collation: "Regole di confronto",
domainConstraint: "Vincolo",
rangeSubtype: "Sottotipo dell'intervallo",
rangeMultirange: "Tipo intervallo multiplo",
},
ddl: {
empty: "Nessun DDL disponibile per questo tipo.",
incomplete: "Il DDL generato è incompleto e non è stato copiato. Apri i dettagli del tipo per controllare gli avvisi.",
},
},
app: {
name: "DBX",
},
@@ -2520,6 +2557,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabella "{name}" eliminata',
editView: "Modifica Vista",
viewSource: "Visualizza Origine",
viewDetails: "Visualizza dettagli",
viewDdl: "Visualizza DDL",
viewDdlLoading: "Caricamento DDL...",
ddlCopied: "DDL copiato",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "基本型",
composite: "複合型",
domain: "ドメイン",
enum: "列挙型",
range: "範囲型",
multirange: "多重範囲型",
},
tabs: {
members: "メンバー",
properties: "プロパティ",
ddl: "DDL",
},
members: {
empty: "この型にはメンバーがありません。",
name: "名前",
type: "型",
nullable: "NULL 許可",
default: "デフォルト",
comment: "コメント",
},
properties: {
empty: "利用可能な属性がありません。",
baseType: "基底型",
notNull: "NOT NULL",
default: "デフォルト",
collation: "照合順序",
domainConstraint: "制約",
rangeSubtype: "範囲のサブタイプ",
rangeMultirange: "多重範囲型",
},
ddl: {
empty: "この型で利用可能な DDL はありません。",
incomplete: "生成された DDL は不完全なためコピーされませんでした。型の詳細で警告を確認してください。",
},
},
app: {
name: "DBX",
},
@@ -2545,6 +2582,7 @@ export default withEnglishFallback({
dropTableSuccess: "テーブル「{name}」をドロップ(削除)しました",
editView: "ビューを編集",
viewSource: "ソースを表示",
viewDetails: "詳細を表示",
viewDdl: "DDLを表示",
dropObject: "オブジェクトを削除",
dropView: "ビューを削除",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "기본형",
composite: "복합형",
domain: "도메인",
enum: "열거형",
range: "범위형",
multirange: "다중 범위형",
},
tabs: {
members: "멤버",
properties: "속성",
ddl: "DDL",
},
members: {
empty: "이 형식에는 멤버가 없습니다.",
name: "이름",
type: "형식",
nullable: "NULL 허용",
default: "기본값",
comment: "설명",
},
properties: {
empty: "사용 가능한 속성이 없습니다.",
baseType: "기본 형식",
notNull: "NULL 불가",
default: "기본값",
collation: "정렬 규칙",
domainConstraint: "제약 조건",
rangeSubtype: "범위 하위 형식",
rangeMultirange: "다중 범위 형식",
},
ddl: {
empty: "이 형식에 사용할 수 있는 DDL이 없습니다.",
incomplete: "생성된 DDL이 불완전하여 복사되지 않았습니다. 형식 세부 정보에서 경고를 확인하세요.",
},
},
app: {
name: "DBX",
},
@@ -2443,6 +2480,7 @@ export default withEnglishFallback({
dropTableSuccess: '테이블 "{name}" 삭제됨',
editView: "뷰 편집",
viewSource: "소스 보기",
viewDetails: "세부 정보 보기",
viewDdl: "DDL 보기",
viewDdlLoading: "DDL을 불러오는 중...",
ddlCopied: "DDL을 복사했습니다",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "Base",
composite: "Composto",
domain: "Domínio",
enum: "Enumeração",
range: "Intervalo",
multirange: "Multi-intervalo",
},
tabs: {
members: "Membros",
properties: "Propriedades",
ddl: "DDL",
},
members: {
empty: "Este tipo não possui membros.",
name: "Nome",
type: "Tipo",
nullable: "Aceita NULL",
default: "Valor padrão",
comment: "Comentário",
},
properties: {
empty: "Nenhum atributo disponível.",
baseType: "Tipo base",
notNull: "Não nulo",
default: "Valor padrão",
collation: "Ordenação",
domainConstraint: "Restrição",
rangeSubtype: "Subtipo do intervalo",
rangeMultirange: "Tipo multi-intervalo",
},
ddl: {
empty: "Nenhum DDL disponível para este tipo.",
incomplete: "O DDL gerado está incompleto e não foi copiado. Abra os detalhes do tipo para revisar os avisos.",
},
},
app: {
name: "DBX",
},
@@ -2522,6 +2559,7 @@ export default withEnglishFallback({
dropTableSuccess: 'Tabela "{name}" removida',
editView: "Editar Visão",
viewSource: "Ver Código-fonte",
viewDetails: "Ver detalhes",
viewDdl: "Ver DDL",
viewDdlLoading: "Carregando DDL...",
ddlCopied: "DDL copiado",
+38
View File
@@ -3,6 +3,43 @@ import docs from "./docs/zh-CN";
import { consul } from "./consulZhCN";
export default withEnglishFallback({
customType: {
kinds: {
base: "基础类型",
composite: "复合类型",
domain: "域",
enum: "枚举",
range: "范围",
multirange: "多范围",
},
tabs: {
members: "成员",
properties: "属性",
ddl: "DDL",
},
members: {
empty: "该类型没有成员。",
name: "名称",
type: "类型",
nullable: "可空",
default: "默认值",
comment: "注释",
},
properties: {
empty: "没有可用属性。",
baseType: "基础类型",
notNull: "非空",
default: "默认值",
collation: "排序规则",
domainConstraint: "约束",
rangeSubtype: "范围子类型",
rangeMultirange: "多范围类型",
},
ddl: {
empty: "该类型没有可用 DDL。",
incomplete: "生成的 DDL 不完整,未复制。请打开类型详情查看警告。",
},
},
app: {
name: "DBX",
},
@@ -2513,6 +2550,7 @@ export default withEnglishFallback({
editView: "编辑视图",
viewSource: "查看源码",
changeOpenMode: "修改打开方式",
viewDetails: "查看详情",
viewDdl: "查看 DDL",
viewDdlLoading: "正在读取 DDL...",
ddlCopied: "DDL 已复制",
+38
View File
@@ -78,6 +78,43 @@ const consul = {
};
export default withEnglishFallback({
customType: {
kinds: {
base: "基礎型別",
composite: "複合型別",
domain: "定義域",
enum: "列舉型別",
range: "範圍型別",
multirange: "多重範圍型別",
},
tabs: {
members: "成員",
properties: "屬性",
ddl: "DDL",
},
members: {
empty: "此型別沒有成員。",
name: "名稱",
type: "型別",
nullable: "可為 NULL",
default: "預設值",
comment: "註解",
},
properties: {
empty: "沒有可用的屬性。",
baseType: "基礎型別",
notNull: "不可為 NULL",
default: "預設值",
collation: "定序規則",
domainConstraint: "限制條件",
rangeSubtype: "範圍子型別",
rangeMultirange: "多重範圍型別",
},
ddl: {
empty: "此型別沒有可用的 DDL。",
incomplete: "產生的 DDL 不完整,未複製。請開啟型別詳細資料查看警告。",
},
},
app: {
name: "DBX",
},
@@ -2521,6 +2558,7 @@ export default withEnglishFallback({
dropTableSuccess: "資料表「{name}」已刪除",
editView: "編輯檢視",
viewSource: "檢視原始碼",
viewDetails: "檢視詳細資料",
viewDdl: "檢視 DDL",
viewDdlLoading: "正在讀取 DDL...",
ddlCopied: "DDL 已複製",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion } from "@/lib/database/databaseObjectCapabilities";
import { customTypeCapabilities, databaseObjectCapabilities, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
describe("databaseObjectCapabilities", () => {
it("exposes supported programmable objects for Dameng", () => {
@@ -39,4 +39,41 @@ describe("databaseObjectCapabilities", () => {
it("normalizes space separated materialized view types", () => {
expect(normalizeSidebarObjectKind("MATERIALIZED VIEW")).toBe("MATERIALIZED_VIEW");
});
it("exposes TYPE for verified PostgreSQL-family databases only", () => {
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(sidebarObjectKindsForDatabase(dbType), dbType).toContain("TYPE");
}
// Unverified PG-like databases must not advertise TYPE this cycle.
for (const dbType of ["highgo", "uxdb", "redshift", "kwdb"] as const) {
expect(sidebarObjectKindsForDatabase(dbType), dbType).not.toContain("TYPE");
}
});
it("only Xugu TYPE nodes can open object source", () => {
expect(supportsTypeObjectSource("xugu")).toBe(true);
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase", undefined] as const) {
expect(supportsTypeObjectSource(dbType), String(dbType)).toBe(false);
}
});
it("keeps sourceReadable in sync with supportsTypeObjectSource", () => {
expect(databaseObjectCapabilities("xugu").sourceReadable).toContain("TYPE");
expect(databaseObjectCapabilities("xugu").sourceReadable).toContain("TYPE_BODY");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).not.toContain("TYPE");
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).not.toContain("TYPE_BODY");
// Non-type programmable objects stay source-readable on PG-family.
expect(databaseObjectCapabilities(dbType).sourceReadable, dbType).toContain("FUNCTION");
}
});
it("enables custom type details only for verified PG-family databases", () => {
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(customTypeCapabilities(dbType), dbType).toEqual({ details: true, members: true, ddl: true });
}
for (const dbType of ["xugu", "highgo", "uxdb", "redshift", "mysql", undefined] as const) {
expect(customTypeCapabilities(dbType), String(dbType)).toEqual({ details: false, members: false, ddl: false });
}
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import type { CustomTypeDetails, TreeNode } from "@/types/database";
const parent: TreeNode = {
id: "c:db:app:types:status",
label: "status",
type: "type",
connectionId: "c",
database: "db",
schema: "app",
};
function details(overrides: Partial<CustomTypeDetails>): CustomTypeDetails {
return {
name: "status",
schema: "app",
kind: "enum",
members: [],
properties: { domainConstraints: [] },
...overrides,
};
}
describe("custom type sidebar tree", () => {
it("builds composite fields with their data types", () => {
const children = buildCustomTypeTreeChildren(parent, details({ kind: "composite", members: [{ name: "city", dataType: "text", ordinal: 1 }] }));
expect(children).toHaveLength(1);
expect(children[0]).toMatchObject({ id: "c:db:app:types:status:type-member:field:city", label: "city", type: "type-member", meta: { kind: "field", displayValue: "text", ordinal: 1 } });
});
it("builds enum values in backend order", () => {
const children = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
}),
);
expect(children.map((child) => child.label)).toEqual(["draft", "published"]);
expect(children.map((child) => child.id)).toEqual(["c:db:app:types:status:type-member:enum-value:draft", "c:db:app:types:status:type-member:enum-value:published"]);
});
it("keeps existing member IDs stable when a new member is inserted", () => {
const before = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 2, enumValue: "published" },
],
}),
);
const after = buildCustomTypeTreeChildren(
parent,
details({
members: [
{ name: "", dataType: "", ordinal: 1, enumValue: "new" },
{ name: "", dataType: "", ordinal: 2, enumValue: "draft" },
{ name: "", dataType: "", ordinal: 3, enumValue: "published" },
],
}),
);
expect(after.find((child) => child.label === "draft")?.id).toBe(before.find((child) => child.label === "draft")?.id);
expect(after.find((child) => child.label === "published")?.id).toBe(before.find((child) => child.label === "published")?.id);
});
it("encodes member identity characters in IDs", () => {
const children = buildCustomTypeTreeChildren(parent, details({ members: [{ name: "", dataType: "text", ordinal: 1, enumValue: "a:b/c" }] }));
expect(children[0].id).toContain("a%3Ab%2Fc");
});
it.each(["domain", "range", "multirange", "base"] as const)("does not add property children for %s types", (kind) => {
expect(
buildCustomTypeTreeChildren(
parent,
details({
kind,
properties: {
baseType: "text",
rangeSubtype: "numeric",
domainConstraints: [{ name: "value_check", definition: "CHECK (VALUE IS NOT NULL)" }],
},
}),
),
).toEqual([]);
});
});
@@ -40,6 +40,13 @@ describe("sidebar tree item layout", () => {
expect(canTreeNodeShowExpander({ type: "type-body", childCount: 0, explicitContainer: true })).toBe(false);
});
it("shows an expander only while a custom type may still load children", () => {
expect(canTreeNodeShowExpander({ type: "type", childCount: undefined })).toBe(true);
expect(canTreeNodeShowExpander({ type: "type", childCount: 2 })).toBe(true);
expect(canTreeNodeShowExpander({ type: "type", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "type-member", childCount: undefined })).toBe(false);
});
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick";
import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
describe("treeNodeClick", () => {
it("opens synonym nodes as synonym source", () => {
@@ -7,6 +7,32 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
});
it("only Xugu type nodes open source on single click", () => {
expect(treeNodeRowAction("type", false, "single", "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(treeNodeRowAction("type", true, "single", dbType), String(dbType)).toBe("toggle");
expect(treeNodeRowAction("type", false, "single", dbType), String(dbType)).toBe("none");
}
// Unknown connection type keeps the conservative no-action behavior.
expect(treeNodeRowAction("type", false, "single", undefined)).toBe("none");
});
it("only Xugu type nodes open source on double click", () => {
expect(treeNodeRowDoubleClickAction("type", false, "double", false, "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
expect(treeNodeRowDoubleClickAction("type", false, "double", true, dbType), String(dbType)).toBe("toggle");
expect(treeNodeRowDoubleClickAction("type", false, "double", false, dbType), String(dbType)).toBe("none");
}
expect(treeNodeRowDoubleClickAction("type", false, "double", false, undefined)).toBe("none");
});
it("keeps source actions for non-type source nodes on PG-family databases", () => {
for (const type of ["procedure", "function", "trigger", "sequence", "package", "package-body"] as const) {
expect(treeNodeRowAction(type, false, "single", "postgres"), type).toBe("open-source");
}
expect(treeNodeRowAction("type-body", false, "single", "xugu")).toBe("open-source");
});
it("opens Consul navigation entries on one click regardless of object activation preference", () => {
expect(isDirectNavigationTreeNode("consul-root")).toBe(true);
expect(isDirectNavigationTreeNode("consul-overview")).toBe(true);
@@ -23,9 +49,9 @@ describe("treeNodeClick", () => {
expect(treeNodeRowAction("package", false)).toBe("open-source");
});
it("toggles an expandable type node before opening its source", () => {
expect(treeNodeRowAction("type", true)).toBe("toggle");
expect(treeNodeRowAction("type", false)).toBe("open-source");
it("toggles an expandable Xugu type node before opening its source", () => {
expect(treeNodeRowAction("type", true, "single", "xugu")).toBe("toggle");
expect(treeNodeRowAction("type", false, "single", "xugu")).toBe("open-source");
});
it("routes package members to their owning package body", () => {
@@ -39,8 +39,30 @@ describe("singleClickRowAction", () => {
expect(singleClickRowAction(row("PACKAGE_BODY", "pkg_body_test"))).toBe("open-source");
});
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("returns open-source for %s", (type) => {
expect(singleClickRowAction(row(type, "programmable_test"))).toBe("open-source");
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("returns open-source for %s on Xugu", (type) => {
expect(singleClickRowAction(row(type, "programmable_test"), "xugu")).toBe("open-source");
});
it("only Xugu TYPE rows open source", () => {
expect(singleClickRowAction(row("TYPE", "app_status"), "xugu")).toBe("open-source");
expect(doubleClickRowAction(row("TYPE", "app_status"), "xugu")).toBe("open-source");
expect(singleClickRowAction(row("TYPE_BODY", "app_status"), "xugu")).toBe("open-source");
for (const dbType of ["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"] as const) {
// Verified PG-family TYPE rows open the read-only details panel.
expect(singleClickRowAction(row("TYPE", "app_status"), dbType), String(dbType)).toBe("type-info");
expect(doubleClickRowAction(row("TYPE", "app_status"), dbType), String(dbType)).toBe("type-info");
// TYPE_BODY has no backend getter on these databases.
expect(singleClickRowAction(row("TYPE_BODY", "app_status"), dbType), String(dbType)).toBe("none");
}
// Unknown connection type keeps the conservative no-action behavior.
expect(singleClickRowAction(row("TYPE", "app_status"), undefined)).toBe("none");
expect(doubleClickRowAction(row("TYPE", "app_status"), undefined)).toBe("none");
});
it("keeps source actions for non-type rows on PG-family databases", () => {
for (const type of ["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"] as const) {
expect(singleClickRowAction(row(type), "postgres"), type).toBe("open-source");
}
});
it("returns none for null/undefined", () => {
+37
View File
@@ -95,6 +95,27 @@ describe("PostgreSQL overloaded routines", () => {
});
});
describe("PostgreSQL custom type metadata", () => {
it("preserves type kind and member capability in simple and grouped trees", () => {
const objects: ObjectInfo[] = [
{ name: "address", object_type: "TYPE", schema: "public", custom_type_kind: "composite", has_members: true },
{ name: "email", object_type: "TYPE", schema: "public", custom_type_kind: "domain", has_members: false },
];
const simple = buildSimpleObjectTreeNodes({ ...context, schema: "public", objects });
expect(simple.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
const grouped = buildGroupedObjectTreeNodes({ ...context, schema: "public", objects });
expect(grouped.find((node) => node.type === "group-types")?.children?.map((node) => ({ name: node.label, kind: node.customTypeKind, hasMembers: node.hasMembers }))).toEqual([
{ name: "address", kind: "composite", hasMembers: true },
{ name: "email", kind: "domain", hasMembers: false },
]);
});
});
describe("programmable database objects", () => {
it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => {
const objects: ObjectInfo[] = [
@@ -156,6 +177,22 @@ describe("programmable database objects", () => {
expect(synonymGroup).toEqual(expect.objectContaining({ objectCount: 1, label: "tree.synonyms" }));
expect(synonymGroup?.children).toEqual([expect.objectContaining({ type: "synonym", objectName: "SYN_SHOP_USERS", valid: true })]);
});
it("groups user-defined types with an object count", () => {
const objects: ObjectInfo[] = [
{ name: "status", object_type: "TYPE", schema: "app", comment: "order status" },
{ name: "email", object_type: "TYPE", schema: "app" },
];
const groups = buildGroupedObjectTreeNodes({ ...context, schema: "app", objects });
const typeGroup = groups.find((node) => node.type === "group-types");
expect(typeGroup).toEqual(expect.objectContaining({ objectCount: 2, label: "tree.types" }));
expect(typeGroup?.children?.map((node) => node.type)).toEqual(["type", "type"]);
expect(typeGroup?.children?.map((node) => node.objectName)).toEqual(["email", "status"]);
expect(typeGroup?.children?.[0]?.comment).toBeUndefined();
expect(typeGroup?.children?.[1]?.comment).toBe("order status");
});
});
describe("PostgreSQL table hierarchy", () => {
+1
View File
@@ -157,6 +157,7 @@ export const listObjectStatistics = forward("listObjectStatistics");
export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
export const getCustomTypeDetails = forward("getCustomTypeDetails");
export const getColumns = forward("getColumns");
export const getAllColumns = forward("getAllColumns");
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
+5
View File
@@ -14,6 +14,7 @@ import type {
CompletionAssistantRequest,
CompletionAssistantResponse,
ObjectStatistics,
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
@@ -742,6 +743,10 @@ export async function getObjectSource(connectionId: string, database: string, sc
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`);
}
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
return get(`/api/schema/custom-type-details?${qs({ connection_id: connectionId, database, schema, table: name })}`);
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`);
}
+4
View File
@@ -27,6 +27,7 @@ import type {
CompletionAssistantRequest,
CompletionAssistantResponse,
ObjectStatistics,
CustomTypeDetails,
ObjectSource,
ObjectSourceKind,
ColumnInfo,
@@ -1055,6 +1056,9 @@ export async function listSchemaInfos(connectionId: string, database: string): P
return invoke("list_schema_infos", { connectionId, database });
}
export async function getCustomTypeDetails(connectionId: string, database: string, schema: string, name: string): Promise<CustomTypeDetails> {
return invoke("get_custom_type_details", { connectionId, database, schema, name });
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {
return invoke("get_columns", {
connectionId,
@@ -13,7 +13,21 @@ const TABLE_VIEW_MV_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZ
const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"];
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
// PostgreSQL-family databases with a verified pg_type listing path. TYPE only
// covers user-created types (enum/domain/composite/range/multirange/base);
// relation auto-generated row types stay hidden.
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "TYPE"];
// KWDB is routed through the PostgreSQL pool but its pg_type catalog
// compatibility is not verified yet, so it stays on the pre-TYPE object set.
const POSTGRES_NO_TYPE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
// Kingbase and Vastbase agents support the same user-defined type listing via
// their own metadata query, but do not expose sequences. Kept separate from
// POSTGRES_OBJECTS and POSTGRES_LIKE_OBJECTS so unverified PG-like databases
// (highgo/uxdb/redshift) never advertise TYPE.
const KINGBASE_VASTBASE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TYPE"];
const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"];
const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"];
@@ -24,13 +38,13 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
// postgres
["postgres", POSTGRES_OBJECTS],
["gaussdb", POSTGRES_OBJECTS],
["kwdb", POSTGRES_OBJECTS],
["kwdb", POSTGRES_NO_TYPE_OBJECTS],
["opengauss", POSTGRES_OBJECTS],
// postgres like
["kingbase", POSTGRES_LIKE_OBJECTS],
["kingbase", KINGBASE_VASTBASE_OBJECTS],
["highgo", POSTGRES_LIKE_OBJECTS],
["uxdb", POSTGRES_LIKE_OBJECTS],
["vastbase", POSTGRES_LIKE_OBJECTS],
["vastbase", KINGBASE_VASTBASE_OBJECTS],
["redshift", POSTGRES_LIKE_OBJECTS],
// oracle
["oracle", ORACLE_OBJECTS],
@@ -69,11 +83,22 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["manticoresearch", ["TABLE", "FUNCTION"]],
["databend", ["TABLE", "VIEW", "PROCEDURE"]],
]);
/**
* Whether a kind is readable as object source for the given connection type.
* TYPE/TYPE_BODY only have a real source implementation on Xugu; PostgreSQL-
* family databases list types without a CREATE TYPE getter this cycle.
*/
function isSourceReadableObjectKind(kind: SidebarObjectKind, dbType?: DatabaseType): boolean {
if (kind === "TABLE") return false;
if (kind === "TYPE" || kind === "TYPE_BODY") return supportsTypeObjectSource(dbType);
return true;
}
export function databaseObjectCapabilities(dbType?: DatabaseType): DatabaseObjectCapabilities {
const sidebarObjects = sidebarObjectKindsForDatabase(dbType);
return {
sidebarObjects,
sourceReadable: sidebarObjects.filter((kind) => kind !== "TABLE"),
sourceReadable: sidebarObjects.filter((kind) => isSourceReadableObjectKind(kind, dbType)),
executable: sidebarObjects.filter((kind) => kind === "PROCEDURE"),
};
}
@@ -83,6 +108,37 @@ export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObj
return DATABASE_TYPE_OBJECTS.get(dbType) ?? [...ROUTINE_OBJECTS];
}
/**
* Whether a connection's TYPE tree nodes may be opened as object source.
*
* Xugu has a real TYPE/TYPE_BODY source implementation. PostgreSQL-family
* databases only list user-defined types this cycle; their CREATE TYPE DDL has
* no unified catalog getter, so opening source would error. Callers must gate
* the source action (single/double click, context menu, shortcuts) on this
* before dispatching getObjectSource.
*/
export function supportsTypeObjectSource(dbType?: DatabaseType): boolean {
return dbType === "xugu";
}
export type CustomTypeCapabilities = {
details: boolean;
members: boolean;
ddl: boolean;
};
const VERIFIED_CUSTOM_TYPE_DATABASES = new Set<DatabaseType>(["postgres", "opengauss", "gaussdb", "kingbase", "vastbase"]);
/**
* Whether a connection may open read-only custom type details (phase 2).
* Kept separate from the listing capability so a future per-kind DDL toggle
* can be introduced without touching the object-list sets.
*/
export function customTypeCapabilities(dbType?: DatabaseType): CustomTypeCapabilities {
const supported = !!dbType && VERIFIED_CUSTOM_TYPE_DATABASES.has(dbType);
return { details: supported, members: supported, ddl: supported };
}
export function supportsPackageMemberExpansion(dbType?: DatabaseType): boolean {
return !!dbType && PACKAGE_MEMBER_EXPANSION_DATABASES.has(dbType);
}
@@ -0,0 +1,42 @@
import type { CustomTypeDetails, CustomTypeTreeMemberMeta, TreeNode } from "@/types/database";
function encodeMemberIdentity(value: string): string {
return encodeURIComponent(value);
}
function typeMemberNode(parent: TreeNode, identity: string, label: string, meta: CustomTypeTreeMemberMeta): TreeNode {
return {
id: `${parent.id}:type-member:${meta.kind}:${encodeMemberIdentity(identity)}`,
label,
type: "type-member",
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
catalog: parent.catalog,
objectName: parent.objectName || parent.label,
meta,
};
}
export function buildCustomTypeTreeChildren(parent: TreeNode, details: CustomTypeDetails): TreeNode[] {
if (details.kind === "composite") {
return details.members.map((member) =>
typeMemberNode(parent, member.name, member.name, {
kind: "field",
displayValue: member.dataType,
ordinal: member.ordinal,
}),
);
}
if (details.kind === "enum") {
return details.members.map((member) =>
typeMemberNode(parent, member.enumValue || member.name, member.enumValue || member.name, {
kind: "enum-value",
ordinal: member.ordinal,
}),
);
}
return [];
}
@@ -10,8 +10,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"synonym",
"package",
"package-body",
"type",
"type-body",
"type-member",
"object-browser",
"redis-db",
"mq-tenant",
@@ -34,7 +34,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder", "type"]);
const pinnableTypes: Set<TreeNodeType> = new Set([
"connection-group",
@@ -157,7 +157,11 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean {
export function canTreeNodeShowExpander({ type, childCount, explicitContainer = false }: { type: TreeNodeType; childCount?: number; explicitContainer?: boolean }): boolean {
if (!canTreeNodeExpand(type) && !((type === "package" || type === "type") && explicitContainer)) return false;
if (childCount === 0 && emptyContainerTypes.has(type)) return false;
// An empty type node only hides its expander when it is not an explicitly
// marked container. Xugu TYPE specifications use explicitContainer to lazy
// load members even when childCount is currently 0; PostgreSQL-family types
// without members rely on the caller's hasMembers gate instead.
if (childCount === 0 && emptyContainerTypes.has(type) && !explicitContainer) return false;
return true;
}
+20 -5
View File
@@ -1,4 +1,5 @@
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import type { DatabaseType, ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none";
@@ -88,12 +89,25 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean {
return documentBrowserNodeTypes.has(type);
}
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction {
/**
* Whether a tree node type may open an object source dialog for the given
* connection type. TYPE/TYPE_BODY only have a real source implementation on
* Xugu; other databases list types without a DDL getter this cycle.
*/
function canOpenTreeNodeSource(type: TreeNodeType, dbType?: DatabaseType): boolean {
if (type === "type" || type === "type-body") return supportsTypeObjectSource(dbType);
return true;
}
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single", dbType?: DatabaseType): TreeNodeRowAction {
if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none";
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
// PostgreSQL-family custom types: open read-only details (toggle when expandable).
if (type === "type" && customTypeCapabilities(dbType).details) return canExpand ? "toggle" : "none";
// Xugu and other databases: expandable package/type nodes toggle their members.
if ((type === "package" || type === "type") && canExpand) return "toggle";
if (sourceNodeTypes.has(type)) return "open-source";
if (sourceNodeTypes.has(type) && canOpenTreeNodeSource(type, dbType)) return "open-source";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canExpand) return "toggle";
return "none";
@@ -106,14 +120,15 @@ export function shouldRunTreeNodeRowAction(action: TreeNodeRowAction, clickDetai
return action !== "none" && clickDetail <= 1;
}
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false): TreeNodeRowDoubleClickAction {
export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBrowser: boolean, activation: SidebarActivation = "single", canExpand = false, dbType?: DatabaseType): TreeNodeRowDoubleClickAction {
// Single-click activation already handles the first click in a dblclick
// sequence. Only double-click activation needs a second-stage table action.
if (type === "table") return activation === "double" ? "activate-data" : "none";
if (activation === "double") {
if (type === "extension") return "open-extension-details";
if (dataNodeTypes.has(type)) return "open-data";
if (sourceNodeTypes.has(type)) return "open-source";
if (type === "type" && customTypeCapabilities(dbType).details) return canExpand ? "toggle" : "none";
if (sourceNodeTypes.has(type) && canOpenTreeNodeSource(type, dbType)) return "open-source";
if (savedSqlNodeTypes.has(type)) return "open-saved-sql";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canOpenObjectBrowser && objectBrowserNodeTypes.has(type) && canExpand) return "open-object-browser-and-expand";
@@ -1,17 +1,24 @@
import type { DatabaseType } from "@/types/database";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
export type ObjectBrowserRowAction = "table-info" | "open-table" | "open-source" | "none";
export type ObjectBrowserRowAction = "table-info" | "type-info" | "open-table" | "open-source" | "none";
/**
* Determine the action for a single click on an object browser row.
* - TABLE → table-info (show table properties panel)
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source
* - TYPE → type-info (read-only type details) on verified PG-family databases
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE_BODY → open-source
* - otherwise → none
*
* TYPE/TYPE_BODY only open source for connections with a real type source
* implementation (Xugu); other databases list types without a DDL getter.
*/
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "table-info";
if (canOpenSource(row)) return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
}
@@ -21,10 +28,11 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined):
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY → open-source
* - otherwise → none
*/
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined, dbType?: DatabaseType): ObjectBrowserRowAction {
if (!row) return "none";
if (row.type === "TABLE") return "open-table";
if (canOpenSource(row)) return "open-source";
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return "type-info";
if (canOpenSource(row, dbType)) return "open-source";
return "none";
}
@@ -38,14 +46,14 @@ export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined):
* the caller defers the single-click via shouldDeferSingleClick so the second
* click can cancel it.
*/
export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, detail: number, activation: "single" | "double"): { action: ObjectBrowserRowAction; isDouble: boolean } {
export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, detail: number, activation: "single" | "double", dbType?: DatabaseType): { action: ObjectBrowserRowAction; isDouble: boolean } {
if (activation === "double") {
if (detail === 2) return { action: doubleClickRowAction(row), isDouble: true };
return { action: singleClickRowAction(row), isDouble: false };
if (detail === 2) return { action: doubleClickRowAction(row, dbType), isDouble: true };
return { action: singleClickRowAction(row, dbType), isDouble: false };
}
// single-click activation
if (detail > 1) return { action: doubleClickRowAction(row), isDouble: true };
return { action: singleClickRowAction(row), isDouble: false };
if (detail > 1) return { action: doubleClickRowAction(row, dbType), isDouble: true };
return { action: singleClickRowAction(row, dbType), isDouble: false };
}
/**
@@ -70,6 +78,10 @@ export function isSourceOnlyObjectBrowserRow(row: ObjectBrowserRow): boolean {
return row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
function canOpenSource(row: ObjectBrowserRow): boolean {
function canOpenSource(row: ObjectBrowserRow, dbType?: DatabaseType): boolean {
// Verified PG-family TYPE rows open the read-only details panel instead;
// Xugu keeps its source editor entry through supportsTypeObjectSource below.
if (row.type === "TYPE" && customTypeCapabilities(dbType).details) return false;
if ((row.type === "TYPE" || row.type === "TYPE_BODY") && !supportsTypeObjectSource(dbType)) return false;
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
+4
View File
@@ -585,6 +585,8 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch
type: simpleNodeType,
objectName: name,
signature: signature || undefined,
customTypeKind: obj.custom_type_kind ?? undefined,
hasMembers: obj.has_members ?? undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined,
@@ -760,6 +762,8 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc
type: childType,
objectName: obj.name,
signature: signature || undefined,
customTypeKind: obj.custom_type_kind ?? undefined,
hasMembers: obj.has_members ?? undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
meta: objectType === "TRIGGER" ? (obj.trigger ?? undefined) : undefined,
@@ -583,7 +583,7 @@ describe("connectionStore metadata loading", () => {
});
it("keeps concurrent table-tree and local-index refreshes in separate cache entries", async () => {
const treeCacheKey = "pg-1:app:public:group-tables:objects-v6";
const treeCacheKey = "pg-1:app:public:group-tables:objects-v8";
const indexCacheKey = `${treeCacheKey}:table-search-index-v1`;
const cachedPayloads = new Map<string, unknown>([
[
@@ -2565,7 +2565,7 @@ describe("connectionStore metadata loading", () => {
const loadPromise = store.loadTables(connection.id, "prulife", "xtdpcky", { force: true });
await vi.waitFor(() => expect(saveSchemaCache).toHaveBeenCalledTimes(1));
expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v7-informix-owner-v2`);
expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v8-informix-owner-v2`);
expect(schemaNode.isLoading).toBe(true);
schemaNode.isExpanded = false;
@@ -2988,4 +2988,75 @@ describe("connectionStore metadata loading", () => {
expect(replacementDb.children?.length ?? 0).toBeGreaterThan(0);
expect(store.isTreeNodeChildrenLoaded(dbId)).toBe(true);
});
it("loads custom type members into the current tree node and ignores stale responses", async () => {
let resolveFirst!: (value: any) => void;
const first = new Promise((resolve) => {
resolveFirst = resolve;
});
const getCustomTypeDetails = vi
.fn()
.mockReturnValueOnce(first)
.mockResolvedValueOnce({
name: "status",
schema: "app",
kind: "enum",
members: [{ name: "", dataType: "", ordinal: 1, enumValue: "published" }],
properties: { domainConstraints: [] },
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
getCustomTypeDetails,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection();
const typeNode: TreeNode = {
id: `${connection.id}:app:types:status`,
label: "status",
objectName: "status",
type: "type",
connectionId: connection.id,
database: "app",
schema: "app",
children: undefined,
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
children: [typeNode],
},
];
const staleLoad = store.loadCustomTypeChildren(typeNode, { force: true });
await vi.waitFor(() => expect(getCustomTypeDetails).toHaveBeenCalledTimes(1));
await store.loadCustomTypeChildren(typeNode, { force: true });
resolveFirst({
name: "status",
schema: "app",
kind: "enum",
members: [{ name: "", dataType: "", ordinal: 1, enumValue: "draft" }],
properties: { domainConstraints: [] },
});
await staleLoad;
expect(typeNode.isExpanded).toBe(true);
expect(typeNode.customTypeKind).toBe("enum");
expect(typeNode.hasMembers).toBe(true);
expect(typeNode.children?.map((child) => child.label)).toEqual(["published"]);
expect(store.isTreeNodeChildrenLoaded(typeNode.id)).toBe(true);
});
});
+36 -6
View File
@@ -121,6 +121,7 @@ import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"
import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
@@ -1193,7 +1194,7 @@ export const useConnectionStore = defineStore("connection", () => {
// recurse into raw objects, its `meta` too), mirroring the markRaw() treatment
// queryStore already applies to result rows. Containers stay reactive so their
// children / isExpanded / isLoading mutations still drive the UI.
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger"]);
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger", "type-member"]);
function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] {
for (const node of nodes) {
@@ -1437,7 +1438,9 @@ export const useConnectionStore = defineStore("connection", () => {
function objectGroupCacheKey(node: TreeNode): string {
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v6");
// objects-v8: object-group listing SQL gained a pg_type branch for
// PostgreSQL-family user-defined types; older cached lists miss TYPE nodes.
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v8");
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion);
}
@@ -1965,7 +1968,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8");
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion);
}
@@ -4272,7 +4275,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v6"));
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v8"));
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined);
return;
@@ -4305,7 +4308,7 @@ export const useConnectionStore = defineStore("connection", () => {
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const searchFilter = activeTreeLoadSearchFilter(options);
const config = getConfig(connectionId);
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8");
const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion);
const querySchema = connectionObjectTreeQuerySchema(config, database, schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema);
@@ -4514,6 +4517,30 @@ export const useConnectionStore = defineStore("connection", () => {
);
}
async function loadCustomTypeChildren(node: TreeNode, options?: LoadTreeOptions) {
if (node.type !== "type" || !node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(node.connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const schema = node.schema || node.database;
const details = await api.getCustomTypeDetails(node.connectionId, node.database, schema, node.objectName || node.label);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildCustomTypeTreeChildren(targetNode, details);
targetNode.customTypeKind = details.kind;
targetNode.hasMembers = children.length > 0;
setChildren(targetNode, children);
targetNode.isExpanded = children.length > 0;
} catch (error) {
recordMetadataLoadError(node.connectionId, error, load);
throw error;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadMoreObjectGroupChildren(node: TreeNode) {
if (node.type !== "load-more" || !node.loadMore) return;
const loadMore = node.loadMore;
@@ -4567,7 +4594,7 @@ export const useConnectionStore = defineStore("connection", () => {
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
targetParent.objectCount = mergedChildren.length;
setChildren(targetParent, nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v6")), nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren);
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
return;
@@ -5356,6 +5383,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadTables(node.connectionId, node.database, node.schema, options);
} else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) {
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog);
} else if (node.type === "type") {
await loadCustomTypeChildren(node, options);
} else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
@@ -7347,6 +7376,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadTables,
loadTableForLocate,
loadObjectGroupChildren,
loadCustomTypeChildren,
loadPackageMembers,
loadXuguTypeMembers,
loadMoreObjectGroupChildren,
+67 -1
View File
@@ -429,6 +429,8 @@ export interface ObjectInfo {
schema?: string | null;
valid?: boolean | null;
signature?: string | null;
custom_type_kind?: CustomTypeKind | null;
has_members?: boolean | null;
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
@@ -455,6 +457,61 @@ export interface ObjectSource {
editable?: boolean;
}
export type CustomTypeKind = "base" | "composite" | "domain" | "enum" | "range" | "multirange";
export interface CustomTypeMember {
name: string;
dataType: string;
ordinal: number;
nullable?: boolean | null;
default?: string | null;
comment?: string | null;
enumValue?: string | null;
}
export interface CustomTypeDomainConstraint {
name: string;
definition: string;
}
export interface CustomTypeProperties {
baseType?: string | null;
notNull?: boolean | null;
default?: string | null;
collation?: string | null;
domainConstraints: CustomTypeDomainConstraint[];
rangeSubtype?: string | null;
rangeMultirangeName?: string | null;
rangeCanonicalFunction?: string | null;
rangeSubtypeDiffFunction?: string | null;
rangeSubtypeOpclass?: string | null;
inputFunction?: string | null;
outputFunction?: string | null;
receiveFunction?: string | null;
sendFunction?: string | null;
analyzeFunction?: string | null;
internallength?: number | null;
passedByValue?: boolean | null;
alignment?: string | null;
storage?: string | null;
}
export interface CustomTypeDdl {
sql: string;
complete: boolean;
warnings?: string[];
}
export interface CustomTypeDetails {
name: string;
schema: string;
kind: CustomTypeKind;
comment?: string | null;
members: CustomTypeMember[];
properties: CustomTypeProperties;
ddl?: CustomTypeDdl | null;
}
export interface ColumnInfo {
name: string;
data_type: string;
@@ -807,6 +864,7 @@ export type TreeNodeType =
| "function"
| "type"
| "type-body"
| "type-member"
| "sequence"
| "synonym"
| "package"
@@ -902,6 +960,8 @@ export interface TreeNode {
tableName?: string;
objectName?: string;
signature?: string;
customTypeKind?: CustomTypeKind;
hasMembers?: boolean;
/** Owning programmable object for a nested metadata member. */
parentName?: string;
parentSchema?: string;
@@ -921,7 +981,7 @@ export interface TreeNode {
tableSearchParentId?: string;
savedSqlId?: string;
savedSqlFolderId?: string;
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta;
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta | CustomTypeTreeMemberMeta;
loadMore?: {
parentId: string;
offset: number;
@@ -929,6 +989,12 @@ export interface TreeNode {
};
}
export interface CustomTypeTreeMemberMeta {
kind: "field" | "enum-value";
displayValue?: string;
ordinal?: number;
}
export interface TableNameFilter {
includePatterns: string[];
excludePatterns: string[];