feat(structure): show table constraints for postgres-compatible dbs

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-28 12:14:04 +08:00
1 parent 7ad05b178f
commit 20a2994fa1
37 files changed
+2725 -156

No files matched your search

+118 -2
View File
@@ -16,6 +16,7 @@ import {
Search,
Inbox,
SearchX,
ShieldCheck,
Code2,
Copy,
Loader2,
@@ -78,7 +79,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
@@ -345,6 +346,7 @@ import {
} from "@/lib/dataGrid/dataGridToolbar";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { reserveDataGridHeaderLine } from "@/lib/dataGrid/dataGridHeaderLayout";
@@ -10638,6 +10640,23 @@ const triggers = ref<TriggerInfo[]>([]);
const triggersLoaded = ref(false);
const triggersLoading = ref(false);
const triggersError = ref("");
const constraints = ref<ConstraintInfo[]>([]);
const constraintsLoaded = ref(false);
const constraintsLoading = ref(false);
const constraintsError = ref("");
const currentConstraintTableIdentity = computed(() =>
columnIndexTableIdentity({
connectionId: props.connectionId,
database: props.database,
catalog: props.tableMeta?.catalog,
schema: props.tableMeta?.schema,
tableName: props.tableMeta?.tableName,
}),
);
let constraintsRequestGeneration = 0;
// The Constraints tab hides foreign keys when a dedicated Foreign Keys tab is
// also shown (each constraint appears once; FK navigation stays in that tab).
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
const searchQuery = ref("");
const cellDetailPanelLayout = computed(() => settingsStore.editorSettings.cellDetailPanelLayout);
const cellDetailPanelIsBottom = computed(() => cellDetailPanelLayout.value === "bottom");
@@ -10723,7 +10742,7 @@ function toggleCellDetailPanelLayout() {
});
}
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(resolvedDatabaseType.value));
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
const mongoConnectionConfig = resolvedConnectionConfig;
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
@@ -10757,6 +10776,14 @@ const tableInfoTabs = computed(() => {
count: foreignKeys.value.length,
});
}
if (tableMetadataCapabilities.value.constraints) {
tabs.push({
id: "constraints",
label: t("grid.tableInfoConstraints"),
icon: ShieldCheck,
count: constraintsForTab.value.length,
});
}
if (tableMetadataCapabilities.value.triggers) {
tabs.push({
id: "triggers",
@@ -10792,6 +10819,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
if (nextTab === "ddl") await fetchDdl();
else if (nextTab === "indexes") await fetchIndexes();
else if (nextTab === "foreignKeys") await fetchForeignKeys();
else if (nextTab === "constraints") await fetchConstraints();
else if (nextTab === "triggers") await fetchTriggers();
}
@@ -10954,6 +10982,54 @@ async function fetchTriggers() {
}
}
async function fetchConstraints() {
const requestIdentity = currentConstraintTableIdentity.value;
if (!props.connectionId || !props.tableMeta || !requestIdentity || constraintsLoaded.value || constraintsLoading.value) return;
const connectionId = props.connectionId;
const database = props.database || "";
const schema = props.tableMeta.schema || props.database || "";
const tableName = props.tableMeta.tableName;
const catalog = props.tableMeta.catalog;
const requestGeneration = ++constraintsRequestGeneration;
constraintsLoading.value = true;
constraintsError.value = "";
try {
const nextConstraints = await api.listConstraints(connectionId, database, schema, tableName, catalog);
if (
!columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
return;
constraints.value = nextConstraints;
constraintsLoaded.value = true;
} catch (e: any) {
if (
!columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
return;
constraintsError.value = String(e?.message || e);
} finally {
if (
columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
constraintsLoading.value = false;
}
}
watch(
() => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName],
() => {
@@ -10975,6 +11051,11 @@ watch(
triggers.value = [];
triggersLoaded.value = false;
triggersError.value = "";
constraints.value = [];
constraintsLoaded.value = false;
constraintsLoading.value = false;
constraintsError.value = "";
constraintsRequestGeneration += 1;
// 表身份变更后,主动触发索引加载,确保索引指示器在切换表后立即可见
if (showIndexIndicatorsInHeader.value && canShowTableIndexes.value && currentIndexTableIdentity.value) {
void fetchIndexes();
@@ -11573,6 +11654,13 @@ const filteredTriggers = computed(() => {
return triggers.value.filter((t) => t.name.toLowerCase().includes(q));
});
const filteredConstraints = computed(() => {
const base = constraintsForTab.value;
if (!searchQuery.value) return base;
const q = searchQuery.value.toLowerCase();
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.toLowerCase().includes(q));
});
const filteredDdlContent = computed(() => {
if (!ddlContent.value) return "";
const html = highlight(ddlContent.value);
@@ -13754,6 +13842,34 @@ function openGridSnapshot() {
</div>
</div>
<div v-else-if="activeTableInfoTab === 'constraints'" class="flex-1 min-h-0 overflow-auto">
<div v-if="constraintsLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="constraintsError" class="p-3 text-xs text-destructive">
{{ constraintsError }}
</div>
<div v-else-if="searchQuery && filteredConstraints.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoNoResults") }}
</div>
<div v-else-if="constraintsForTab.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoEmpty") }}
</div>
<div v-else class="divide-y">
<div v-for="constraint in filteredConstraints" :key="constraint.name" class="p-3 text-xs" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium truncate">{{ constraint.name }}</span>
<span class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ constraint.constraint_type }}</span>
<span v-if="!constraint.enabled" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintDisabled") }}</span>
<span v-else-if="!constraint.valid" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintNotValidated") }}</span>
</div>
<div v-if="constraint.columns.length" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ constraint.columns.join(", ") }}</div>
<div v-if="constraint.ref_table" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}</div>
<div v-if="constraint.definition" class="mt-1 font-mono text-[11px] text-muted-foreground break-all whitespace-pre-wrap">{{ constraint.definition }}</div>
</div>
</div>
</div>
<pre
v-else-if="activeTableInfoTab === 'ddl' && !ddlLoading"
ref="ddlPreRef"
@@ -40,6 +40,7 @@ import {
Scissors,
Search,
ScrollText,
ShieldCheck,
Square,
Table2,
TableProperties,
@@ -61,12 +62,13 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia
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";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import {
buildDropObjectSql,
@@ -226,6 +228,12 @@ const tableForeignKeysLoaded = ref(false);
const tableTriggers = ref<TriggerInfo[]>([]);
const tableTriggersLoading = ref(false);
const tableTriggersLoaded = ref(false);
const tableConstraints = ref<ConstraintInfo[]>([]);
const tableConstraintsLoading = ref(false);
const tableConstraintsLoaded = ref(false);
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/TableStructureEditor.
const tableConstraintsForTab = computed(() => constraintsForConstraintsTab(tableConstraints.value, tableMetadataCapabilities.value.foreignKeys));
const tableInfoSearchQuery = ref("");
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
const SIDE_PANEL_MIN_WIDTH = 280;
@@ -977,6 +985,9 @@ const tableInfoTabs = computed<TableInfoTabItem[]>(() => {
if (tableMetadataCapabilities.value.foreignKeys) {
tabs.push({ id: "foreignKeys", label: t("grid.tableInfoForeignKeys"), icon: Link2, count: tableForeignKeys.value.length });
}
if (tableMetadataCapabilities.value.constraints) {
tabs.push({ id: "constraints", label: t("grid.tableInfoConstraints"), icon: ShieldCheck, count: tableConstraintsForTab.value.length });
}
if (tableMetadataCapabilities.value.triggers) {
tabs.push({ id: "triggers", label: t("grid.tableInfoTriggers"), icon: RotateCcw, count: tableTriggers.value.length });
}
@@ -1007,6 +1018,13 @@ const filteredTableTriggers = computed(() => {
return tableTriggers.value.filter((tr) => tr.name.toLowerCase().includes(q));
});
const filteredTableConstraints = computed(() => {
const base = tableConstraintsForTab.value;
if (!tableInfoSearchQuery.value) return base;
const q = tableInfoSearchQuery.value.toLowerCase();
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.toLowerCase().includes(q));
});
const filteredTableDdlContent = computed(() => {
if (!tableDdlContent.value) return "";
const html = highlight(tableDdlContent.value);
@@ -1033,11 +1051,13 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
tableIndexes.value = [];
tableForeignKeys.value = [];
tableTriggers.value = [];
tableConstraints.value = [];
tableColumnsLoaded.value = false;
tableDdlLoaded.value = false;
tableIndexesLoaded.value = false;
tableForeignKeysLoaded.value = false;
tableTriggersLoaded.value = false;
tableConstraintsLoaded.value = false;
tableInfoSearchQuery.value = "";
// Determine initial tab: explicit request > previously activated
const firstTab = initialTab ?? tableInfoTab.value;
@@ -1053,6 +1073,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
else if (nextTab === "columns") await fetchTableColumns();
else if (nextTab === "indexes") await fetchTableIndexes();
else if (nextTab === "foreignKeys") await fetchTableForeignKeys();
else if (nextTab === "constraints") await fetchTableConstraints();
else if (nextTab === "triggers") await fetchTableTriggers();
}
@@ -1185,6 +1206,30 @@ async function fetchTableTriggers(force = false) {
}
}
async function fetchTableConstraints(force = false) {
const row = sidePanelRow.value;
if (!row || (tableConstraintsLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableConstraintsLoading.value = true;
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: constraints } = await loadObjectMetadataFacet(request, "constraints", () => api.listConstraints(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableConstraints.value = constraints;
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableConstraints.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) {
tableConstraintsLoaded.value = loadedSuccessfully;
tableConstraintsLoading.value = false;
}
}
}
async function refreshActiveTableInfo() {
if (sidePanelMode.value !== "table-info" || !sidePanelRow.value) return;
sidePanelGuard.bump();
@@ -1205,6 +1250,10 @@ async function refreshActiveTableInfo() {
tableForeignKeys.value = [];
tableForeignKeysLoaded.value = false;
await fetchTableForeignKeys(true);
} else if (tableInfoTab.value === "constraints") {
tableConstraints.value = [];
tableConstraintsLoaded.value = false;
await fetchTableConstraints(true);
} else if (tableInfoTab.value === "triggers") {
tableTriggers.value = [];
tableTriggersLoaded.value = false;
@@ -3554,6 +3603,30 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
</div>
<div v-else-if="tableInfoTab === 'constraints'" class="flex-1 min-h-0 overflow-auto">
<div v-if="tableConstraintsLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="tableInfoSearchQuery && filteredTableConstraints.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoNoResults") }}
</div>
<div v-else-if="tableConstraintsForTab.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoEmpty") }}
</div>
<div v-else class="divide-y">
<div v-for="constraint in filteredTableConstraints" :key="constraint.name" class="p-3 text-xs" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium truncate">{{ constraint.name }}</span>
<span class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ constraint.constraint_type }}</span>
<span v-if="!constraint.enabled" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintDisabled") }}</span>
<span v-else-if="!constraint.valid" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintNotValidated") }}</span>
</div>
<div v-if="constraint.columns.length" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ constraint.columns.join(", ") }}</div>
<div v-if="constraint.ref_table" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}</div>
<div v-if="constraint.definition" class="mt-1 font-mono text-[11px] text-muted-foreground break-all whitespace-pre-wrap">{{ constraint.definition }}</div>
</div>
</div>
</div>
<div v-else-if="tableInfoTab === 'triggers'" class="flex-1 min-h-0 overflow-auto">
<div v-if="tableTriggersLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
@@ -39,6 +39,7 @@ import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
import { getPostgresDataTypeHelp, gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { getSqliteDataTypeHelp } from "@/lib/table/sqliteDataTypeHelp";
import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visibleTableStructureRefreshScope, type TableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading";
import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, sanitizeStructureIndexesForCapabilities, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, normalizeUnsupportedConcurrentIndexes, type ConcurrentIndexAvailability } from "@/lib/table/concurrentIndexAvailability";
@@ -203,6 +204,7 @@ async function fetchDdl(force = false) {
}
}
const errorMessage = ref("");
const secondaryMetadataErrors = ref<Partial<Record<ObjectMetadataFacet, string>>>({});
const columns = ref<EditableStructureColumn[]>([]);
const copyColumnsDialogOpen = ref(false);
const copySourceTables = ref<TableInfo[]>([]);
@@ -244,6 +246,9 @@ const sqliteSchemaRevision = ref<string>();
const foreignKeys = ref<EditableStructureForeignKey[]>([]);
const constraints = ref<ConstraintInfo[]>([]);
const constraintsLoaded = ref(false);
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/ObjectBrowser.
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
const triggers = ref<EditableStructureTrigger[]>([]);
const triggersLoaded = ref(false);
const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || constraintsLoading.value || triggersLoading.value);
@@ -1561,6 +1566,7 @@ function resetState() {
constraintsLoading.value = false;
triggersLoading.value = false;
errorMessage.value = "";
secondaryMetadataErrors.value = {};
isPartitionedParent.value = false;
partitionStatusKnown.value = true;
concurrentAvailabilityInvalidated.value = false;
@@ -1799,6 +1805,7 @@ async function loadStructure(
if (!silent) loading.value = true;
setSecondaryMetadataLoading(effectiveScope, true);
errorMessage.value = "";
secondaryMetadataErrors.value = {};
let secondaryMetadataScheduled = false;
let loadedSuccessfully = false;
try {
@@ -1886,8 +1893,30 @@ async function loadStructure(
}
}
const applySecondaryMetadata = async () => {
const [nextIndexes, nextForeignKeys, nextConstraints, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
const [indexesResult, foreignKeysResult, constraintsResult, triggersResult] = await Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
if (requestId !== structureLoadRequestId) return;
type SecondaryMetadataResult = { facet: ObjectMetadataFacet; result: PromiseSettledResult<unknown> };
const secondaryResults: SecondaryMetadataResult[] = [
{ facet: "indexes", result: indexesResult },
{ facet: "foreign-keys", result: foreignKeysResult },
{ facet: "constraints", result: constraintsResult },
{ facet: "triggers", result: triggersResult },
];
const failedFacets = secondaryResults.filter((entry): entry is { facet: ObjectMetadataFacet; result: PromiseRejectedResult } => entry.result.status === "rejected");
for (const { facet, result } of failedFacets) {
console.warn(`[DBX][structure-editor:${facet}-metadata-failed]`, result.reason);
}
if (showErrors && failedFacets.length > 0) {
for (const { facet, result } of failedFacets) {
secondaryMetadataErrors.value[facet] = result.reason?.message || String(result.reason);
}
}
const nextIndexes = indexesResult.status === "fulfilled" ? indexesResult.value : undefined;
const nextForeignKeys = foreignKeysResult.status === "fulfilled" ? foreignKeysResult.value : undefined;
const nextConstraints = constraintsResult.status === "fulfilled" ? constraintsResult.value : undefined;
const nextTriggers = triggersResult.status === "fulfilled" ? triggersResult.value : undefined;
if (nextIndexes) {
indexes.value = createIndexDrafts(nextIndexes);
loadedMetadataFacets.add("indexes");
@@ -1912,7 +1941,6 @@ async function loadStructure(
const secondaryMetadataPromise = applySecondaryMetadata()
.catch((error) => {
console.warn("[DBX][structure-editor:secondary-metadata-failed]", error);
if (showErrors && requestId === structureLoadRequestId) errorMessage.value = error?.message || String(error);
})
.finally(() => {
if (requestId === structureLoadRequestId) setSecondaryMetadataLoading(effectiveScope, false);
@@ -4371,6 +4399,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors.indexes" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.indexes }}
</div>
<table v-else class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
@@ -4486,6 +4517,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors['foreign-keys']" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors["foreign-keys"] }}
</div>
<div v-else-if="foreignKeys.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
@@ -4536,11 +4570,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="constraints.length === 0" class="py-10 text-center text-muted-foreground">
<div v-else-if="secondaryMetadataErrors.constraints" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.constraints }}
</div>
<div v-else-if="constraintsForTab.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
<div v-else class="space-y-1.5">
<div v-for="constraint in constraints" :key="constraint.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="constraint.enabled ? '' : 'opacity-60'">
<div v-for="constraint in constraintsForTab" :key="constraint.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono font-medium">{{ constraint.name }}</span>
<Badge variant="outline" class="shrink-0">{{ constraint.constraint_type }}</Badge>
@@ -4559,6 +4596,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors.triggers" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.triggers }}
</div>
<div v-else-if="triggers.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
@@ -8,4 +8,11 @@ describe("TableStructureEditor constraints", () => {
expect(source).toContain('class="mt-1 whitespace-pre-wrap break-words font-mono text-muted-foreground">{{ constraint.definition }}');
expect(source).not.toContain('class="mt-1 truncate font-mono text-muted-foreground">{{ constraint.definition }}');
});
it("isolates secondary metadata failures to their owning tabs", () => {
expect(source).toContain("Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise])");
expect(source).toContain("secondaryMetadataErrors.value[facet] = result.reason?.message || String(result.reason)");
expect(source).toContain('v-else-if="secondaryMetadataErrors.constraints"');
expect(source).not.toContain("errorMessage.value = reason?.message || String(reason)");
});
});
@@ -7,6 +7,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import * as objectMetadataCache from "@/lib/metadata/objectMetadataCache";
import type { ConnectionConfig, QueryTab } from "@/types/database";
vi.mock("vue-i18n", () => ({
@@ -22,6 +23,11 @@ vi.mock("@/lib/backend/api", () => ({
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/lib/metadata/objectMetadataCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/metadata/objectMetadataCache")>();
return { ...actual, invalidateObjectMetadataCache: vi.fn() };
});
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
@@ -129,6 +135,7 @@ describe("useSqlExecution", () => {
beforeEach(() => {
installLocalStorage();
setActivePinia(createPinia());
vi.mocked(objectMetadataCache.invalidateObjectMetadataCache).mockClear();
});
it("invalidates object metadata after successful connection-level DDL", async () => {
@@ -158,6 +165,35 @@ describe("useSqlExecution", () => {
expect(loadDatabases).toHaveBeenCalledWith("conn-1", { force: true });
});
it("invalidates table facets after successful Kingbase constraint DDL", async () => {
const sql = "ALTER TABLE public.orders DISABLE CONSTRAINT orders_check";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), schema: "public", sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("kingbase"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
useSettingsStore().editorSettings.confirmDangerousSqlExecution = false;
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const invalidateObjectMetadata = vi.mocked(objectMetadataCache.invalidateObjectMetadataCache).mockResolvedValue(undefined);
const refreshObjects = vi.spyOn(connectionStore, "refreshObjectListTreeNode").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
requestDangerConfirmation: async () => true,
});
await execution.tryExecute();
expect(invalidateObjectMetadata).toHaveBeenCalledWith({ connectionId: "conn-1", database: "app", schema: "public" });
expect(refreshObjects).toHaveBeenCalledWith("conn-1", "app", "public");
});
it("sends every placeholder syntax and @set unchanged when substitution is disabled", async () => {
const sql = ["@set tenant = 42;", "SELECT ?, :named, ${shell}, #{mybatis}, @sqlserver, @tenant;"].join("\n");
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
@@ -10,6 +10,7 @@ import { supportsConnectionScopedQueryExecution } from "@/lib/database/databaseF
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
@@ -333,8 +334,10 @@ export function useSqlExecution(deps: {
const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(tab.connectionId);
await invalidateObjectMetadataCache({ connectionId: tab.connectionId });
await connectionStore.loadDatabases(tab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: tab.connectionId, database: tab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
}
}
@@ -478,8 +481,10 @@ export function useSqlExecution(deps: {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(executionTab.connectionId);
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
}
}
+3
View File
@@ -2076,6 +2076,9 @@ export default {
columnRegularIndex: "Regular index",
tableInfoForeignKeys: "Foreign Keys",
foreignKeyNavigate: "Go to {table}",
tableInfoConstraints: "Constraints",
tableInfoConstraintDisabled: "Disabled",
tableInfoConstraintNotValidated: "Not validated",
tableInfoTriggers: "Triggers",
tableInfoNullable: "Nullable",
tableInfoEmpty: "No metadata",
+3
View File
@@ -1945,6 +1945,9 @@ export default withEnglishFallback({
columnRegularIndex: "Índice normal",
tableInfoForeignKeys: "Claves foráneas",
foreignKeyNavigate: "Ir a {table}",
tableInfoConstraints: "Restricciones",
tableInfoConstraintDisabled: "Deshabilitada",
tableInfoConstraintNotValidated: "Sin validar",
tableInfoTriggers: "Disparadores",
tableInfoNullable: "Nulable",
tableInfoEmpty: "Sin metadatos",
+3
View File
@@ -1932,6 +1932,9 @@ export default withEnglishFallback({
columnRegularIndex: "Indice normale",
tableInfoForeignKeys: "Chiavi Esterne",
foreignKeyNavigate: "Vai a {table}",
tableInfoConstraints: "Vincoli",
tableInfoConstraintDisabled: "Disabilitata",
tableInfoConstraintNotValidated: "Non validata",
tableInfoTriggers: "Trigger",
tableInfoNullable: "Nullabile",
tableInfoEmpty: "Nessun metadato",
+3
View File
@@ -1950,6 +1950,9 @@ export default withEnglishFallback({
columnRegularIndex: "通常インデックス",
tableInfoForeignKeys: "外部キー",
foreignKeyNavigate: "{table} へ移動",
tableInfoConstraints: "制約",
tableInfoConstraintDisabled: "無効",
tableInfoConstraintNotValidated: "未検証",
tableInfoTriggers: "トリガー",
tableInfoNullable: "NULL許容",
tableInfoEmpty: "メタデータなし",
+3
View File
@@ -1908,6 +1908,9 @@ export default withEnglishFallback({
columnRegularIndex: "일반 인덱스",
tableInfoForeignKeys: "외래 키",
foreignKeyNavigate: "{table}(으)로 이동",
tableInfoConstraints: "제약 조건",
tableInfoConstraintDisabled: "비활성화됨",
tableInfoConstraintNotValidated: "검증 안 됨",
tableInfoTriggers: "트리거",
tableInfoNullable: "NULL 허용",
tableInfoEmpty: "메타데이터 없음",
+3
View File
@@ -1934,6 +1934,9 @@ export default withEnglishFallback({
columnRegularIndex: "Índice comum",
tableInfoForeignKeys: "Chaves Estrangeiras",
foreignKeyNavigate: "Ir para {table}",
tableInfoConstraints: "Restrições",
tableInfoConstraintDisabled: "Desabilitada",
tableInfoConstraintNotValidated: "Não validada",
tableInfoTriggers: "Gatilhos",
tableInfoNullable: "Permite Nulo",
tableInfoEmpty: "Sem metadados",
+3
View File
@@ -2001,6 +2001,9 @@ export default withEnglishFallback({
columnRegularIndex: "普通索引",
tableInfoForeignKeys: "外键",
foreignKeyNavigate: "跳转到 {table}",
tableInfoConstraints: "约束",
tableInfoConstraintDisabled: "已禁用",
tableInfoConstraintNotValidated: "未验证",
tableInfoTriggers: "触发器",
tableInfoNullable: "可空",
tableInfoEmpty: "暂无元数据",
+3
View File
@@ -1938,6 +1938,9 @@ export default withEnglishFallback({
columnRegularIndex: "一般索引",
tableInfoForeignKeys: "外鍵",
foreignKeyNavigate: "跳轉到 {table}",
tableInfoConstraints: "約束",
tableInfoConstraintDisabled: "已停用",
tableInfoConstraintNotValidated: "未驗證",
tableInfoTriggers: "觸發器",
tableInfoNullable: "可為空",
tableInfoEmpty: "無",
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { constraintsForConstraintsTab, isForeignKeyConstraint } from "@/lib/table/constraintPresentation";
import type { ConstraintInfo } from "@/types/database";
function constraint(name: string, constraint_type: string): ConstraintInfo {
return {
name,
constraint_type,
definition: "",
columns: [],
ref_schema: null,
ref_table: null,
ref_columns: [],
match_type: null,
on_update: null,
on_delete: null,
deferrable: false,
initially_deferred: false,
enabled: true,
valid: true,
};
}
const pk = constraint("t_pkey", "PRIMARY KEY");
const fk = constraint("t_parent_fk", "FOREIGN KEY");
const unique = constraint("t_code_key", "UNIQUE");
const check = constraint("t_qty_check", "CHECK");
const all = [pk, fk, unique, check];
describe("isForeignKeyConstraint", () => {
it("normalizes case and whitespace and accepts the bare F contype letter", () => {
expect(isForeignKeyConstraint(constraint("a", "FOREIGN KEY"))).toBe(true);
expect(isForeignKeyConstraint(constraint("a", "foreign key"))).toBe(true);
expect(isForeignKeyConstraint(constraint("a", " Foreign Key "))).toBe(true);
expect(isForeignKeyConstraint(constraint("a", "F"))).toBe(true);
expect(isForeignKeyConstraint(constraint("a", "f"))).toBe(true);
});
it("rejects non-foreign-key constraint types", () => {
expect(isForeignKeyConstraint(pk)).toBe(false);
expect(isForeignKeyConstraint(unique)).toBe(false);
expect(isForeignKeyConstraint(check)).toBe(false);
expect(isForeignKeyConstraint(constraint("a", "EXCLUDE"))).toBe(false);
});
});
describe("constraintsForConstraintsTab", () => {
it("drops foreign keys when a dedicated Foreign Keys tab is shown", () => {
expect(constraintsForConstraintsTab(all, true)).toEqual([pk, unique, check]);
});
it("normalizes foreign-key spellings before filtering", () => {
const variants = [constraint("a", "foreign key"), constraint("b", " Foreign Key "), constraint("c", "F"), constraint("d", "PRIMARY KEY")];
expect(constraintsForConstraintsTab(variants, true)).toEqual([variants[3]]);
});
it("keeps foreign keys when there is no dedicated Foreign Keys tab", () => {
expect(constraintsForConstraintsTab(all, false)).toEqual(all);
});
it("keeps non-foreign-key constraints in order and handles an empty list", () => {
expect(constraintsForConstraintsTab([fk], true)).toEqual([]);
expect(constraintsForConstraintsTab([], true)).toEqual([]);
});
});
@@ -15,6 +15,10 @@ describe("tableMetadataCapabilities", () => {
it("exposes structured constraints only for dialects that implement list_constraints", () => {
expect(getTableMetadataCapabilities("oracle").constraints).toBe(true);
expect(getTableMetadataCapabilities("postgres").constraints).toBe(true);
expect(getTableMetadataCapabilities("kingbase").constraints).toBe(true);
expect(getTableMetadataCapabilities("vastbase").constraints).toBe(true);
expect(getTableMetadataCapabilities("opengauss").constraints).toBe(true);
expect(getTableMetadataCapabilities("mysql").constraints).toBe(false);
expect(getTableMetadataCapabilities(undefined).constraints).toBe(false);
});
@@ -0,0 +1,26 @@
import type { ConstraintInfo } from "@/types/database";
/**
* Whether a constraint is a foreign key. `constraint_type` is a plain string
* from the driver/agent API, so normalize (trim + upper-case) and also accept
* the bare `F` letter (PostgreSQL's `pg_constraint.contype`) rather than
* relying on an exact "FOREIGN KEY" spelling.
*/
export function isForeignKeyConstraint(constraint: ConstraintInfo): boolean {
const type = constraint.constraint_type.trim().toUpperCase();
return type === "FOREIGN KEY" || type === "F";
}
/**
* Constraints to render on the structured "Constraints" tab.
*
* Foreign keys are omitted when a dedicated Foreign Keys tab is also shown,
* so each constraint appears exactly once and the FK tab's navigation keeps
* working (this mirrors the existing Oracle convention, whose
* list_constraints already excludes FK rows). When there is no FK tab,
* foreign keys remain visible here so they are never hidden entirely.
*/
export function constraintsForConstraintsTab(constraints: ConstraintInfo[], hasForeignKeysTab: boolean): ConstraintInfo[] {
if (!hasForeignKeysTab) return constraints;
return constraints.filter((constraint) => !isForeignKeyConstraint(constraint));
}
@@ -13,9 +13,10 @@ const defaultCapabilities: TableMetadataCapabilities = {
columns: true,
indexes: true,
foreignKeys: true,
// Structured PK/UNIQUE/CHECK constraint metadata (list_constraints) is only
// implemented by a handful of agent-backed drivers today; leave it off by
// default so every other dialect doesn't grow a permanently empty tab.
// Structured constraint metadata (list_constraints) is only enabled for
// drivers that implement it (PostgreSQL natively; Oracle/Xugu via agents);
// leave it off by default so every other dialect doesn't grow a permanently
// empty tab.
constraints: false,
triggers: true,
ddl: true,
@@ -25,6 +26,20 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
oracle: {
constraints: true,
},
kingbase: {
constraints: true,
},
vastbase: {
constraints: true,
},
opengauss: {
constraints: true,
},
// PostgreSQL reports full pg_constraint metadata (PK/FK/UNIQUE/CHECK/
// EXCLUDE/NOT NULL) through list_constraints.
postgres: {
constraints: true,
},
mongodb: {
columns: false,
foreignKeys: false,