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

@@ -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" />