feat(postgres): list user-defined types with details and DDL for PG-family databases
This commit is contained in:
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">
|
||||
|
||||
+246
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ビューを削除",
|
||||
|
||||
@@ -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을 복사했습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 已复制",
|
||||
|
||||
@@ -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 已複製",
|
||||
|
||||
+38
-1
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 })}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user