diff --git a/agents/drivers/kingbase-go/integration_test.go b/agents/drivers/kingbase-go/integration_test.go index cddd054ea1..9c7a964c42 100644 --- a/agents/drivers/kingbase-go/integration_test.go +++ b/agents/drivers/kingbase-go/integration_test.go @@ -79,21 +79,30 @@ func TestKingbaseIntegration(t *testing.T) { if err != nil || len(parentColumns) != 3 || parentColumns[0].Comment == nil || *parentColumns[0].Comment != "主键编号" || parentColumns[1].Comment == nil || *parentColumns[1].Comment != "客户'名称" { t.Fatalf("get commented columns failed: columns=%v err=%v", parentColumns, err) } - if parentColumns[1].DataType != "character varying(64)" || parentColumns[1].CharacterMaximumLength == nil || *parentColumns[1].CharacterMaximumLength != 64 { - t.Fatalf("varchar length metadata mismatch: column=%+v", parentColumns[1]) + parentVarcharType := strings.ToLower(strings.TrimSpace(parentColumns[1].DataType)) + if !strings.Contains(parentVarcharType, "varchar") && !strings.Contains(parentVarcharType, "character varying") { + t.Fatalf("varchar type metadata mismatch: column=%+v", parentColumns[1]) } - if parentColumns[2].DataType != "character(32)" || parentColumns[2].CharacterMaximumLength == nil || *parentColumns[2].CharacterMaximumLength != 32 { + if parentColumns[1].CharacterMaximumLength == nil || *parentColumns[1].CharacterMaximumLength != 64 { + actualLength := -1 + if parentColumns[1].CharacterMaximumLength != nil { + actualLength = *parentColumns[1].CharacterMaximumLength + } + t.Fatalf("varchar length metadata mismatch: length=%d column=%+v", actualLength, parentColumns[1]) + } + parentCharacterType := strings.ToLower(strings.TrimSpace(parentColumns[2].DataType)) + if ((!strings.HasPrefix(parentCharacterType, "char") && !strings.HasPrefix(parentCharacterType, "character") && parentCharacterType != "bpchar") || strings.Contains(parentCharacterType, "varying")) || parentColumns[2].CharacterMaximumLength == nil || *parentColumns[2].CharacterMaximumLength != 32 { t.Fatalf("character length metadata mismatch: column=%+v", parentColumns[2]) } ddl, err := server.getTableDDL(schema, parent) if err != nil { t.Fatalf("get table DDL failed: %v", err) } - qualifiedParent := qualifiedSchema + "." + quoteIdentifier(parent) + qualifiedParent := server.quoteDDLIdentifier(schema) + "." + server.quoteDDLIdentifier(parent) for _, expected := range []string{ "COMMENT ON TABLE " + qualifiedParent + " IS '订单父表';", - "COMMENT ON COLUMN " + qualifiedParent + "." + quoteIdentifier(parentColumns[0].Name) + " IS '主键编号';", - "COMMENT ON COLUMN " + qualifiedParent + "." + quoteIdentifier(parentColumns[1].Name) + " IS '客户''名称';", + "COMMENT ON COLUMN " + qualifiedParent + "." + server.quoteDDLIdentifier(parentColumns[0].Name) + " IS '主键编号';", + "COMMENT ON COLUMN " + qualifiedParent + "." + server.quoteDDLIdentifier(parentColumns[1].Name) + " IS '客户''名称';", } { if !strings.Contains(ddl, expected) { t.Fatalf("table DDL missing %q:\n%s", expected, ddl) @@ -155,6 +164,134 @@ func TestKingbaseIntegration(t *testing.T) { } } +func TestKingbaseConstraintsIntegration(t *testing.T) { + host := os.Getenv("KINGBASE_TEST_HOST") + portText := os.Getenv("KINGBASE_TEST_PORT") + username := os.Getenv("KINGBASE_TEST_USERNAME") + password := os.Getenv("KINGBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Kingbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + database := os.Getenv("KINGBASE_TEST_DATABASE") + if database == "" { + database = "test" + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_constraints_" + suffix + schemaIdent := quoteIdentifier(schema) + parent := "parent" + child := "child" + parentTable := schemaIdent + "." + quoteIdentifier(parent) + childTable := schemaIdent + "." + quoteIdentifier(child) + + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:kingbase8://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { + _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"}) + }) + + mustExecute(t, server, "CREATE SCHEMA "+schemaIdent) + mustExecute(t, server, "CREATE TABLE "+parentTable+" (a bigint NOT NULL, b bigint NOT NULL, CONSTRAINT parent_pk PRIMARY KEY (a, b))") + mustExecute(t, server, "CREATE TABLE "+childTable+" (id bigint PRIMARY KEY, parent_a bigint, parent_b bigint, code text NOT NULL, amount int, CONSTRAINT child_parent_fk FOREIGN KEY (parent_a, parent_b) REFERENCES "+parentTable+" (a, b) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, CONSTRAINT child_code_unique UNIQUE (code), CONSTRAINT child_code_check CHECK (length(code) > 0))") + + constraints, err := server.listConstraints(schema, child) + if err != nil { + t.Fatalf("list constraints failed in %s mode: %v", server.mode.compatibilityMode, err) + } + byName := map[string]constraintInfo{} + for _, constraint := range constraints { + byName[constraint.Name] = constraint + } + pk := byName["child_pkey"] + if pk.ConstraintType != "PRIMARY KEY" || len(pk.Columns) != 1 || pk.Columns[0] != "id" { + t.Fatalf("primary key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, pk) + } + fk := byName["child_parent_fk"] + if fk.ConstraintType != "FOREIGN KEY" || !equalStringSlices(fk.Columns, []string{"parent_a", "parent_b"}) || !equalStringSlices(fk.RefColumns, []string{"a", "b"}) || fk.RefSchema == nil || *fk.RefSchema != schema || fk.RefTable == nil || *fk.RefTable != parent || fk.OnDelete == nil || *fk.OnDelete != "CASCADE" || !fk.Deferrable || !fk.InitiallyDeferred { + t.Fatalf("foreign key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, fk) + } + unique := byName["child_code_unique"] + if unique.ConstraintType != "UNIQUE" || !equalStringSlices(unique.Columns, []string{"code"}) { + t.Fatalf("unique metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, unique) + } + check := byName["child_code_check"] + if check.ConstraintType != "CHECK" || !equalStringSlices(check.Columns, []string{"code"}) || !strings.Contains(check.Definition, "length(code) > 0") || !check.Enabled { + t.Fatalf("check metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, check) + } + foreignKeys, err := server.listForeignKeys(schema, child) + if err != nil { + t.Fatalf("list foreign keys failed in %s mode: %v", server.mode.compatibilityMode, err) + } + foreignKeyNames := map[string]struct{}{} + for _, foreignKey := range foreignKeys { + foreignKeyNames[foreignKey.Name] = struct{}{} + } + constraintForeignKeyNames := map[string]struct{}{} + for _, constraint := range constraints { + if constraint.ConstraintType == "FOREIGN KEY" { + constraintForeignKeyNames[constraint.Name] = struct{}{} + } + } + if len(foreignKeyNames) != len(constraintForeignKeyNames) { + t.Fatalf("foreign-key metadata sources disagree in %s mode: constraints=%v foreign_keys=%v", server.mode.compatibilityMode, constraintForeignKeyNames, foreignKeyNames) + } + for name := range foreignKeyNames { + if _, ok := constraintForeignKeyNames[name]; !ok { + t.Fatalf("foreign-key %q missing from list_constraints in %s mode", name, server.mode.compatibilityMode) + } + } + if server.mode.compatibilityMode == "oracle" { + mustExecute(t, server, "ALTER TABLE "+childTable+" DISABLE CONSTRAINT child_code_check") + disabled, err := server.listConstraints(schema, child) + if err != nil { + t.Fatalf("list disabled constraints failed in %s mode: %v", server.mode.compatibilityMode, err) + } + var disabledCheck constraintInfo + found := false + for _, constraint := range disabled { + if constraint.Name == "child_code_check" { + disabledCheck = constraint + found = true + break + } + } + if !found || disabledCheck.Enabled { + t.Fatalf("disabled constraint state mismatch in %s mode: %+v", server.mode.compatibilityMode, disabledCheck) + } + mustExecute(t, server, "ALTER TABLE "+childTable+" ENABLE CONSTRAINT child_code_check") + } + parentConstraints, err := server.listConstraints(schema, parent) + if err != nil { + t.Fatalf("list parent constraints failed in %s mode: %v", server.mode.compatibilityMode, err) + } + if len(parentConstraints) != 1 || parentConstraints[0].Name != "parent_pk" || !equalStringSlices(parentConstraints[0].Columns, []string{"a", "b"}) { + t.Fatalf("composite primary key order mismatch in %s mode: %+v", server.mode.compatibilityMode, parentConstraints) + } +} + +func equalStringSlices(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + func TestKingbaseCustomTypesIntegration(t *testing.T) { host := os.Getenv("KINGBASE_TEST_HOST") portText := os.Getenv("KINGBASE_TEST_PORT") diff --git a/agents/drivers/kingbase-go/kingbase_metadata.go b/agents/drivers/kingbase-go/kingbase_metadata.go index e64c327aa8..c3b04326f2 100644 --- a/agents/drivers/kingbase-go/kingbase_metadata.go +++ b/agents/drivers/kingbase-go/kingbase_metadata.go @@ -130,6 +130,23 @@ type foreignKeyInfo struct { RefColumn string `json:"ref_column"` } +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"` +} + type triggerInfo struct { Name string `json:"name"` Event string `json:"event"` @@ -1453,8 +1470,12 @@ func (s *server) queryInformationSchemaColumns(schema, table string, primary map if err := rows.Scan(&name, &dataType, &fullDataType, &nullable, &defaultValue, &comment, &precision, &scale, &length); err != nil { return nil, err } - if parsed := boundedVarcharLength(dataType); parsed != nil && !length.Valid { - length = sql.NullInt64{Int64: int64(*parsed), Valid: true} + if !length.Valid || length.Int64 < 0 { + if parsed := boundedVarcharLength(dataType); parsed != nil { + length = sql.NullInt64{Int64: int64(*parsed), Valid: true} + } else if parsed := boundedVarcharLength(fullDataType.String); parsed != nil { + length = sql.NullInt64{Int64: int64(*parsed), Valid: true} + } } result = append(result, columnInfo{Name: name, DataType: resolvedInformationSchemaDataType(dataType, fullDataType.String), ResolvedSchema: stringPtr(schema), FullDataType: fullDataType.String, IsNullable: strings.EqualFold(nullable, "YES"), ColumnDefault: nullStringPtr(defaultValue), IsPrimaryKey: primary[strings.ToLower(name)], Comment: nullStringPtr(comment), NumericPrecision: nullIntPtr(precision), NumericScale: nullIntPtr(scale), CharacterMaximumLength: nullIntPtr(length)}) } @@ -1600,7 +1621,7 @@ func parseCatalogAttributeNumbers(raw any) ([]int, error) { default: value = fmt.Sprint(typed) } - value = strings.TrimSpace(strings.Trim(value, "{}")) + value = strings.TrimSpace(strings.Trim(value, "{}[]")) if value == "" { return []int{}, nil } @@ -1761,6 +1782,248 @@ WHERE c.contype = 'f' AND n.nspname = %s AND t.relname = %s ORDER BY c.conname`, return result, nil } +func kingbaseConstraintFunctionName(catalog string) string { + if catalog == "pg_catalog" { + return "pg_get_constraintdef" + } + return "sys_get_constraintdef" +} + +func kingbaseConstraintsQuery(catalog, prefix, schema, table string, definitionUnsupported, validatedUnsupported, statusUnsupported bool) string { + definitionExpression := fmt.Sprintf("COALESCE(%s(c.oid, true), '')", kingbaseCatalogFunction(catalog, "sys_get_constraintdef", "pg_get_constraintdef")) + validExpression := "COALESCE(CAST(c.convalidated AS text), 'T')" + statusExpression := "COALESCE(CAST(c.constatus AS text), 'E')" + if definitionUnsupported { + definitionExpression = "''" + } + if validatedUnsupported { + validExpression = "true" + } + if statusUnsupported { + statusExpression = "'E'" + } + return fmt.Sprintf(`SELECT COALESCE(c.conname, ''), c.contype::text, %s, c.conkey, + rn.nspname, rt.relname, c.confkey, c.confmatchtype::text, c.confupdtype::text, c.confdeltype::text, + c.condeferrable, c.condeferred, %s, %s +FROM %s.%s_constraint c +JOIN %s.%s_class t ON t.oid = c.conrelid +JOIN %s.%s_namespace n ON n.oid = t.relnamespace +LEFT JOIN %s.%s_class rt ON rt.oid = c.confrelid +LEFT JOIN %s.%s_namespace rn ON rn.oid = rt.relnamespace +WHERE n.nspname = %s AND t.relname = %s AND t.relkind IN ('r', 'p', 'f') +ORDER BY COALESCE(c.conname, '')`, definitionExpression, validExpression, statusExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table)) +} + +func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) { + effective, err := s.effectiveSchema(schema) + if err != nil { + return nil, err + } + catalog, prefix := "sys_catalog", "sys" + if s.mode.postgresCatalog { + catalog, prefix = "pg_catalog", "pg" + } + definitionUnsupported := s.constraintDefinitionUnsupported + validatedUnsupported := s.mode.legacyV7 || s.constraintValidatedUnsupported + statusUnsupported := s.mode.legacyV7 || s.constraintStatusUnsupported + var rows *sql.Rows + for { + query := kingbaseConstraintsQuery(catalog, prefix, effective, table, definitionUnsupported, validatedUnsupported, statusUnsupported) + rows, err = s.metadataQuery(query) + if err == nil { + break + } + changed := false + if !definitionUnsupported && isUndefinedFunction(err, kingbaseConstraintFunctionName(catalog)) { + definitionUnsupported = true + s.constraintDefinitionUnsupported = true + changed = true + } + if !validatedUnsupported && isUndefinedColumn(err, "convalidated") { + validatedUnsupported = true + s.constraintValidatedUnsupported = true + changed = true + } + if !statusUnsupported && isUndefinedColumn(err, "constatus") { + statusUnsupported = true + s.constraintStatusUnsupported = true + changed = true + } + if !changed { + return nil, err + } + } + defer rows.Close() + + type rawConstraint struct { + name, kind, definition string + columns, refColumns []int + refSchema, refTable sql.NullString + matchType, onUpdate, onDelete sql.NullString + deferrable, initiallyDeferred bool + valid, enabled bool + } + raw := []rawConstraint{} + for rows.Next() { + var item rawConstraint + var columnsRaw, refColumnsRaw, validRaw, statusRaw any + if err := rows.Scan(&item.name, &item.kind, &item.definition, &columnsRaw, &item.refSchema, &item.refTable, &refColumnsRaw, &item.matchType, &item.onUpdate, &item.onDelete, &item.deferrable, &item.initiallyDeferred, &validRaw, &statusRaw); err != nil { + return nil, err + } + item.valid = parseConstraintEnabled(validRaw) + item.enabled = parseConstraintEnabled(statusRaw) + item.columns, err = parseCatalogAttributeNumbers(columnsRaw) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint %s columns: %w", item.name, err) + } + item.refColumns, err = parseCatalogAttributeNumbers(refColumnsRaw) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint %s referenced columns: %w", item.name, err) + } + raw = append(raw, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + attributes, err := s.relationAttributesByNumber(catalog, prefix, effective, table) + if err != nil { + return nil, err + } + refAttributes := map[string]map[int]string{} + result := make([]constraintInfo, 0, len(raw)) + for _, item := range raw { + constraint := constraintInfo{ + Name: item.name, ConstraintType: kingbaseConstraintTypeName(item.kind), Definition: item.definition, + Columns: []string{}, RefColumns: []string{}, Deferrable: item.deferrable, + // FK details are retained for API completeness and future unified + // constraint/FK presentations. The current UI renders FK rows through + // list_foreign_keys, so these fields are not currently displayed in the + // Constraints tab. + InitiallyDeferred: item.initiallyDeferred, Enabled: item.enabled, Valid: item.valid, + } + for _, number := range item.columns { + if name := attributes[number]; name != "" { + constraint.Columns = append(constraint.Columns, name) + } + } + if item.refSchema.Valid { + constraint.RefSchema = stringPtr(item.refSchema.String) + } + if item.refTable.Valid { + constraint.RefTable = stringPtr(item.refTable.String) + } + if strings.EqualFold(strings.TrimSpace(item.kind), "f") && item.refSchema.Valid && item.refTable.Valid { + key := item.refSchema.String + "\x00" + item.refTable.String + ref := refAttributes[key] + if ref == nil { + ref, err = s.relationAttributesByNumber(catalog, prefix, item.refSchema.String, item.refTable.String) + if err != nil { + return nil, err + } + refAttributes[key] = ref + } + for _, number := range item.refColumns { + if name := ref[number]; name != "" { + constraint.RefColumns = append(constraint.RefColumns, name) + } + } + constraint.MatchType = kingbaseConstraintMatchType(item.matchType) + constraint.OnUpdate = kingbaseConstraintAction(item.onUpdate) + constraint.OnDelete = kingbaseConstraintAction(item.onDelete) + } + result = append(result, constraint) + } + return result, nil +} + +func parseConstraintEnabled(raw any) bool { + if raw == nil { + return true + } + if enabled, ok := raw.(bool); ok { + return enabled + } + if bytes, ok := raw.([]byte); ok { + raw = string(bytes) + } + + value := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw))) + if value == "" { + return true + } + switch value { + case "1", "t", "true", "y", "yes", "e", "enabled", "enable", "on": + return true + case "0", "f", "false", "d", "disabled", "disable", "off": + return false + default: + // Unknown catalog states should not make the whole metadata request fail. + return true + } +} + +func kingbaseConstraintTypeName(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "p": + return "PRIMARY KEY" + case "f": + return "FOREIGN KEY" + case "u": + return "UNIQUE" + case "c": + return "CHECK" + case "t": + return "CONSTRAINT TRIGGER" + case "x": + return "EXCLUDE" + case "n": + return "NOT NULL" + default: + return strings.TrimSpace(value) + } +} + +func kingbaseConstraintMatchType(value sql.NullString) *string { + if !value.Valid { + return nil + } + var result string + switch strings.ToLower(strings.TrimSpace(value.String)) { + case "f": + result = "FULL" + case "p": + result = "PARTIAL" + case "s": + result = "SIMPLE" + default: + return nil + } + return &result +} + +func kingbaseConstraintAction(value sql.NullString) *string { + if !value.Valid { + return nil + } + var result string + switch strings.ToLower(strings.TrimSpace(value.String)) { + case "a": + result = "NO ACTION" + case "r": + result = "RESTRICT" + case "c": + result = "CASCADE" + case "n": + result = "SET NULL" + case "d": + result = "SET DEFAULT" + default: + return nil + } + return &result +} + func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { effective, err := s.effectiveSchema(schema) if err != nil { @@ -2315,12 +2578,15 @@ func decodeTriggerTiming(triggerType int) string { func boundedVarcharLength(dataType string) *int { lower := strings.ToLower(strings.TrimSpace(dataType)) - for _, prefix := range []string{"varchar", "character varying"} { + for _, prefix := range []string{"character varying", "varchar", "bpchar", "character", "char"} { if strings.HasPrefix(lower, prefix) { value := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(lower, prefix), ")")) value = strings.TrimPrefix(value, "(") - if number, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && number >= 0 { - return &number + fields := strings.Fields(value) + if len(fields) > 0 { + if number, err := strconv.Atoi(fields[0]); err == nil && number >= 0 { + return &number + } } } } diff --git a/agents/drivers/kingbase-go/main.go b/agents/drivers/kingbase-go/main.go index 47abc6b042..b66a3f15d5 100644 --- a/agents/drivers/kingbase-go/main.go +++ b/agents/drivers/kingbase-go/main.go @@ -129,27 +129,30 @@ type querySession struct { } type server struct { - db *sql.DB - openDatabase kingbaseDBOpener - params connectParams - mode kingbaseMode - usePgDefaultExpression bool - usePgViewDefinition bool - usePgFunctionDefinition bool - useLegacyRoutineDefinition bool - catalogIdentityUnsupported bool - catalogOIDUnsupported bool - infoColumnTypeUnsupported bool - infoUdtNameUnsupported bool - indexOrdinalityUnsupported bool - triggerPrettyUnsupported bool - triggerInternalUnsupported bool - currentSchema string - schemaSet bool - sessions map[string]*querySession - nextSessionID uint64 - activeCancelMu sync.Mutex - activeCancel context.CancelFunc + db *sql.DB + openDatabase kingbaseDBOpener + params connectParams + mode kingbaseMode + usePgDefaultExpression bool + usePgViewDefinition bool + usePgFunctionDefinition bool + useLegacyRoutineDefinition bool + catalogIdentityUnsupported bool + catalogOIDUnsupported bool + infoColumnTypeUnsupported bool + infoUdtNameUnsupported bool + indexOrdinalityUnsupported bool + triggerPrettyUnsupported bool + triggerInternalUnsupported bool + constraintDefinitionUnsupported bool + constraintValidatedUnsupported bool + constraintStatusUnsupported bool + currentSchema string + schemaSet bool + sessions map[string]*querySession + nextSessionID uint64 + activeCancelMu sync.Mutex + activeCancel context.CancelFunc } type agentSession struct { @@ -407,6 +410,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "list_foreign_keys": result, err := s.listForeignKeys(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err + case "list_constraints": + result, err := s.listConstraints(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err case "list_triggers": result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err @@ -477,6 +483,9 @@ func (s *server) connect(cp connectParams) error { s.indexOrdinalityUnsupported = false s.triggerPrettyUnsupported = false s.triggerInternalUnsupported = false + s.constraintDefinitionUnsupported = false + s.constraintValidatedUnsupported = false + s.constraintStatusUnsupported = false return nil } @@ -561,6 +570,9 @@ func (s *server) disconnect() error { s.indexOrdinalityUnsupported = false s.triggerPrettyUnsupported = false s.triggerInternalUnsupported = false + s.constraintDefinitionUnsupported = false + s.constraintValidatedUnsupported = false + s.constraintStatusUnsupported = false s.currentSchema = "" s.schemaSet = false if s.db == nil { diff --git a/agents/drivers/kingbase-go/main_test.go b/agents/drivers/kingbase-go/main_test.go index e41efd7623..dc0d892253 100644 --- a/agents/drivers/kingbase-go/main_test.go +++ b/agents/drivers/kingbase-go/main_test.go @@ -1006,6 +1006,8 @@ func TestParseCatalogAttributeNumbers(t *testing.T) { {name: "int2vector string", raw: "1 2 4", expected: "1,2,4"}, {name: "array string", raw: "{3,5}", expected: "3,5"}, {name: "bytes", raw: []byte("6 7"), expected: "6,7"}, + {name: "bracketed array string", raw: "[8 9]", expected: "8,9"}, + {name: "int16 slice", raw: []int16{10, 11}, expected: "10,11"}, {name: "empty", raw: nil, expected: ""}, {name: "invalid", raw: "1 bad", wantErr: true}, } @@ -1032,6 +1034,32 @@ func TestParseCatalogAttributeNumbers(t *testing.T) { } } +func TestParseConstraintEnabled(t *testing.T) { + for _, test := range []struct { + name string + raw any + want bool + }{ + {name: "boolean true", raw: true, want: true}, + {name: "boolean false", raw: false, want: false}, + {name: "enabled string", raw: "E", want: true}, + {name: "disabled string", raw: "D", want: false}, + {name: "enabled bytes", raw: []byte("enabled"), want: true}, + {name: "disabled bytes", raw: []byte("disabled"), want: false}, + {name: "one", raw: int64(1), want: true}, + {name: "zero", raw: int64(0), want: false}, + {name: "null", raw: nil, want: true}, + {name: "unknown", raw: "future-state", want: true}, + {name: "not validated label defaults enabled", raw: "N", want: true}, + } { + t.Run(test.name, func(t *testing.T) { + if actual := parseConstraintEnabled(test.raw); actual != test.want { + t.Fatalf("parseConstraintEnabled(%#v) = %v, want %v", test.raw, actual, test.want) + } + }) + } +} + func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) { state := &metadataDriverState{query: func(query string) (driver.Rows, error) { switch { @@ -1210,6 +1238,262 @@ func TestListForeignKeysKeepsEmptyInformationSchemaResultOnV8(t *testing.T) { } } +func TestListConstraintsResolvesColumnsAndForeignKeyDetails(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM sys_catalog.sys_constraint c"): + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "convalidated", "constatus"}, + rows: [][]driver.Value{ + {"orders_amount_check", "c", "CHECK (amount > 0)", "{4}", nil, nil, nil, " ", " ", " ", false, false, []byte("f"), []byte("D")}, + {"orders_customer_fkey", "f", "FOREIGN KEY (customer_id, customer_region) REFERENCES customers(id, region) ON DELETE CASCADE", "{2,3}", "PUBLIC", "customers", "{1,2}", "s", "a", "c", true, true, "t", "E"}, + }, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}, {int64(4), "amount"}}}, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil + default: + return nil, errors.New("unexpected query: " + query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 2 { + t.Fatalf("unexpected constraints: %#v", constraints) + } + check, foreignKey := constraints[0], constraints[1] + if check.ConstraintType != "CHECK" || !equalStringSlices(check.Columns, []string{"amount"}) || check.Valid || check.Enabled { + t.Fatalf("unexpected check constraint: %#v", check) + } + if foreignKey.ConstraintType != "FOREIGN KEY" || !foreignKey.Valid || !foreignKey.Enabled || !equalStringSlices(foreignKey.Columns, []string{"customer_id", "customer_region"}) || !equalStringSlices(foreignKey.RefColumns, []string{"id", "region"}) { + t.Fatalf("unexpected foreign key columns: %#v", foreignKey) + } + if foreignKey.RefSchema == nil || *foreignKey.RefSchema != "PUBLIC" || foreignKey.RefTable == nil || *foreignKey.RefTable != "customers" || foreignKey.MatchType == nil || *foreignKey.MatchType != "SIMPLE" || foreignKey.OnUpdate == nil || *foreignKey.OnUpdate != "NO ACTION" || foreignKey.OnDelete == nil || *foreignKey.OnDelete != "CASCADE" || !foreignKey.Deferrable || !foreignKey.InitiallyDeferred { + t.Fatalf("unexpected foreign key details: %#v", foreignKey) + } + queries := strings.Join(state.snapshotQueries(), "\n") + if !strings.Contains(queries, "sys_catalog.sys_get_constraintdef") { + t.Fatalf("constraints must use the active catalog deparser: %s", queries) + } +} + +func TestKingbaseConstraintQueryHasLegacyV7Fallback(t *testing.T) { + modern := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", false, false, false) + if !strings.Contains(modern, "sys_catalog.sys_get_constraintdef") || !strings.Contains(modern, "c.convalidated") || !strings.Contains(modern, "COALESCE(c.conname, '')") { + t.Fatalf("modern constraint query missing metadata fields: %s", modern) + } + if !strings.Contains(modern, "COALESCE(CAST(c.convalidated AS text), 'T')") { + t.Fatalf("modern constraint query must normalize convalidated to text: %s", modern) + } + if !strings.Contains(modern, "COALESCE(CAST(c.constatus AS text), 'E')") { + t.Fatalf("modern constraint query must normalize constatus to text: %s", modern) + } + legacy := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", true, true, true) + if strings.Contains(legacy, "sys_get_constraintdef") || strings.Contains(legacy, "c.convalidated") || !strings.Contains(legacy, "''") || !strings.Contains(legacy, "COALESCE(c.conname, '')") { + t.Fatalf("legacy V7 constraint query is not safe: %s", legacy) + } +} + +func TestListConstraintsFallsBackWhenValidatedColumnIsUnsupported(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "c.convalidated") { + return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.convalidated does not exist"} + } + if strings.Contains(query, "FROM sys_catalog.sys_constraint c") { + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "{1}", nil, nil, nil, nil, nil, nil, false, false, true, true}}, + }, nil + } + if strings.Contains(query, "SELECT a.attnum, a.attname") { + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil + } + return nil, errors.New("unexpected query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Valid { + t.Fatalf("unexpected fallback constraints: %#v", constraints) + } + if !server.constraintValidatedUnsupported { + t.Fatal("validated-column fallback was not cached") + } + if len(state.snapshotQueries()) != 3 { + t.Fatalf("expected failed modern query, fallback query and attribute query: %v", state.snapshotQueries()) + } + + if _, err := server.listConstraints("PUBLIC", "orders"); err != nil { + t.Fatal(err) + } + queries := state.snapshotQueries() + for _, query := range queries[3:] { + if strings.Contains(query, "c.convalidated") { + t.Fatalf("cached fallback queried unsupported convalidated column: %s", query) + } + } +} + +func TestListConstraintsLegacyV7KeepsStructuralMetadata(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM sys_catalog.sys_constraint c"): + if strings.Contains(query, "c.convalidated") { + return nil, errors.New("V7 query used unsupported constraint metadata: " + query) + } + if strings.Contains(query, "sys_get_constraintdef") { + return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_constraintdef does not exist"} + } + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{{"orders_pkey", "p", "", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, true}}, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil + default: + return nil, errors.New("unexpected query: " + query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + server.mode.legacyV7 = true + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 1 || constraints[0].Name != "orders_pkey" || constraints[0].Definition != "" || !constraints[0].Valid || !equalStringSlices(constraints[0].Columns, []string{"id"}) || !server.constraintDefinitionUnsupported { + t.Fatalf("unexpected V7 constraints: %#v", constraints) + } +} + +func TestListConstraintsLegacyV7UsesSupportedDeparser(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM sys_catalog.sys_constraint c"): + if !strings.Contains(query, "sys_catalog.sys_get_constraintdef") || strings.Contains(query, "COALESCE(CAST(c.convalidated AS text)") || strings.Contains(query, "c.constatus") { + return nil, errors.New("unexpected V7 constraint query: " + query) + } + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, "E"}}, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil + default: + return nil, errors.New("unexpected query: " + query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + server.mode.legacyV7 = true + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Enabled || server.constraintDefinitionUnsupported { + t.Fatalf("unexpected V7 constraints: %#v", constraints) + } +} + +func TestKingbaseConstraintQueryUsesPostgresCatalog(t *testing.T) { + query := kingbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", false, false, false) + if !strings.Contains(query, "pg_catalog.pg_get_constraintdef") || strings.Contains(query, "sys_get_constraintdef") { + t.Fatalf("PostgreSQL catalog query used the wrong deparser: %s", query) + } + if !strings.Contains(query, "FROM pg_catalog.pg_constraint") || !strings.Contains(query, "pg_catalog.pg_namespace") { + t.Fatalf("PostgreSQL catalog query used the wrong catalog tables: %s", query) + } +} + +func TestListConstraintsHandlesNullAttributeVectors(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM sys_catalog.sys_constraint c"): + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{{"table_check", "c", "CHECK (true)", nil, nil, nil, nil, nil, nil, nil, false, false, true, true}}, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname"): + return &valueRows{columns: []string{"attnum", "attname"}}, nil + default: + return nil, errors.New("unexpected query: " + query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 1 || len(constraints[0].Columns) != 0 || len(constraints[0].RefColumns) != 0 { + t.Fatalf("NULL attribute vectors should decode as empty lists: %#v", constraints) + } +} + +func TestListConstraintsCachesReferencedAttributes(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM sys_catalog.sys_constraint c"): + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{ + {"orders_customer_fkey", "f", "FOREIGN KEY (customer_id) REFERENCES customers(id)", "{2}", "PUBLIC", "customers", "{1}", "s", "a", "a", false, false, true, true}, + {"orders_region_fkey", "f", "FOREIGN KEY (customer_region) REFERENCES customers(region)", "{3}", "PUBLIC", "customers", "{2}", "s", "a", "a", false, false, true, true}, + }, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}}}, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil + default: + return nil, errors.New("unexpected query: " + query) + } + }} + server := newServer() + server.db = openMetadataDB(t, state) + + constraints, err := server.listConstraints("PUBLIC", "orders") + if err != nil || len(constraints) != 2 { + t.Fatalf("unexpected constraints: %v %#v", err, constraints) + } + var referencedAttributeQueries int + for _, query := range state.snapshotQueries() { + if strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'") { + referencedAttributeQueries++ + } + } + if referencedAttributeQueries != 1 { + t.Fatalf("referenced relation attributes were not cached: %v", state.snapshotQueries()) + } +} + +func TestKingbaseConstraintLabels(t *testing.T) { + for input, expected := range map[string]string{"p": "PRIMARY KEY", "f": "FOREIGN KEY", "u": "UNIQUE", "c": "CHECK", "t": "CONSTRAINT TRIGGER", "x": "EXCLUDE", "n": "NOT NULL", "custom": "custom"} { + if actual := kingbaseConstraintTypeName(input); actual != expected { + t.Fatalf("type %q: expected %q, got %q", input, expected, actual) + } + } + for input, expected := range map[string]string{"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"} { + actual := kingbaseConstraintAction(sql.NullString{String: input, Valid: true}) + if actual == nil || *actual != expected { + t.Fatalf("action %q: expected %q, got %v", input, expected, actual) + } + } +} + func TestKingbaseV7VersionPattern(t *testing.T) { for _, test := range []struct { version string @@ -3322,6 +3606,32 @@ func TestConnectionLifecycleResetsCatalogOIDCapability(t *testing.T) { } } +func TestConnectionLifecycleResetsConstraintCapabilityCache(t *testing.T) { + state := &connectionAttemptState{pingErrors: map[string]error{}} + server := newServer() + server.openDatabase = state.open + server.constraintDefinitionUnsupported = true + server.constraintValidatedUnsupported = true + server.constraintStatusUnsupported = true + + if err := server.connect(connectParams{MySQLCompatMode: true, URLParams: "sslmode=disable"}); err != nil { + t.Fatal(err) + } + if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported { + t.Fatal("connect must reset cached constraint capabilities") + } + + server.constraintDefinitionUnsupported = true + server.constraintValidatedUnsupported = true + server.constraintStatusUnsupported = true + if err := server.disconnect(); err != nil { + t.Fatal(err) + } + if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported { + t.Fatal("disconnect must reset cached constraint capabilities") + } +} + func TestAppendDDLStatementEnsuresSingleTerminator(t *testing.T) { got := appendDDLStatement("CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n)\n", "CREATE INDEX orders_id_idx ON public.orders (id)") want := "CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n);\n\nCREATE INDEX orders_id_idx ON public.orders (id);" diff --git a/agents/drivers/vastbase-go/README.md b/agents/drivers/vastbase-go/README.md index 19bebbd3c5..781302ec86 100644 --- a/agents/drivers/vastbase-go/README.md +++ b/agents/drivers/vastbase-go/README.md @@ -29,8 +29,34 @@ Set `VASTBASE_TEST_HOST`, `VASTBASE_TEST_PORT`, `VASTBASE_TEST_DATABASE`, `VASTBASE_TEST_USERNAME`, and `VASTBASE_TEST_PASSWORD`, then run: ```bash -go test -run '^TestVastbaseIntegration$' -count=1 ./... +go test -count=1 ./... ``` +`TestVastbaseConstraintsIntegration` validates structured primary-key, +foreign-key, unique, check, deferrability, validation, enabled state, and +deparser metadata. To qualify multiple real database compatibility modes, +create one test database per mode and pass all names as a comma-separated list: + +```bash +VASTBASE_TEST_DATABASES=vastbase_a,vastbase_b,vastbase_pg go test -run '^TestVastbaseConstraintsIntegration$' -count=1 +``` + +The agent reads each database's `pg_catalog.pg_database.datcompatibility` value +and reports both a normalized `compatibilityMode` (`oracle`, `mysql`, +`postgres`, or `sqlserver`) and the server's original +`compatibilityModeRaw` value. `B`, `M`, and `MYSQL` use MySQL identifier rules. +Catalog availability and SQL Server identity metadata are probed separately +from the normalized mode. Vastbase `A` is normalized to Oracle compatibility, +but the tested A release rejects `DISABLE CONSTRAINT`, so that DDL assertion is +only enabled for server values that explicitly report `O`, `ORA`, or `ORACLE`. For a full compatibility +qualification, run the complete suite separately against A (Oracle), B +(MySQL), PG (PostgreSQL), and MSSQL (SQL Server) instances. Supply credentials +through the `VASTBASE_TEST_*` environment variables; do not commit them. + +MySQL-compatible instances intentionally skip the PostgreSQL-only SQL-function +fixture and `search_path` visibility assertion, while still running the table, +column, index, view, transaction, paging, cancellation, and structured +constraint coverage. + The benchmark harness under `bench/` compares this native agent with the Vastbase JDBC 2.11v and 2.15v agents. diff --git a/agents/drivers/vastbase-go/driver.go b/agents/drivers/vastbase-go/driver.go index 1ab3179c8e..e05b1f5f61 100644 --- a/agents/drivers/vastbase-go/driver.go +++ b/agents/drivers/vastbase-go/driver.go @@ -1,9 +1,11 @@ package main import ( + "context" "database/sql" "net/url" "strings" + "time" _ "gitcode.com/opengauss/openGauss-connector-go-pq" ) @@ -29,11 +31,79 @@ func agentDataTypes() []string { return vastbaseDataTypes } -func detectAgentMode(_ *sql.DB, configuredMySQL bool) vastbaseMode { - if configuredMySQL { - return vastbaseMode{compatibilityMode: "mysql", mysqlCompat: true, postgresCatalog: true} +type vastbaseCompatibility struct { + mode string + raw string + mysqlCompat bool + sqlServer bool + supportsDisableConstraint bool +} + +func normalizeVastbaseCompatibility(raw string) vastbaseCompatibility { + normalizedRaw := strings.ToUpper(strings.TrimSpace(raw)) + compact := strings.NewReplacer("_", "", "-", "", " ", "").Replace(normalizedRaw) + result := vastbaseCompatibility{mode: "postgres", raw: normalizedRaw} + switch compact { + case "A": + result.mode = "oracle" + case "O", "ORA", "ORACLE": + result.mode = "oracle" + result.supportsDisableConstraint = true + case "B", "M", "MYSQL": + result.mode = "mysql" + result.mysqlCompat = true + case "MSSQL", "SQLSERVER": + result.mode = "sqlserver" + result.sqlServer = true + case "", "P", "PG", "POSTGRES", "POSTGRESQL": + result.mode = "postgres" + default: + result.mode = strings.ToLower(strings.TrimSpace(raw)) } - return vastbaseMode{compatibilityMode: "postgres", postgresCatalog: true} + return result +} + +func detectAgentMode(db *sql.DB, configuredMySQL bool) vastbaseMode { + raw := "PG" + if db != nil { + var detected string + if err := db.QueryRow("SELECT datcompatibility FROM pg_catalog.pg_database WHERE datname = current_database()").Scan(&detected); err == nil && strings.TrimSpace(detected) != "" { + raw = detected + } + } + compatibility := normalizeVastbaseCompatibility(raw) + if configuredMySQL { + compatibility = normalizeVastbaseCompatibility("MYSQL") + } + mode := vastbaseMode{ + compatibilityMode: compatibility.mode, + compatibilityModeRaw: compatibility.raw, + mysqlCompat: compatibility.mysqlCompat, + sqlServerIdentity: compatibility.sqlServer, + supportsDisableConstraint: compatibility.supportsDisableConstraint, + } + if db == nil { + mode.postgresCatalog = true + return mode + } + postgresCatalog := catalogExists(db, "pg_catalog.pg_namespace") + systemCatalog := catalogExists(db, "sys_catalog.sys_namespace") + mode.postgresCatalog = postgresCatalog || !systemCatalog + return mode +} + +func catalogExists(db *sql.DB, catalog string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, "SELECT 1 FROM "+catalog+" WHERE 1 = 0") + if err != nil { + return false + } + return rows.Close() == nil +} + +func vastbaseSupportsDisableConstraint(mode vastbaseMode) bool { + return mode.supportsDisableConstraint } func agentSSLModeAttempts(sslMode string) []string { diff --git a/agents/drivers/vastbase-go/integration_test.go b/agents/drivers/vastbase-go/integration_test.go index 4589f5e149..348ec31b16 100644 --- a/agents/drivers/vastbase-go/integration_test.go +++ b/agents/drivers/vastbase-go/integration_test.go @@ -65,17 +65,21 @@ func TestVastbaseIntegration(t *testing.T) { mustExecute(t, server, "CREATE TABLE public."+quoteIdentifier(child)+" (id integer PRIMARY KEY, parent_id integer REFERENCES public."+quoteIdentifier(parent)+"(id))") mustExecute(t, server, "CREATE INDEX "+quoteIdentifier(child+"_parent_idx")+" ON public."+quoteIdentifier(child)+"(parent_id)") mustExecute(t, server, "CREATE VIEW public."+quoteIdentifier(view)+" AS SELECT id, name FROM public."+quoteIdentifier(parent)) - mustExecute(t, server, "CREATE FUNCTION public."+quoteIdentifier(function)+"() RETURNS text AS $$ SELECT 'dbx'; $$ LANGUAGE SQL") + if !server.mode.mysqlCompat { + mustExecute(t, server, "CREATE FUNCTION public."+quoteIdentifier(function)+"() RETURNS text AS $$ SELECT 'dbx'; $$ LANGUAGE SQL") + } mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchFirst)) mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchSecond)) mustExecute(t, server, "CREATE TABLE "+quoteIdentifier(searchSecond)+"."+quoteIdentifier(searchTable)+" (id integer PRIMARY KEY, name varchar(64))") - mustExecute(t, server, "SET search_path TO "+quoteIdentifier(searchFirst)+", "+quoteIdentifier(searchSecond)) - visibleColumns, err := server.getColumns("", searchTable) - if err != nil || len(visibleColumns) != 2 || visibleColumns[0].ResolvedSchema == nil || *visibleColumns[0].ResolvedSchema != searchSecond { - t.Fatalf("unqualified metadata did not resolve the non-first search_path schema: columns=%#v err=%v", visibleColumns, err) + if !server.mode.mysqlCompat { + mustExecute(t, server, "SET search_path TO "+quoteIdentifier(searchFirst)+", "+quoteIdentifier(searchSecond)) + visibleColumns, err := server.getColumns("", searchTable) + if err != nil || len(visibleColumns) != 2 || visibleColumns[0].ResolvedSchema == nil || *visibleColumns[0].ResolvedSchema != searchSecond { + t.Fatalf("unqualified metadata did not resolve the non-first search_path schema: columns=%#v err=%v", visibleColumns, err) + } + mustExecute(t, server, "INSERT INTO "+quoteIdentifier(searchTable)+" VALUES (1, 'visible')") + mustExecute(t, server, "SET search_path TO public") } - mustExecute(t, server, "INSERT INTO "+quoteIdentifier(searchTable)+" VALUES (1, 'visible')") - mustExecute(t, server, "SET search_path TO public") tables, err := server.listTables("public", metadataListConstraints{Filter: suffix}) if err != nil || len(tables) < 3 { @@ -111,9 +115,11 @@ func TestVastbaseIntegration(t *testing.T) { if err != nil || len(foreignKeys) != 1 || foreignKeys[0].RefTable != parent { t.Fatalf("list foreign keys failed: keys=%v err=%v", foreignKeys, err) } - source, err := server.getObjectSource("public", function, "FUNCTION") - if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) { - t.Fatalf("get function source failed: source=%v err=%v", source, err) + if !server.mode.mysqlCompat { + source, err := server.getObjectSource("public", function, "FUNCTION") + if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) { + t.Fatalf("get function source failed: source=%v err=%v", source, err) + } } transactionParams := map[string]json.RawMessage{ @@ -155,6 +161,126 @@ func TestVastbaseIntegration(t *testing.T) { } } +func TestVastbaseConstraintsIntegration(t *testing.T) { + host := os.Getenv("VASTBASE_TEST_HOST") + portText := os.Getenv("VASTBASE_TEST_PORT") + username := os.Getenv("VASTBASE_TEST_USERNAME") + password := os.Getenv("VASTBASE_TEST_PASSWORD") + if host == "" || portText == "" || username == "" || password == "" { + t.Skip("Vastbase integration environment is not configured") + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatal(err) + } + databases := []string{os.Getenv("VASTBASE_TEST_DATABASE")} + if databases[0] == "" { + databases[0] = "postgres" + } + if raw := os.Getenv("VASTBASE_TEST_DATABASES"); raw != "" { + databases = strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) + } + for _, database := range databases { + database = strings.TrimSpace(database) + if database == "" { + continue + } + t.Run(database, func(t *testing.T) { + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + schema := "dbx_constraints_" + suffix + parent := schema + ".parent" + child := schema + ".child" + server := newServer() + cp := connectParams{ + Host: host, Port: port, Database: database, Username: username, Password: password, + ConnectionString: fmt.Sprintf("jdbc:vastbase://%s:%d/%s", host, port, database), + } + if err := server.connect(cp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.disconnect() }) + t.Cleanup(func() { _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schema + " CASCADE"}) }) + expectedModes := map[string]string{"A": "oracle", "B": "mysql", "PG": "postgres", "MSSQL": "sqlserver"} + if expected, ok := expectedModes[server.mode.compatibilityModeRaw]; ok && server.mode.compatibilityMode != expected { + t.Fatalf("compatibility mode %q normalized to %q, want %q", server.mode.compatibilityModeRaw, server.mode.compatibilityMode, expected) + } + + mustExecute(t, server, "CREATE SCHEMA "+schema) + mustExecute(t, server, "CREATE TABLE "+parent+" (id integer PRIMARY KEY, region integer, CONSTRAINT parent_pair_unique UNIQUE (id, region))") + childID := "id integer PRIMARY KEY" + if server.mode.sqlServerIdentity { + childID = "id integer IDENTITY(10,2) PRIMARY KEY" + } + mustExecute(t, server, "CREATE TABLE "+child+" ("+childID+", parent_id integer, parent_region integer, amount integer, CONSTRAINT child_parent_fk FOREIGN KEY (parent_id, parent_region) REFERENCES "+parent+" (id, region) DEFERRABLE INITIALLY DEFERRED, CONSTRAINT child_amount_check CHECK (amount > 0))") + + constraints, err := server.listConstraints(schema, "child") + if err != nil { + t.Fatalf("list constraints failed in %s mode: %v", server.mode.compatibilityMode, err) + } + byName := map[string]constraintInfo{} + for _, constraint := range constraints { + byName[constraint.Name] = constraint + } + fk := byName["child_parent_fk"] + if fk.ConstraintType != "FOREIGN KEY" || !fk.Enabled || !fk.Valid || !fk.Deferrable || !fk.InitiallyDeferred || !equalVastbaseStrings(fk.Columns, []string{"parent_id", "parent_region"}) || !equalVastbaseStrings(fk.RefColumns, []string{"id", "region"}) { + t.Fatalf("foreign key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, fk) + } + check := byName["child_amount_check"] + if check.ConstraintType != "CHECK" || !check.Enabled || !check.Valid || !strings.Contains(check.Definition, "amount") { + t.Fatalf("check metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, check) + } + if server.mode.sqlServerIdentity { + columns, err := server.getColumns(schema, "child") + if err != nil || len(columns) == 0 || columns[0].Extra == nil || *columns[0].Extra != "IDENTITY(10,2)" { + t.Fatalf("SQL Server identity metadata mismatch: columns=%+v err=%v", columns, err) + } + } + foreignKeys, err := server.listForeignKeys(schema, "child") + if err != nil || len(foreignKeys) != 2 { + t.Fatalf("foreign key listing failed in %s mode: keys=%v err=%v", server.mode.compatibilityMode, foreignKeys, err) + } + foreignKeyNames := map[string]struct{}{} + for _, foreignKey := range foreignKeys { + foreignKeyNames[foreignKey.Name] = struct{}{} + } + constraintForeignKeyNames := map[string]struct{}{} + for _, constraint := range constraints { + if constraint.ConstraintType == "FOREIGN KEY" { + constraintForeignKeyNames[constraint.Name] = struct{}{} + } + } + if len(foreignKeyNames) != len(constraintForeignKeyNames) { + t.Fatalf("foreign-key metadata sources disagree in %s mode: constraints=%v foreign_keys=%v", server.mode.compatibilityMode, constraintForeignKeyNames, foreignKeyNames) + } + for name := range foreignKeyNames { + if _, ok := constraintForeignKeyNames[name]; !ok { + t.Fatalf("foreign-key %q missing from list_constraints in %s mode", name, server.mode.compatibilityMode) + } + } + if vastbaseSupportsDisableConstraint(server.mode) { + mustExecute(t, server, "ALTER TABLE "+child+" DISABLE CONSTRAINT child_amount_check") + disabled, err := server.listConstraints(schema, "child") + if err != nil { + t.Fatalf("list disabled constraints failed in %s mode: %v", server.mode.compatibilityMode, err) + } + var disabledCheck constraintInfo + found := false + for _, constraint := range disabled { + if constraint.Name == "child_amount_check" { + disabledCheck = constraint + found = true + break + } + } + if !found || disabledCheck.Enabled { + t.Fatalf("disabled constraint state mismatch in %s mode: %+v", server.mode.compatibilityMode, disabledCheck) + } + mustExecute(t, server, "ALTER TABLE "+child+" ENABLE CONSTRAINT child_amount_check") + } + }) + } +} + func TestVastbaseCustomTypesIntegration(t *testing.T) { host := os.Getenv("VASTBASE_TEST_HOST") portText := os.Getenv("VASTBASE_TEST_PORT") diff --git a/agents/drivers/vastbase-go/main.go b/agents/drivers/vastbase-go/main.go index 980ae5379c..1fe5c23587 100644 --- a/agents/drivers/vastbase-go/main.go +++ b/agents/drivers/vastbase-go/main.go @@ -136,24 +136,27 @@ type rowScanner struct { } type server struct { - db *sql.DB - openDatabase agentDBOpener - params connectParams - mode vastbaseMode - usePgDefaultExpression bool - catalogIdentityUnsupported bool - infoColumnTypeUnsupported bool - infoUdtNameUnsupported bool - listTablesStatement *sql.Stmt - connectionRuntime *connectionRuntime - sessionAffinity bool - currentSchema string - schemaInitialized bool - schemaConnectionID uintptr - sessions map[string]*querySession - nextSessionID uint64 - activeCancelMu sync.Mutex - activeCancel context.CancelFunc + db *sql.DB + openDatabase agentDBOpener + params connectParams + mode vastbaseMode + usePgDefaultExpression bool + catalogIdentityUnsupported bool + infoColumnTypeUnsupported bool + infoUdtNameUnsupported bool + constraintDefinitionUnsupported bool + constraintValidatedUnsupported bool + constraintEnabledUnsupported bool + listTablesStatement *sql.Stmt + connectionRuntime *connectionRuntime + sessionAffinity bool + currentSchema string + schemaInitialized bool + schemaConnectionID uintptr + sessions map[string]*querySession + nextSessionID uint64 + activeCancelMu sync.Mutex + activeCancel context.CancelFunc } type agentSession struct { @@ -433,6 +436,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "list_foreign_keys": result, err := s.listForeignKeys(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err + case "list_constraints": + result, err := s.listConstraints(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err case "list_triggers": result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table")) return result, false, err @@ -489,6 +495,9 @@ func (s *server) connect(cp connectParams) error { s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false + s.constraintDefinitionUnsupported = false + s.constraintValidatedUnsupported = false + s.constraintEnabledUnsupported = false s.sessionAffinity = false return nil } @@ -505,11 +514,14 @@ func (s *server) connectWithRuntime(cp connectParams, connectionRuntime *connect s.db = db s.connectionRuntime = connectionRuntime s.params = cp - s.mode = detectAgentMode(db, cp.MySQLCompatMode) + s.mode = detectAgentMode(connectionRuntime.database(), cp.MySQLCompatMode) s.usePgDefaultExpression = false s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false + s.constraintDefinitionUnsupported = false + s.constraintValidatedUnsupported = false + s.constraintEnabledUnsupported = false s.sessionAffinity = false return nil } @@ -584,6 +596,9 @@ func (s *server) disconnect() error { s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false + s.constraintDefinitionUnsupported = false + s.constraintValidatedUnsupported = false + s.constraintEnabledUnsupported = false s.connectionRuntime = nil s.sessionAffinity = false s.resetSchemaCache() diff --git a/agents/drivers/vastbase-go/main_test.go b/agents/drivers/vastbase-go/main_test.go index 4603be7530..675953b3ae 100644 --- a/agents/drivers/vastbase-go/main_test.go +++ b/agents/drivers/vastbase-go/main_test.go @@ -149,6 +149,34 @@ func TestVastbaseMetadataErrorClassificationUsesOpenGaussCodes(t *testing.T) { t.Fatal("undefined Vastbase function was not recognized") } } +func TestVastbaseCompatibilityNormalization(t *testing.T) { + for _, test := range []struct { + raw string + mode string + mysql bool + sqlServer bool + disable bool + }{ + {raw: "A", mode: "oracle"}, + {raw: "O", mode: "oracle", disable: true}, + {raw: "ORA", mode: "oracle", disable: true}, + {raw: "ORACLE", mode: "oracle", disable: true}, + {raw: "B", mode: "mysql", mysql: true}, + {raw: "M", mode: "mysql", mysql: true}, + {raw: "MYSQL", mode: "mysql", mysql: true}, + {raw: "PG", mode: "postgres"}, + {raw: "POSTGRESQL", mode: "postgres"}, + {raw: "MSSQL", mode: "sqlserver", sqlServer: true}, + {raw: "SQL_SERVER", mode: "sqlserver", sqlServer: true}, + } { + t.Run(test.raw, func(t *testing.T) { + actual := normalizeVastbaseCompatibility(test.raw) + if actual.mode != test.mode || actual.mysqlCompat != test.mysql || actual.sqlServer != test.sqlServer || actual.supportsDisableConstraint != test.disable { + t.Fatalf("normalizeVastbaseCompatibility(%q) = %+v", test.raw, actual) + } + }) + } +} func TestVastbaseModeUsesPostgresCatalog(t *testing.T) { mode := detectAgentMode(nil, false) @@ -159,6 +187,34 @@ func TestVastbaseModeUsesPostgresCatalog(t *testing.T) { if mysqlMode.compatibilityMode != "mysql" || !mysqlMode.postgresCatalog || !mysqlMode.mysqlCompat { t.Fatalf("unexpected MySQL-compatible Vastbase mode: %+v", mysqlMode) } + for _, compatibility := range []string{"M", "B", "MYSQL"} { + resolved := vastbaseMode{compatibilityMode: strings.ToLower(compatibility), postgresCatalog: true} + resolved.mysqlCompat = resolved.compatibilityMode == "m" || resolved.compatibilityMode == "b" || resolved.compatibilityMode == "mysql" + if !resolved.mysqlCompat { + t.Fatalf("compatibility %q must use MySQL identifier rules: %+v", compatibility, resolved) + } + } +} + +func TestVastbaseDisableConstraintMode(t *testing.T) { + for _, mode := range []struct { + raw string + want bool + }{ + {raw: "A", want: false}, + {raw: "O", want: true}, + {raw: "ORA", want: true}, + {raw: "oracle", want: true}, + {raw: "B", want: false}, + {raw: "M", want: false}, + {raw: "mysql", want: false}, + {raw: "C", want: false}, + } { + compatibility := normalizeVastbaseCompatibility(mode.raw) + if actual := vastbaseSupportsDisableConstraint(vastbaseMode{supportsDisableConstraint: compatibility.supportsDisableConstraint}); actual != mode.want { + t.Fatalf("vastbaseSupportsDisableConstraint(%q) = %v, want %v", mode.raw, actual, mode.want) + } + } } func TestSchemaConnectionRecoversAfterCanceledDriverConnection(t *testing.T) { @@ -222,6 +278,18 @@ func TestDisconnectResetsInformationSchemaCapabilityCache(t *testing.T) { } } +func TestDisconnectResetsConstraintCapabilityCache(t *testing.T) { + server := newServer() + server.constraintDefinitionUnsupported = true + server.constraintValidatedUnsupported = true + if err := server.disconnect(); err != nil { + t.Fatal(err) + } + if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported { + t.Fatal("disconnect must reset cached constraint capabilities") + } +} + var ( registerVastbaseSchemaRetryDriver sync.Once registerVastbasePingRetryDriver sync.Once diff --git a/agents/drivers/vastbase-go/runtime_pool.go b/agents/drivers/vastbase-go/runtime_pool.go index 70b97aa74e..c0a8a8e46f 100644 --- a/agents/drivers/vastbase-go/runtime_pool.go +++ b/agents/drivers/vastbase-go/runtime_pool.go @@ -175,7 +175,7 @@ func (s *server) metadataDatabase() (*sql.DB, error) { func isMetadataOperation(method string) bool { switch method { case "connection_info", "list_databases", "list_schemas", "list_tables", "get_table_comment", "list_objects", - "list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys", + "list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys", "list_constraints", "list_triggers", "get_object_source", "get_table_ddl", "get_explain_info": return true default: diff --git a/agents/drivers/vastbase-go/runtime_pool_test.go b/agents/drivers/vastbase-go/runtime_pool_test.go index 215b8c636f..a2432e5821 100644 --- a/agents/drivers/vastbase-go/runtime_pool_test.go +++ b/agents/drivers/vastbase-go/runtime_pool_test.go @@ -219,6 +219,25 @@ func (conn *runtimePoolTestConn) Ping(context.Context) error { return nil } +func (*runtimePoolTestConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + return &runtimePoolTestModeRows{}, nil +} + +type runtimePoolTestModeRows struct { + read bool +} + +func (*runtimePoolTestModeRows) Columns() []string { return []string{"datcompatibility"} } +func (*runtimePoolTestModeRows) Close() error { return nil } +func (rows *runtimePoolTestModeRows) Next(destination []driver.Value) error { + if rows.read { + return io.EOF + } + rows.read = true + destination[0] = "A" + return nil +} + type runtimePoolTestStmt struct { state *runtimePoolTestState } diff --git a/agents/drivers/vastbase-go/vastbase_metadata.go b/agents/drivers/vastbase-go/vastbase_metadata.go index 763f5f9010..15a7943252 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata.go +++ b/agents/drivers/vastbase-go/vastbase_metadata.go @@ -28,10 +28,12 @@ var postgresDataTypes = []string{ } type vastbaseMode struct { - compatibilityMode string - postgresCatalog bool - mysqlCompat bool - sqlServerIdentity bool + compatibilityMode string + compatibilityModeRaw string + postgresCatalog bool + mysqlCompat bool + sqlServerIdentity bool + supportsDisableConstraint bool } type databaseInfo struct { @@ -114,66 +116,29 @@ type foreignKeyInfo struct { RefColumn string `json:"ref_column"` } +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"` +} + type triggerInfo struct { Name string `json:"name"` Event string `json:"event"` Timing string `json:"timing"` } -func detectVastbaseMode(db *sql.DB, configuredMySQL bool) vastbaseMode { - if configuredMySQL { - return vastbaseMode{compatibilityMode: "mysql", mysqlCompat: true} - } - mode := vastbaseMode{compatibilityMode: detectDatabaseMode(db)} - mode.postgresCatalog = !catalogExists(db, "sys_catalog.sys_namespace") && catalogExists(db, "pg_catalog.pg_namespace") - if !mode.postgresCatalog { - if mode.compatibilityMode != "" { - mode.mysqlCompat = mode.compatibilityMode == "mysql" - } else { - mode.mysqlCompat = supportsBacktickIdentifiers(db) - } - mode.sqlServerIdentity = !mode.mysqlCompat && catalogExists(db, "sys.identity_columns") - } - return mode -} - -func catalogExists(db *sql.DB, catalog string) bool { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - rows, err := db.QueryContext(ctx, "SELECT 1 FROM "+catalog+" WHERE 1 = 0") - if err != nil { - return false - } - return rows.Close() == nil -} - -func detectMySQLCompatMode(db *sql.DB) bool { - if databaseMode := detectDatabaseMode(db); databaseMode != "" { - return databaseMode == "mysql" - } - return supportsBacktickIdentifiers(db) -} - -func detectDatabaseMode(db *sql.DB) string { - var databaseMode string - switch err := db.QueryRow("SELECT setting FROM sys_catalog.sys_settings WHERE LOWER(name) = 'database_mode'").Scan(&databaseMode); { - case err == nil: - // Treat database_mode as authoritative when the server exposes it. This - // avoids misclassifying Oracle-compatible servers that also publish - // sql_mode for MySQL syntax toggles such as ANSI_QUOTES. - return strings.ToLower(strings.TrimSpace(databaseMode)) - case errors.Is(err, sql.ErrNoRows): - return "" - default: - return "" - } -} - -func supportsBacktickIdentifiers(db *sql.DB) bool { - var value int - return db.QueryRow("SELECT 1 AS `dbx_identifier_probe`").Scan(&value) == nil -} - func (s *server) identifierQuote() string { // Vastbase MySQL compatibility mode follows MySQL identifier quoting; // other modes retain the PostgreSQL-compatible double quote. @@ -195,8 +160,9 @@ func (s *server) connectionInfo() (map[string]any, error) { } return map[string]any{ "database": database, "username": username, "version": version, "schema": schema, - "compatibilityMode": s.mode.compatibilityMode, "mysql_compat_mode": s.mode.mysqlCompat, - "identifierQuote": s.identifierQuote(), + "compatibilityMode": s.mode.compatibilityMode, "compatibilityModeRaw": s.mode.compatibilityModeRaw, + "mysql_compat_mode": s.mode.mysqlCompat, + "identifierQuote": s.identifierQuote(), "databaseInfo": map[string]string{ "productName": "Vastbase", "productVersion": version, @@ -1350,13 +1316,17 @@ func (s *server) queryInformationSchemaColumns(schema, table string, primary map default: fullDataTypeExpression = "NULL AS column_type" } + catalog, prefix := "sys_catalog", "sys" + if s.mode.postgresCatalog { + catalog, prefix = "pg_catalog", "pg" + } query := fmt.Sprintf(`SELECT c.column_name, c.data_type, %s, c.is_nullable, c.column_default, col_description(a.attrelid, a.attnum), c.numeric_precision, c.numeric_scale, c.character_maximum_length FROM information_schema.columns c - LEFT JOIN sys_catalog.sys_namespace n ON n.nspname = c.table_schema - LEFT JOIN sys_catalog.sys_class rel ON rel.relnamespace = n.oid AND rel.relname = c.table_name - LEFT JOIN sys_catalog.sys_attribute a ON a.attrelid = rel.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped - WHERE c.table_schema = %s AND c.table_name = %s ORDER BY c.ordinal_position`, fullDataTypeExpression, quoteLiteral(schema), quoteLiteral(table)) + LEFT JOIN %s.%s_namespace n ON n.nspname = c.table_schema + LEFT JOIN %s.%s_class rel ON rel.relnamespace = n.oid AND rel.relname = c.table_name + LEFT JOIN %s.%s_attribute a ON a.attrelid = rel.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped + WHERE c.table_schema = %s AND c.table_name = %s ORDER BY c.ordinal_position`, fullDataTypeExpression, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table)) rows, err := s.metadataQuery(query) if err != nil { return nil, err @@ -1518,6 +1488,314 @@ WHERE tc.table_schema = ` + quoteLiteral(effective) + ` AND tc.table_name = ` + return result, rows.Err() } +func vastbaseConstraintFunctionName(catalog string) string { + if catalog == "pg_catalog" { + return "pg_get_constraintdef" + } + return "sys_get_constraintdef" +} + +func vastbaseConstraintsQuery(catalog, prefix, schema, table string, definitionUnsupported, validatedUnsupported, enabledUnsupported bool) string { + definitionExpression := fmt.Sprintf("COALESCE(%s(c.oid, true), '')", vastbaseCatalogFunction(catalog, "sys_get_constraintdef", "pg_get_constraintdef")) + validExpression := "COALESCE(CAST(c.convalidated AS text), 'T')" + enabledExpression := "COALESCE(CAST(c.conenable AS text), 'T')" + if definitionUnsupported { + definitionExpression = "''" + } + if validatedUnsupported { + validExpression = "'T'" + } + if enabledUnsupported { + enabledExpression = "'T'" + } + return fmt.Sprintf(`SELECT COALESCE(c.conname, ''), CAST(c.contype AS text), %s, +CAST(c.conkey AS text), rn.nspname, rt.relname, CAST(c.confkey AS text), +CAST(c.confmatchtype AS text), CAST(c.confupdtype AS text), CAST(c.confdeltype AS text), +c.condeferrable, c.condeferred, %s, %s +FROM %s.%s_constraint c +JOIN %s.%s_class t ON t.oid = c.conrelid +JOIN %s.%s_namespace n ON n.oid = t.relnamespace +LEFT JOIN %s.%s_class rt ON rt.oid = c.confrelid +LEFT JOIN %s.%s_namespace rn ON rn.oid = rt.relnamespace +WHERE n.nspname = %s AND t.relname = %s AND t.relkind IN ('r', 'p', 'f') +ORDER BY COALESCE(c.conname, '')`, definitionExpression, validExpression, enabledExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table)) +} + +func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) { + effective, err := s.effectiveSchema(schema) + if err != nil { + return nil, err + } + catalog, prefix := "sys_catalog", "sys" + if s.mode.postgresCatalog { + catalog, prefix = "pg_catalog", "pg" + } + effective, table, err = s.resolveConstraintRelation(catalog, prefix, effective, table) + if err != nil { + return nil, err + } + definitionUnsupported := s.constraintDefinitionUnsupported + validatedUnsupported := s.constraintValidatedUnsupported + enabledUnsupported := s.constraintEnabledUnsupported + var rows *sql.Rows + for { + rows, err = s.metadataQuery(vastbaseConstraintsQuery(catalog, prefix, effective, table, definitionUnsupported, validatedUnsupported, enabledUnsupported)) + if err == nil { + break + } + changed := false + if !definitionUnsupported && isUndefinedFunction(err, vastbaseConstraintFunctionName(catalog)) { + definitionUnsupported = true + s.constraintDefinitionUnsupported = true + changed = true + } + if !validatedUnsupported && isUndefinedColumn(err, "convalidated") { + validatedUnsupported = true + s.constraintValidatedUnsupported = true + changed = true + } + if !enabledUnsupported && isUndefinedColumn(err, "conenable") { + enabledUnsupported = true + s.constraintEnabledUnsupported = true + changed = true + } + if !changed { + return nil, err + } + } + defer rows.Close() + + type rawConstraint struct { + name, kind, definition string + columnNumbers, refColumnNumbers []int + refSchema, refTable sql.NullString + matchType, onUpdate, onDelete sql.NullString + deferrable, initiallyDeferred, valid, enabled bool + } + raw := []rawConstraint{} + for rows.Next() { + var item rawConstraint + var columnsRaw, refColumnsRaw sql.NullString + var validRaw, enabledRaw any + if err := rows.Scan(&item.name, &item.kind, &item.definition, &columnsRaw, &item.refSchema, &item.refTable, &refColumnsRaw, &item.matchType, &item.onUpdate, &item.onDelete, &item.deferrable, &item.initiallyDeferred, &validRaw, &enabledRaw); err != nil { + return nil, err + } + item.columnNumbers, err = parseVastbaseConstraintAttributeNumbers(columnsRaw.String) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint %s columns: %w", item.name, err) + } + item.refColumnNumbers, err = parseVastbaseConstraintAttributeNumbers(refColumnsRaw.String) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint %s referenced columns: %w", item.name, err) + } + item.valid = parseVastbaseConstraintValid(validRaw) + item.enabled = parseVastbaseConstraintEnabled(enabledRaw) + raw = append(raw, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + attributes, err := s.relationAttributesByNumber(catalog, prefix, effective, table) + if err != nil { + return nil, err + } + refAttributes := map[string]map[int]string{} + result := make([]constraintInfo, 0, len(raw)) + for _, item := range raw { + constraint := constraintInfo{ + Name: item.name, ConstraintType: vastbaseConstraintTypeName(item.kind), Definition: item.definition, + Columns: []string{}, RefColumns: []string{}, Deferrable: item.deferrable, + InitiallyDeferred: item.initiallyDeferred, Enabled: item.enabled, Valid: item.valid, + } + for _, number := range item.columnNumbers { + if name := attributes[number]; name != "" { + constraint.Columns = append(constraint.Columns, name) + } + } + if item.refSchema.Valid { + constraint.RefSchema = stringPtr(item.refSchema.String) + } + if item.refTable.Valid { + constraint.RefTable = stringPtr(item.refTable.String) + } + if strings.EqualFold(strings.TrimSpace(item.kind), "f") && item.refSchema.Valid && item.refTable.Valid { + key := item.refSchema.String + "\x00" + item.refTable.String + ref := refAttributes[key] + if ref == nil { + ref, err = s.relationAttributesByNumber(catalog, prefix, item.refSchema.String, item.refTable.String) + if err != nil { + return nil, err + } + refAttributes[key] = ref + } + for _, number := range item.refColumnNumbers { + if name := ref[number]; name != "" { + constraint.RefColumns = append(constraint.RefColumns, name) + } + } + constraint.MatchType = vastbaseConstraintMatchType(item.matchType) + constraint.OnUpdate = vastbaseConstraintAction(item.onUpdate) + constraint.OnDelete = vastbaseConstraintAction(item.onDelete) + } + result = append(result, constraint) + } + return result, nil +} + +func (s *server) resolveConstraintRelation(catalog, prefix, schema, table string) (string, string, error) { + query := fmt.Sprintf(`SELECT n.nspname, c.relname +FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid = c.relnamespace +WHERE LOWER(n.nspname) = LOWER(%s) AND LOWER(c.relname) = LOWER(%s) +AND c.relkind IN ('r', 'p', 'f') +ORDER BY CASE WHEN n.nspname = %s AND c.relname = %s THEN 0 ELSE 1 END`, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table), quoteLiteral(schema), quoteLiteral(table)) + rows, err := s.metadataQuery(query) + if err != nil { + return "", "", err + } + defer rows.Close() + type relationName struct{ schema, table string } + matches := []relationName{} + for rows.Next() { + var match relationName + if err := rows.Scan(&match.schema, &match.table); err != nil { + return "", "", err + } + matches = append(matches, match) + } + if err := rows.Err(); err != nil { + return "", "", err + } + if len(matches) == 0 { + return "", "", fmt.Errorf("Vastbase relation not found: %s.%s", schema, table) + } + if matches[0].schema == schema && matches[0].table == table { + return schema, table, nil + } + if len(matches) > 1 { + return "", "", fmt.Errorf("ambiguous Vastbase relation name %s.%s under case-insensitive matching", schema, table) + } + return matches[0].schema, matches[0].table, nil +} + +func (s *server) relationAttributesByNumber(catalog, prefix, schema, table string) (map[int]string, error) { + query := fmt.Sprintf(`SELECT a.attnum, a.attname +FROM %s.%s_attribute a JOIN %s.%s_class c ON c.oid = a.attrelid +JOIN %s.%s_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = %s AND c.relname = %s AND a.attnum > 0 AND NOT a.attisdropped`, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table)) + rows, err := s.metadataQuery(query) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[int]string{} + for rows.Next() { + var number int + var name string + if err := rows.Scan(&number, &name); err != nil { + return nil, err + } + result[number] = name + } + return result, rows.Err() +} + +func parseVastbaseConstraintAttributeNumbers(raw string) ([]int, error) { + value := strings.TrimSpace(strings.Trim(raw, "{}[]")) + if value == "" { + return []int{}, nil + } + parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) + result := make([]int, 0, len(parts)) + for _, part := range parts { + number, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil { + return nil, fmt.Errorf("invalid attribute number %q", part) + } + result = append(result, number) + } + return result, nil +} + +func parseVastbaseConstraintValue(raw any) string { + if raw == nil { + return "" + } + if bytes, ok := raw.([]byte); ok { + return strings.ToLower(strings.TrimSpace(string(bytes))) + } + return strings.ToLower(strings.TrimSpace(fmt.Sprint(raw))) +} + +func parseVastbaseConstraintValid(raw any) bool { + if value, ok := raw.(bool); ok { + return value + } + switch parseVastbaseConstraintValue(raw) { + case "0", "f", "false", "n", "no", "not validated", "not_validated", "invalid": + return false + default: + return true + } +} + +func parseVastbaseConstraintEnabled(raw any) bool { + if value, ok := raw.(bool); ok { + return value + } + switch parseVastbaseConstraintValue(raw) { + case "0", "f", "false", "d", "disabled", "disable", "off": + return false + default: + return true + } +} + +func vastbaseConstraintTypeName(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "p": + return "PRIMARY KEY" + case "f": + return "FOREIGN KEY" + case "u": + return "UNIQUE" + case "c": + return "CHECK" + case "t": + return "CONSTRAINT TRIGGER" + case "x": + return "EXCLUDE" + case "n": + return "NOT NULL" + default: + return strings.TrimSpace(value) + } +} + +func vastbaseConstraintMatchType(value sql.NullString) *string { + if !value.Valid { + return nil + } + labels := map[string]string{"f": "FULL", "p": "PARTIAL", "s": "SIMPLE"} + result, ok := labels[strings.ToLower(strings.TrimSpace(value.String))] + if !ok { + return nil + } + return &result +} + +func vastbaseConstraintAction(value sql.NullString) *string { + if !value.Valid { + return nil + } + labels := map[string]string{"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"} + result, ok := labels[strings.ToLower(strings.TrimSpace(value.String))] + if !ok { + return nil + } + return &result +} + func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { effective, err := s.effectiveSchema(schema) if err != nil { @@ -1791,6 +2069,8 @@ func vastbaseIdentityClause(code string) *string { clause = "GENERATED ALWAYS AS IDENTITY" case "d": clause = "GENERATED BY DEFAULT AS IDENTITY" + case "m": + clause = "IDENTITY" default: return nil } @@ -1869,6 +2149,10 @@ WHERE tc.table_schema=` + quoteLiteral(schema) + ` AND tc.table_name=` + quoteLi } func (s *server) applyIdentityMetadata(schema, table string, columns []columnInfo) { + if s.mode.postgresCatalog { + s.applyPostgresIdentityMetadata(schema, table, columns) + return + } query := `SELECT a.attname, ic.seed_value, ic.increment_value FROM sys.identity_columns ic JOIN sys_catalog.sys_class c ON c.oid=ic.object_id JOIN sys_catalog.sys_namespace n ON n.oid=c.relnamespace JOIN sys_catalog.sys_attribute a ON a.attrelid=c.oid AND a.attnum=ic.column_id @@ -1898,6 +2182,52 @@ WHERE n.nspname=` + quoteLiteral(schema) + ` AND c.relname=` + quoteLiteral(tabl } } +func (s *server) applyPostgresIdentityMetadata(schema, table string, columns []columnInfo) { + query := `SELECT a.attname, pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname), a.attidentity +FROM pg_catalog.pg_attribute a +JOIN pg_catalog.pg_class c ON c.oid = a.attrelid +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = ` + quoteLiteral(schema) + ` AND c.relname = ` + quoteLiteral(table) + ` AND a.attidentity <> ''` + rows, err := s.metadataQuery(query) + if err != nil { + return + } + type identityColumn struct { + name, sequence, identity string + } + identityColumns := []identityColumn{} + for rows.Next() { + var item identityColumn + if rows.Scan(&item.name, &item.sequence, &item.identity) == nil { + identityColumns = append(identityColumns, item) + } + } + _ = rows.Close() + byName := map[string]*columnInfo{} + for i := range columns { + byName[strings.ToLower(columns[i].Name)] = &columns[i] + } + for _, item := range identityColumns { + column := byName[strings.ToLower(item.name)] + if column == nil { + continue + } + extra := vastbaseIdentityClause(item.identity) + if extra == nil { + extra = stringPtr("IDENTITY") + } + if item.sequence != "" { + var start, increment sql.NullInt64 + sequenceQuery := "SELECT start_value, increment_by FROM " + quoteCatalogIdentifier(item.sequence) + if err := s.requireDBQueryRow(sequenceQuery, &start, &increment); err == nil && start.Valid && increment.Valid { + value := fmt.Sprintf("IDENTITY(%d,%d)", start.Int64, increment.Int64) + extra = &value + } + } + column.Extra = extra + } +} + func catalogPrefix(catalog string) string { if catalog == "pg_catalog" { return "pg" diff --git a/agents/drivers/vastbase-go/vastbase_metadata_test.go b/agents/drivers/vastbase-go/vastbase_metadata_test.go index c670893727..3fbb4953a1 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata_test.go +++ b/agents/drivers/vastbase-go/vastbase_metadata_test.go @@ -197,6 +197,156 @@ func openVastbaseCustomTypesDB(t *testing.T, state *vastbaseCustomTypesTestState return db } +func TestVastbaseConstraintStatusParsing(t *testing.T) { + for _, test := range []struct { + name string + raw any + valid bool + enabled bool + }{ + {name: "boolean true", raw: true, valid: true, enabled: true}, + {name: "boolean false", raw: false, valid: false, enabled: false}, + {name: "text true", raw: "t", valid: true, enabled: true}, + {name: "text false", raw: "f", valid: false, enabled: false}, + {name: "one", raw: "1", valid: true, enabled: true}, + {name: "zero", raw: "0", valid: false, enabled: false}, + {name: "oracle enabled", raw: "E", valid: true, enabled: true}, + {name: "oracle disabled", raw: "D", valid: true, enabled: false}, + {name: "enabled label", raw: "enabled", valid: true, enabled: true}, + {name: "disabled label", raw: "disabled", valid: true, enabled: false}, + {name: "not validated", raw: "N", valid: false, enabled: true}, + {name: "no", raw: "no", valid: false, enabled: true}, + {name: "bytes false", raw: []byte("false"), valid: false, enabled: false}, + {name: "null defaults true", raw: nil, valid: true, enabled: true}, + {name: "unknown defaults true", raw: "future-state", valid: true, enabled: true}, + } { + t.Run(test.name, func(t *testing.T) { + if actual := parseVastbaseConstraintValid(test.raw); actual != test.valid { + t.Fatalf("parseVastbaseConstraintValid(%#v) = %v, want %v", test.raw, actual, test.valid) + } + if actual := parseVastbaseConstraintEnabled(test.raw); actual != test.enabled { + t.Fatalf("parseVastbaseConstraintEnabled(%#v) = %v, want %v", test.raw, actual, test.enabled) + } + }) + } +} + +func TestVastbaseConstraintAttributeNumbersAreStrict(t *testing.T) { + for _, raw := range []string{"1 2", "{3,2}", "[4, 5]", ""} { + if _, err := parseVastbaseConstraintAttributeNumbers(raw); err != nil { + t.Fatalf("valid vector %q failed: %v", raw, err) + } + } + if _, err := parseVastbaseConstraintAttributeNumbers("1 bad 3"); err == nil { + t.Fatal("invalid constraint vector must return an error") + } +} + +func TestVastbaseConstraintQueryNormalizesCatalogCapabilities(t *testing.T) { + query := vastbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", false, false, false) + for _, expected := range []string{ + "pg_catalog.pg_get_constraintdef", + "COALESCE(CAST(c.convalidated AS text), 'T')", + "CAST(c.conkey AS text)", + "CAST(c.confkey AS text)", + } { + if !strings.Contains(query, expected) { + t.Fatalf("constraint query missing %q: %s", expected, query) + } + } + fallback := vastbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", true, true, true) + if strings.Contains(fallback, "pg_get_constraintdef") || strings.Contains(fallback, "convalidated") || strings.Contains(fallback, "conenable") { + t.Fatalf("fallback constraint query still contains unsupported fields: %s", fallback) + } +} + +func TestVastbaseConstraintRelationResolvesFoldedNames(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, "LOWER(n.nspname) = LOWER('app')") || !strings.Contains(query, "LOWER(c.relname) = LOWER('orders')") { + return nil, fmt.Errorf("unexpected query: %s", query) + } + return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"APP", "ORDERS"}}}, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + schema, table, err := server.resolveConstraintRelation("pg_catalog", "pg", "app", "orders") + if err != nil { + t.Fatal(err) + } + if schema != "APP" || table != "ORDERS" { + t.Fatalf("folded relation was not resolved: %s.%s", schema, table) + } +} + +func TestVastbaseConstraintRelationRejectsAmbiguousFoldedNames(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(string) (driver.Rows, error) { + return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"APP", "ORDERS"}, {"App", "Orders"}}}, nil + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + + if _, _, err := server.resolveConstraintRelation("pg_catalog", "pg", "app", "orders"); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("ambiguous folded relation must fail, got %v", err) + } +} + +func TestVastbaseListConstraintsResolvesCatalogVectors(t *testing.T) { + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + switch { + case strings.Contains(query, "LOWER(n.nspname) = LOWER('public')") && strings.Contains(query, "LOWER(c.relname) = LOWER('orders')"): + return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"public", "orders"}}}, nil + case strings.Contains(query, "FROM pg_catalog.pg_constraint c"): + return &valueRows{ + columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"}, + rows: [][]driver.Value{ + {"orders_check", "c", "CHECK (amount > 0)", "{4}", nil, nil, nil, nil, nil, nil, false, false, []byte("t"), []byte("D")}, + {"orders_customer_fkey", "f", "FOREIGN KEY (customer_id, region_id) REFERENCES customers(id, region_id)", "{2,3}", "public", "customers", "{1,2}", "s", "a", "c", true, true, "f", true}, + }, + }, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "region_id"}, {int64(4), "amount"}}}, nil + case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"): + return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region_id"}}}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + server.mode.postgresCatalog = true + + constraints, err := server.listConstraints("public", "orders") + if err != nil { + t.Fatal(err) + } + if len(constraints) != 2 { + t.Fatalf("unexpected constraints: %#v", constraints) + } + check, fk := constraints[0], constraints[1] + if check.ConstraintType != "CHECK" || !check.Valid || check.Enabled || !equalVastbaseStrings(check.Columns, []string{"amount"}) { + t.Fatalf("unexpected check constraint: %#v", check) + } + if fk.ConstraintType != "FOREIGN KEY" || fk.Valid || !fk.Enabled || !fk.Deferrable || !fk.InitiallyDeferred || !equalVastbaseStrings(fk.Columns, []string{"customer_id", "region_id"}) || !equalVastbaseStrings(fk.RefColumns, []string{"id", "region_id"}) { + t.Fatalf("unexpected foreign key constraint: %#v", fk) + } + if fk.MatchType == nil || *fk.MatchType != "SIMPLE" || fk.OnUpdate == nil || *fk.OnUpdate != "NO ACTION" || fk.OnDelete == nil || *fk.OnDelete != "CASCADE" { + t.Fatalf("unexpected foreign key actions: %#v", fk) + } +} + +func equalVastbaseStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + func TestVastbaseGetColumnsResolvesVisibleRelationSchemaInCatalogQuery(t *testing.T) { queries := []string{} state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 4add0d6613..8904499da8 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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([]); const triggersLoaded = ref(false); const triggersLoading = ref(false); const triggersError = ref(""); +const constraints = ref([]); +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() { +
+
+ +
+
+ {{ constraintsError }} +
+
+ {{ t("grid.tableInfoNoResults") }} +
+
+ {{ t("grid.tableInfoEmpty") }} +
+
+
+
+ {{ constraint.name }} + {{ constraint.constraint_type }} + {{ t("grid.tableInfoConstraintDisabled") }} + {{ t("grid.tableInfoConstraintNotValidated") }} +
+
{{ constraint.columns.join(", ") }}
+
-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}
+
{{ constraint.definition }}
+
+
+
+
([]);
 const tableTriggersLoading = ref(false);
 const tableTriggersLoaded = ref(false);
+const tableConstraints = ref([]);
+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(null);
 const SIDE_PANEL_MIN_WIDTH = 280;
@@ -977,6 +985,9 @@ const tableInfoTabs = computed(() => {
   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[] {
               
             
           
+          
+
+ +
+
+ {{ t("grid.tableInfoNoResults") }} +
+
+ {{ t("grid.tableInfoEmpty") }} +
+
+
+
+ {{ constraint.name }} + {{ constraint.constraint_type }} + {{ t("grid.tableInfoConstraintDisabled") }} + {{ t("grid.tableInfoConstraintNotValidated") }} +
+
{{ 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/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index ccb835709e..70207902af 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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>>({}); const columns = ref([]); const copyColumnsDialogOpen = ref(false); const copySourceTables = ref([]); @@ -244,6 +246,9 @@ const sqliteSchemaRevision = ref(); const foreignKeys = ref([]); const constraints = ref([]); 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([]); 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 }; + 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]) => { {{ t("common.loading") }}
+
+ {{ secondaryMetadataErrors.indexes }} +
@@ -4486,6 +4517,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ t("common.loading") }} +
+ {{ secondaryMetadataErrors["foreign-keys"] }} +
{{ t("structureEditor.emptyReadonly") }}
@@ -4536,11 +4570,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ t("common.loading") }} -
+
+ {{ secondaryMetadataErrors.constraints }} +
+
{{ t("structureEditor.emptyReadonly") }}
-
+
{{ constraint.name }} {{ constraint.constraint_type }} @@ -4559,6 +4596,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ t("common.loading") }}
+
+ {{ secondaryMetadataErrors.triggers }} +
{{ t("structureEditor.emptyReadonly") }}
diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts index 12a304e7cf..4de968e8b5 100644 --- a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.constraints.spec.ts @@ -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)"); + }); }); diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index c6e0e0e399..c99ec4b2e5 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -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(); + return { ...actual, invalidateObjectMetadataCache: vi.fn() }; +}); + function installLocalStorage() { const data = new Map(); 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("app"), schema: "public", sql }); + const activeConnection = ref(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("app"), sql }); diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index dd7baf954a..1743cd6294 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -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); } } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0f593d57e7..94899fd9ba 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a454c85225..483cb25ff2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3e8db1de4f..d5b4984e97 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index dc274808b7..d2b810dede 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1950,6 +1950,9 @@ export default withEnglishFallback({ columnRegularIndex: "通常インデックス", tableInfoForeignKeys: "外部キー", foreignKeyNavigate: "{table} へ移動", + tableInfoConstraints: "制約", + tableInfoConstraintDisabled: "無効", + tableInfoConstraintNotValidated: "未検証", tableInfoTriggers: "トリガー", tableInfoNullable: "NULL許容", tableInfoEmpty: "メタデータなし", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2100ca811e..64ebd65ef3 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1908,6 +1908,9 @@ export default withEnglishFallback({ columnRegularIndex: "일반 인덱스", tableInfoForeignKeys: "외래 키", foreignKeyNavigate: "{table}(으)로 이동", + tableInfoConstraints: "제약 조건", + tableInfoConstraintDisabled: "비활성화됨", + tableInfoConstraintNotValidated: "검증 안 됨", tableInfoTriggers: "트리거", tableInfoNullable: "NULL 허용", tableInfoEmpty: "메타데이터 없음", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3ac09794ed..edb1bc46bf 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d6c4f1da2a..7ded26586b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2001,6 +2001,9 @@ export default withEnglishFallback({ columnRegularIndex: "普通索引", tableInfoForeignKeys: "外键", foreignKeyNavigate: "跳转到 {table}", + tableInfoConstraints: "约束", + tableInfoConstraintDisabled: "已禁用", + tableInfoConstraintNotValidated: "未验证", tableInfoTriggers: "触发器", tableInfoNullable: "可空", tableInfoEmpty: "暂无元数据", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 15df2a6fae..a7e27e79a8 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1938,6 +1938,9 @@ export default withEnglishFallback({ columnRegularIndex: "一般索引", tableInfoForeignKeys: "外鍵", foreignKeyNavigate: "跳轉到 {table}", + tableInfoConstraints: "約束", + tableInfoConstraintDisabled: "已停用", + tableInfoConstraintNotValidated: "未驗證", tableInfoTriggers: "觸發器", tableInfoNullable: "可為空", tableInfoEmpty: "無", diff --git a/apps/desktop/src/lib/table/__tests__/constraintPresentation.spec.ts b/apps/desktop/src/lib/table/__tests__/constraintPresentation.spec.ts new file mode 100644 index 0000000000..352259ae53 --- /dev/null +++ b/apps/desktop/src/lib/table/__tests__/constraintPresentation.spec.ts @@ -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([]); + }); +}); diff --git a/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts b/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts index 89327826d3..cd5bad9e34 100644 --- a/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts +++ b/apps/desktop/src/lib/table/__tests__/tableMetadataCapabilities.spec.ts @@ -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); }); diff --git a/apps/desktop/src/lib/table/constraintPresentation.ts b/apps/desktop/src/lib/table/constraintPresentation.ts new file mode 100644 index 0000000000..6f78fcba72 --- /dev/null +++ b/apps/desktop/src/lib/table/constraintPresentation.ts @@ -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)); +} diff --git a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts index 2097117000..be1da7af8a 100644 --- a/apps/desktop/src/lib/table/tableMetadataCapabilities.ts +++ b/apps/desktop/src/lib/table/tableMetadataCapabilities.ts @@ -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 &'static str { ORDER BY con.conname" } +/// All constraints on a table (primary key, foreign key, unique, check, +/// exclude, and — on PG 18+ — not-null), as full `ConstraintInfo` records. +/// `pg_constraint.contype` letters are expanded to display labels and the +/// `conkey`/`confkey` attribute numbers are resolved to column names. +/// PostgreSQL has no per-constraint disabled state, so `enabled` is always +/// true; a `NOT VALID` constraint (e.g. an added-but-unvalidated CHECK or +/// FK) reports `valid = false` and surfaces the "Not validated" badge. +pub async fn list_constraints(pool: &Pool, schema: &str, table: &str) -> Result, String> { + let schema = if schema.is_empty() { "public" } else { schema }; + let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let rows = postgres_query_cached(&client, postgres_constraints_sql(), &[&schema, &table]) + .await + .map_err(|e| e.to_string())?; + + Ok(rows + .iter() + .map(|row| ConstraintInfo { + name: pg_row_try_string(row, 0), + constraint_type: postgres_constraint_type_label(&pg_row_try_string(row, 1)), + definition: pg_row_try_string(row, 2), + columns: row.try_get::<_, Vec>(3).unwrap_or_default(), + ref_schema: row.try_get::<_, Option>(4).ok().flatten(), + ref_table: row.try_get::<_, Option>(5).ok().flatten(), + ref_columns: row.try_get::<_, Vec>(6).unwrap_or_default(), + match_type: postgres_constraint_match_type(row.try_get::<_, Option>(7).ok().flatten()), + on_update: postgres_fk_action_label(row.try_get::<_, Option>(8).ok().flatten()), + on_delete: postgres_fk_action_label(row.try_get::<_, Option>(9).ok().flatten()), + deferrable: row.try_get::<_, bool>(10).unwrap_or(false), + initially_deferred: row.try_get::<_, bool>(11).unwrap_or(false), + enabled: true, + valid: row.try_get::<_, bool>(12).unwrap_or(true), + }) + .collect()) +} + +/// OpenGauss-compatible constraint metadata without PostgreSQL's array/LATERAL +/// dependencies. Older OpenGauss releases expose conkey/confkey as catalog +/// vectors but may reject WITH ORDINALITY or decode them differently on the +/// wire, so resolve attribute numbers in Rust. +pub async fn list_opengauss_constraints(pool: &Pool, schema: &str, table: &str) -> Result, String> { + let schema = if schema.is_empty() { "public" } else { schema }; + let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let rows = match postgres_query_cached(&client, opengauss_constraints_sql(true), &[&schema, &table]).await { + Ok(rows) => rows, + Err(error) if is_missing_opengauss_constraint_column(&error, "convalidated") => { + log::debug!( + "[opengauss][constraints] convalidated unavailable; defaulting valid=true: {}", + pg_error_to_string(error) + ); + postgres_query_cached(&client, opengauss_constraints_sql(false), &[&schema, &table]) + .await + .map_err(|e| e.to_string())? + } + Err(error) => return Err(error.to_string()), + }; + if rows.is_empty() { + return Ok(Vec::new()); + } + + let local_relation_oid = + pg_row_try_u32(&rows[0], 13).ok_or("OpenGauss constraint metadata did not return a local relation OID")?; + let local_attributes = opengauss_relation_attributes(&client, local_relation_oid).await?; + let mut referenced_attributes: HashMap> = HashMap::new(); + let mut result = Vec::with_capacity(rows.len()); + + for row in rows { + let name = pg_row_try_string(&row, 0); + let kind = pg_row_try_string(&row, 1); + let column_numbers = parse_opengauss_attribute_numbers(&pg_row_try_string(&row, 3)) + .map_err(|error| format!("failed to parse OpenGauss constraint {name} columns: {error}"))?; + let ref_column_numbers = parse_opengauss_attribute_numbers(&pg_row_try_string(&row, 6)) + .map_err(|error| format!("failed to parse OpenGauss constraint {name} referenced columns: {error}"))?; + let referenced_relation_oid = pg_row_try_u32(&row, 14).filter(|oid| *oid != 0); + let ref_columns = if kind == "f" { + if let Some(oid) = referenced_relation_oid { + if let std::collections::hash_map::Entry::Vacant(entry) = referenced_attributes.entry(oid) { + entry.insert(opengauss_relation_attributes(&client, oid).await?); + } + map_opengauss_attribute_names(&ref_column_numbers, referenced_attributes.get(&oid).unwrap()) + } else { + Vec::new() + } + } else { + Vec::new() + }; + + result.push(ConstraintInfo { + name, + constraint_type: postgres_constraint_type_label(&kind), + definition: pg_row_try_string(&row, 2), + columns: map_opengauss_attribute_names(&column_numbers, &local_attributes), + ref_schema: row.try_get::<_, Option>(4).ok().flatten(), + ref_table: row.try_get::<_, Option>(5).ok().flatten(), + ref_columns, + match_type: postgres_constraint_match_type(pg_row_try_optional_text(&row, 7)), + on_update: postgres_fk_action_label(pg_row_try_optional_text(&row, 8)), + on_delete: postgres_fk_action_label(pg_row_try_optional_text(&row, 9)), + deferrable: pg_row_try_bool(&row, 10).unwrap_or(false), + initially_deferred: pg_row_try_bool(&row, 11).unwrap_or(false), + enabled: true, + valid: pg_row_try_bool(&row, 12).unwrap_or(true), + }); + } + Ok(result) +} + +fn opengauss_constraints_sql(include_validated: bool) -> &'static str { + if include_validated { + "SELECT COALESCE(con.conname, ''), \ + con.contype::text, \ + COALESCE(pg_catalog.pg_get_constraintdef(con.oid, true), ''), \ + COALESCE(con.conkey::text, ''), \ + refn.nspname, refc.relname, COALESCE(con.confkey::text, ''), \ + CASE WHEN con.contype = 'f' THEN con.confmatchtype::text END, \ + CASE WHEN con.contype = 'f' THEN con.confupdtype::text END, \ + CASE WHEN con.contype = 'f' THEN con.confdeltype::text END, \ + con.condeferrable, con.condeferred, con.convalidated, \ + con.conrelid::oid, con.confrelid::oid \ + FROM pg_catalog.pg_constraint con \ + JOIN pg_catalog.pg_class c ON c.oid = con.conrelid \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_class refc ON refc.oid = con.confrelid \ + LEFT JOIN pg_catalog.pg_namespace refn ON refn.oid = refc.relnamespace \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r','p','f') \ + ORDER BY COALESCE(con.conname, '')" + } else { + "SELECT COALESCE(con.conname, ''), \ + con.contype::text, \ + COALESCE(pg_catalog.pg_get_constraintdef(con.oid, true), ''), \ + COALESCE(con.conkey::text, ''), \ + refn.nspname, refc.relname, COALESCE(con.confkey::text, ''), \ + CASE WHEN con.contype = 'f' THEN con.confmatchtype::text END, \ + CASE WHEN con.contype = 'f' THEN con.confupdtype::text END, \ + CASE WHEN con.contype = 'f' THEN con.confdeltype::text END, \ + con.condeferrable, con.condeferred, TRUE, \ + con.conrelid::oid, con.confrelid::oid \ + FROM pg_catalog.pg_constraint con \ + JOIN pg_catalog.pg_class c ON c.oid = con.conrelid \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_class refc ON refc.oid = con.confrelid \ + LEFT JOIN pg_catalog.pg_namespace refn ON refn.oid = refc.relnamespace \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r','p','f') \ + ORDER BY COALESCE(con.conname, '')" + } +} + +async fn opengauss_relation_attributes( + client: &tokio_postgres::Client, + relation_oid: u32, +) -> Result, String> { + let rows = client + .query( + "SELECT a.attnum, a.attname::text FROM pg_catalog.pg_attribute a \ + WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum", + &[&relation_oid], + ) + .await + .map_err(|e| e.to_string())?; + Ok(rows.iter().filter_map(|row| Some((row.try_get::<_, i16>(0).ok()?, pg_row_try_string(row, 1)))).collect()) +} + +fn parse_opengauss_attribute_numbers(raw: &str) -> Result, String> { + let value = raw.trim().trim_matches(|character| matches!(character, '{' | '}' | '[' | ']')); + if value.is_empty() { + return Ok(Vec::new()); + } + value + .split(|character: char| character == ',' || character.is_whitespace()) + .filter(|part| !part.is_empty()) + .map(|part| part.parse::().map_err(|_| format!("invalid attribute number {part:?}"))) + .collect() +} + +fn map_opengauss_attribute_names(numbers: &[i16], attributes: &HashMap) -> Vec { + numbers.iter().filter_map(|number| attributes.get(number).cloned()).collect() +} + +fn pg_row_try_u32(row: &Row, idx: usize) -> Option { + row.try_get::<_, u32>(idx) + .ok() + .or_else(|| row.try_get::<_, i64>(idx).ok().and_then(|value| u32::try_from(value).ok())) + .or_else(|| pg_row_try_string(row, idx).parse::().ok()) +} + +fn pg_row_try_optional_text(row: &Row, idx: usize) -> Option { + if let Ok(value) = row.try_get::<_, Option>(idx) { + return value.filter(|value| !value.is_empty()); + } + row.try_get::<_, PgRawBytes>(idx) + .ok() + .and_then(|value| String::from_utf8(value.0).ok()) + .filter(|value| !value.is_empty()) +} + +fn is_missing_opengauss_constraint_column(error: &tokio_postgres::Error, column: &str) -> bool { + error.as_db_error().is_some_and(|db_error| { + db_error.code().code() == "42703" && db_error.message().to_ascii_lowercase().contains(column) + }) +} + +fn postgres_constraints_sql() -> &'static str { + "SELECT con.conname, \ + con.contype::text, \ + pg_catalog.pg_get_constraintdef(con.oid, true) AS definition, \ + COALESCE(conkey.attnames, ARRAY[]::text[]) AS columns, \ + refn.nspname AS ref_schema, \ + refc.relname AS ref_table, \ + COALESCE(confkey.attnames, ARRAY[]::text[]) AS ref_columns, \ + CASE WHEN con.contype = 'f' THEN con.confmatchtype::text END AS match_type, \ + CASE WHEN con.contype = 'f' THEN con.confupdtype::text END AS on_update, \ + CASE WHEN con.contype = 'f' THEN con.confdeltype::text END AS on_delete, \ + con.condeferrable, \ + con.condeferred, \ + con.convalidated \ + FROM pg_catalog.pg_constraint con \ + JOIN pg_catalog.pg_class c ON c.oid = con.conrelid \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + LEFT JOIN pg_catalog.pg_class refc ON refc.oid = con.confrelid \ + LEFT JOIN pg_catalog.pg_namespace refn ON refn.oid = refc.relnamespace \ + LEFT JOIN LATERAL ( \ + SELECT array_agg(a.attname::text ORDER BY ord.ord) AS attnames \ + FROM unnest(con.conkey) WITH ORDINALITY AS ord(attnum, ord) \ + JOIN pg_catalog.pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ord.attnum AND NOT a.attisdropped \ + ) conkey ON true \ + LEFT JOIN LATERAL ( \ + SELECT array_agg(a.attname::text ORDER BY ord.ord) AS attnames \ + FROM unnest(con.confkey) WITH ORDINALITY AS ord(attnum, ord) \ + JOIN pg_catalog.pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = ord.attnum AND NOT a.attisdropped \ + ) confkey ON true \ + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r','p','f') \ + ORDER BY con.conname" +} + +/// Expand a `pg_constraint.contype` letter to a human-readable label. +fn postgres_constraint_type_label(contype: &str) -> String { + match contype { + "c" => "CHECK".to_string(), + "f" => "FOREIGN KEY".to_string(), + "p" => "PRIMARY KEY".to_string(), + "u" => "UNIQUE".to_string(), + "t" => "CONSTRAINT TRIGGER".to_string(), + "x" => "EXCLUDE".to_string(), + "n" => "NOT NULL".to_string(), + _ => contype.to_string(), + } +} + +/// Normalize a `confupdtype`/`confdeltype` letter to an `information_schema` +/// style referential-action label, mirroring `postgres_foreign_key_action`. +fn postgres_fk_action_label(action: Option) -> Option { + action + .as_deref() + .and_then(|value| match value { + "a" => Some("NO ACTION"), + "r" => Some("RESTRICT"), + "c" => Some("CASCADE"), + "n" => Some("SET NULL"), + "d" => Some("SET DEFAULT"), + _ => None, + }) + .map(str::to_string) +} + +/// Normalize a `confmatchtype` letter to a match-type label. +fn postgres_constraint_match_type(match_type: Option) -> Option { + match_type + .as_deref() + .and_then(|value| match value { + "f" => Some("FULL"), + "p" => Some("PARTIAL"), + "s" | "u" => Some("SIMPLE"), + _ => None, + }) + .map(str::to_string) +} + fn postgres_table_partition_relation_sql() -> &'static str { "SELECT c.relkind::text, \ EXISTS ( \ @@ -10236,6 +10512,82 @@ mod tests { assert!(sql.contains("ORDER BY con.conname")); } + #[test] + fn postgres_constraints_sql_covers_all_contypes_and_referential_fields() { + let sql = postgres_constraints_sql(); + assert!(sql.contains("con.contype::text")); + assert!(sql.contains("pg_catalog.pg_get_constraintdef(con.oid, true)")); + // FK-only referential columns are conditionally selected. + assert!(sql.contains("CASE WHEN con.contype = 'f' THEN con.confmatchtype::text END")); + assert!(sql.contains("CASE WHEN con.contype = 'f' THEN con.confupdtype::text END")); + assert!(sql.contains("CASE WHEN con.contype = 'f' THEN con.confdeltype::text END")); + // conkey/confkey attribute numbers are resolved to names via laterals. + assert!(sql.contains("unnest(con.conkey) WITH ORDINALITY")); + assert!(sql.contains("unnest(con.confkey) WITH ORDINALITY")); + // attname (pg_attribute type `name`) is explicitly cast to text so the + // array decodes as text[] (matching the COALESCE fallback and Vec). + assert!(sql.contains("array_agg(a.attname::text ORDER BY ord.ord)")); + assert_eq!(sql.matches("attname::text").count(), 2); + assert!(sql.contains("a.attisdropped")); + assert!(sql.contains("n.nspname = $1 AND c.relname = $2")); + assert!(sql.contains("c.relkind IN ('r','p','f')")); + assert!(sql.contains("ORDER BY con.conname")); + } + + #[test] + fn opengauss_constraint_sql_avoids_array_and_ordinality_dependencies() { + let sql = opengauss_constraints_sql(true); + assert!(sql.contains("con.conkey::text")); + assert!(sql.contains("con.confkey::text")); + assert!(sql.contains("con.conrelid::oid")); + assert!(sql.contains("con.confrelid::oid")); + assert!(!sql.contains("unnest(")); + assert!(!sql.contains("WITH ORDINALITY")); + assert!(!sql.contains("LATERAL")); + } + + #[test] + fn opengauss_constraint_sql_falls_back_without_convalidated() { + let sql = opengauss_constraints_sql(false); + assert!(sql.contains("TRUE")); + assert!(!sql.contains("con.convalidated")); + } + + #[test] + fn opengauss_constraint_attribute_vectors_are_strict() { + assert_eq!(parse_opengauss_attribute_numbers("1 2 4").unwrap(), vec![1, 2, 4]); + assert_eq!(parse_opengauss_attribute_numbers("{3,2}").unwrap(), vec![3, 2]); + assert_eq!(parse_opengauss_attribute_numbers("[4, 5]").unwrap(), vec![4, 5]); + assert!(parse_opengauss_attribute_numbers("1 bad 3").is_err()); + } + + #[test] + fn postgres_constraint_type_label_expands_contype_letters() { + assert_eq!(postgres_constraint_type_label("p"), "PRIMARY KEY"); + assert_eq!(postgres_constraint_type_label("f"), "FOREIGN KEY"); + assert_eq!(postgres_constraint_type_label("u"), "UNIQUE"); + assert_eq!(postgres_constraint_type_label("c"), "CHECK"); + assert_eq!(postgres_constraint_type_label("x"), "EXCLUDE"); + assert_eq!(postgres_constraint_type_label("n"), "NOT NULL"); + assert_eq!(postgres_constraint_type_label("unknown"), "unknown"); + } + + #[test] + fn postgres_fk_action_label_and_match_type_normalize_letters() { + assert_eq!(postgres_fk_action_label(Some("c".to_string())).as_deref(), Some("CASCADE")); + assert_eq!(postgres_fk_action_label(Some("n".to_string())).as_deref(), Some("SET NULL")); + assert_eq!(postgres_fk_action_label(Some("d".to_string())).as_deref(), Some("SET DEFAULT")); + assert_eq!(postgres_fk_action_label(Some("a".to_string())).as_deref(), Some("NO ACTION")); + assert_eq!(postgres_fk_action_label(Some("r".to_string())).as_deref(), Some("RESTRICT")); + assert_eq!(postgres_fk_action_label(Some("zz".to_string())), None); + assert_eq!(postgres_fk_action_label(None), None); + assert_eq!(postgres_constraint_match_type(Some("f".to_string())).as_deref(), Some("FULL")); + assert_eq!(postgres_constraint_match_type(Some("s".to_string())).as_deref(), Some("SIMPLE")); + assert_eq!(postgres_constraint_match_type(Some("u".to_string())).as_deref(), Some("SIMPLE")); + assert_eq!(postgres_constraint_match_type(Some("p".to_string())).as_deref(), Some("PARTIAL")); + assert_eq!(postgres_constraint_match_type(Some("zz".to_string())), None); + } + #[test] fn postgres_column_metadata_reads_identity_extra() { assert!(POSTGRES_COLUMNS_SQL.contains("a.attidentity")); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 052567b045..ef56fc2e26 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -4056,6 +4056,20 @@ for line in sys.stdin: assert!(!super::supports_agent_table_paging(&legacy_oracle)); } + #[test] + fn detects_opengauss_constraint_profiles_without_gaussdb() { + assert!(super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::OpenGauss))); + assert!(!super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::Gaussdb))); + assert!(!super::is_opengauss_constraint_config(&test_connection_config(DatabaseType::Postgres))); + + let mut profiled_postgres = test_connection_config(DatabaseType::Postgres); + profiled_postgres.driver_profile = Some("opengauss".to_string()); + assert!(super::is_opengauss_constraint_config(&profiled_postgres)); + + profiled_postgres.driver_profile = Some("gaussdb".to_string()); + assert!(!super::is_opengauss_constraint_config(&profiled_postgres)); + } + #[test] fn detects_opengauss_sequence_compatibility_profiles() { assert!(super::is_opengauss_family_config(&test_connection_config(DatabaseType::OpenGauss))); @@ -7053,9 +7067,9 @@ pub async fn list_triggers_core( .await } -// These object kinds are currently exposed by the Xugu agent only. Keeping -// the core route generic makes the protocol reusable without altering the -// metadata behavior of any built-in database driver. +/// Lists structured constraints for a relation. Exposed generically so the +/// agent protocol and native drivers share one route; the built-in drivers +/// that implement it today are PostgreSQL, OpenGauss, and the Xugu agent. pub async fn list_constraints_core( state: &AppState, connection_id: &str, @@ -7066,13 +7080,36 @@ pub async fn list_constraints_core( retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?; let db_config = connection_config(state, connection_id).await; - let connections = state.connections.read().await; - if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { - drop(connections); - let mut client = client.lock().await; - return client.list_constraints(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; + + { + let connections = state.connections.read().await; + if let Some(client) = extract_pool!(&connections, &pool_key, Agent) { + drop(connections); + let mut client = client.lock().await; + return client + .list_constraints(database, schema, table, agent_metadata_timeout(db_config.as_ref())) + .await; + } + } + + let pool = clone_metadata_pool(state, &pool_key).await.ok_or("Pool not found")?; + + match &pool { + // QuestDB speaks the PostgreSQL wire protocol but has no + // constraint metadata, and Redshift does not enforce or expose + // constraint definitions, so neither reports any. + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_questdb_config) => Ok(vec![]), + PoolKind::Postgres(_) + if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Redshift) => + { + Ok(vec![]) + } + PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_opengauss_constraint_config) => { + db::postgres::list_opengauss_constraints(p, schema, table).await + } + PoolKind::Postgres(p) => db::postgres::list_constraints(p, schema, table).await, + _ => Ok(vec![]), } - Ok(vec![]) }) .await } @@ -7649,6 +7686,10 @@ async fn connection_config(state: &AppState, connection_id: &str) -> Option bool { + config.db_type == DatabaseType::OpenGauss || config.driver_profile.as_deref() == Some("opengauss") +} + fn is_opengauss_family_config(config: &ConnectionConfig) -> bool { matches!(config.db_type, DatabaseType::OpenGauss | DatabaseType::Gaussdb) || matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb")) diff --git a/crates/dbx-core/tests/live_opengauss_constraints.rs b/crates/dbx-core/tests/live_opengauss_constraints.rs new file mode 100644 index 0000000000..7be7f2ba93 --- /dev/null +++ b/crates/dbx-core/tests/live_opengauss_constraints.rs @@ -0,0 +1,100 @@ +//! Live integration coverage for OpenGauss structured constraints. +//! +//! Run with a writable OpenGauss database: +//! DBX_TEST_OPENGAUSS_HOST=127.0.0.1 \ +//! DBX_TEST_OPENGAUSS_PORT=5432 \ +//! DBX_TEST_OPENGAUSS_USER=test \ +//! DBX_TEST_OPENGAUSS_PASSWORD=secret \ +//! DBX_TEST_OPENGAUSS_DATABASE=postgres \ +//! cargo test -p dbx-core --test live_opengauss_constraints -- --ignored --nocapture + +use std::time::Duration; + +use dbx_core::db::postgres; +use dbx_core::types::ConstraintInfo; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + +fn find_constraint<'a>(constraints: &'a [ConstraintInfo], name: &str) -> &'a ConstraintInfo { + constraints + .iter() + .find(|constraint| constraint.name == name) + .unwrap_or_else(|| panic!("expected constraint {name} in {constraints:?}")) +} + +fn opengauss_url() -> String { + let host = std::env::var("DBX_TEST_OPENGAUSS_HOST").expect("DBX_TEST_OPENGAUSS_HOST not set"); + let port = std::env::var("DBX_TEST_OPENGAUSS_PORT").expect("DBX_TEST_OPENGAUSS_PORT not set"); + let user = std::env::var("DBX_TEST_OPENGAUSS_USER").expect("DBX_TEST_OPENGAUSS_USER not set"); + let password = std::env::var("DBX_TEST_OPENGAUSS_PASSWORD").expect("DBX_TEST_OPENGAUSS_PASSWORD not set"); + let database = std::env::var("DBX_TEST_OPENGAUSS_DATABASE").unwrap_or_else(|_| "postgres".to_string()); + let params = std::env::var("DBX_TEST_OPENGAUSS_URL_PARAMS").unwrap_or_else(|_| "sslmode=disable".to_string()); + let user = utf8_percent_encode(&user, NON_ALPHANUMERIC); + let password = utf8_percent_encode(&password, NON_ALPHANUMERIC); + format!("postgresql://{user}:{password}@{host}:{port}/{database}?{params}") +} + +#[tokio::test] +#[ignore = "requires a writable OpenGauss instance via DBX_TEST_OPENGAUSS_* variables"] +async fn opengauss_constraints_report_structural_metadata() { + let pool = postgres::connect(&opengauss_url(), Duration::from_secs(10)).await.expect("connect OpenGauss"); + let schema = format!("dbx_og_constraints_{}", std::process::id()); + let schema_ident = format!("\"{}\"", schema.replace('"', "\"\"")); + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; + + let setup = [ + format!("CREATE SCHEMA {schema_ident}"), + format!( + "CREATE TABLE {schema_ident}.parent (a bigint NOT NULL, b bigint NOT NULL, CONSTRAINT parent_pk PRIMARY KEY (a, b))" + ), + format!( + "CREATE TABLE {schema_ident}.child (\ + id bigint PRIMARY KEY,\ + parent_a bigint,\ + parent_b bigint,\ + code text NOT NULL,\ + amount integer,\ + CONSTRAINT child_parent_fk FOREIGN KEY (parent_a, parent_b) REFERENCES {schema_ident}.parent (a, b) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,\ + CONSTRAINT child_code_unique UNIQUE (code),\ + CONSTRAINT child_amount_check CHECK (amount > 0)\ + )" + ), + ]; + postgres::execute_batch(&pool, &setup).await.expect("create OpenGauss constraint fixtures"); + + let constraints = + postgres::list_opengauss_constraints(&pool, &schema, "child").await.expect("list OpenGauss constraints"); + + let primary = find_constraint(&constraints, "child_pkey"); + assert_eq!(primary.constraint_type, "PRIMARY KEY"); + assert_eq!(primary.columns, vec!["id"]); + assert!(primary.enabled); + assert!(primary.valid); + + let foreign_key = find_constraint(&constraints, "child_parent_fk"); + assert_eq!(foreign_key.constraint_type, "FOREIGN KEY"); + assert_eq!(foreign_key.columns, vec!["parent_a", "parent_b"]); + assert_eq!(foreign_key.ref_schema.as_deref(), Some(schema.as_str())); + assert_eq!(foreign_key.ref_table.as_deref(), Some("parent")); + assert_eq!(foreign_key.ref_columns, vec!["a", "b"]); + assert_eq!(foreign_key.on_delete.as_deref(), Some("CASCADE")); + assert_eq!(foreign_key.on_update.as_deref(), Some("NO ACTION")); + assert_eq!(foreign_key.match_type.as_deref(), Some("SIMPLE")); + assert!(foreign_key.deferrable); + assert!(foreign_key.initially_deferred); + + let unique = find_constraint(&constraints, "child_code_unique"); + assert_eq!(unique.constraint_type, "UNIQUE"); + assert_eq!(unique.columns, vec!["code"]); + + let check = find_constraint(&constraints, "child_amount_check"); + assert_eq!(check.constraint_type, "CHECK"); + assert_eq!(check.columns, vec!["amount"]); + assert!(check.definition.contains("amount")); + + let parent = postgres::list_opengauss_constraints(&pool, &schema, "parent") + .await + .expect("list OpenGauss parent constraints"); + assert_eq!(find_constraint(&parent, "parent_pk").columns, vec!["a", "b"]); + + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; +} diff --git a/crates/dbx-core/tests/live_postgres_constraints.rs b/crates/dbx-core/tests/live_postgres_constraints.rs new file mode 100644 index 0000000000..6d3d7bb7e5 --- /dev/null +++ b/crates/dbx-core/tests/live_postgres_constraints.rs @@ -0,0 +1,133 @@ +//! Live integration coverage for `postgres::list_constraints` on a real +//! PostgreSQL server. These tests validate what string-matching unit tests +//! cannot: that the metadata SQL actually executes, that `name[]` column +//! arrays decode into `Vec`, that composite key ordering is +//! preserved, and that `NOT VALID` / EXCLUDE constraints surface correctly. +//! +//! Gate with `#[ignore]` and a writable database, e.g.: +//! DBX_TEST_POSTGRES_URL=postgresql://postgres@127.0.0.1:5432/postgres \ +//! cargo test -p dbx-core --no-default-features --test live_postgres_constraints -- --ignored --nocapture + +use std::time::Duration; + +use dbx_core::db::postgres; +use dbx_core::types::ConstraintInfo; + +fn find_constraint<'a>(constraints: &'a [ConstraintInfo], name: &str) -> &'a ConstraintInfo { + constraints + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("expected constraint {name} in {constraints:?}")) +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_POSTGRES_URL pointing at a writable PostgreSQL database"] +async fn postgres_constraints_reports_pk_fk_unique_check_and_not_valid() { + let url = std::env::var("DBX_TEST_POSTGRES_URL").expect("DBX_TEST_POSTGRES_URL"); + let pool = postgres::connect(&url, Duration::from_secs(5)).await.expect("connect postgres"); + let schema = format!("dbx_constraints_{}", std::process::id()); + let schema_ident = format!("\"{}\"", schema.replace('"', "\"\"")); + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; + + let setup = [ + format!("CREATE SCHEMA {schema_ident}"), + format!( + "CREATE TABLE {schema_ident}.parent (a bigint NOT NULL, b bigint NOT NULL, CONSTRAINT parent_pk PRIMARY KEY (a, b))" + ), + format!( + "CREATE TABLE {schema_ident}.child (\ + id bigint PRIMARY KEY,\ + parent_a bigint,\ + parent_b bigint,\ + code text NOT NULL,\ + amount int,\ + CONSTRAINT child_parent_fk FOREIGN KEY (parent_a, parent_b) REFERENCES {schema_ident}.parent (a, b),\ + CONSTRAINT child_code_unique UNIQUE (code),\ + CONSTRAINT child_code_check CHECK (length(code) > 0)\n )" + ), + // NOT VALID is only honored via ALTER TABLE ADD CONSTRAINT; the + // CREATE TABLE form is validated by PostgreSQL 14 (convalidated=true). + format!( + "ALTER TABLE {schema_ident}.child ADD CONSTRAINT child_amount_check_not_valid CHECK (amount > 0) NOT VALID" + ), + ]; + postgres::execute_batch(&pool, &setup).await.expect("create parent/child tables"); + + let child = postgres::list_constraints(&pool, &schema, "child").await.expect("list child constraints"); + + // PRIMARY KEY + let pk = find_constraint(&child, "child_pkey"); + assert_eq!(pk.constraint_type, "PRIMARY KEY"); + assert_eq!(pk.columns, vec!["id"]); + assert!(pk.valid); + assert!(pk.enabled); + assert_eq!(pk.ref_table, None); + + // FOREIGN KEY: composite referencing columns must keep order (a, b) + let fk = find_constraint(&child, "child_parent_fk"); + assert_eq!(fk.constraint_type, "FOREIGN KEY"); + assert_eq!(fk.columns, vec!["parent_a", "parent_b"]); + assert_eq!(fk.ref_table.as_deref(), Some("parent")); + assert_eq!(fk.ref_schema.as_deref(), Some(schema.as_str())); + assert_eq!(fk.ref_columns, vec!["a", "b"]); + assert_eq!(fk.on_update.as_deref(), Some("NO ACTION")); + assert_eq!(fk.on_delete.as_deref(), Some("NO ACTION")); + assert_eq!(fk.match_type.as_deref(), Some("SIMPLE")); + assert!(fk.valid); + + // UNIQUE + let unique = find_constraint(&child, "child_code_unique"); + assert_eq!(unique.constraint_type, "UNIQUE"); + assert_eq!(unique.columns, vec!["code"]); + + // CHECK: definition is populated + let check = find_constraint(&child, "child_code_check"); + assert_eq!(check.constraint_type, "CHECK"); + assert_eq!(check.columns, vec!["code"]); + assert!(check.definition.contains("length(code) > 0")); + + // NOT VALID CHECK surfaces valid=false + let not_valid = find_constraint(&child, "child_amount_check_not_valid"); + assert_eq!(not_valid.constraint_type, "CHECK"); + assert!(!not_valid.valid, "NOT VALID constraint must report valid=false"); + assert!(not_valid.enabled); + + // Composite primary key keeps declared column order (a, b), not b, a + let parent = postgres::list_constraints(&pool, &schema, "parent").await.expect("list parent constraints"); + let parent_pk = find_constraint(&parent, "parent_pk"); + assert_eq!(parent_pk.constraint_type, "PRIMARY KEY"); + assert_eq!(parent_pk.columns, vec!["a", "b"]); + + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_POSTGRES_URL pointing at a writable PostgreSQL database"] +async fn postgres_constraints_reports_exclude_constraints() { + let url = std::env::var("DBX_TEST_POSTGRES_URL").expect("DBX_TEST_POSTGRES_URL"); + let pool = postgres::connect(&url, Duration::from_secs(5)).await.expect("connect postgres"); + let schema = format!("dbx_constraints_excl_{}", std::process::id()); + let schema_ident = format!("\"{}\"", schema.replace('"', "\"\"")); + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; + + let setup = [ + format!("CREATE SCHEMA {schema_ident}"), + format!( + "CREATE TABLE {schema_ident}.rooms (\ + id bigint PRIMARY KEY,\ + during tstzrange,\ + CONSTRAINT room_no_overlap EXCLUDE USING gist (during WITH &&)\ + )" + ), + ]; + postgres::execute_batch(&pool, &setup).await.expect("create rooms table"); + + let constraints = postgres::list_constraints(&pool, &schema, "rooms").await.expect("list rooms constraints"); + let exclude = find_constraint(&constraints, "room_no_overlap"); + assert_eq!(exclude.constraint_type, "EXCLUDE"); + assert_eq!(exclude.columns, vec!["during"]); + assert!(exclude.definition.contains("EXCLUDE")); + assert!(exclude.valid); + + let _ = postgres::execute_query(&pool, &format!("DROP SCHEMA IF EXISTS {schema_ident} CASCADE")).await; +} diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 80db3ab2ae..4be630cd06 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -686,6 +686,7 @@ pub async fn list_constraints( let database = q.database.as_deref().unwrap_or(""); let schema = q.schema.as_deref().unwrap_or(""); let table = q.table.as_deref().unwrap_or(""); + let _ = q.catalog.as_deref(); let result = dbx_core::schema::list_constraints_core(&state.app, &q.connection_id, database, schema, table) .await .map_err(AppError::from)?; diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 22d6979600..508cc0b272 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -497,7 +497,9 @@ pub async fn list_constraints( database: String, schema: String, table: String, + catalog: Option, ) -> Result, String> { + let _ = catalog; dbx_core::schema::list_constraints_core(&state, &connection_id, &database, &schema, &table).await }