diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java index 6f54536bf7..3bba50c452 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -29,6 +29,9 @@ public final class AgentProtocol { public static final String METHOD_LIST_INDEXES = "list_indexes"; public static final String METHOD_LIST_FOREIGN_KEYS = "list_foreign_keys"; public static final String METHOD_LIST_TRIGGERS = "list_triggers"; + public static final String METHOD_LIST_CONSTRAINTS = "list_constraints"; + public static final String METHOD_LIST_PARTITIONS = "list_partitions"; + public static final String METHOD_LIST_SUBPARTITIONS = "list_subpartitions"; public static final String METHOD_EXECUTE_QUERY = "execute_query"; public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page"; public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page"; @@ -114,6 +117,9 @@ public final class AgentProtocol { METHOD_LIST_INDEXES, METHOD_LIST_FOREIGN_KEYS, METHOD_LIST_TRIGGERS, + METHOD_LIST_CONSTRAINTS, + METHOD_LIST_PARTITIONS, + METHOD_LIST_SUBPARTITIONS, METHOD_EXECUTE_QUERY, METHOD_EXECUTE_QUERY_PAGE, METHOD_FETCH_QUERY_PAGE, diff --git a/agents/common/src/main/resources/agent-protocol-v1.json b/agents/common/src/main/resources/agent-protocol-v1.json index db51f9650f..cec73f1784 100644 --- a/agents/common/src/main/resources/agent-protocol-v1.json +++ b/agents/common/src/main/resources/agent-protocol-v1.json @@ -52,6 +52,9 @@ "list_indexes", "list_foreign_keys", "list_triggers", + "list_constraints", + "list_partitions", + "list_subpartitions", "execute_query", "execute_query_page", "fetch_query_page", diff --git a/agents/common/src/main/resources/agent-protocol-v2.json b/agents/common/src/main/resources/agent-protocol-v2.json index 08900d5d76..96798e8e6a 100644 --- a/agents/common/src/main/resources/agent-protocol-v2.json +++ b/agents/common/src/main/resources/agent-protocol-v2.json @@ -59,6 +59,9 @@ "list_indexes", "list_foreign_keys", "list_triggers", + "list_constraints", + "list_partitions", + "list_subpartitions", "execute_query", "execute_query_page", "fetch_query_page", diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 43a80e4adb..70a0d506da 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -90,7 +90,7 @@ const xuguTableConstraintsSQL = ` SELECT c.CONS_NAME, c.CONS_TYPE, c.DEFINE, rs.SCHEMA_NAME, rt.TABLE_NAME, c.MATCH_TYPE, c.UPDATE_ACTION, c.DELETE_ACTION, - c.DEFERRABLE, c.INITDEFERRED, c.ENABLE + c.DEFERRABLE, c.INITDEFERRED, c.ENABLE, c.VALID FROM ALL_CONSTRAINTS c JOIN ALL_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID @@ -98,9 +98,11 @@ LEFT JOIN ALL_TABLES rt ON rt.DB_ID = c.DB_ID AND rt.TABLE_ID = c.REF_TABLE_ID LEFT JOIN ALL_SCHEMAS rs ON rs.DB_ID = rt.DB_ID AND rs.SCHEMA_ID = rt.SCHEMA_ID WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(t.TABLE_NAME) = UPPER(?) + AND c.CONS_TYPE <> 'F' ORDER BY c.CONS_NAME` const xuguTablePartitionsSQL = ` -SELECT p.PARTI_NAME, p.PARTI_VAL +SELECT p.PARTI_NO, p.PARTI_NAME, p.PARTI_VAL, p.ONLINE, + t.PARTI_TYPE, t.PARTI_KEY, t.AUTO_PARTI_TYPE, t.AUTO_PARTI_SPAN FROM ALL_PARTIS p JOIN ALL_TABLES t ON t.DB_ID = p.DB_ID AND t.TABLE_ID = p.TABLE_ID JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID @@ -108,7 +110,8 @@ WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(t.TABLE_NAME) = UPPER(?) ORDER BY p.PARTI_NO` const xuguTableSubpartitionsSQL = ` -SELECT p.SUBPARTI_NAME, p.SUBPARTI_VAL +SELECT p.SUBPARTI_NO, p.SUBPARTI_NAME, p.SUBPARTI_VAL, + t.SUBPARTI_TYPE, t.SUBPARTI_KEY FROM ALL_SUBPARTIS p JOIN ALL_TABLES t ON t.DB_ID = p.DB_ID AND t.TABLE_ID = p.TABLE_ID JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID @@ -310,10 +313,51 @@ func (i indexInfo) MarshalJSON() ([]byte, error) { } type foreignKeyInfo struct { - Name string `json:"name"` - Column string `json:"column"` - RefTable string `json:"ref_table"` - RefColumn string `json:"ref_column"` + Name string `json:"name"` + Column string `json:"column"` + RefSchema *string `json:"ref_schema,omitempty"` + RefTable string `json:"ref_table"` + RefColumn string `json:"ref_column"` + OnUpdate *string `json:"on_update,omitempty"` + OnDelete *string `json:"on_delete,omitempty"` +} + +// constraintInfo represents the user-visible ALL_CONSTRAINTS metadata that +// DBeaver's Xugu extension exposes below a table. +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 partitionInfo struct { + Name string `json:"name"` + Position int `json:"position"` + Value string `json:"value"` + PartitionType string `json:"partition_type"` + PartitionKey string `json:"partition_key"` + Online *bool `json:"online,omitempty"` + AutoPartitionType *string `json:"auto_partition_type,omitempty"` + AutoPartitionSpan *int `json:"auto_partition_span,omitempty"` +} + +type subpartitionInfo struct { + Name string `json:"name"` + Position int `json:"position"` + Value string `json:"value"` + PartitionType string `json:"partition_type"` + PartitionKey string `json:"partition_key"` } // xuguTableMetadata mirrors the ALL_TABLES properties that affect a CREATE @@ -353,6 +397,7 @@ type xuguConstraintInfo struct { Deferrable bool InitiallyDeferred bool Enabled bool + Valid bool } type xuguPartitionInfo struct { @@ -819,6 +864,12 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any table := stringParam(params, "table") result, err := s.listForeignKeys(schema, table) return result, false, err + case "list_constraints": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + result, err := s.listConstraints(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err case "list_triggers": if err := s.useDatabase(stringParam(params, "database")); err != nil { return nil, false, err @@ -827,6 +878,18 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any table := stringParam(params, "table") result, err := s.listTriggers(schema, table) return result, false, err + case "list_partitions": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + result, err := s.listPartitions(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err + case "list_subpartitions": + if err := s.useDatabase(stringParam(params, "database")); err != nil { + return nil, false, err + } + result, err := s.listSubpartitions(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err case "get_explain_info": sqlText := stringParam(params, "sql") plan, err := s.getExplainInfo(sqlText) @@ -1009,12 +1072,16 @@ func newXuguDatabaseSession( func buildDSN(params connectParams) string { connectionString := strings.TrimSpace(params.ConnectionString) + selectedDatabase := strings.TrimSpace(params.Database) if looksLikeXuguDSN(connectionString) { + if selectedDatabase != "" { + return overrideXuguDSNDatabase(connectionString, selectedDatabase) + } return connectionString } if parsed := parseXuguURL(connectionString); parsed.Host != "" { - if parsed.Database == "" { - parsed.Database = params.Database + if selectedDatabase != "" { + parsed.Database = selectedDatabase } if parsed.Username == "" { parsed.Username = params.Username @@ -1026,6 +1093,9 @@ func buildDSN(params connectParams) string { } if jdbc := parseXuguJDBCURL(connectionString); jdbc.Host != "" { + if selectedDatabase != "" { + jdbc.Database = selectedDatabase + } return buildXuguDSN(jdbc.Host, jdbc.Port, jdbc.Database, params.Username, params.Password, params.URLParams) } @@ -1037,6 +1107,49 @@ func looksLikeXuguDSN(value string) bool { return strings.Contains(upper, "IP=") && strings.Contains(upper, "DB=") && strings.Contains(upper, "USER=") } +func overrideXuguDSNDatabase(dsn, database string) string { + var result strings.Builder + result.Grow(len(dsn) + len(database)) + + segmentStart := 0 + inQuotes := false + for index := 0; index < len(dsn); index++ { + switch dsn[index] { + case '\'': + if inQuotes && index+1 < len(dsn) && dsn[index+1] == '\'' { + index++ + continue + } + inQuotes = !inQuotes + case ';': + if !inQuotes { + result.WriteString(overrideXuguDSNSegmentDatabase(dsn[segmentStart:index], database)) + result.WriteByte(';') + segmentStart = index + 1 + } + } + } + result.WriteString(overrideXuguDSNSegmentDatabase(dsn[segmentStart:], database)) + return result.String() +} + +func overrideXuguDSNSegmentDatabase(segment, database string) string { + separator := strings.IndexByte(segment, '=') + if separator < 0 || !strings.EqualFold(strings.TrimSpace(segment[:separator]), "DB") { + return segment + } + + // Xugu DSN values may quote semicolons and escape quotes by doubling them. + return segment[:separator+1] + encodeXuguDSNValue(database) +} + +func encodeXuguDSNValue(value string) string { + if !strings.ContainsAny(value, ";'") { + return value + } + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + type xuguURLInfo struct { Host string Port int @@ -1732,11 +1845,12 @@ func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error) } table = strings.ToUpper(strings.TrimSpace(table)) rows, err := s.queryRows(` -SELECT c.CONS_NAME, c.DEFINE, rt.TABLE_NAME -FROM SYS_CONSTRAINTS c -JOIN SYS_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID -JOIN SYS_TABLES rt ON rt.DB_ID = c.DB_ID AND rt.TABLE_ID = c.REF_TABLE_ID -JOIN SYS_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +SELECT c.CONS_NAME, c.DEFINE, rs.SCHEMA_NAME, rt.TABLE_NAME, c.UPDATE_ACTION, c.DELETE_ACTION +FROM ALL_CONSTRAINTS c +JOIN ALL_TABLES t ON t.DB_ID = c.DB_ID AND t.TABLE_ID = c.TABLE_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +LEFT JOIN ALL_TABLES rt ON rt.DB_ID = c.DB_ID AND rt.TABLE_ID = c.REF_TABLE_ID +LEFT JOIN ALL_SCHEMAS rs ON rs.DB_ID = rt.DB_ID AND rs.SCHEMA_ID = rt.SCHEMA_ID WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(t.TABLE_NAME) = UPPER(?) AND c.CONS_TYPE = 'F' @@ -1750,13 +1864,18 @@ ORDER BY c.CONS_NAME`, []any{schema, table}) defer s.closeRows(rows) var result []foreignKeyInfo for rows.Next() { - var name, define, refTable string - if err := rows.Scan(&name, &define, &refTable); err != nil { + var name, define, refSchema, refTable, updateAction, deleteAction any + if err := rows.Scan(&name, &define, &refSchema, &refTable, &updateAction, &deleteAction); err != nil { return nil, err } - local, ref := parseForeignKeyColumns(define) + local, ref := parseForeignKeyColumns(xuguString(define)) for i, column := range local { - item := foreignKeyInfo{Name: name, Column: column, RefTable: refTable} + item := foreignKeyInfo{ + Name: xuguString(name), Column: column, RefTable: xuguString(refTable), + RefSchema: optionalString(xuguString(refSchema)), + OnUpdate: optionalString(xuguReferentialAction(xuguString(updateAction))), + OnDelete: optionalString(xuguReferentialAction(xuguString(deleteAction))), + } if i < len(ref) { item.RefColumn = ref[i] } @@ -1774,9 +1893,9 @@ func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { table = strings.ToUpper(strings.TrimSpace(table)) rows, err := s.queryRows(` SELECT tr.TRIG_NAME, tr.TRIG_EVENT, tr.TRIG_TIME -FROM SYS_TRIGGERS tr -JOIN SYS_TABLES t ON t.DB_ID = tr.DB_ID AND t.TABLE_ID = tr.OBJ_ID -JOIN SYS_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID +FROM ALL_TRIGGERS tr +JOIN ALL_TABLES t ON t.DB_ID = tr.DB_ID AND t.TABLE_ID = tr.OBJ_ID +JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(t.TABLE_NAME) = UPPER(?) ORDER BY tr.TRIG_NAME`, []any{schema, table}) @@ -1801,6 +1920,65 @@ ORDER BY tr.TRIG_NAME`, []any{schema, table}) return emptyIfNil(result), rows.Err() } +func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + table = strings.ToUpper(strings.TrimSpace(table)) + rows, err := s.queryRows(xuguTableConstraintsSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []constraintInfo{}, nil + } + return nil, err + } + defer s.closeRows(rows) + + var result []constraintInfo + for rows.Next() { + var name, kind, definition, refSchema, refTable any + var matchType, updateAction, deleteAction, deferrable, initiallyDeferred, enabled, valid any + if err := rows.Scan(&name, &kind, &definition, &refSchema, &refTable, &matchType, &updateAction, &deleteAction, + &deferrable, &initiallyDeferred, &enabled, &valid); err != nil { + return nil, err + } + item := constraintInfo{ + Name: xuguString(name), ConstraintType: xuguConstraintTypeName(xuguString(kind)), Definition: xuguString(definition), + Columns: []string{}, RefColumns: []string{}, Deferrable: truthy(deferrable), InitiallyDeferred: truthy(initiallyDeferred), + Enabled: truthy(enabled), Valid: truthy(valid), + } + if xuguString(kind) == "F" { + item.Columns, item.RefColumns = parseForeignKeyColumns(item.Definition) + item.RefSchema = optionalString(xuguString(refSchema)) + item.RefTable = optionalString(xuguString(refTable)) + item.MatchType = optionalString(xuguMatchTypeName(xuguString(matchType))) + item.OnUpdate = optionalString(xuguReferentialAction(xuguString(updateAction))) + item.OnDelete = optionalString(xuguReferentialAction(xuguString(deleteAction))) + } else if xuguString(kind) != "C" { + item.Columns = parseQuotedIdentifiers(item.Definition) + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listPartitions(schema, table string) ([]partitionInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + return s.listPartitionMetadata(schema, strings.ToUpper(strings.TrimSpace(table))) +} + +func (s *server) listSubpartitions(schema, table string) ([]subpartitionInfo, error) { + schema, err := s.normalizeSchema(schema) + if err != nil { + return nil, err + } + return s.listSubpartitionMetadata(schema, strings.ToUpper(strings.TrimSpace(table))) +} + func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) { var err error schema, err = s.normalizeSchema(schema) @@ -2409,9 +2587,9 @@ func (s *server) tableConstraints(schema, table string) ([]xuguConstraintInfo, e for rows.Next() { var item xuguConstraintInfo var name, constraintType, definition, referenceSchema, referenceTable any - var matchType, updateAction, deleteAction, deferrable, initiallyDeferred, enabled any + var matchType, updateAction, deleteAction, deferrable, initiallyDeferred, enabled, valid any if err := rows.Scan(&name, &constraintType, &definition, &referenceSchema, &referenceTable, - &matchType, &updateAction, &deleteAction, &deferrable, &initiallyDeferred, &enabled); err != nil { + &matchType, &updateAction, &deleteAction, &deferrable, &initiallyDeferred, &enabled, &valid); err != nil { return nil, err } item.Name = xuguString(name) @@ -2425,6 +2603,7 @@ func (s *server) tableConstraints(schema, table string) ([]xuguConstraintInfo, e item.Deferrable = truthy(deferrable) item.InitiallyDeferred = truthy(initiallyDeferred) item.Enabled = truthy(enabled) + item.Valid = truthy(valid) result = append(result, item) } return emptyIfNil(result), rows.Err() @@ -2442,8 +2621,12 @@ func (s *server) tablePartitions(schema, table string, subpartition bool) ([]xug defer s.closeRows(rows) var result []xuguPartitionInfo for rows.Next() { - var name, value any - if err := rows.Scan(&name, &value); err != nil { + var position, name, value, ignored1, ignored2, ignored3, ignored4, ignored5 any + if subpartition { + if err := rows.Scan(&position, &name, &value, &ignored1, &ignored2); err != nil { + return nil, err + } + } else if err := rows.Scan(&position, &name, &value, &ignored1, &ignored2, &ignored3, &ignored4, &ignored5); err != nil { return nil, err } result = append(result, xuguPartitionInfo{Name: xuguString(name), Value: xuguString(value)}) @@ -2451,6 +2634,59 @@ func (s *server) tablePartitions(schema, table string, subpartition bool) ([]xug return emptyIfNil(result), rows.Err() } +func (s *server) listPartitionMetadata(schema, table string) ([]partitionInfo, error) { + rows, err := s.queryRows(xuguTablePartitionsSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []partitionInfo{}, nil + } + return nil, err + } + defer s.closeRows(rows) + var result []partitionInfo + for rows.Next() { + var no, name, value, online, partitionType, partitionKey, autoType, autoSpan any + if err := rows.Scan(&no, &name, &value, &online, &partitionType, &partitionKey, &autoType, &autoSpan); err != nil { + return nil, err + } + item := partitionInfo{Name: xuguString(name), Position: xuguInt(no), Value: xuguString(value), + PartitionType: xuguPartitionType(xuguInt(partitionType)), PartitionKey: xuguString(partitionKey)} + if value := xuguString(online); value != "" { + isOnline := truthy(online) + item.Online = &isOnline + } + if name := xuguAutoPartitionUnit(xuguInt(autoType)); name != "" { + item.AutoPartitionType = &name + } + if span := xuguInt(autoSpan); span > 0 { + item.AutoPartitionSpan = &span + } + result = append(result, item) + } + return emptyIfNil(result), rows.Err() +} + +func (s *server) listSubpartitionMetadata(schema, table string) ([]subpartitionInfo, error) { + rows, err := s.queryRows(xuguTableSubpartitionsSQL, []any{schema, table}) + if err != nil { + if isXuguMetadataAccessError(err) { + return []subpartitionInfo{}, nil + } + return nil, err + } + defer s.closeRows(rows) + var result []subpartitionInfo + for rows.Next() { + var no, name, value, partitionType, partitionKey any + if err := rows.Scan(&no, &name, &value, &partitionType, &partitionKey); err != nil { + return nil, err + } + result = append(result, subpartitionInfo{Name: xuguString(name), Position: xuguInt(no), Value: xuguString(value), + PartitionType: xuguPartitionType(xuguInt(partitionType)), PartitionKey: xuguString(partitionKey)}) + } + return emptyIfNil(result), rows.Err() +} + // renderXuguTableDDL is deliberately independent from database access. The // exporter can consequently be regression-tested against the catalog data // returned by Xugu without requiring a privileged DBMS_METADATA call. @@ -2729,6 +2965,52 @@ func xuguMatchClause(value string) string { } } +func xuguMatchTypeName(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "A": + return "FULL" + case "P": + return "PARTIAL" + case "U": + return "SIMPLE" + default: + return "" + } +} + +func xuguConstraintTypeName(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "P": + return "PRIMARY KEY" + case "U": + return "UNIQUE" + case "C": + return "CHECK" + case "F": + return "FOREIGN KEY" + case "N": + return "NOT NULL" + case "D": + return "DEFAULT" + case "R": + return "REFERENCED KEY" + default: + return strings.TrimSpace(value) + } +} + +func xuguAutoPartitionUnit(value int) string { + return map[int]string{1: "YEAR", 2: "MONTH", 3: "DAY", 4: "HOUR"}[value] +} + +func optionalString(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + func xuguPartitionType(value int) string { switch value { case 1: diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 9e09bfe784..ddcb3d7972 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -317,6 +317,104 @@ func TestBuildDSNParsesDBXURL(t *testing.T) { } } +func TestBuildDSNOverridesSelectedDatabase(t *testing.T) { + tests := []struct { + name string + params connectParams + want string + }{ + { + name: "native DSN", + params: connectParams{ + Database: "SHOP_DEMO", + ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138", + }, + want: "IP=db.example.com;DB=SHOP_DEMO;User=SYSDBA;PWD=secret;Port=5138", + }, + { + name: "JDBC URL", + params: connectParams{ + Database: "SHOP_DEMO", + Username: "sysdba", + Password: "secret", + ConnectionString: "jdbc:xugu://db.example.com:15138/SYSTEM?note=DB=shadow", + URLParams: "TRACE_LABEL=DB=SYSTEM", + }, + want: "IP=db.example.com;DB=SHOP_DEMO;User=sysdba;PWD=secret;Port=15138;CHAR_SET=UTF8;TRACE_LABEL=DB=SYSTEM", + }, + { + name: "DBX URL", + params: connectParams{ + Database: "SHOP_DEMO", + ConnectionString: "xugu://sysdba:secret@db.example.com:15138/SYSTEM?note=DB=shadow", + URLParams: "TRACE_LABEL=DB=SYSTEM", + }, + want: "IP=db.example.com;DB=SHOP_DEMO;User=sysdba;PWD=secret;Port=15138;CHAR_SET=UTF8;TRACE_LABEL=DB=SYSTEM", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := buildDSN(test.params); got != test.want { + t.Fatalf("unexpected dsn:\n got: %s\nwant: %s", got, test.want) + } + }) + } +} + +func TestBuildDSNPreservesConnectionDatabaseWithoutSelection(t *testing.T) { + tests := []struct { + name string + params connectParams + want string + }{ + { + name: "native DSN", + params: connectParams{ + Database: " ", + ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138", + }, + want: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret;Port=5138", + }, + { + name: "JDBC URL", + params: connectParams{ + Username: "sysdba", + Password: "secret", + ConnectionString: "jdbc:xugu://db.example.com:15138/SYSTEM", + }, + want: "IP=db.example.com;DB=SYSTEM;User=sysdba;PWD=secret;Port=15138;CHAR_SET=UTF8", + }, + { + name: "DBX URL", + params: connectParams{ + ConnectionString: "xugu://sysdba:secret@db.example.com:15138/SYSTEM", + }, + want: "IP=db.example.com;DB=SYSTEM;User=sysdba;PWD=secret;Port=15138;CHAR_SET=UTF8", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := buildDSN(test.params); got != test.want { + t.Fatalf("unexpected dsn:\n got: %s\nwant: %s", got, test.want) + } + }) + } +} + +func TestBuildDSNOverridesOnlyNativeDatabaseParameters(t *testing.T) { + dsn := buildDSN(connectParams{ + Database: " sales;east's ", + ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD='secret;DB=shadow';Port=5138;NOTE='DB=archive;keep';db=LEGACY", + }) + want := "IP=db.example.com;DB='sales;east''s';User=SYSDBA;PWD='secret;DB=shadow';Port=5138;NOTE='DB=archive;keep';db='sales;east''s'" + + if dsn != want { + t.Fatalf("unexpected dsn:\n got: %s\nwant: %s", dsn, want) + } +} + func TestBuildDSNAppendsURLParams(t *testing.T) { dsn := buildDSN(connectParams{ Host: "db.example.com", @@ -547,6 +645,70 @@ func TestIndexSQLUsesLowPrivilegeDictionary(t *testing.T) { } } +func TestTableChildMetadataUsesLowPrivilegeDictionary(t *testing.T) { + for name, query := range map[string]string{ + "constraints": xuguTableConstraintsSQL, + "partitions": xuguTablePartitionsSQL, + "subpartitions": xuguTableSubpartitionsSQL, + } { + upper := strings.ToUpper(query) + if !strings.Contains(upper, "ALL_") { + t.Fatalf("%s metadata should query ALL_* views: %s", name, query) + } + if strings.Contains(upper, "SYS_") { + t.Fatalf("%s metadata must not require SYS_* privileges: %s", name, query) + } + } + if !strings.Contains(strings.ToUpper(xuguTableConstraintsSQL), "C.CONS_TYPE <> 'F'") { + t.Fatalf("generic constraints must exclude foreign keys: %s", xuguTableConstraintsSQL) + } +} + +func TestTableChildMetadataPresentationHelpers(t *testing.T) { + if got := xuguConstraintTypeName("F"); got != "FOREIGN KEY" { + t.Fatalf("foreign key type = %q", got) + } + if got := xuguMatchTypeName("U"); got != "SIMPLE" { + t.Fatalf("simple match type = %q", got) + } + if got := xuguAutoPartitionUnit(2); got != "MONTH" { + t.Fatalf("auto partition unit = %q", got) + } +} + +func TestTableChildMetadataRPCsReturnCatalogObjects(t *testing.T) { + db, err := sql.Open("xugu-test-table-objects", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + s.params.Database = "SHOP_DEMO" + + for _, test := range []struct { + method string + want string + }{ + {method: "list_constraints", want: "PRIMARY KEY"}, + {method: "list_partitions", want: "RANGE"}, + {method: "list_subpartitions", want: "LIST"}, + } { + response, shutdown := s.handleLine(fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"%s","params":{"database":"SHOP_DEMO","schema":"SYSDBA","table":"SHOP_ORDERS"}}`, test.method)) + if shutdown || response.Error != nil { + t.Fatalf("%s failed: shutdown=%v error=%v", test.method, shutdown, response.Error) + } + encoded, err := json.Marshal(response.Result) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encoded), test.want) { + t.Fatalf("%s result did not contain %q: %s", test.method, test.want, encoded) + } + } +} + func TestXuguMetadataAccessErrorDetection(t *testing.T) { if !isXuguMetadataAccessError(errors.New("[E18012] 权限不够")) { t.Fatal("expected E18012 permission error to be treated as metadata access error") @@ -890,6 +1052,45 @@ func init() { sql.Register("xugu-test-blocking", &xuguBlockingDriver{}) sql.Register("xugu-test-fast", &xuguFastDriver{}) sql.Register("xugu-test-legacy-columns", &xuguLegacyColumnsDriver{}) + sql.Register("xugu-test-table-objects", &xuguTableObjectsDriver{}) +} + +type xuguTableObjectsDriver struct{} + +func (d *xuguTableObjectsDriver) Open(name string) (driver.Conn, error) { + return &xuguTableObjectsConn{}, nil +} + +type xuguTableObjectsConn struct{} + +func (c *xuguTableObjectsConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguTableObjectsConn) Close() error { return nil } +func (c *xuguTableObjectsConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } +func (c *xuguTableObjectsConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + switch upper := strings.ToUpper(query); { + case strings.Contains(upper, "FROM ALL_CONSTRAINTS"): + if !strings.Contains(upper, "C.CONS_TYPE <> 'F'") { + return nil, errors.New("generic constraints query must exclude foreign keys") + } + return &xuguStaticRows{ + columns: []string{"CONS_NAME", "CONS_TYPE", "DEFINE", "SCHEMA_NAME", "TABLE_NAME", "MATCH_TYPE", "UPDATE_ACTION", "DELETE_ACTION", "DEFERRABLE", "INITDEFERRED", "ENABLE", "VALID"}, + values: [][]driver.Value{{"PK_ORDERS", "P", `("ORDER_ID")`, nil, nil, nil, nil, nil, false, false, true, true}}, + }, nil + case strings.Contains(upper, "FROM ALL_PARTIS"): + return &xuguStaticRows{ + columns: []string{"PARTI_NO", "PARTI_NAME", "PARTI_VAL", "ONLINE", "PARTI_TYPE", "PARTI_KEY", "AUTO_PARTI_TYPE", "AUTO_PARTI_SPAN"}, + values: [][]driver.Value{{int64(1), "P_2025", "'2026-01-01'", true, int64(1), `"ORDER_TIME"`, int64(0), int64(0)}}, + }, nil + case strings.Contains(upper, "FROM ALL_SUBPARTIS"): + return &xuguStaticRows{ + columns: []string{"SUBPARTI_NO", "SUBPARTI_NAME", "SUBPARTI_VAL", "SUBPARTI_TYPE", "SUBPARTI_KEY"}, + values: [][]driver.Value{{int64(1), "SP_PENDING", "'10'", int64(2), `"ORDER_STATUS"`}}, + }, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } } type xuguLegacyColumnsDriver struct{} diff --git a/agents/versions.json b/agents/versions.json index f8726e16d4..eafe57361d 100644 --- a/agents/versions.json +++ b/agents/versions.json @@ -33,7 +33,7 @@ "highgo": "0.1.31", "yashandb": "0.1.29", "oscar": "0.1.8", - "xugu": "0.1.22", + "xugu": "0.1.23", "iris": "0.1.24", "iotdb": "0.1.21", "etcd": "0.1.21", diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index a89805e120..3a3a53068b 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -466,6 +466,9 @@ const groupTypes: Set = new Set([ "group-indexes", "group-fkeys", "group-triggers", + "group-constraints", + "group-table-partitions", + "group-table-subpartitions", "group-tables", "group-views", "group-materialized-views", @@ -648,6 +651,14 @@ async function toggle() { await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-constraints" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await connectionStore.loadConstraints(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-table-partitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await connectionStore.loadPartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-table-subpartitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await connectionStore.loadSubpartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) { await connectionStore.refreshTreeNode(node); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 03781d4031..d4f6b4fbeb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2062,6 +2062,7 @@ export default { indexes: "Indexes", foreignKeys: "Foreign Keys", triggers: "Triggers", + constraints: "Constraints", tables: "Tables", views: "Views", materializedViews: "Materialized Views", @@ -2073,6 +2074,7 @@ export default { gridfs: "GridFS", buckets: "Buckets", partitions: "Partitions", + subpartitions: "Subpartitions", childTables: "Subtables", loadMore: "Load more...", objectBrowser: "Browse in Object Browser ({count})", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4b7eb3e106..f48a7c3356 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2003,6 +2003,7 @@ export default withEnglishFallback({ indexes: "Índices", foreignKeys: "Claves foráneas", triggers: "Disparadores", + constraints: "Restricciones", tables: "Tablas", views: "Vistas", materializedViews: "Vistas materializadas", @@ -2011,6 +2012,7 @@ export default withEnglishFallback({ sequences: "Secuencias", packages: "Paquetes", partitions: "Particiones", + subpartitions: "Subparticiones", childTables: "Subtablas", loadMore: "Cargar más...", objectBrowser: "Explorar en el navegador de objetos ({count})", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7085f21734..b0e3dce8b9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2001,6 +2001,7 @@ export default withEnglishFallback({ indexes: "Indici", foreignKeys: "Chiavi Esterne", triggers: "Trigger", + constraints: "Vincoli", tables: "Tabelle", views: "Viste", materializedViews: "Viste Materializzate", @@ -2009,6 +2010,7 @@ export default withEnglishFallback({ sequences: "Sequenze", packages: "Pacchetti", partitions: "Partizioni", + subpartitions: "Sottopartizioni", childTables: "Sottotabelle", loadMore: "Carica altro...", objectBrowser: "Sfoglia in Esplora Oggetti ({count})", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 65da9be1ba..2431f8a89c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2001,6 +2001,7 @@ export default withEnglishFallback({ indexes: "インデックス", foreignKeys: "外部キー", triggers: "トリガー", + constraints: "制約", tables: "テーブル", views: "ビュー", procedures: "プロシージャ", @@ -2008,6 +2009,7 @@ export default withEnglishFallback({ sequences: "シーケンス", packages: "パッケージ", partitions: "パーティション", + subpartitions: "サブパーティション", childTables: "子テーブル", loadMore: "もっと読み込む...", objectBrowser: "オブジェクトブラウザで参照({count}件)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a8ffea37f2..c1d7b7e1c3 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2004,6 +2004,7 @@ export default withEnglishFallback({ indexes: "Índices", foreignKeys: "Chaves estrangeiras", triggers: "Triggers", + constraints: "Restrições", tables: "Tabelas", views: "Visões", procedures: "Procedimentos", @@ -2011,6 +2012,7 @@ export default withEnglishFallback({ sequences: "Sequências", packages: "Pacotes", partitions: "Partições", + subpartitions: "Subpartições", childTables: "Subtabelas", loadMore: "Carregar mais...", objectBrowser: "Navegar no Navegador de Objetos ({count})", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 09b5b6e14d..7282ca464e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2061,6 +2061,7 @@ export default withEnglishFallback({ indexes: "索引", foreignKeys: "外键", triggers: "触发器", + constraints: "约束", tables: "表", views: "视图", materializedViews: "物化视图", @@ -2070,6 +2071,7 @@ export default withEnglishFallback({ packages: "包", types: "类型", partitions: "分区", + subpartitions: "子分区", childTables: "子表", loadMore: "加载更多...", objectBrowser: "在对象浏览器中查看 ({count})", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index df60f16965..d9b4615764 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2003,6 +2003,7 @@ export default withEnglishFallback({ indexes: "索引", foreignKeys: "外鍵", triggers: "觸發器", + constraints: "約束", tables: "資料表", views: "檢視", procedures: "預存程序", @@ -2010,6 +2011,7 @@ export default withEnglishFallback({ sequences: "序列", packages: "套件", partitions: "分割區", + subpartitions: "子分割區", childTables: "子表", loadMore: "載入更多...", objectBrowser: "在物件瀏覽器中檢視 ({count})", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 850945663c..29f6fd4b17 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -158,6 +158,9 @@ export const listDataTypes = forward("listDataTypes"); export const listIndexes = forward("listIndexes"); export const listForeignKeys = forward("listForeignKeys"); export const listTriggers = forward("listTriggers"); +export const listConstraints = forward("listConstraints"); +export const listPartitions = forward("listPartitions"); +export const listSubpartitions = forward("listSubpartitions"); export const getTableDdl = forward("getTableDdl"); export const listFunctions = forward("listFunctions"); export const listSequences = forward("listSequences"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 0f9860b160..792f054161 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -19,6 +19,9 @@ import type { IndexInfo, ForeignKeyInfo, TriggerInfo, + ConstraintInfo, + PartitionInfo, + SubpartitionInfo, ExtensionInfo, FunctionInfo, SequenceInfo, @@ -685,6 +688,18 @@ export async function listTriggers(connectionId: string, database: string, schem return get(`/api/schema/triggers?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } +export async function listConstraints(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/constraints?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); +} + +export async function listPartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/partitions?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); +} + +export async function listSubpartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return get(`/api/schema/subpartitions?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); +} + export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog })}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index d87f904b1b..924ab6bd9e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -22,6 +22,9 @@ import type { IndexInfo, ForeignKeyInfo, TriggerInfo, + ConstraintInfo, + PartitionInfo, + SubpartitionInfo, FunctionInfo, SequenceInfo, RuleInfo, @@ -1177,6 +1180,18 @@ export async function listTriggers(connectionId: string, database: string, schem return invoke("list_triggers", { connectionId, database, schema, table, catalog }); } +export async function listConstraints(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_constraints", { connectionId, database, schema, table, catalog }); +} + +export async function listPartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_partitions", { connectionId, database, schema, table, catalog }); +} + +export async function listSubpartitions(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { + return invoke("list_subpartitions", { connectionId, database, schema, table, catalog }); +} + export async function getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise { return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog }); } diff --git a/apps/desktop/src/lib/connection/__tests__/agentDriverInstallHint.spec.ts b/apps/desktop/src/lib/connection/__tests__/agentDriverInstallHint.spec.ts index fe7d014acb..c5ed49a59a 100644 --- a/apps/desktop/src/lib/connection/__tests__/agentDriverInstallHint.spec.ts +++ b/apps/desktop/src/lib/connection/__tests__/agentDriverInstallHint.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint"; +import { driverStoreFocusForInstallError, hasInstalledAgentVersion } from "@/lib/connection/agentDriverInstallHint"; describe("driverStoreFocusForInstallError", () => { it("focuses the missing agent driver for driver-not-installed errors", () => { @@ -38,3 +38,11 @@ describe("driverStoreFocusForInstallError", () => { expect(driverStoreFocusForInstallError("No reachable ZooKeeper server within 2000ms: zk:2181", "zookeeper", undefined)).toBeNull(); }); }); + +describe("hasInstalledAgentVersion", () => { + it("requires an installed Agent at or above the requested release", () => { + expect(hasInstalledAgentVersion([{ db_type: "xugu", installed: true, installed_version: "0.1.23" }], "xugu", "0.1.23")).toBe(true); + expect(hasInstalledAgentVersion([{ db_type: "xugu", installed: true, installed_version: "0.1.22" }], "xugu", "0.1.23")).toBe(false); + expect(hasInstalledAgentVersion([{ db_type: "xugu", installed: true, installed_version: null }], "xugu", "0.1.23")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/connection/agentDriverInstallHint.ts b/apps/desktop/src/lib/connection/agentDriverInstallHint.ts index 14a870d46f..7506b8537b 100644 --- a/apps/desktop/src/lib/connection/agentDriverInstallHint.ts +++ b/apps/desktop/src/lib/connection/agentDriverInstallHint.ts @@ -4,9 +4,25 @@ import { supportsDriverManagement } from "@/lib/database/databaseCapabilities"; export interface AgentDriverInstallState { db_type: string; installed: boolean; + installed_version?: string | null; update_available?: boolean; } +/** Returns whether a locally installed native Agent meets a required release. */ +export function hasInstalledAgentVersion(drivers: readonly AgentDriverInstallState[], driverKey: string, minimumVersion: string): boolean { + const installedVersion = drivers.find((driver) => driver.db_type === driverKey && driver.installed)?.installed_version; + if (!installedVersion) return false; + + const parse = (version: string): number[] | null => { + const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + return match ? match.slice(1).map(Number) : null; + }; + const installed = parse(installedVersion); + const minimum = parse(minimumVersion); + if (!installed || !minimum) return false; + return installed[0] > minimum[0] || (installed[0] === minimum[0] && (installed[1] > minimum[1] || (installed[1] === minimum[1] && installed[2] >= minimum[2]))); +} + export function agentDriverInstallKey(dbType: DatabaseType | undefined, driverProfile?: string): string | undefined { if (dbType === "oracle") return "oracle"; if (dbType === "mongodb") return "mongodb"; diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 107dbc3027..7d42cd1dc1 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -12,7 +12,7 @@ const toggleLeafNodeTypes = new Set(["redis-db", "mq-tenant", "etc const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser"]); const sourceNodeTypes = new Set(["materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body"]); const savedSqlNodeTypes = new Set(["saved-sql-file"]); -const tableChildGroupNodeTypes = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-partitions"]); +const tableChildGroupNodeTypes = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-constraints", "group-partitions", "group-table-partitions", "group-table-subpartitions"]); const databaseChildGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]); export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null { diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index 4fcb37590d..67054bc243 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -53,6 +53,15 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: Zap, colorClass: "text-orange-400" }; case "trigger": return { icon: Zap, colorClass: "text-orange-300" }; + case "group-constraints": + case "constraint": + return { icon: Key, colorClass: "text-amber-500" }; + case "group-table-partitions": + case "group-table-subpartitions": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" }; + case "partition": + case "subpartition": + return { icon: TableProperties, colorClass: "text-green-400" }; case "object-browser": return { icon: TableProperties, colorClass: "text-primary" }; case "user-admin": diff --git a/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts new file mode 100644 index 0000000000..bf2f563d25 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.xuguTableChildren.spec.ts @@ -0,0 +1,162 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function connection(dbType: ConnectionConfig["db_type"]): ConnectionConfig { + return { + id: `${dbType}-1`, + name: dbType, + db_type: dbType, + host: "127.0.0.1", + port: 5138, + username: "SYSDBA", + password: "", + database: "SHOP_DEMO", + } as ConnectionConfig; +} + +function findNode(nodes: TreeNode[], id: string): TreeNode | undefined { + for (const node of nodes) { + if (node.id === id) return node; + const child = node.children && findNode(node.children, id); + if (child) return child; + } + return undefined; +} + +async function setup(dbType: ConnectionConfig["db_type"], overrides: Record = {}) { + const api = { + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listConstraints: vi.fn().mockResolvedValue([]), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listInstalledAgentsLocal: vi.fn().mockResolvedValue([{ db_type: "xugu", installed: true, installed_version: "0.1.23" }]), + listPartitions: vi.fn().mockResolvedValue([]), + listSubpartitions: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => api); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const config = connection(dbType); + const tableId = `${config.id}:SHOP_DEMO:SYSDBA:SHOP_ORDERS`; + store.connections = [config]; + store.connectedIds.add(config.id); + store.treeNodes = [ + { + id: config.id, + label: config.name, + type: "connection", + connectionId: config.id, + isExpanded: true, + children: [ + { + id: tableId, + label: "SHOP_ORDERS", + type: "table", + connectionId: config.id, + database: "SHOP_DEMO", + schema: "SYSDBA", + tableName: "SHOP_ORDERS", + isExpanded: false, + children: [], + }, + ], + }, + ]; + + await store.loadTableGroups(config.id, "SHOP_DEMO", "SHOP_ORDERS", "SYSDBA", tableId); + return { api, config, store, tableId }; +} + +describe("connectionStore Xugu table child metadata", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("adds Xugu-only constraint and partition groups without changing PostgreSQL groups", async () => { + const xugu = await setup("xugu"); + const xuguChildren = findNode(xugu.store.treeNodes, xugu.tableId)?.children?.map((node) => node.type); + expect(xuguChildren).toEqual(["group-columns", "group-constraints", "group-fkeys", "group-triggers", "group-indexes", "group-table-partitions", "group-table-subpartitions"]); + + vi.resetModules(); + setActivePinia(createPinia()); + const postgres = await setup("postgres"); + const postgresChildren = findNode(postgres.store.treeNodes, postgres.tableId)?.children?.map((node) => node.type); + expect(postgresChildren).toEqual(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]); + }); + + it("hides Xugu-only groups when the installed Agent predates their metadata methods", async () => { + const { store, tableId } = await setup("xugu", { + listInstalledAgentsLocal: vi.fn().mockResolvedValue([{ db_type: "xugu", installed: true, installed_version: "0.1.22" }]), + }); + + const childTypes = findNode(store.treeNodes, tableId)?.children?.map((node) => node.type); + expect(childTypes).toEqual(["group-columns", "group-fkeys", "group-triggers", "group-indexes"]); + }); + + it("routes Xugu child groups to their dedicated metadata calls, including empty results", async () => { + const listConstraints = vi.fn().mockResolvedValue([ + { + name: "PK_SHOP_ORDERS", + constraint_type: "PRIMARY KEY", + definition: '"ORDER_ID"', + columns: ["ORDER_ID"], + ref_columns: [], + deferrable: false, + initially_deferred: false, + enabled: true, + valid: true, + }, + ]); + const { api, config, store, tableId } = await setup("xugu", { listConstraints }); + + const constraints = findNode(store.treeNodes, `${tableId}:__constraints`)!; + const partitions = findNode(store.treeNodes, `${tableId}:__table-partitions`)!; + const subpartitions = findNode(store.treeNodes, `${tableId}:__table-subpartitions`)!; + + await store.loadTreeNodeChildren(constraints); + await store.loadTreeNodeChildren(partitions); + await store.loadTreeNodeChildren(subpartitions); + + expect(listConstraints).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined); + expect(api.listPartitions).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined); + expect(api.listSubpartitions).toHaveBeenCalledWith(config.id, "SHOP_DEMO", "SYSDBA", "SHOP_ORDERS", undefined); + expect(constraints.children?.[0]).toMatchObject({ type: "constraint", label: "PK_SHOP_ORDERS (PRIMARY KEY)" }); + expect(partitions.children).toEqual([]); + expect(subpartitions.children).toEqual([]); + expect(partitions.isExpanded).toBe(true); + expect(subpartitions.isExpanded).toBe(true); + }); + + it("keeps a rejected Xugu metadata group collapsed and clears its loading state", async () => { + const listPartitions = vi.fn().mockRejectedValue(new Error("metadata denied")); + const { store, tableId } = await setup("xugu", { listPartitions }); + const partitions = findNode(store.treeNodes, `${tableId}:__table-partitions`)!; + + await expect(store.loadTreeNodeChildren(partitions)).rejects.toThrow("metadata denied"); + + expect(partitions.isLoading).toBe(false); + expect(partitions.isExpanded).toBe(false); + expect(partitions.children).toEqual([]); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b542016e94..ecc77f6e0f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -87,7 +87,7 @@ import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation"; import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex"; import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; -import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; +import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; @@ -115,6 +115,7 @@ const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000; export const COMPLETION_METADATA_CONCURRENCY = 2; const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy"; const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)"; +const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23"; const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt"; function sidebarObjectGroupPageSize(): number { const settingsStore = useSettingsStore(); @@ -271,6 +272,7 @@ export const useConnectionStore = defineStore("connection", () => { const lastConnectionHealthCheckAt = ref>({}); const agentDrivers = ref([]); let agentDriversRefreshPromise: Promise | null = null; + let localAgentDriversRefreshPromise: Promise | null = null; const loadedTreeNodeChildrenIds = ref>(new Set()); const connectionErrors = ref>({}); const connectingIds = ref>(new Set()); @@ -669,6 +671,27 @@ export const useConnectionStore = defineStore("connection", () => { return agentDriversRefreshPromise; } + function refreshLocalAgentDrivers(): Promise { + if (localAgentDriversRefreshPromise) return localAgentDriversRefreshPromise; + localAgentDriversRefreshPromise = api + .listInstalledAgentsLocal() + .then((drivers) => { + agentDrivers.value = drivers; + }) + .catch(() => undefined) + .finally(() => { + localAgentDriversRefreshPromise = null; + }); + return localAgentDriversRefreshPromise; + } + + async function supportsXuguTableChildMetadata(): Promise { + if (!hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION)) { + await refreshLocalAgentDrivers(); + } + return hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION); + } + function maybeAppendAgentDriverUpdateHint(connectionId: string, baseMessage: string) { const config = getConfig(connectionId); const message = connectionErrorWithDriverUpdateHint(config, baseMessage); @@ -3805,9 +3828,26 @@ export const useConnectionStore = defineStore("connection", () => { ]; const config = getConfig(connectionId); - const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(config)); + const effectiveDbType = effectiveDatabaseTypeForConnection(config); + const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType); + const isXugu = effectiveDbType === "xugu"; + const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata()); + if (supportsXuguChildMetadata) { + children.push({ + id: `${parentId}:__constraints`, + label: "tree.constraints", + type: "group-constraints", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }); + } if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) { - if (metadataCapabilities.indexes) { + if (metadataCapabilities.indexes && !isXugu) { children.push({ id: `${parentId}:__indexes`, label: "tree.indexes", @@ -3851,6 +3891,50 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }); } + if (isXugu) { + if (metadataCapabilities.indexes) { + children.push({ + id: `${parentId}:__indexes`, + label: "tree.indexes", + type: "group-indexes", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }); + } + if (supportsXuguChildMetadata) { + children.push( + { + id: `${parentId}:__table-partitions`, + label: "tree.partitions", + type: "group-table-partitions", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }, + { + id: `${parentId}:__table-subpartitions`, + label: "tree.subpartitions", + type: "group-table-subpartitions", + connectionId, + database, + schema, + catalog, + tableName: table, + isExpanded: false, + children: [], + }, + ); + } + } } setChildren(node, children); @@ -4031,6 +4115,93 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadConstraints(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { + const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__constraints`; + const node = findNode(treeNodes.value, parentId); + if (!node) return; + node.isLoading = true; + try { + const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + setChildren( + node, + constraints.map((constraint) => ({ + id: `${parentId}:${constraint.name}`, + label: `${constraint.name} (${constraint.constraint_type})${constraint.valid ? "" : " · INVALID"}`, + type: "constraint" as const, + connectionId, + database, + schema, + tableName: table, + meta: constraint, + })), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + async function loadPartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { + const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-partitions`; + const node = findNode(treeNodes.value, parentId); + if (!node) return; + node.isLoading = true; + try { + const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + setChildren( + node, + partitions.map((partition) => ({ + id: `${parentId}:${partition.name}`, + label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, + type: "partition" as const, + connectionId, + database, + schema, + tableName: table, + meta: partition, + })), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + async function loadSubpartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { + const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-subpartitions`; + const node = findNode(treeNodes.value, parentId); + if (!node) return; + node.isLoading = true; + try { + const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); + setChildren( + node, + partitions.map((partition) => ({ + id: `${parentId}:${partition.name}`, + label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, + type: "subpartition" as const, + connectionId, + database, + schema, + tableName: table, + meta: partition, + })), + ); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + function collectExpandedNodeIds(nodes: TreeNode[], ids = new Set()): Set { for (const node of nodes) { if (node.isExpanded) ids.add(node.id); @@ -4107,6 +4278,12 @@ export const useConnectionStore = defineStore("connection", () => { await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-constraints" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await loadConstraints(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-table-partitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await loadPartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + } else if (node.type === "group-table-subpartitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await loadSubpartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (objectTypesForGroupNode(node.type)) { await loadObjectGroupChildren(node, options); } else if (node.type === "group-partitions") { @@ -5668,10 +5845,14 @@ export const useConnectionStore = defineStore("connection", () => { loadMoreObjectGroupChildren, loadAllObjectGroupChildren, loadTableGroups, + loadTreeNodeChildren, loadColumns, loadIndexes, loadForeignKeys, loadTriggers, + loadConstraints, + loadPartitions, + loadSubpartitions, listCompletionTables, listCompletionObjects, listCompletionColumns, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 970baa6f4d..15ebb8857d 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -467,6 +467,42 @@ export interface TriggerInfo { statement?: string | null; } +export interface ConstraintInfo { + name: string; + constraint_type: string; + definition: string; + columns: string[]; + ref_schema?: string | null; + ref_table?: string | null; + ref_columns: string[]; + match_type?: string | null; + on_update?: string | null; + on_delete?: string | null; + deferrable: boolean; + initially_deferred: boolean; + enabled: boolean; + valid: boolean; +} + +export interface PartitionInfo { + name: string; + position: number; + value: string; + partition_type: string; + partition_key: string; + online?: boolean | null; + auto_partition_type?: string | null; + auto_partition_span?: number | null; +} + +export interface SubpartitionInfo { + name: string; + position: number; + value: string; + partition_type: string; + partition_key: string; +} + export interface FunctionInfo { name: string; function_type: string; @@ -644,6 +680,9 @@ export type TreeNodeType = | "group-indexes" | "group-fkeys" | "group-triggers" + | "group-constraints" + | "group-table-partitions" + | "group-table-subpartitions" | "group-tables" | "group-views" | "group-materialized-views" @@ -667,6 +706,9 @@ export type TreeNodeType = | "index" | "fkey" | "trigger" + | "constraint" + | "partition" + | "subpartition" | "redis-db" | "mq-tenant" | "nacos-namespace" @@ -730,7 +772,7 @@ export interface TreeNode { tableSearchParentId?: string; savedSqlId?: string; savedSqlFolderId?: string; - meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta; + meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta; loadMore?: { parentId: string; offset: number; diff --git a/crates/dbx-core/assets/agent-protocol-v1.json b/crates/dbx-core/assets/agent-protocol-v1.json index db51f9650f..cec73f1784 100644 --- a/crates/dbx-core/assets/agent-protocol-v1.json +++ b/crates/dbx-core/assets/agent-protocol-v1.json @@ -52,6 +52,9 @@ "list_indexes", "list_foreign_keys", "list_triggers", + "list_constraints", + "list_partitions", + "list_subpartitions", "execute_query", "execute_query_page", "fetch_query_page", diff --git a/crates/dbx-core/assets/agent-protocol-v2.json b/crates/dbx-core/assets/agent-protocol-v2.json index 08900d5d76..96798e8e6a 100644 --- a/crates/dbx-core/assets/agent-protocol-v2.json +++ b/crates/dbx-core/assets/agent-protocol-v2.json @@ -59,6 +59,9 @@ "list_indexes", "list_foreign_keys", "list_triggers", + "list_constraints", + "list_partitions", + "list_subpartitions", "execute_query", "execute_query_page", "fetch_query_page", diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 25601dc9ce..c66e99073b 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -406,6 +406,9 @@ pub enum AgentMethod { ListIndexes, ListForeignKeys, ListTriggers, + ListConstraints, + ListPartitions, + ListSubpartitions, GetTableDdl, ExecuteQuery, ExecuteQueryPage, @@ -422,7 +425,7 @@ pub enum AgentMethod { } impl AgentMethod { - pub const ALL: [Self; 33] = [ + pub const ALL: [Self; 36] = [ Self::Handshake, Self::Connect, Self::OpenSession, @@ -444,6 +447,9 @@ impl AgentMethod { Self::ListIndexes, Self::ListForeignKeys, Self::ListTriggers, + Self::ListConstraints, + Self::ListPartitions, + Self::ListSubpartitions, Self::ExecuteQuery, Self::ExecuteQueryPage, Self::FetchQueryPage, @@ -481,6 +487,9 @@ impl AgentMethod { Self::ListIndexes => "list_indexes", Self::ListForeignKeys => "list_foreign_keys", Self::ListTriggers => "list_triggers", + Self::ListConstraints => "list_constraints", + Self::ListPartitions => "list_partitions", + Self::ListSubpartitions => "list_subpartitions", Self::ExecuteQuery => "execute_query", Self::ExecuteQueryPage => "execute_query_page", Self::FetchQueryPage => "fetch_query_page", @@ -1192,6 +1201,51 @@ impl AgentDriverClient { .await } + pub async fn list_constraints( + &mut self, + database: &str, + schema: &str, + table: &str, + timeout_duration: Option, + ) -> Result { + self.call_method_with_timeout( + AgentMethod::ListConstraints, + agent_schema_table_params(database, schema, table), + timeout_duration, + ) + .await + } + + pub async fn list_partitions( + &mut self, + database: &str, + schema: &str, + table: &str, + timeout_duration: Option, + ) -> Result { + self.call_method_with_timeout( + AgentMethod::ListPartitions, + agent_schema_table_params(database, schema, table), + timeout_duration, + ) + .await + } + + pub async fn list_subpartitions( + &mut self, + database: &str, + schema: &str, + table: &str, + timeout_duration: Option, + ) -> Result { + self.call_method_with_timeout( + AgentMethod::ListSubpartitions, + agent_schema_table_params(database, schema, table), + timeout_duration, + ) + .await + } + pub async fn get_table_ddl( &mut self, database: &str, @@ -2271,6 +2325,9 @@ for line in sys.stdin: assert_eq!(AgentMethod::ListIndexes.as_str(), "list_indexes"); assert_eq!(AgentMethod::ListForeignKeys.as_str(), "list_foreign_keys"); assert_eq!(AgentMethod::ListTriggers.as_str(), "list_triggers"); + assert_eq!(AgentMethod::ListConstraints.as_str(), "list_constraints"); + assert_eq!(AgentMethod::ListPartitions.as_str(), "list_partitions"); + assert_eq!(AgentMethod::ListSubpartitions.as_str(), "list_subpartitions"); assert_eq!(AgentMethod::GetTableDdl.as_str(), "get_table_ddl"); assert_eq!(AgentMethod::ExecuteQuery.as_str(), "execute_query"); assert_eq!(AgentMethod::ExecuteQueryPage.as_str(), "execute_query_page"); @@ -2323,6 +2380,9 @@ for line in sys.stdin: let _list_indexes = AgentDriverClient::list_indexes::; let _list_foreign_keys = AgentDriverClient::list_foreign_keys::; let _list_triggers = AgentDriverClient::list_triggers::; + let _list_constraints = AgentDriverClient::list_constraints::; + let _list_partitions = AgentDriverClient::list_partitions::; + let _list_subpartitions = AgentDriverClient::list_subpartitions::; let _get_table_ddl = AgentDriverClient::get_table_ddl::; let _execute_query = AgentDriverClient::execute_query::; let _execute_query_page = AgentDriverClient::execute_query_page::; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index fb33e8d1d0..dcc120a87e 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -5046,6 +5046,74 @@ 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. +pub async fn list_constraints_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || async { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).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; + } + Ok(vec![]) + }) + .await +} + +pub async fn list_partitions_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || async { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).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_partitions(database, schema, table, agent_metadata_timeout(db_config.as_ref())).await; + } + Ok(vec![]) + }) + .await +} + +pub async fn list_subpartitions_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || async { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).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_subpartitions(database, schema, table, agent_metadata_timeout(db_config.as_ref())) + .await; + } + Ok(vec![]) + }) + .await +} + pub async fn list_functions_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 0367ac9065..8e8b4a499d 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -282,6 +282,59 @@ pub struct TriggerInfo { pub statement: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConstraintInfo { + pub name: String, + pub constraint_type: String, + pub definition: String, + #[serde(default)] + pub columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ref_schema: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ref_table: Option, + #[serde(default)] + pub ref_columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub match_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_update: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_delete: Option, + #[serde(default)] + pub deferrable: bool, + #[serde(default)] + pub initially_deferred: bool, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub valid: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PartitionInfo { + pub name: String, + pub position: i32, + pub value: String, + pub partition_type: String, + pub partition_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub online: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_partition_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_partition_span: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubpartitionInfo { + pub name: String, + pub position: i32, + pub value: String, + pub partition_type: String, + pub partition_key: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FunctionInfo { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 975586a642..ed39e57617 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -298,6 +298,9 @@ async fn main() { .route("/schema/indexes", get(routes::schema::list_indexes)) .route("/schema/foreign-keys", get(routes::schema::list_foreign_keys)) .route("/schema/triggers", get(routes::schema::list_triggers)) + .route("/schema/constraints", get(routes::schema::list_constraints)) + .route("/schema/partitions", get(routes::schema::list_partitions)) + .route("/schema/subpartitions", get(routes::schema::list_subpartitions)) .route("/schema/functions", get(routes::schema::list_functions)) .route("/schema/sequences", get(routes::schema::list_sequences)) .route("/schema/rules", get(routes::schema::list_rules)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 76ec17d98f..591df52bce 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -403,6 +403,45 @@ pub async fn list_triggers( Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) } +pub async fn list_constraints( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + 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 result = dbx_core::schema::list_constraints_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) +} + +pub async fn list_partitions( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + 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 result = dbx_core::schema::list_partitions_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) +} + +pub async fn list_subpartitions( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + 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 result = dbx_core::schema::list_subpartitions_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError::from)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?)) +} + pub async fn get_ddl( State(state): State>, Query(q): Query, diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 58b27ab48f..70bbf0a133 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -385,6 +385,39 @@ pub async fn list_triggers( dbx_core::schema::list_triggers_core(&state, &connection_id, &database, &schema, &table).await } +#[tauri::command] +pub async fn list_constraints( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + table: String, +) -> Result, String> { + dbx_core::schema::list_constraints_core(&state, &connection_id, &database, &schema, &table).await +} + +#[tauri::command] +pub async fn list_partitions( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + table: String, +) -> Result, String> { + dbx_core::schema::list_partitions_core(&state, &connection_id, &database, &schema, &table).await +} + +#[tauri::command] +pub async fn list_subpartitions( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + table: String, +) -> Result, String> { + dbx_core::schema::list_subpartitions_core(&state, &connection_id, &database, &schema, &table).await +} + #[tauri::command] pub async fn get_table_ddl( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 341312d593..adbe45bc22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1409,6 +1409,9 @@ pub fn run() { commands::schema::list_indexes, commands::schema::list_foreign_keys, commands::schema::list_triggers, + commands::schema::list_constraints, + commands::schema::list_partitions, + commands::schema::list_subpartitions, commands::schema::get_table_ddl, commands::schema::list_functions, commands::schema::list_sequences,