diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index b133c54350..2e02f2d249 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -48,6 +48,7 @@ var ( oracleNamedPlSQLBlockEndRegexp = regexp.MustCompile(`(?is)\bEND\s+([A-Z0-9_$#]+)\s*;\s*$`) oracleUnsupportedServerCharsetRegexp = regexp.MustCompile(`server use charset with id: ([0-9]+).*not supported by the driver`) oracleVersionNumberRegexp = regexp.MustCompile(`(?:^|[^0-9])([0-9]+)\.[0-9]+`) + oracleNotNullConstraintRegexp = regexp.MustCompile(`(?i)^\s*\(*\s*(?:"((?:[^"]|"")*)"|([A-Z0-9_$#]+))\s+IS\s+NOT\s+NULL\s*\)*\s*$`) oracleDatabaseVersionQueries = []string{ oracleDatabaseVersionSQL, `SELECT BANNER FROM V$VERSION WHERE BANNER LIKE 'Oracle Database%' AND ROWNUM = 1`, @@ -452,6 +453,38 @@ type triggerInfo struct { Statement *string `json:"statement,omitempty"` } +// constraintInfo represents primary key, unique, and check constraints for a +// table. Foreign keys are served separately by listForeignKeys, so this only +// covers constraint types 'P', 'U', and 'C'. +type constraintInfo struct { + Name string `json:"name"` + ConstraintType string `json:"constraint_type"` + Definition string `json:"definition"` + Columns []string `json:"columns"` + RefSchema *string `json:"ref_schema,omitempty"` + RefTable *string `json:"ref_table,omitempty"` + RefColumns []string `json:"ref_columns"` + MatchType *string `json:"match_type,omitempty"` + OnUpdate *string `json:"on_update,omitempty"` + OnDelete *string `json:"on_delete,omitempty"` + Deferrable bool `json:"deferrable"` + InitiallyDeferred bool `json:"initially_deferred"` + Enabled bool `json:"enabled"` + Valid bool `json:"valid"` +} + +func (c constraintInfo) MarshalJSON() ([]byte, error) { + type alias constraintInfo + value := alias(c) + if value.Columns == nil { + value.Columns = []string{} + } + if value.RefColumns == nil { + value.RefColumns = []string{} + } + return json.Marshal(value) +} + type server struct { db *sql.DB params connectParams @@ -853,6 +886,11 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any table := stringParam(params, "table") result, err := s.listForeignKeys(schema, table) return result, false, err + case "list_constraints": + schema := stringParam(params, "schema") + table := stringParam(params, "table") + result, err := s.listConstraints(schema, table) + return result, false, err case "list_triggers": schema := stringParam(params, "schema") table := stringParam(params, "table") @@ -2548,6 +2586,123 @@ ORDER BY ac.CONSTRAINT_NAME, acc.POSITION`, []any{schema, table}) return emptyIfNil(result), rows.Err() } +func oracleConstraintTypeName(kind string) string { + switch kind { + case "P": + return "PRIMARY KEY" + case "U": + return "UNIQUE" + case "C": + return "CHECK" + default: + return kind + } +} + +func oracleSystemNotNullConstraint(kind string, generated sql.NullString, definition string, column, nullable sql.NullString) bool { + if kind != "C" || !generated.Valid || generated.String != "GENERATED NAME" || !column.Valid || !nullable.Valid || nullable.String != "N" { + return false + } + matches := oracleNotNullConstraintRegexp.FindStringSubmatch(definition) + if matches == nil { + return false + } + if matches[1] != "" { + return strings.ReplaceAll(matches[1], `""`, `"`) == column.String + } + return strings.EqualFold(matches[2], column.String) +} + +// listConstraints returns primary key, unique, and check constraints for a +// table. Oracle represents every NOT NULL column as a system-generated CHECK +// constraint (e.g. "COL" IS NOT NULL); those are excluded here so the result +// only contains constraints a user would recognize as such, matching how +// tools like DBeaver/Navicat present Oracle constraints. +func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) { + schema, err := s.normalizeSchemaForIdentity(schema) + if err != nil { + return nil, err + } + table = strings.TrimSpace(table) + // SEARCH_CONDITION is a LONG column: Oracle rejects LONG values in WHERE + // clauses, functions, or ORDER BY (ORA-00932), so it can only appear in + // the SELECT list here. The NOT-NULL-check exclusion below is therefore + // applied in Go after scanning, not in SQL. + rows, err := s.queryRows(` +SELECT ac.CONSTRAINT_NAME, + ac.CONSTRAINT_TYPE, + ac.SEARCH_CONDITION, + ac.GENERATED, + ac.STATUS, + ac.DEFERRABLE, + ac.DEFERRED, + ac.VALIDATED, + acc.COLUMN_NAME, + acc.POSITION, + atc.NULLABLE +FROM ALL_CONSTRAINTS ac +LEFT JOIN ALL_CONS_COLUMNS acc ON acc.OWNER = ac.OWNER AND acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME +LEFT JOIN ALL_TAB_COLUMNS atc ON atc.OWNER = ac.OWNER AND atc.TABLE_NAME = ac.TABLE_NAME AND atc.COLUMN_NAME = acc.COLUMN_NAME +WHERE ac.OWNER = :1 + AND ac.TABLE_NAME = :2 + AND ac.CONSTRAINT_TYPE IN ('P', 'U', 'C') +ORDER BY ac.CONSTRAINT_NAME, acc.POSITION`, []any{schema, table}) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + + byName := map[string]*constraintInfo{} + skipped := map[string]bool{} + order := []string{} + for rows.Next() { + var name, kind string + var condition, generated, status, deferrable, deferred, validated, column, nullable sql.NullString + var position sql.NullInt64 + if err := rows.Scan(&name, &kind, &condition, &generated, &status, &deferrable, &deferred, &validated, &column, &position, &nullable); err != nil { + return nil, err + } + if skipped[name] { + continue + } + item := byName[name] + if item == nil { + definition := "" + if condition.Valid { + definition = strings.TrimSpace(condition.String) + } + if oracleSystemNotNullConstraint(kind, generated, definition, column, nullable) { + skipped[name] = true + continue + } + item = &constraintInfo{ + Name: name, + ConstraintType: oracleConstraintTypeName(kind), + Definition: definition, + Columns: []string{}, + RefColumns: []string{}, + Deferrable: deferrable.Valid && deferrable.String == "DEFERRABLE", + InitiallyDeferred: deferred.Valid && deferred.String == "DEFERRED", + Enabled: status.Valid && status.String == "ENABLED", + Valid: validated.Valid && validated.String == "VALIDATED", + } + byName[name] = item + order = append(order, name) + } + if column.Valid && column.String != "" { + item.Columns = append(item.Columns, column.String) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + result := make([]constraintInfo, 0, len(order)) + for _, name := range order { + result = append(result, *byName[name]) + } + return emptyIfNil(result), nil +} + func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { schema, err := s.normalizeSchema(schema) if err != nil { diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index ab9d8c3560..6e7e5de935 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -207,6 +207,15 @@ func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) { if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"included_columns":null`) { t.Fatalf("index info should marshal nil slices as arrays: %s", text) } + + data, err = json.Marshal(constraintInfo{}) + if err != nil { + t.Fatal(err) + } + text = string(data) + if strings.Contains(text, `"columns":null`) || strings.Contains(text, `"ref_columns":null`) { + t.Fatalf("constraint info should marshal nil slices as arrays: %s", text) + } } func TestGetTableDDLResultMarshalsAsString(t *testing.T) { @@ -746,6 +755,104 @@ func TestListForeignKeysIncludesReferencedSchemaAndDeleteRule(t *testing.T) { } } +func TestListConstraintsGroupsMultiColumnPrimaryKeyAndMapsTypes(t *testing.T) { + const schema = "HR" + const table = "ORDERS" + db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{ + { + queryContains: "FROM ALL_CONSTRAINTS ac", + args: []driver.Value{schema, table}, + columns: []string{"CONSTRAINT_NAME", "CONSTRAINT_TYPE", "SEARCH_CONDITION", "GENERATED", "STATUS", "DEFERRABLE", "DEFERRED", "VALIDATED", "COLUMN_NAME", "POSITION", "NULLABLE"}, + rows: [][]driver.Value{ + {"PK_ORDERS", "P", nil, "USER NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", "TENANT_ID", int64(1), "N"}, + {"PK_ORDERS", "P", nil, "USER NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", "ORDER_ID", int64(2), "N"}, + {"UQ_ORDERS_CODE", "U", nil, "USER NAME", "ENABLED", "DEFERRABLE", "DEFERRED", "VALIDATED", "ORDER_CODE", int64(1), "Y"}, + {"CK_ORDERS_AMOUNT", "C", "AMOUNT > 0", "USER NAME", "DISABLED", "NOT DEFERRABLE", "IMMEDIATE", "NOT VALIDATED", nil, nil, nil}, + }, + }, + }) + s := newServer() + s.db = db + + got, err := s.listConstraints(schema, table) + if err != nil { + t.Fatal(err) + } + want := []constraintInfo{ + {Name: "PK_ORDERS", ConstraintType: "PRIMARY KEY", Definition: "", Columns: []string{"TENANT_ID", "ORDER_ID"}, RefColumns: []string{}, Enabled: true, Valid: true}, + {Name: "UQ_ORDERS_CODE", ConstraintType: "UNIQUE", Definition: "", Columns: []string{"ORDER_CODE"}, RefColumns: []string{}, Deferrable: true, InitiallyDeferred: true, Enabled: true, Valid: true}, + {Name: "CK_ORDERS_AMOUNT", ConstraintType: "CHECK", Definition: "AMOUNT > 0", Columns: []string{}, RefColumns: []string{}, Enabled: false, Valid: false}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("listConstraints() = %#v, want %#v", got, want) + } + if scripted.next != len(scripted.steps) { + t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next) + } +} + +func TestListConstraintsExcludesGeneratedNotNullChecksButKeepsRealChecks(t *testing.T) { + const schema = "HR" + const table = "ORDERS" + db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{ + { + queryContains: "FROM ALL_CONSTRAINTS ac", + args: []driver.Value{schema, table}, + columns: []string{"CONSTRAINT_NAME", "CONSTRAINT_TYPE", "SEARCH_CONDITION", "GENERATED", "STATUS", "DEFERRABLE", "DEFERRED", "VALIDATED", "COLUMN_NAME", "POSITION", "NULLABLE"}, + rows: [][]driver.Value{ + {"SYS_C008648", "C", `"ORDER_CODE" IS NOT NULL`, "GENERATED NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", "ORDER_CODE", int64(1), "N"}, + {"SYS_C008649", "C", `"OPTIONAL_CODE" IS NOT NULL`, "GENERATED NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", "OPTIONAL_CODE", int64(1), "Y"}, + {"CK_REQUIRED_CODE", "C", `"REQUIRED_CODE" IS NOT NULL`, "USER NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", "REQUIRED_CODE", int64(1), "N"}, + {"CK_ORDERS_AMOUNT", "C", "AMOUNT > 0", "USER NAME", "ENABLED", "NOT DEFERRABLE", "IMMEDIATE", "VALIDATED", nil, nil, nil}, + }, + }, + }) + s := newServer() + s.db = db + + got, err := s.listConstraints(schema, table) + if err != nil { + t.Fatal(err) + } + want := []constraintInfo{ + {Name: "SYS_C008649", ConstraintType: "CHECK", Definition: `"OPTIONAL_CODE" IS NOT NULL`, Columns: []string{"OPTIONAL_CODE"}, RefColumns: []string{}, Enabled: true, Valid: true}, + {Name: "CK_REQUIRED_CODE", ConstraintType: "CHECK", Definition: `"REQUIRED_CODE" IS NOT NULL`, Columns: []string{"REQUIRED_CODE"}, RefColumns: []string{}, Enabled: true, Valid: true}, + {Name: "CK_ORDERS_AMOUNT", ConstraintType: "CHECK", Definition: "AMOUNT > 0", Columns: []string{}, RefColumns: []string{}, Enabled: true, Valid: true}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("listConstraints() = %#v, want %#v (system-generated NOT NULL check must be excluded, real CHECK must survive)", got, want) + } + if scripted.next != len(scripted.steps) { + t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next) + } +} + +func TestListConstraintsPreservesQuotedMixedCaseSchema(t *testing.T) { + const schema = "AppOwner" + const table = "Orders" + db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{ + { + queryContains: "FROM ALL_CONSTRAINTS ac", + args: []driver.Value{schema, table}, + columns: []string{"CONSTRAINT_NAME", "CONSTRAINT_TYPE", "SEARCH_CONDITION", "GENERATED", "STATUS", "DEFERRABLE", "DEFERRED", "VALIDATED", "COLUMN_NAME", "POSITION", "NULLABLE"}, + rows: [][]driver.Value{}, + }, + }) + s := newServer() + s.db = db + + constraints, err := s.listConstraints(schema, table) + if err != nil { + t.Fatal(err) + } + if len(constraints) != 0 { + t.Fatalf("listConstraints(%q) = %#v, want empty result", schema, constraints) + } + if scripted.next != len(scripted.steps) { + t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next) + } +} + func TestListForeignKeysAndTriggersPreserveQuotedCloneTableName(t *testing.T) { const schema = "HR" const table = "ORDERS_copy" diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 0d1be6ab2f..5eb8089099 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -40,7 +40,7 @@ import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTabl import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; +import type { ConstraintInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; import { applyManticoreDdlColumnExtras, buildStructureTargetLabel, @@ -95,6 +95,7 @@ type StructureScrollerRef = HTMLElement | { $el?: HTMLElement }; const columnsScrollerRef = ref(); const indexesScrollerRef = ref(); const foreignKeysScrollerRef = ref(); +const constraintsScrollerRef = ref(); const triggersScrollerRef = ref(); const ddlScrollerRef = ref(); const dynamicDataTypeOptionsCache = new Map(); @@ -141,6 +142,7 @@ const sqlPreviewLoading = ref(false); const sqlPreviewPending = ref(false); const indexesLoading = ref(false); const foreignKeysLoading = ref(false); +const constraintsLoading = ref(false); const triggersLoading = ref(false); const ddlContent = ref(""); const ddlLoading = ref(false); @@ -192,9 +194,11 @@ const pendingStatements = ref([]); const warnings = ref([]); const sqliteSchemaRevision = ref(); const foreignKeys = ref([]); +const constraints = ref([]); +const constraintsLoaded = ref(false); const triggers = ref([]); const triggersLoaded = ref(false); -const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || triggersLoading.value); +const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || constraintsLoading.value || triggersLoading.value); function sameList(left: string[] | null | undefined, right: string[] | null | undefined): boolean { const a = left ?? []; @@ -262,6 +266,10 @@ function captureStructureRefreshScope(): TableStructureRefreshScope { columns: columns.value.some(columnChanged), indexes: indexes.value.some(indexChanged), foreignKeys: foreignKeys.value.some(foreignKeyChanged), + // Constraints have no editable draft of their own, but column/index/FK + // saves can change what constraints exist (e.g. toggling a primary key), + // so refresh the tab if it was ever loaded rather than leaving it stale. + constraints: constraintsLoaded.value, triggers: triggers.value.some(triggerChanged), tableComment: tableComment.value !== originalTableComment.value, }; @@ -881,6 +889,7 @@ function structureScrollerForTab(tab: TableInfoTab): HTMLElement | undefined { if (tab === "columns") return structureScrollerElement(columnsScrollerRef.value); if (tab === "indexes") return structureScrollerElement(indexesScrollerRef.value); if (tab === "foreignKeys") return structureScrollerElement(foreignKeysScrollerRef.value); + if (tab === "constraints") return structureScrollerElement(constraintsScrollerRef.value); if (tab === "triggers") return structureScrollerElement(triggersScrollerRef.value); if (tab === "ddl") return structureScrollerElement(ddlScrollerRef.value); return undefined; @@ -927,6 +936,8 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft { columns: cloneDraftValue(columns.value), indexes: cloneDraftValue(indexes.value), foreignKeys: cloneDraftValue(foreignKeys.value), + constraints: cloneDraftValue(constraints.value), + constraintsLoaded: constraintsLoaded.value, triggers: cloneDraftValue(triggers.value), triggersLoaded: triggersLoaded.value, loadedMetadataFacets: [...loadedMetadataFacets], @@ -953,6 +964,9 @@ function restoreDraft(draft: TableStructureEditorDraft) { columns.value = cloneDraftValue(draft.columns || []); indexes.value = cloneDraftValue(draft.indexes || []); foreignKeys.value = cloneDraftValue(draft.foreignKeys || []); + constraints.value = cloneDraftValue(draft.constraints || []); + // Drafts created before constraint loading existed have no saved facet. + constraintsLoaded.value = draft.constraintsLoaded ?? false; triggers.value = cloneDraftValue(draft.triggers || []); // Drafts created before lazy trigger loading always contained live trigger metadata. triggersLoaded.value = draft.triggersLoaded ?? true; @@ -964,6 +978,7 @@ function restoreDraft(draft: TableStructureEditorDraft) { if (activeScope.columns) loadedMetadataFacets.add("columns"); if (activeScope.indexes || draft.indexes?.length) loadedMetadataFacets.add("indexes"); if (activeScope.foreignKeys || draft.foreignKeys?.length) loadedMetadataFacets.add("foreign-keys"); + if (activeScope.constraints || constraintsLoaded.value) loadedMetadataFacets.add("constraints"); if (activeScope.triggers || triggersLoaded.value) loadedMetadataFacets.add("triggers"); if (activeScope.tableComment) loadedMetadataFacets.add("comment"); } @@ -1226,6 +1241,7 @@ function resetState() { sqlPreviewPending.value = false; indexesLoading.value = false; foreignKeysLoading.value = false; + constraintsLoading.value = false; triggersLoading.value = false; errorMessage.value = ""; columns.value = []; @@ -1234,6 +1250,8 @@ function resetState() { warnings.value = []; sqliteSchemaRevision.value = undefined; foreignKeys.value = []; + constraints.value = []; + constraintsLoaded.value = false; triggers.value = []; triggersLoaded.value = false; selectedColumnId.value = null; @@ -1259,6 +1277,10 @@ async function reloadStructureFromDatabase() { triggers.value = []; triggersLoaded.value = false; } + if (activeTab.value !== "constraints") { + constraints.value = []; + constraintsLoaded.value = false; + } const refreshDdl = activeTab.value === "ddl"; const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName }; invalidateTableMetadataCache(metadataMatch); @@ -1275,6 +1297,7 @@ async function reloadStructureFromDatabase() { function setSecondaryMetadataLoading(scope: TableStructureRefreshScope, value: boolean) { if (scope.indexes && tableMetadataCapabilities.value.indexes) indexesLoading.value = value; if (scope.foreignKeys && tableMetadataCapabilities.value.foreignKeys) foreignKeysLoading.value = value; + if (scope.constraints && tableMetadataCapabilities.value.constraints) constraintsLoading.value = value; if (scope.triggers && tableMetadataCapabilities.value.triggers) triggersLoading.value = value; } @@ -1330,6 +1353,11 @@ async function loadStructure( ? loadObjectMetadataFacet(metadataRequest, "foreign-keys", () => api.listForeignKeys(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value) : Promise.resolve([]) : Promise.resolve(undefined); + const constraintsPromise = scope.constraints + ? tableMetadataCapabilities.value.constraints + ? loadObjectMetadataFacet(metadataRequest, "constraints", () => api.listConstraints(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value) + : Promise.resolve([]) + : Promise.resolve(undefined); const triggersPromise = scope.triggers ? tableMetadataCapabilities.value.triggers ? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value) @@ -1366,7 +1394,7 @@ async function loadStructure( loadedMetadataFacets.add("comment"); } const applySecondaryMetadata = async () => { - const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, triggersPromise]); + const [nextIndexes, nextForeignKeys, nextConstraints, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]); if (requestId !== structureLoadRequestId) return; if (nextIndexes) { indexes.value = createIndexDrafts(nextIndexes); @@ -1376,6 +1404,11 @@ async function loadStructure( foreignKeys.value = createForeignKeyDrafts(nextForeignKeys); loadedMetadataFacets.add("foreign-keys"); } + if (nextConstraints) { + constraints.value = nextConstraints; + constraintsLoaded.value = true; + loadedMetadataFacets.add("constraints"); + } if (nextTriggers) { triggers.value = createTriggerDrafts(nextTriggers); triggersLoaded.value = true; @@ -2605,6 +2638,10 @@ watch(refreshVersion, (version, previous) => { triggers.value = []; triggersLoaded.value = false; } + if (activeTab.value !== "constraints") { + constraints.value = []; + constraintsLoaded.value = false; + } void loadStructure(true, visibleTableStructureRefreshScope(activeTab.value)); }); @@ -2674,6 +2711,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ t("structureEditor.columns") }} {{ t("structureEditor.indexes") }} {{ t("structureEditor.foreignKeys") }} + {{ t("structureEditor.constraints") }} {{ t("structureEditor.triggers") }}
@@ -3302,6 +3340,29 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
+ +
+ + {{ t("common.loading") }} +
+
+ {{ t("structureEditor.emptyReadonly") }} +
+
+
+
+ {{ constraint.name }} + {{ constraint.constraint_type }} + {{ t("structureEditor.constraintDisabled") }} + {{ t("structureEditor.constraintNotValidated") }} +
+
{{ constraint.columns.join(", ") }}
+
-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}
+
{{ constraint.definition }}
+
+
+
+
diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts new file mode 100644 index 0000000000..12a304e7cf --- /dev/null +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8"); + +describe("TableStructureEditor constraints", () => { + it("renders complete check expressions instead of truncating them", () => { + 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 }}'); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8eb0af6bda..abc27712e3 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3430,6 +3430,9 @@ export default { columns: "Columns", indexes: "Indexes", foreignKeys: "Foreign Keys", + constraints: "Constraints", + constraintDisabled: "Disabled", + constraintNotValidated: "Not validated", triggers: "Triggers", ddl: "DDL", addColumn: "Add Column", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8cef85eaa5..fd8c2c32b3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3371,6 +3371,9 @@ export default withEnglishFallback({ columns: "Columnas", indexes: "Índices", foreignKeys: "Claves foráneas", + constraints: "Restricciones", + constraintDisabled: "Deshabilitada", + constraintNotValidated: "No validada", triggers: "Disparadores", ddl: "DDL", addColumn: "Agregar columna", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 31c1b7a0aa..994d898409 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3369,6 +3369,9 @@ export default withEnglishFallback({ columns: "Colonne", indexes: "Indici", foreignKeys: "Chiavi Esterne", + constraints: "Vincoli", + constraintDisabled: "Disabilitato", + constraintNotValidated: "Non convalidato", triggers: "Trigger", ddl: "DDL", addColumn: "Aggiungi Colonna", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 58083fa311..417164c5f5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3430,6 +3430,9 @@ export default withEnglishFallback({ columns: "列", indexes: "インデックス", foreignKeys: "外部キー", + constraints: "制約", + constraintDisabled: "無効", + constraintNotValidated: "未検証", triggers: "トリガー", ddl: "DDL", addColumn: "列を追加", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 47935deddd..50e3c26f80 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3256,6 +3256,9 @@ export default withEnglishFallback({ columns: "컬럼", indexes: "인덱스", foreignKeys: "외래 키", + constraints: "제약 조건", + constraintDisabled: "비활성화됨", + constraintNotValidated: "검증되지 않음", triggers: "트리거", ddl: "DDL", addColumn: "컬럼 추가", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 99a020166a..e7194354f5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3371,6 +3371,9 @@ export default withEnglishFallback({ columns: "Colunas", indexes: "Índices", foreignKeys: "Chaves estrangeiras", + constraints: "Restrições", + constraintDisabled: "Desabilitada", + constraintNotValidated: "Não validada", triggers: "Triggers", ddl: "DDL", addColumn: "Adicionar coluna", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eecd119269..e9800e3be7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3418,6 +3418,9 @@ export default withEnglishFallback({ columns: "字段", indexes: "索引", foreignKeys: "外键", + constraints: "约束", + constraintDisabled: "已禁用", + constraintNotValidated: "未验证", triggers: "触发器", ddl: "DDL", addColumn: "新增字段", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2f3522387d..f11636a295 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3131,6 +3131,9 @@ export default withEnglishFallback({ columns: "欄位", indexes: "索引", foreignKeys: "外鍵", + constraints: "約束", + constraintDisabled: "已停用", + constraintNotValidated: "未驗證", triggers: "觸發器", ddl: "DDL", addColumn: "新增欄位", diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts index 57d0a9cab0..80817a55b5 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts @@ -3,11 +3,12 @@ import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visib describe("table structure metadata loading", () => { it.each([ - ["columns", { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true }], - ["indexes", { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true }], - ["foreignKeys", { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true }], - ["triggers", { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true }], - ["ddl", { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false }], + ["columns", { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }], + ["indexes", { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true }], + ["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true }], + ["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true }], + ["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }], + ["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: false }], ] as const)("requests only the metadata required by the %s tab", (tab, expected) => { expect(visibleTableStructureRefreshScope(tab)).toEqual(expected); }); @@ -15,13 +16,18 @@ describe("table structure metadata loading", () => { it("requests only index metadata after columns and comments are already loaded", () => { const scope = unloadedTableStructureRefreshScope("indexes", new Set(["columns", "comment"])); - expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, triggers: false, tableComment: false }); + expect(scope).toEqual({ columns: false, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: false }); expect(hasTableStructureRefreshWork(scope)).toBe(true); expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("indexes", new Set(["columns", "indexes", "comment"])))).toBe(false); }); it("requests trigger metadata only until that facet is loaded", () => { - expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: false }); + expect(unloadedTableStructureRefreshScope("triggers", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: false }); expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("triggers", new Set(["triggers", "comment"])))).toBe(false); }); + + it("requests constraint metadata only until that facet is loaded", () => { + expect(unloadedTableStructureRefreshScope("constraints", new Set(["comment"]))).toEqual({ columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: false }); + expect(hasTableStructureRefreshWork(unloadedTableStructureRefreshScope("constraints", new Set(["constraints", "comment"])))).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/metadata/objectMetadataCache.ts b/apps/desktop/src/lib/metadata/objectMetadataCache.ts index e62b70edd0..5b51160cc1 100644 --- a/apps/desktop/src/lib/metadata/objectMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/objectMetadataCache.ts @@ -44,7 +44,7 @@ async function deleteSchemaCachePrefixSafe(prefix: string): Promise { } } -export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "triggers" | "comment"; +export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment"; function cacheSegment(value: string | undefined): string { return encodeURIComponent(value ?? ""); diff --git a/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts b/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts index b08e5d515b..89327826d3 100644 --- a/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts +++ b/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts @@ -7,8 +7,15 @@ describe("tableMetadataCapabilities", () => { columns: false, indexes: true, foreignKeys: false, + constraints: false, triggers: false, ddl: false, }); }); + + it("exposes structured constraints only for dialects that implement list_constraints", () => { + expect(getTableMetadataCapabilities("oracle").constraints).toBe(true); + expect(getTableMetadataCapabilities("mysql").constraints).toBe(false); + expect(getTableMetadataCapabilities(undefined).constraints).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts index eb79441d03..2097117000 100644 --- a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts +++ b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts @@ -4,6 +4,7 @@ export interface TableMetadataCapabilities { columns: boolean; indexes: boolean; foreignKeys: boolean; + constraints: boolean; triggers: boolean; ddl: boolean; } @@ -12,11 +13,18 @@ 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. + constraints: false, triggers: true, ddl: true, }; const capabilityByType: Partial>> = { + oracle: { + constraints: true, + }, mongodb: { columns: false, foreignKeys: false, @@ -108,11 +116,19 @@ export function firstStructureMetadataTab(capabilities: TableMetadataCapabilitie if (capabilities.columns) return "columns"; if (capabilities.indexes) return "indexes"; if (capabilities.foreignKeys) return "foreignKeys"; + if (capabilities.constraints) return "constraints"; if (capabilities.triggers) return "triggers"; if (!isCreateMode && capabilities.ddl) return "ddl"; return "columns"; } export function isStructureMetadataTabSupported(tab: TableInfoTab, capabilities: TableMetadataCapabilities, isCreateMode: boolean): boolean { - return (tab === "columns" && capabilities.columns) || (tab === "indexes" && capabilities.indexes) || (tab === "foreignKeys" && capabilities.foreignKeys) || (tab === "triggers" && capabilities.triggers) || (tab === "ddl" && capabilities.ddl && !isCreateMode); + return ( + (tab === "columns" && capabilities.columns) || + (tab === "indexes" && capabilities.indexes) || + (tab === "foreignKeys" && capabilities.foreignKeys) || + (tab === "constraints" && capabilities.constraints) || + (tab === "triggers" && capabilities.triggers) || + (tab === "ddl" && capabilities.ddl && !isCreateMode) + ); } diff --git a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts index 7dae7b7355..66ae9f5b6e 100644 --- a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts +++ b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts @@ -5,6 +5,7 @@ export interface TableStructureRefreshScope { columns: boolean; indexes: boolean; foreignKeys: boolean; + constraints: boolean; triggers: boolean; tableComment: boolean; } @@ -12,15 +13,17 @@ export interface TableStructureRefreshScope { export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): TableStructureRefreshScope { switch (activeTab) { case "columns": - return { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true }; + return { columns: true, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }; case "indexes": - return { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true }; + return { columns: true, indexes: true, foreignKeys: false, constraints: false, triggers: false, tableComment: true }; case "foreignKeys": - return { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true }; + return { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true }; + case "constraints": + return { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true }; case "triggers": - return { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true }; + return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }; case "ddl": - return { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false }; + return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: false }; } } @@ -30,11 +33,12 @@ export function unloadedTableStructureRefreshScope(activeTab: TableInfoTab, load columns: visibleScope.columns && !loadedFacets.has("columns"), indexes: visibleScope.indexes && !loadedFacets.has("indexes"), foreignKeys: visibleScope.foreignKeys && !loadedFacets.has("foreign-keys"), + constraints: visibleScope.constraints && !loadedFacets.has("constraints"), triggers: visibleScope.triggers && !loadedFacets.has("triggers"), tableComment: visibleScope.tableComment && !loadedFacets.has("comment"), }; } export function hasTableStructureRefreshWork(scope: TableStructureRefreshScope): boolean { - return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment; + return scope.columns || scope.indexes || scope.foreignKeys || scope.constraints || scope.triggers || scope.tableComment; } diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 9bcbbc2f53..9a6a83e275 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -1019,7 +1019,7 @@ function normalizeToolbarItems(items: Partial | undefined): Toolba }; } -const TABLE_INFO_TABS = new Set(["ddl", "columns", "indexes", "foreignKeys", "triggers"]); +const TABLE_INFO_TABS = new Set(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers"]); function normalizeTableInfoTab(value: unknown): TableInfoTab { return typeof value === "string" && TABLE_INFO_TABS.has(value as TableInfoTab) ? (value as TableInfoTab) : DEFAULT_EDITOR_SETTINGS.tableInfoActiveTab; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 47457cfc76..adf4d03eeb 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1037,7 +1037,7 @@ export interface TableNameFilter { excludePatterns: string[]; } -export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl"; +export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "ddl"; export interface TableStructureEditorTarget { kind: "column" | "index"; @@ -1053,6 +1053,8 @@ export interface TableStructureEditorDraft { columns: import("@/lib/table/tableStructureEditorSql").EditableStructureColumn[]; indexes: import("@/lib/table/tableStructureEditorSql").EditableStructureIndex[]; foreignKeys: import("@/lib/table/tableStructureEditorSql").EditableStructureForeignKey[]; + constraints?: ConstraintInfo[]; + constraintsLoaded?: boolean; triggers: import("@/lib/table/tableStructureEditorSql").EditableStructureTrigger[]; triggersLoaded?: boolean; loadedMetadataFacets?: import("@/lib/metadata/objectMetadataCache").ObjectMetadataFacet[];