feat(structure): show table constraints for postgres-compatible dbs
This commit is contained in:
1 parent
7ad05b178f
commit
20a2994fa1
37 files changed
+2725
-156
No files matched your search
@@ -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>
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1950,6 +1950,9 @@ export default withEnglishFallback({
|
||||
columnRegularIndex: "通常インデックス",
|
||||
tableInfoForeignKeys: "外部キー",
|
||||
foreignKeyNavigate: "{table} へ移動",
|
||||
tableInfoConstraints: "制約",
|
||||
tableInfoConstraintDisabled: "無効",
|
||||
tableInfoConstraintNotValidated: "未検証",
|
||||
tableInfoTriggers: "トリガー",
|
||||
tableInfoNullable: "NULL許容",
|
||||
tableInfoEmpty: "メタデータなし",
|
||||
|
||||
@@ -1908,6 +1908,9 @@ export default withEnglishFallback({
|
||||
columnRegularIndex: "일반 인덱스",
|
||||
tableInfoForeignKeys: "외래 키",
|
||||
foreignKeyNavigate: "{table}(으)로 이동",
|
||||
tableInfoConstraints: "제약 조건",
|
||||
tableInfoConstraintDisabled: "비활성화됨",
|
||||
tableInfoConstraintNotValidated: "검증 안 됨",
|
||||
tableInfoTriggers: "트리거",
|
||||
tableInfoNullable: "NULL 허용",
|
||||
tableInfoEmpty: "메타데이터 없음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2001,6 +2001,9 @@ export default withEnglishFallback({
|
||||
columnRegularIndex: "普通索引",
|
||||
tableInfoForeignKeys: "外键",
|
||||
foreignKeyNavigate: "跳转到 {table}",
|
||||
tableInfoConstraints: "约束",
|
||||
tableInfoConstraintDisabled: "已禁用",
|
||||
tableInfoConstraintNotValidated: "未验证",
|
||||
tableInfoTriggers: "触发器",
|
||||
tableInfoNullable: "可空",
|
||||
tableInfoEmpty: "暂无元数据",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user