diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 0384e18643..6a69ad65fa 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -304,11 +304,12 @@ type tableInfo struct { } type objectInfo struct { - Name string `json:"name"` - ObjectType string `json:"object_type"` - Schema string `json:"schema"` - Comment *string `json:"comment"` - Valid *bool `json:"valid,omitempty"` + Name string `json:"name"` + ObjectType string `json:"object_type"` + Schema string `json:"schema"` + Comment *string `json:"comment"` + Valid *bool `json:"valid,omitempty"` + XuguTypeMembersExpandable *bool `json:"xugu_type_members_expandable,omitempty"` } type metadataListConstraints struct { @@ -1940,15 +1941,19 @@ func readXuguObjectRows(rows *sql.Rows, schema string) ([]objectInfo, error) { var result []objectInfo for rows.Next() { var item objectInfo - var valid any + var valid, xuguTypeMembersExpandable any item.Schema = schema - if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment, &valid); err != nil { + if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment, &valid, &xuguTypeMembersExpandable); err != nil { return nil, err } if valid != nil { value := truthy(valid) item.Valid = &value } + if normalizeValue(xuguTypeMembersExpandable) != nil { + value := truthy(xuguTypeMembersExpandable) + item.XuguTypeMembersExpandable = &value + } result = append(result, item) } return emptyIfNil(result), rows.Err() @@ -2095,13 +2100,13 @@ func xuguListObjectsQuery(schema string, constraints metadataListConstraints) xu } sources := []objectSource{ {objectTypes: []string{"TABLE"}, sql: ` -SELECT t.TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE, t.COMMENTS, NULL AS VALID +SELECT t.TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE, t.COMMENTS, NULL AS VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_TABLES t JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?)`}, {objectTypes: []string{"VIEW"}, sql: ` -SELECT v.VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE, v.COMMENTS, v.VALID +SELECT v.VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE, v.COMMENTS, v.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_VIEWS v JOIN ALL_SCHEMAS s ON s.DB_ID = v.DB_ID AND s.SCHEMA_ID = v.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID @@ -2109,52 +2114,53 @@ WHERE s.DB_ID = CURRENT_DB_ID {objectTypes: []string{"PROCEDURE", "FUNCTION"}, sql: ` SELECT p.PROC_NAME AS OBJECT_NAME, CASE WHEN p.RET_TYPE IS NULL THEN 'PROCEDURE' ELSE 'FUNCTION' END AS OBJECT_TYPE, - p.COMMENTS, p.VALID + p.COMMENTS, p.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_PROCEDURES p JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?)`}, {objectTypes: []string{"PACKAGE"}, sql: ` -SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE' AS OBJECT_TYPE, p.COMMENTS, p.VALID +SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE' AS OBJECT_TYPE, p.COMMENTS, p.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_PACKAGES p JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?)`}, {objectTypes: []string{"PACKAGE_BODY"}, sql: ` -SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE_BODY' AS OBJECT_TYPE, p.COMMENTS, p.ALL_OK +SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE_BODY' AS OBJECT_TYPE, p.COMMENTS, p.ALL_OK, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_PACKAGES p JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?) AND p.BODY IS NOT NULL`}, {objectTypes: []string{"TRIGGER"}, sql: ` -SELECT tr.TRIG_NAME AS OBJECT_NAME, 'TRIGGER' AS OBJECT_TYPE, tr.COMMENTS, tr.VALID +SELECT tr.TRIG_NAME AS OBJECT_NAME, 'TRIGGER' AS OBJECT_TYPE, tr.COMMENTS, tr.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_TRIGGERS tr JOIN ALL_SCHEMAS s ON s.DB_ID = tr.DB_ID AND s.SCHEMA_ID = tr.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?)`}, {objectTypes: []string{"SEQUENCE"}, sql: ` - SELECT q.SEQ_NAME AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS COMMENTS, NULL AS VALID + SELECT q.SEQ_NAME AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS COMMENTS, NULL AS VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_SEQUENCES q JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?) AND q.IS_SYS = FALSE`}, {objectTypes: []string{"SYNONYM"}, sql: ` -SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID +SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_SYNONYMS y JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?) AND y.IS_PUBLIC = FALSE`}, {objectTypes: []string{"TYPE"}, sql: ` -SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID +SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID, + CASE WHEN u.UDT_TYPE = 1001 THEN TRUE ELSE FALSE END AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_TYPES u JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?)`}, {objectTypes: []string{"TYPE_BODY"}, sql: ` -SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE_BODY' AS OBJECT_TYPE, u.COMMENTS, u.VALID +SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE_BODY' AS OBJECT_TYPE, u.COMMENTS, u.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE FROM ALL_TYPES u JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID @@ -2195,7 +2201,7 @@ WHERE s.DB_ID = CURRENT_DB_ID } return xuguConstrainedMetadataListQuery( strings.Join(baseSQLParts, "\nUNION ALL\n"), - "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", + "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID, XUGU_TYPE_MEMBERS_EXPANDABLE", "OBJECT_NAME", "OBJECT_TYPE", baseArgs, diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 9f424d9280..4fe4b4b913 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -1010,7 +1010,7 @@ func TestXuguListObjectsQueryIncludesProgrammableObjects(t *testing.T) { ObjectTypes: []string{"procedure", "function", "package", "package-body", "trigger", "sequence", "synonym", "type", "type-body"}, }) - for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_SYNONYMS", "y.IS_PUBLIC = FALSE", "ALL_TYPES", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?,?)"} { + for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_SYNONYMS", "y.IS_PUBLIC = FALSE", "ALL_TYPES", "u.UDT_TYPE = 1001", "XUGU_TYPE_MEMBERS_EXPANDABLE", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?,?)"} { if !strings.Contains(query.SQL, want) { t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL) } @@ -2441,8 +2441,8 @@ func (c *xuguPermissionMetadataConn) QueryContext(_ context.Context, query strin } if strings.Contains(upper, "FROM ALL_SYNONYMS") && strings.Contains(upper, "AS OBJECT_TYPE") && !strings.Contains(upper, "FROM ALL_TABLES") { return &xuguStaticRows{ - columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "VALID"}, - values: [][]driver.Value{{"PRIVATE_SYNONYM", "SYNONYM", nil, true}}, + columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "VALID", "XUGU_TYPE_MEMBERS_EXPANDABLE"}, + values: [][]driver.Value{{"PRIVATE_SYNONYM", "SYNONYM", nil, true, nil}}, }, nil } if strings.Contains(upper, "ALL_") || strings.Contains(upper, "SYS_") { diff --git a/agents/drivers/xugu/package_members.go b/agents/drivers/xugu/package_members.go index 43de0ae606..56d1db315b 100644 --- a/agents/drivers/xugu/package_members.go +++ b/agents/drivers/xugu/package_members.go @@ -27,38 +27,6 @@ WHERE s.DB_ID = CURRENT_DB_ID ORDER BY CASE WHEN s.SCHEMA_NAME = ? THEN 0 ELSE 1 END, CASE WHEN p.PACK_NAME = ? THEN 0 ELSE 1 END` -type completionAssistantRequest struct { - ConnectionID string `json:"connection_id"` - Database string `json:"database"` - Schema string `json:"schema"` - ObjectKinds []string `json:"object_kinds"` - Mask string `json:"mask"` - CaseSensitive bool `json:"case_sensitive"` - GlobalSearch bool `json:"global_search"` - MaxResults int `json:"max_results"` - ParentSchema string `json:"parent_schema"` - ParentName string `json:"parent_name"` - MatchMode string `json:"match_mode"` -} - -type completionAssistantCandidate struct { - Name string `json:"name"` - Kind string `json:"kind"` - Database *string `json:"database"` - Schema *string `json:"schema"` - ParentSchema *string `json:"parent_schema"` - ParentName *string `json:"parent_name"` - Comment *string `json:"comment"` - DataType *string `json:"data_type"` - Signature *string `json:"signature"` -} - -type completionAssistantResponse struct { - Candidates []completionAssistantCandidate `json:"candidates"` - Incomplete bool `json:"incomplete"` - FallbackUsed bool `json:"fallback_used"` -} - type xuguPackageMember struct { Name string Kind string @@ -66,7 +34,7 @@ type xuguPackageMember struct { ReturnType string } -func (s *server) completionAssistantSearch(request completionAssistantRequest) (completionAssistantResponse, error) { +func (s *server) completionAssistantSearchPackageMembers(request completionAssistantRequest) (completionAssistantResponse, error) { parentName := strings.TrimSpace(request.ParentName) if parentName == "" || !xuguCompletionRequestsRoutines(request.ObjectKinds) { return completionAssistantResponse{}, errors.New("completion assistant search is not supported for this request") diff --git a/agents/drivers/xugu/type_members.go b/agents/drivers/xugu/type_members.go new file mode 100644 index 0000000000..2fcc69a07e --- /dev/null +++ b/agents/drivers/xugu/type_members.go @@ -0,0 +1,753 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// DBeaver exposes object type members through ALL_TYPE_ATTRS, +// ALL_TYPE_METHODS, and ALL_METHOD_PARAMS. Prefer that catalogue-based model: +// it retains overload numbers, parameter modes, and type modifiers. A source +// fallback is used only on server versions that do not expose those views. +const xuguObjectTypeSQL = ` +SELECT u.UDT_TYPE, COALESCE(TO_CHAR(u.SPEC), '') +FROM ALL_TYPES u +JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID +WHERE s.DB_ID = CURRENT_DB_ID + AND s.SCHEMA_NAME = ? + AND u.TYPE_NAME = ?` + +const xuguObjectTypeCaseFoldedSQL = ` +SELECT u.UDT_TYPE, COALESCE(TO_CHAR(u.SPEC), '') +FROM ALL_TYPES u +JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID +WHERE s.DB_ID = CURRENT_DB_ID + AND UPPER(s.SCHEMA_NAME) = UPPER(?) + AND UPPER(u.TYPE_NAME) = UPPER(?) +ORDER BY CASE WHEN s.SCHEMA_NAME = ? THEN 0 ELSE 1 END, + CASE WHEN u.TYPE_NAME = ? THEN 0 ELSE 1 END` + +const xuguTypeAttributesSQL = ` +SELECT ATTR_NAME, ATTR_NO, ATTR_TYPE_OWNER, ATTR_TYPE_NAME, ATTR_TYPE_MOD +FROM ALL_TYPE_ATTRS +WHERE OWNER = ? + AND TYPE_NAME = ? +ORDER BY ATTR_NO` + +const xuguTypeAttributesCaseFoldedSQL = ` +SELECT ATTR_NAME, ATTR_NO, ATTR_TYPE_OWNER, ATTR_TYPE_NAME, ATTR_TYPE_MOD +FROM ALL_TYPE_ATTRS +WHERE UPPER(OWNER) = UPPER(?) + AND UPPER(TYPE_NAME) = UPPER(?) +ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, + CASE WHEN TYPE_NAME = ? THEN 0 ELSE 1 END, + ATTR_NO` + +const xuguTypeMethodsSQL = ` +SELECT M.METHOD_NAME, M.METHOD_NO, M.METHOD_TYPE, + R.RESULT_TYPE_OWNER, R.RESULT_TYPE_NAME, R.RESULT_TYPE_MOD +FROM ALL_TYPE_METHODS M +LEFT OUTER JOIN ALL_METHOD_RESULTS R + ON R.OWNER = M.OWNER + AND R.TYPE_NAME = M.TYPE_NAME + AND R.METHOD_NAME = M.METHOD_NAME + AND R.METHOD_NO = M.METHOD_NO +WHERE M.OWNER = ? + AND M.TYPE_NAME = ? +ORDER BY M.METHOD_NO` + +const xuguTypeMethodsCaseFoldedSQL = ` +SELECT M.METHOD_NAME, M.METHOD_NO, M.METHOD_TYPE, + R.RESULT_TYPE_OWNER, R.RESULT_TYPE_NAME, R.RESULT_TYPE_MOD +FROM ALL_TYPE_METHODS M +LEFT OUTER JOIN ALL_METHOD_RESULTS R + ON R.OWNER = M.OWNER + AND R.TYPE_NAME = M.TYPE_NAME + AND R.METHOD_NAME = M.METHOD_NAME + AND R.METHOD_NO = M.METHOD_NO +WHERE UPPER(M.OWNER) = UPPER(?) + AND UPPER(M.TYPE_NAME) = UPPER(?) +ORDER BY CASE WHEN M.OWNER = ? THEN 0 ELSE 1 END, + CASE WHEN M.TYPE_NAME = ? THEN 0 ELSE 1 END, + M.METHOD_NO` + +const xuguTypeMethodParametersSQL = ` +SELECT PARAM_NAME, PARAM_NO, PARAM_MODE, PARAM_TYPE_OWNER, PARAM_TYPE_NAME, PARAM_TYPE_MOD +FROM ALL_METHOD_PARAMS +WHERE OWNER = ? + AND TYPE_NAME = ? + AND METHOD_NAME = ? + AND METHOD_NO = ? +ORDER BY PARAM_NO` + +const xuguTypeMethodParametersCaseFoldedSQL = ` +SELECT PARAM_NAME, PARAM_NO, PARAM_MODE, PARAM_TYPE_OWNER, PARAM_TYPE_NAME, PARAM_TYPE_MOD +FROM ALL_METHOD_PARAMS +WHERE UPPER(OWNER) = UPPER(?) + AND UPPER(TYPE_NAME) = UPPER(?) + AND UPPER(METHOD_NAME) = UPPER(?) + AND METHOD_NO = ? +ORDER BY CASE WHEN OWNER = ? THEN 0 ELSE 1 END, + CASE WHEN TYPE_NAME = ? THEN 0 ELSE 1 END, + CASE WHEN METHOD_NAME = ? THEN 0 ELSE 1 END, + PARAM_NO` + +const xuguObjectTypeKind = 1001 + +type completionAssistantRequest struct { + ConnectionID string `json:"connection_id"` + Database string `json:"database"` + Schema string `json:"schema"` + ObjectKinds []string `json:"object_kinds"` + Mask string `json:"mask"` + CaseSensitive bool `json:"case_sensitive"` + GlobalSearch bool `json:"global_search"` + MaxResults int `json:"max_results"` + ParentSchema string `json:"parent_schema"` + ParentName string `json:"parent_name"` + ParentType string `json:"parent_type"` + MatchMode string `json:"match_mode"` +} + +type completionAssistantCandidate struct { + Name string `json:"name"` + Kind string `json:"kind"` + Database *string `json:"database"` + Schema *string `json:"schema"` + ParentSchema *string `json:"parent_schema"` + ParentName *string `json:"parent_name"` + Comment *string `json:"comment"` + DataType *string `json:"data_type"` + Signature *string `json:"signature"` +} + +type completionAssistantResponse struct { + Candidates []completionAssistantCandidate `json:"candidates"` + Incomplete bool `json:"incomplete"` + FallbackUsed bool `json:"fallback_used"` +} + +type xuguTypeAttribute struct { + Name string + Number int + DataType string +} + +type xuguTypeMethod struct { + Name string + Number int + Kind string + Signature string + ReturnType string +} + +type xuguTypeDefinition struct { + Kind int + Spec string +} + +func (s *server) completionAssistantSearch(request completionAssistantRequest) (completionAssistantResponse, error) { + switch strings.ToLower(strings.TrimSpace(request.ParentType)) { + case "type": + return s.completionAssistantSearchTypeMembers(request) + case "package": + return s.completionAssistantSearchPackageMembers(request) + } + // Package members shipped before type members. Keep that request shape + // working for already-running desktop clients that do not send parent_type. + return s.completionAssistantSearchPackageMembers(request) +} + +func (s *server) completionAssistantSearchTypeMembers(request completionAssistantRequest) (completionAssistantResponse, error) { + parentName := strings.TrimSpace(request.ParentName) + if parentName == "" || !xuguCompletionRequestsTypeMembers(request.ObjectKinds) { + return completionAssistantResponse{}, errors.New("completion assistant search is not supported for this request") + } + parentSchema := strings.TrimSpace(request.ParentSchema) + if parentSchema == "" { + parentSchema = strings.TrimSpace(request.Schema) + } + if parentSchema == "" { + return completionAssistantResponse{}, errors.New("parent_schema is required for Xugu type members") + } + + typeDefinition, err := s.xuguTypeDefinition(parentSchema, parentName) + if err != nil { + return completionAssistantResponse{}, err + } + if typeDefinition.Kind != xuguObjectTypeKind { + return completionAssistantResponse{Candidates: []completionAssistantCandidate{}, FallbackUsed: false}, nil + } + + limit := request.MaxResults + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + candidates := make([]completionAssistantCandidate, 0) + if xuguCompletionRequestsTypeAttributes(request.ObjectKinds) { + attributes, err := s.xuguTypeAttributes(parentSchema, parentName) + if err != nil || len(attributes) == 0 { + attributes = parseXuguObjectTypeAttributes(typeDefinition.Spec) + } + for _, attribute := range attributes { + if !xuguCompletionNameMatches(attribute.Name, request) { + continue + } + candidates = append(candidates, xuguTypeAttributeCandidate(request.Database, parentSchema, parentName, attribute)) + } + } + if xuguCompletionRequestsTypeMethods(request.ObjectKinds) { + methods, err := s.xuguTypeMethods(parentSchema, parentName) + if err != nil || len(methods) == 0 { + methods = parseXuguObjectTypeMethods(typeDefinition.Spec) + } + for _, method := range methods { + if !xuguCompletionMethodKindRequested(method.Kind, request.ObjectKinds) || !xuguCompletionNameMatches(method.Name, request) { + continue + } + candidates = append(candidates, xuguTypeMethodCandidate(request.Database, parentSchema, parentName, method)) + } + } + + incomplete := len(candidates) > limit + if incomplete { + candidates = candidates[:limit] + } + return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete, FallbackUsed: false}, nil +} + +func (s *server) xuguTypeDefinition(schema, typeName string) (xuguTypeDefinition, error) { + query := func(sqlText string, args ...any) (xuguTypeDefinition, bool, error) { + rows, err := s.queryRows(sqlText, args) + if err != nil { + return xuguTypeDefinition{}, false, err + } + defer s.closeRows(rows) + if !rows.Next() { + return xuguTypeDefinition{}, false, rows.Err() + } + var kind sql.NullInt64 + var spec sql.NullString + if err := rows.Scan(&kind, &spec); err != nil { + return xuguTypeDefinition{}, false, err + } + return xuguTypeDefinition{Kind: int(kind.Int64), Spec: spec.String}, true, nil + } + if definition, found, err := query(xuguObjectTypeSQL, schema, typeName); err != nil || found { + return definition, err + } + definition, found, err := query(xuguObjectTypeCaseFoldedSQL, schema, typeName, schema, typeName) + if err != nil { + return xuguTypeDefinition{}, err + } + if found { + return definition, nil + } + return xuguTypeDefinition{}, fmt.Errorf("type not found: %s.%s", schema, typeName) +} + +// Some current XuguDB builds expose ALL_TYPES but do not publish the auxiliary +// ALL_TYPE_ATTRS / ALL_TYPE_METHODS views that older DBeaver integrations use. +// In that case the type specification is still authoritative and is the only +// low-privilege metadata source available. Parse only its top-level object +// declaration; this fallback never inspects or guesses from a TYPE BODY. +func parseXuguObjectTypeAttributes(spec string) []xuguTypeAttribute { + attributes, _ := parseXuguObjectTypeMembers(spec) + return attributes +} + +func parseXuguObjectTypeMethods(spec string) []xuguTypeMethod { + _, methods := parseXuguObjectTypeMembers(spec) + return methods +} + +func parseXuguObjectTypeMembers(spec string) ([]xuguTypeAttribute, []xuguTypeMethod) { + body, ok := xuguObjectTypeMemberBody(spec) + if !ok { + return nil, nil + } + attributes := make([]xuguTypeAttribute, 0) + methods := make([]xuguTypeMethod, 0) + for index, declaration := range splitXuguTypeTopLevel(body, ',') { + declaration = strings.TrimSpace(declaration) + if declaration == "" { + continue + } + if method, ok := parseXuguObjectTypeMethod(declaration, index+1); ok { + methods = append(methods, method) + continue + } + if attribute, ok := parseXuguObjectTypeAttribute(declaration, index+1); ok { + attributes = append(attributes, attribute) + } + } + return attributes, methods +} + +func xuguObjectTypeMemberBody(spec string) (string, bool) { + index := xuguIndexKeywordOutsideSQL(spec, "OBJECT") + if index < 0 { + return "", false + } + start := index + len("OBJECT") + for start < len(spec) && isXuguTypeSpace(spec[start]) { + start++ + } + if start >= len(spec) || spec[start] != '(' { + return "", false + } + end, ok := xuguTypeClosingParen(spec, start) + if !ok { + return "", false + } + return spec[start+1 : end], true +} + +func parseXuguObjectTypeAttribute(declaration string, number int) (xuguTypeAttribute, bool) { + name, next, ok := readXuguTypeIdentifier(declaration, 0) + if !ok { + return xuguTypeAttribute{}, false + } + dataType := strings.TrimSpace(declaration[next:]) + if dataType == "" { + return xuguTypeAttribute{}, false + } + return xuguTypeAttribute{Name: name, Number: number, DataType: compactXuguTypeFragment(dataType)}, true +} + +func parseXuguObjectTypeMethod(declaration string, number int) (xuguTypeMethod, bool) { + keywordStart := 0 + for { + word, next, ok := readXuguTypeIdentifier(declaration, keywordStart) + if !ok { + return xuguTypeMethod{}, false + } + upper := strings.ToUpper(word) + if upper == "STATIC" || upper == "MEMBER" || upper == "FINAL" || upper == "INSTANTIABLE" || upper == "OVERRIDING" || upper == "NOT" { + keywordStart = next + continue + } + if upper == "CONSTRUCTOR" { + procedure, afterProcedure, ok := readXuguTypeIdentifier(declaration, next) + if !ok || !strings.EqualFold(procedure, "PROCEDURE") { + return xuguTypeMethod{}, false + } + upper = "PROCEDURE" + next = afterProcedure + } + if upper != "FUNCTION" && upper != "PROCEDURE" { + return xuguTypeMethod{}, false + } + name, afterName, ok := readXuguTypeIdentifier(declaration, next) + if !ok { + return xuguTypeMethod{}, false + } + remainder := strings.TrimSpace(declaration[afterName:]) + signature := "" + if strings.HasPrefix(remainder, "(") { + end, ok := xuguTypeClosingParen(remainder, 0) + if !ok { + return xuguTypeMethod{}, false + } + signature = xuguTypeParameterSignature(remainder[1:end]) + remainder = strings.TrimSpace(remainder[end+1:]) + } + returnType := "" + if upper == "FUNCTION" { + if returnIndex := xuguIndexKeywordOutsideSQL(remainder, "RETURN"); returnIndex >= 0 { + returnType = xuguTypeReturnType(remainder[returnIndex+len("RETURN"):]) + } + } + return xuguTypeMethod{Name: name, Number: number, Kind: upper, Signature: signature, ReturnType: returnType}, true + } +} + +func xuguTypeReturnType(value string) string { + end := len(value) + for _, keyword := range []string{"PIPELINED", "DETERMINISTIC", "PARALLEL_ENABLE", "RESULT_CACHE", "AUTHID"} { + if index := xuguIndexKeywordOutsideSQL(value, keyword); index >= 0 && index < end { + end = index + } + } + return compactXuguTypeFragment(value[:end]) +} + +func xuguTypeParameterSignature(value string) string { + parameters := splitXuguTypeTopLevel(value, ',') + result := make([]string, 0, len(parameters)) + for _, parameter := range parameters { + parameter = strings.TrimSpace(parameter) + if parameter == "" { + continue + } + // DBeaver suppresses the implicit IN mode in routine signatures. Keep OUT + // and IN OUT visible because they change call semantics. + if _, afterName, ok := readXuguTypeIdentifier(parameter, 0); ok { + remainder := strings.TrimSpace(parameter[afterName:]) + upper := strings.ToUpper(remainder) + if strings.HasPrefix(upper, "IN ") && !strings.HasPrefix(upper, "IN OUT ") { + parameter = strings.TrimSpace(parameter[:afterName] + " " + remainder[len("IN "):]) + } + } + result = append(result, compactXuguTypeFragment(parameter)) + } + return strings.Join(result, ", ") +} + +func compactXuguTypeFragment(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func readXuguTypeIdentifier(value string, start int) (string, int, bool) { + start = skipXuguTypeSpaceAndComments(value, start) + if start >= len(value) { + return "", start, false + } + if value[start] == '"' { + return readXuguQuotedIdentifier(value, start) + } + if !isXuguTypeIdentifierStart(value[start]) { + return "", start, false + } + end := start + 1 + for end < len(value) && isXuguTypeIdentifierPart(value[end]) { + end++ + } + return value[start:end], end, true +} + +func skipXuguTypeSpaceAndComments(value string, start int) int { + for start < len(value) { + if isXuguTypeSpace(value[start]) { + start++ + continue + } + next, skipped := skipXuguTypeQuotedOrComment(value, start) + if !skipped || value[start] == '\'' || value[start] == '"' { + return start + } + start = next + } + return start +} + +func isXuguTypeIdentifierStart(value byte) bool { + return value == '_' || value == '$' || value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' +} + +func isXuguTypeIdentifierPart(value byte) bool { + return isXuguTypeIdentifierStart(value) || value >= '0' && value <= '9' || value == '#' +} + +func isXuguTypeSpace(value byte) bool { + return value == ' ' || value == '\t' || value == '\n' || value == '\r' +} + +func xuguTypeClosingParen(value string, start int) (int, bool) { + depth := 0 + for index := start; index < len(value); index++ { + next, skipped := skipXuguTypeQuotedOrComment(value, index) + if skipped { + index = next - 1 + continue + } + switch value[index] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return index, true + } + } + } + return 0, false +} + +func splitXuguTypeTopLevel(value string, separator byte) []string { + result := make([]string, 0) + start, depth := 0, 0 + for index := 0; index < len(value); index++ { + next, skipped := skipXuguTypeQuotedOrComment(value, index) + if skipped { + index = next - 1 + continue + } + switch value[index] { + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + default: + if value[index] == separator && depth == 0 { + result = append(result, value[start:index]) + start = index + 1 + } + } + } + return append(result, value[start:]) +} + +func xuguIndexKeywordOutsideSQL(value, keyword string) int { + upperKeyword := strings.ToUpper(keyword) + for index := 0; index < len(value); { + next, skipped := skipXuguTypeQuotedOrComment(value, index) + if skipped { + index = next + continue + } + if isXuguTypeSpace(value[index]) { + index++ + continue + } + word, next, ok := readXuguTypeIdentifier(value, index) + if !ok { + index++ + continue + } + if strings.ToUpper(word) == upperKeyword { + return index + } + index = next + } + return -1 +} + +func skipXuguTypeQuotedOrComment(value string, start int) (int, bool) { + if start >= len(value) { + return start, false + } + switch value[start] { + case '\'', '"': + quote := value[start] + for index := start + 1; index < len(value); index++ { + if value[index] != quote { + continue + } + if index+1 < len(value) && value[index+1] == quote { + index++ + continue + } + return index + 1, true + } + return len(value), true + case '-': + if start+1 < len(value) && value[start+1] == '-' { + index := strings.IndexByte(value[start+2:], '\n') + if index < 0 { + return len(value), true + } + return start + index + 3, true + } + case '/': + if start+1 < len(value) && value[start+1] == '*' { + index := strings.Index(value[start+2:], "*/") + if index < 0 { + return len(value), true + } + return start + index + 4, true + } + } + return start, false +} + +func (s *server) xuguTypeAttributes(schema, typeName string) ([]xuguTypeAttribute, error) { + load := func(sqlText string, args ...any) ([]xuguTypeAttribute, error) { + rows, err := s.queryRows(sqlText, args) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + attributes := make([]xuguTypeAttribute, 0) + for rows.Next() { + var name, owner, dataType, modifier sql.NullString + var number sql.NullInt64 + if err := rows.Scan(&name, &number, &owner, &dataType, &modifier); err != nil { + return nil, err + } + attributes = append(attributes, xuguTypeAttribute{Name: name.String, Number: int(number.Int64), DataType: xuguQualifiedTypeName(owner.String, dataType.String, modifier.String)}) + } + return attributes, rows.Err() + } + attributes, err := load(xuguTypeAttributesSQL, schema, typeName) + if err != nil || len(attributes) > 0 { + return attributes, err + } + return load(xuguTypeAttributesCaseFoldedSQL, schema, typeName, schema, typeName) +} + +func (s *server) xuguTypeMethods(schema, typeName string) ([]xuguTypeMethod, error) { + load := func(sqlText string, args ...any) ([]xuguTypeMethod, error) { + rows, err := s.queryRows(sqlText, args) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + methods := make([]xuguTypeMethod, 0) + for rows.Next() { + var name, methodType, resultOwner, resultType, resultModifier sql.NullString + var number sql.NullInt64 + if err := rows.Scan(&name, &number, &methodType, &resultOwner, &resultType, &resultModifier); err != nil { + return nil, err + } + kind := strings.ToUpper(strings.TrimSpace(methodType.String)) + if kind != "FUNCTION" && kind != "PROCEDURE" { + continue + } + parameters, err := s.xuguTypeMethodParameters(schema, typeName, name.String, int(number.Int64)) + if err != nil { + return nil, err + } + methods = append(methods, xuguTypeMethod{ + Name: name.String, + Number: int(number.Int64), + Kind: kind, + Signature: strings.Join(parameters, ", "), + ReturnType: xuguQualifiedTypeName(resultOwner.String, resultType.String, resultModifier.String), + }) + } + return methods, rows.Err() + } + methods, err := load(xuguTypeMethodsSQL, schema, typeName) + if err != nil || len(methods) > 0 { + return methods, err + } + return load(xuguTypeMethodsCaseFoldedSQL, schema, typeName, schema, typeName) +} + +func (s *server) xuguTypeMethodParameters(schema, typeName, methodName string, methodNumber int) ([]string, error) { + load := func(sqlText string, args ...any) ([]string, error) { + rows, err := s.queryRows(sqlText, args) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + parameters := make([]string, 0) + for rows.Next() { + var name, mode, owner, dataType, modifier sql.NullString + var number sql.NullInt64 + if err := rows.Scan(&name, &number, &mode, &owner, &dataType, &modifier); err != nil { + return nil, err + } + parts := make([]string, 0, 3) + if value := strings.TrimSpace(mode.String); value != "" && !strings.EqualFold(value, "IN") { + parts = append(parts, strings.ToUpper(value)) + } + if value := strings.TrimSpace(name.String); value != "" { + parts = append(parts, value) + } + if value := xuguQualifiedTypeName(owner.String, dataType.String, modifier.String); value != "" { + parts = append(parts, value) + } + parameters = append(parameters, strings.Join(parts, " ")) + } + return parameters, rows.Err() + } + parameters, err := load(xuguTypeMethodParametersSQL, schema, typeName, methodName, methodNumber) + if err != nil || len(parameters) > 0 { + return parameters, err + } + return load(xuguTypeMethodParametersCaseFoldedSQL, schema, typeName, methodName, methodNumber, schema, typeName, methodName) +} + +func xuguCompletionRequestsTypeMembers(kinds []string) bool { + return xuguCompletionRequestsTypeAttributes(kinds) || xuguCompletionRequestsTypeMethods(kinds) +} + +func xuguCompletionRequestsTypeAttributes(kinds []string) bool { + for _, kind := range kinds { + if strings.EqualFold(strings.TrimSpace(kind), "column") { + return true + } + } + return false +} + +func xuguCompletionRequestsTypeMethods(kinds []string) bool { + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine", "procedure", "function": + return true + } + } + return false +} + +func xuguCompletionMethodKindRequested(methodKind string, kinds []string) bool { + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine": + return true + case "procedure": + if methodKind == "PROCEDURE" { + return true + } + case "function": + if methodKind == "FUNCTION" { + return true + } + } + } + return false +} + +func xuguQualifiedTypeName(owner, name, modifier string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + owner = strings.TrimSpace(owner) + modifier = strings.TrimSpace(modifier) + if owner != "" { + name = owner + "." + name + } + if modifier != "" { + if strings.HasPrefix(modifier, "(") { + name += modifier + } else { + name += " " + modifier + } + } + return name +} + +func xuguTypeAttributeCandidate(database, schema, typeName string, attribute xuguTypeAttribute) completionAssistantCandidate { + return completionAssistantCandidate{ + Name: attribute.Name, + Kind: "column", + Database: xuguStringPointerOrNil(database), + Schema: xuguStringPointerOrNil(schema), + ParentSchema: xuguStringPointerOrNil(schema), + ParentName: xuguStringPointerOrNil(typeName), + DataType: xuguStringPointerOrNil(attribute.DataType), + } +} + +func xuguTypeMethodCandidate(database, schema, typeName string, method xuguTypeMethod) completionAssistantCandidate { + return completionAssistantCandidate{ + Name: method.Name, + Kind: strings.ToLower(method.Kind), + Database: xuguStringPointerOrNil(database), + Schema: xuguStringPointerOrNil(schema), + ParentSchema: xuguStringPointerOrNil(schema), + ParentName: xuguStringPointerOrNil(typeName), + DataType: xuguStringPointerOrNil(method.ReturnType), + Signature: xuguStringPointerOrNil(method.Signature), + } +} + +func xuguStringPointerOrNil(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} diff --git a/agents/drivers/xugu/type_members_live_test.go b/agents/drivers/xugu/type_members_live_test.go new file mode 100644 index 0000000000..6407c9c8a1 --- /dev/null +++ b/agents/drivers/xugu/type_members_live_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "os" + "testing" + "time" +) + +func TestLiveXuguTypeMemberCatalog(t *testing.T) { + if os.Getenv("XUGU_LIVE_TEST") != "1" { + t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings to run the XuguDB integration test") + } + params := connectParams{ + Host: os.Getenv("XUGU_LIVE_HOST"), + Port: defaultXuguPort, + Database: os.Getenv("XUGU_LIVE_DATABASE"), + Username: os.Getenv("XUGU_LIVE_USERNAME"), + Password: os.Getenv("XUGU_LIVE_PASSWORD"), + } + if params.Host == "" || params.Database == "" || params.Username == "" || params.Password == "" { + t.Skip("XUGU_LIVE_HOST, XUGU_LIVE_DATABASE, XUGU_LIVE_USERNAME, and XUGU_LIVE_PASSWORD are required") + } + db, err := openDB(params) + if err != nil { + t.Skipf("live XuguDB is unavailable: %v", err) + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + typeName := "DBX_TYPE_MEMBER_LIVE" + _, _ = db.ExecContext(ctx, "DROP TYPE "+quoteIdentifier(typeName)) + _, err = db.ExecContext(ctx, `CREATE OR REPLACE TYPE DBX_TYPE_MEMBER_LIVE AS OBJECT ( + item_id INTEGER, + item_name VARCHAR(80), + STATIC FUNCTION compute_total(quantity INTEGER, price NUMERIC(12,2)) RETURN NUMERIC(18,2), + MEMBER PROCEDURE rename_item(new_name VARCHAR(40)) +)`) + if err != nil { + t.Skipf("cannot create live test type: %v", err) + } + defer func() { _, _ = db.ExecContext(context.Background(), "DROP TYPE "+quoteIdentifier(typeName)) }() + _, err = db.ExecContext(ctx, `CREATE OR REPLACE TYPE BODY DBX_TYPE_MEMBER_LIVE AS + STATIC FUNCTION compute_total(quantity INTEGER, price NUMERIC(12,2)) RETURN NUMERIC(18,2) IS + BEGIN RETURN quantity * price; END; + MEMBER PROCEDURE rename_item(new_name VARCHAR(40)) IS + BEGIN item_name := new_name; END; +END;`) + if err != nil { + t.Fatalf("create type body: %v", err) + } + s := newServer() + s.db = db + s.params = params + s.currentDatabase = params.Database + result, err := s.completionAssistantSearch(completionAssistantRequest{ + Database: params.Database, Schema: params.Username, ParentSchema: params.Username, ParentName: typeName, ParentType: "type", + ObjectKinds: []string{"column", "routine"}, MaxResults: 50, + }) + if err != nil { + t.Fatalf("read type members: %v", err) + } + if len(result.Candidates) != 4 { + t.Fatalf("member count = %d, want 4: %#v", len(result.Candidates), result.Candidates) + } + want := map[string]string{"item_id": "column", "item_name": "column", "compute_total": "function", "rename_item": "procedure"} + for _, candidate := range result.Candidates { + if want[candidate.Name] != candidate.Kind { + t.Fatalf("unexpected member %#v", candidate) + } + } + for _, candidate := range result.Candidates { + if candidate.Name == "compute_total" && (candidate.Signature == nil || *candidate.Signature != "quantity INTEGER, price NUMERIC(12,2)") { + t.Fatalf("function signature = %#v", candidate.Signature) + } + if candidate.Name == "rename_item" && (candidate.Signature == nil || *candidate.Signature != "new_name VARCHAR(40)") { + t.Fatalf("procedure signature = %#v", candidate.Signature) + } + } + + if _, err := db.ExecContext(ctx, "DROP TYPE "+quoteIdentifier(typeName)); err != nil { + t.Fatalf("drop live type: %v", err) + } + _, err = db.ExecContext(ctx, "CREATE OR REPLACE TYPE DBX_TYPE_MEMBER_LIVE AS VARRAY(3) OF INTEGER") + if err != nil { + t.Fatalf("create live collection type: %v", err) + } + result, err = s.completionAssistantSearch(completionAssistantRequest{ + Database: params.Database, Schema: params.Username, ParentSchema: params.Username, ParentName: typeName, ParentType: "type", + ObjectKinds: []string{"column", "routine"}, MaxResults: 50, + }) + if err != nil { + t.Fatalf("read collection members: %v", err) + } + if len(result.Candidates) != 0 { + t.Fatalf("collection type must not expose object members: %#v", result.Candidates) + } + if _, err := db.ExecContext(ctx, "DROP TYPE "+quoteIdentifier(typeName)); err != nil { + t.Fatalf("drop live collection type: %v", err) + } + if _, err := db.ExecContext(ctx, "SELECT 1"); err != nil { + t.Fatalf("live connection should remain usable after metadata reads: %v", err) + } +} diff --git a/agents/drivers/xugu/type_members_test.go b/agents/drivers/xugu/type_members_test.go new file mode 100644 index 0000000000..65c9670490 --- /dev/null +++ b/agents/drivers/xugu/type_members_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func TestXuguTypeMemberQueriesUseLowPrivilegeDictionary(t *testing.T) { + for name, query := range map[string]string{ + "object type": xuguObjectTypeSQL, + "attributes": xuguTypeAttributesSQL, + "methods": xuguTypeMethodsSQL, + "parameters": xuguTypeMethodParametersSQL, + } { + 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(xuguObjectTypeCaseFoldedSQL, "CASE WHEN s.SCHEMA_NAME = ? THEN 0 ELSE 1 END") { + t.Fatalf("case-folded type lookup must retain exact-match priority: %s", xuguObjectTypeCaseFoldedSQL) + } + if !strings.Contains(xuguTypeMethodsCaseFoldedSQL, "CASE WHEN M.TYPE_NAME = ? THEN 0 ELSE 1 END") { + t.Fatalf("case-folded method lookup must retain exact-match priority: %s", xuguTypeMethodsCaseFoldedSQL) + } +} + +func TestXuguCompletionAssistantListsTypeAttributesAndMethods(t *testing.T) { + db, err := sql.Open("xugu-test-type-members", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + s.params.Database = "TEST_DB" + s.currentDatabase = "TEST_DB" + + response, shutdown := s.handleLine(`{"jsonrpc":"2.0","id":1,"method":"completion_assistant_search_v1","params":{"database":"TEST_DB","schema":"AppSchema","parent_schema":"AppSchema","parent_name":"OrderType","parent_type":"type","object_kinds":["column","routine"],"max_results":20}}`) + if shutdown || response.Error != nil { + t.Fatalf("type member request failed: shutdown=%v error=%v", shutdown, response.Error) + } + encoded, err := json.Marshal(response.Result) + if err != nil { + t.Fatal(err) + } + var result completionAssistantResponse + if err := json.Unmarshal(encoded, &result); err != nil { + t.Fatal(err) + } + if len(result.Candidates) != 4 { + t.Fatalf("candidate count = %d, want 4: %#v", len(result.Candidates), result.Candidates) + } + if got := result.Candidates[0]; got.Kind != "column" || got.Name != "orderId" || got.DataType == nil || *got.DataType != "INTEGER" { + t.Fatalf("unexpected type attribute: %#v", got) + } + if got := result.Candidates[2]; got.Kind != "function" || got.Name != "Total" || got.Signature == nil || *got.Signature != "quantity INTEGER, price NUMERIC(12,2)" || got.DataType == nil || *got.DataType != "NUMERIC(18,2)" { + t.Fatalf("unexpected type function: %#v", got) + } + if got := result.Candidates[3]; got.Kind != "procedure" || got.Name != "Rename" || got.Signature == nil || *got.Signature != "NEW_LABEL VARCHAR(40)" { + t.Fatalf("unexpected type procedure: %#v", got) + } + for _, candidate := range result.Candidates { + if candidate.ParentSchema == nil || *candidate.ParentSchema != "AppSchema" || candidate.ParentName == nil || *candidate.ParentName != "OrderType" { + t.Fatalf("candidate must retain its owning type: %#v", candidate) + } + } +} + +func TestXuguCompletionAssistantLeavesCollectionTypesWithoutMembers(t *testing.T) { + db, err := sql.Open("xugu-test-type-members", "collection") + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := newServer() + s.db = db + result, err := s.completionAssistantSearch(completionAssistantRequest{ + Database: "TEST_DB", + Schema: "AppSchema", + ParentSchema: "AppSchema", + ParentName: "OrderList", + ParentType: "type", + ObjectKinds: []string{"column", "routine"}, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Candidates) != 0 || result.FallbackUsed { + t.Fatalf("collection type should expose no object members, got %#v", result) + } +} + +func TestXuguCompletionAssistantFiltersAndLimitsTypeMembers(t *testing.T) { + db, err := sql.Open("xugu-test-type-members", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := newServer() + s.db = db + result, err := s.completionAssistantSearch(completionAssistantRequest{ + Database: "TEST_DB", + Schema: "AppSchema", + ParentSchema: "AppSchema", + ParentName: "OrderType", + ParentType: "type", + ObjectKinds: []string{"routine"}, + Mask: "re", + MatchMode: "prefix", + CaseSensitive: false, + MaxResults: 1, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Candidates) != 1 || result.Candidates[0].Name != "Rename" || result.Incomplete { + t.Fatalf("unexpected filtered members: %#v", result) + } +} + +func TestXuguCompletionAssistantDispatchesPackageAndTypeMembersSeparately(t *testing.T) { + db, err := sql.Open("xugu-test-type-members", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := newServer() + s.db = db + + packageResult, err := s.completionAssistantSearch(completionAssistantRequest{ + Database: "TEST_DB", Schema: "AppSchema", ParentSchema: "AppSchema", ParentName: "OrderAPI", ParentType: "package", ObjectKinds: []string{"routine"}, + }) + if err != nil { + t.Fatalf("package member request failed: %v", err) + } + if len(packageResult.Candidates) != 2 || packageResult.Candidates[0].Name != "create_order" || packageResult.Candidates[1].Name != "order_total" { + t.Fatalf("unexpected package members: %#v", packageResult.Candidates) + } + + legacyPackageResult, err := s.completionAssistantSearch(completionAssistantRequest{ + Database: "TEST_DB", Schema: "AppSchema", ParentSchema: "AppSchema", ParentName: "OrderAPI", ObjectKinds: []string{"routine"}, + }) + if err != nil || len(legacyPackageResult.Candidates) != 2 { + t.Fatalf("legacy package request must remain supported, result=%#v err=%v", legacyPackageResult, err) + } +} + +func TestParseXuguObjectTypeMembersPreservesQuotedNamesAndSignatures(t *testing.T) { + attributes, methods := parseXuguObjectTypeMembers(`CREATE TYPE "Order Type" AS OBJECT ( + /* attributes may use quoted identifiers */ + "line""Code" VARCHAR(40), + amount NUMERIC(12, 2), + STATIC FUNCTION normalize(p_value IN VARCHAR(100), p_result OUT VARCHAR(100)) RETURN VARCHAR(100), + MEMBER FUNCTION normalize(p_value INTEGER) RETURN INTEGER PIPELINED, + CONSTRUCTOR PROCEDURE "Order Type"(p_id INTEGER) RETURN SELF AS RESULT, + -- A quoted routine name and IN OUT parameter are both significant. + MEMBER PROCEDURE "set Label"(p_label IN OUT VARCHAR(80)) +)`) + + if len(attributes) != 2 { + t.Fatalf("attribute count = %d, want 2: %#v", len(attributes), attributes) + } + if got := attributes[0]; got.Name != `line"Code` || got.DataType != "VARCHAR(40)" { + t.Fatalf("unexpected quoted attribute: %#v", got) + } + if got := attributes[1]; got.Name != "amount" || got.DataType != "NUMERIC(12, 2)" { + t.Fatalf("unexpected numeric attribute: %#v", got) + } + if len(methods) != 4 { + t.Fatalf("method count = %d, want 4: %#v", len(methods), methods) + } + if got := methods[0]; got.Kind != "FUNCTION" || got.Name != "normalize" || got.Signature != "p_value VARCHAR(100), p_result OUT VARCHAR(100)" || got.ReturnType != "VARCHAR(100)" { + t.Fatalf("unexpected static function: %#v", got) + } + if got := methods[1]; got.Kind != "FUNCTION" || got.Signature != "p_value INTEGER" || got.ReturnType != "INTEGER" { + t.Fatalf("unexpected overloaded function: %#v", got) + } + if got := methods[2]; got.Kind != "PROCEDURE" || got.Name != "Order Type" || got.Signature != "p_id INTEGER" { + t.Fatalf("unexpected constructor procedure: %#v", got) + } + if got := methods[3]; got.Kind != "PROCEDURE" || got.Name != "set Label" || got.Signature != "p_label IN OUT VARCHAR(80)" { + t.Fatalf("unexpected quoted procedure: %#v", got) + } +} + +type xuguTypeMembersDriver struct{} + +func (d *xuguTypeMembersDriver) Open(name string) (driver.Conn, error) { + return &xuguTypeMembersConn{collection: name == "collection"}, nil +} + +type xuguTypeMembersConn struct { + collection bool +} + +func (c *xuguTypeMembersConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguTypeMembersConn) Close() error { return nil } +func (c *xuguTypeMembersConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } + +func (c *xuguTypeMembersConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + upper := strings.ToUpper(query) + switch { + case strings.Contains(upper, "FROM ALL_PACKAGES"): + return &xuguStaticRows{columns: []string{"SPEC"}, values: [][]driver.Value{{`CREATE PACKAGE OrderAPI AS + PROCEDURE create_order(p_id IN INTEGER); + FUNCTION order_total(p_id IN INTEGER) RETURN NUMERIC; +END;`}}}, nil + case strings.Contains(upper, "FROM ALL_TYPES"): + kind := int64(xuguObjectTypeKind) + if c.collection { + kind = 1005 + } + return &xuguStaticRows{columns: []string{"UDT_TYPE", "SPEC"}, values: [][]driver.Value{{kind, ""}}}, nil + case strings.Contains(upper, "FROM ALL_TYPE_ATTRS"): + return &xuguStaticRows{ + columns: []string{"ATTR_NAME", "ATTR_NO", "ATTR_TYPE_OWNER", "ATTR_TYPE_NAME", "ATTR_TYPE_MOD"}, + values: [][]driver.Value{{"orderId", int64(1), nil, "INTEGER", nil}, {"displayName", int64(2), nil, "VARCHAR", "(80)"}}, + }, nil + case strings.Contains(upper, "FROM ALL_TYPE_METHODS"): + return &xuguStaticRows{ + columns: []string{"METHOD_NAME", "METHOD_NO", "METHOD_TYPE", "RESULT_TYPE_OWNER", "RESULT_TYPE_NAME", "RESULT_TYPE_MOD"}, + values: [][]driver.Value{{"Total", int64(1), "FUNCTION", nil, "NUMERIC", "(18,2)"}, {"Rename", int64(2), "PROCEDURE", nil, nil, nil}}, + }, nil + case strings.Contains(upper, "FROM ALL_METHOD_PARAMS"): + if len(args) > 3 && args[3].Value == int64(2) { + return &xuguStaticRows{ + columns: []string{"PARAM_NAME", "PARAM_NO", "PARAM_MODE", "PARAM_TYPE_OWNER", "PARAM_TYPE_NAME", "PARAM_TYPE_MOD"}, + values: [][]driver.Value{{"NEW_LABEL", int64(1), "IN", nil, "VARCHAR", "(40)"}}, + }, nil + } + return &xuguStaticRows{ + columns: []string{"PARAM_NAME", "PARAM_NO", "PARAM_MODE", "PARAM_TYPE_OWNER", "PARAM_TYPE_NAME", "PARAM_TYPE_MOD"}, + values: [][]driver.Value{{"quantity", int64(1), "IN", nil, "INTEGER", nil}, {"price", int64(2), "IN", nil, "NUMERIC", "(12,2)"}}, + }, nil + } + return nil, fmt.Errorf("unexpected query: %s", query) +} + +func init() { + sql.Register("xugu-test-type-members", &xuguTypeMembersDriver{}) +} diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index d489efad7c..83a6cb0602 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -71,6 +71,7 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu"; import { objectTypesForGroupNode } from "@/lib/table/tableTree"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; +import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates"; import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint"; @@ -610,6 +611,12 @@ async function toggle() { return; } + if (isXuguTypeMemberContainer(node, connectionStore.getConfig(node.connectionId || "")?.db_type)) { + await connectionStore.loadXuguTypeMembers(node); + emitNodeToggled(node, wasExpanded); + return; + } + if (node.isExpanded) { node.isExpanded = false; if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id); @@ -3405,6 +3412,7 @@ const canExpand = computed(() => canTreeNodeShowExpander({ type: activeNode.value.type, childCount: activeNode.value.children?.length ?? 0, + explicitContainer: activeNode.value.xuguTypeMembersExpandable === true, }), ); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index c4de7617e8..7da275d6b9 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -558,7 +558,7 @@ const canExpand = computed(() => canTreeNodeShowExpander({ type: activeNode.value.type, childCount: activeNode.value.children?.length ?? 0, - explicitContainer: activeNode.value.children !== undefined, + explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true, }), ); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts index 7b5dd1c94b..78e3c63fce 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts @@ -34,6 +34,12 @@ describe("sidebar tree item layout", () => { expect(canTreeNodeShowExpander({ type: "procedure", childCount: 0, explicitContainer: true })).toBe(false); }); + it("allows only an explicitly marked Xugu type specification to expand", () => { + expect(canTreeNodeShowExpander({ type: "type", childCount: 0, explicitContainer: true })).toBe(true); + expect(canTreeNodeShowExpander({ type: "type", childCount: 0 })).toBe(false); + expect(canTreeNodeShowExpander({ type: "type-body", childCount: 0, explicitContainer: true })).toBe(false); + }); + it("aligns comments to the longest sibling name without crossing parent groups", () => { const widths = alignedSidebarCommentLabelWidths([ { id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 }, diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts index d6c1e6b01f..aa84e1de9d 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts @@ -23,6 +23,11 @@ describe("treeNodeClick", () => { expect(treeNodeRowAction("package", false)).toBe("open-source"); }); + it("toggles an expandable type node before opening its source", () => { + expect(treeNodeRowAction("type", true)).toBe("toggle"); + expect(treeNodeRowAction("type", false)).toBe("open-source"); + }); + it("routes package members to their owning package body", () => { expect( objectSourceTargetForTreeNode({ diff --git a/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts new file mode 100644 index 0000000000..bd7f30998d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; +import type { TreeNode } from "@/types/database"; + +const typeNode: TreeNode = { + id: "connection:database:schema:type:OrderType", + label: "OrderType", + objectName: "OrderType", + type: "type", + connectionId: "connection", + database: "database", + schema: "AppSchema", + isExpanded: false, + xuguTypeMembersExpandable: true, +}; + +describe("Xugu object type members", () => { + it("keeps the member loader exclusive to Xugu type specifications", () => { + expect(isXuguTypeMemberContainer(typeNode, "xugu")).toBe(true); + expect(isXuguTypeMemberContainer(typeNode, "oracle")).toBe(false); + expect(isXuguTypeMemberContainer({ ...typeNode, type: "type-body" }, "xugu")).toBe(false); + expect(isXuguTypeMemberContainer({ ...typeNode, xuguTypeMembersExpandable: false }, "xugu")).toBe(false); + expect(isXuguTypeMemberContainer({ ...typeNode, database: undefined }, "xugu")).toBe(false); + }); + + it("renders attributes and callable members without treating them as top-level routines", () => { + const nodes = buildXuguTypeMemberNodes(typeNode, [ + { name: "itemId", kind: "column", data_type: "INTEGER" }, + { name: "total", kind: "function", signature: "quantity INTEGER, price NUMERIC(12,2)", data_type: "NUMERIC(18,2)" }, + { name: "rename", kind: "procedure", signature: "OUT result VARCHAR(40)" }, + { name: "itemId", kind: "column", data_type: "INTEGER" }, + ]); + + expect(nodes.map((node) => ({ type: node.type, label: node.label }))).toEqual([ + { type: "type-attribute", label: "itemId (INTEGER)" }, + { type: "type-method", label: "FUNCTION total(quantity INTEGER, price NUMERIC(12,2)) → NUMERIC(18,2)" }, + { type: "type-method", label: "PROCEDURE rename(OUT result VARCHAR(40))" }, + ]); + expect(nodes.every((node) => node.parentType === "type" && node.parentName === "OrderType" && node.children === undefined)).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index d14fd39793..2e9f74fe12 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -99,14 +99,20 @@ describe("programmable database objects", () => { it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => { const objects: ObjectInfo[] = [ { name: "TRG_AUDIT", object_type: "TRIGGER", schema: "APP", valid: false }, - { name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true }, + { name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: true }, + { name: "ADDRESS_LIST", object_type: "TYPE", schema: "APP", valid: true, xugu_type_members_expandable: false }, { name: "ADDRESS_T", object_type: "TYPE_BODY", schema: "APP", valid: true }, ]; const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects }); expect(nodes).toEqual( - expect.arrayContaining([expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }), expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true }), expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true })]), + expect.arrayContaining([ + expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }), + expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true, xuguTypeMembersExpandable: true }), + expect.objectContaining({ type: "type", objectName: "ADDRESS_LIST", valid: true, xuguTypeMembersExpandable: false }), + expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true }), + ]), ); }); diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index 5d75e70ca5..ad7f25e6ca 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -156,7 +156,7 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean { } export function canTreeNodeShowExpander({ type, childCount, explicitContainer = false }: { type: TreeNodeType; childCount?: number; explicitContainer?: boolean }): boolean { - if (!canTreeNodeExpand(type) && !(type === "package" && explicitContainer)) return false; + if (!canTreeNodeExpand(type) && !((type === "package" || type === "type") && explicitContainer)) return false; if (childCount === 0 && emptyContainerTypes.has(type)) return false; return true; } diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 2ec91563ec..93db74aa50 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -92,7 +92,7 @@ export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activa if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none"; if (type === "extension") return "open-extension-details"; if (dataNodeTypes.has(type)) return "open-data"; - if (type === "package" && canExpand) return "toggle"; + if ((type === "package" || type === "type") && canExpand) return "toggle"; if (sourceNodeTypes.has(type)) return "open-source"; if (toggleLeafNodeTypes.has(type)) return "toggle"; if (canExpand) return "toggle"; diff --git a/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts b/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts new file mode 100644 index 0000000000..5f3b565522 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts @@ -0,0 +1,57 @@ +import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; + +export function isXuguTypeMemberContainer(node: TreeNode, databaseType?: string): boolean { + return databaseType === "xugu" && node.type === "type" && node.xuguTypeMembersExpandable === true && !!node.connectionId && !!node.database; +} + +export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { + const parentName = typeNode.objectName || typeNode.label; + const parentSchema = typeNode.schema; + const seen = new Set(); + const attributes: TreeNode[] = []; + const methods: TreeNode[] = []; + + for (const candidate of candidates) { + const name = candidate.name.trim(); + if (!name || (candidate.kind !== "column" && candidate.kind !== "procedure" && candidate.kind !== "function")) continue; + const signature = candidate.signature?.trim() || ""; + const dataType = candidate.data_type?.trim() || ""; + const key = `${candidate.kind}\0${name}\0${signature}\0${dataType}`; + if (seen.has(key)) continue; + seen.add(key); + + const base = { + connectionId: typeNode.connectionId, + database: typeNode.database, + schema: parentSchema, + parentName, + parentSchema, + parentType: "type" as const, + isExpanded: false, + children: undefined, + }; + if (candidate.kind === "column") { + attributes.push({ + ...base, + id: `${typeNode.id}:attribute:${name}`, + label: dataType ? `${name} (${dataType})` : name, + type: "type-attribute", + objectName: name, + }); + continue; + } + + const call = `${name}(${signature})`; + const label = candidate.kind === "function" ? `FUNCTION ${call}${dataType ? ` → ${dataType}` : ""}` : `PROCEDURE ${call}`; + methods.push({ + ...base, + id: `${typeNode.id}:method:${candidate.kind}:${name}:${signature}`, + label, + type: "type-method", + objectName: name, + signature: signature || undefined, + }); + } + + return [...attributes, ...methods]; +} diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 3da9d473b2..87b4869581 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -587,6 +587,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch signature: signature || undefined, comment: obj.comment, valid: obj.valid ?? undefined, + xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true, connectionId, database, schema: childSchema, @@ -760,6 +761,7 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc signature: signature || undefined, comment: obj.comment, valid: obj.valid ?? undefined, + xuguTypeMembersExpandable: objectType === "TYPE" && obj.xugu_type_members_expandable === true, connectionId, database, schema: childSchema, diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 19a9af6ae2..3c8d0ee9f1 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -193,6 +193,7 @@ describe("connectionStore metadata loading", () => { search_in_definitions: false, parent_schema: "app_schema", parent_name: "business_api", + parent_type: "package", match_mode: "prefix", }); expect(packageNode.isExpanded).toBe(true); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 481a927b89..fe9a7a5896 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -110,6 +110,7 @@ import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; +import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; @@ -1871,7 +1872,8 @@ export const useConnectionStore = defineStore("connection", () => { schema: options.effectiveSchema, objects: supplementalObjects, }); - if (supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(options.connectionId)))) { + const databaseType = effectiveDatabaseTypeForConnection(getConfig(options.connectionId)); + if (supportsPackageMemberExpansion(databaseType)) { supplementalChildren = markPackageNodesExpandable(supplementalChildren); } if (supplementalChildren.length === 0) return; @@ -5608,6 +5610,7 @@ export const useConnectionStore = defineStore("connection", () => { search_in_definitions: !!request.search_in_definitions, parent_schema: request.parent_schema ?? "", parent_name: request.parent_name ?? "", + parent_type: request.parent_type ?? "", match_mode: request.match_mode ?? "prefix", }); } @@ -5621,7 +5624,8 @@ export const useConnectionStore = defineStore("connection", () => { async function loadPackageMembers(node: TreeNode, options?: LoadTreeOptions): Promise { if (node.type !== "package" || !node.connectionId || !node.database) return; - if (!supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(node.connectionId)))) return; + const databaseType = effectiveDatabaseTypeForConnection(getConfig(node.connectionId)); + if (!supportsPackageMemberExpansion(databaseType)) return; const connectionId = node.connectionId; const database = node.database; const schema = node.schema; @@ -5654,6 +5658,7 @@ export const useConnectionStore = defineStore("connection", () => { search_in_definitions: false, parent_schema: schema ?? null, parent_name: packageName, + ...(databaseType === "xugu" ? { parent_type: "package" as const } : {}), match_mode: "prefix", }); const targetNode = treeNodeLoadTarget(load); @@ -5671,6 +5676,60 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadXuguTypeMembers(node: TreeNode): Promise { + if (!isXuguTypeMemberContainer(node, getConfig(node.connectionId || "")?.db_type)) return; + const connectionId = node.connectionId; + const database = node.database; + if (!connectionId || !database) return; + if (node.isExpanded) { + node.isExpanded = false; + if (!sidebarSearchQuery.value) releaseCollapsedTreeNodeChildren(node.id); + return; + } + if (node.children && node.children.length > 0) { + node.isExpanded = true; + return; + } + + const schema = node.schema || ""; + const parentName = node.objectName || node.label; + node.isLoading = true; + try { + const [attributes, methods] = await Promise.all([ + completionAssistantSearch({ + connection_id: connectionId, + database, + schema, + object_kinds: ["column"], + mask: "", + max_results: 500, + global_search: false, + parent_schema: schema, + parent_name: parentName, + parent_type: "type", + match_mode: "prefix", + }), + completionAssistantSearch({ + connection_id: connectionId, + database, + schema, + object_kinds: ["routine"], + mask: "", + max_results: 500, + global_search: false, + parent_schema: schema, + parent_name: parentName, + parent_type: "type", + match_mode: "prefix", + }), + ]); + node.children = buildXuguTypeMemberNodes(node, [...attributes.candidates, ...methods.candidates]); + node.isExpanded = true; + } finally { + node.isLoading = false; + } + } + const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]); const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set(["mysql", "postgres", "sqlserver", "oracle"]); @@ -7283,6 +7342,7 @@ export const useConnectionStore = defineStore("connection", () => { loadTableForLocate, loadObjectGroupChildren, loadPackageMembers, + loadXuguTypeMembers, loadMoreObjectGroupChildren, loadAllObjectGroupChildren, loadTableGroups, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 42e6674079..8e00bb333f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -105,6 +105,7 @@ export interface CompletionAssistantRequest { search_in_definitions?: boolean; parent_schema?: string | null; parent_name?: string | null; + parent_type?: "package" | "type" | null; match_mode?: CompletionAssistantMatchMode | null; } @@ -433,6 +434,7 @@ export interface ObjectInfo { updated_at?: string | null; parent_schema?: string | null; parent_name?: string | null; + xugu_type_members_expandable?: boolean | null; } export interface ObjectStatistics { @@ -829,6 +831,8 @@ export type TreeNodeType = | "table-search-control" | "load-more" | "column" + | "type-attribute" + | "type-method" | "index" | "fkey" | "trigger" @@ -890,10 +894,12 @@ export interface TreeNode { tableName?: string; objectName?: string; signature?: string; - /** Owning package/type for nested routine members. */ + /** Owning programmable object for a nested metadata member. */ parentName?: string; parentSchema?: string; parentType?: TreeNodeType; + /** Set only for XuguDB object types whose members can be loaded lazily. */ + xuguTypeMembersExpandable?: boolean; tableType?: string; comment?: string | null; valid?: boolean | null; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index f9c43e4b19..e5172198b9 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -2537,6 +2537,7 @@ mod tests { updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, } } diff --git a/crates/dbx-core/src/db/manticoresearch.rs b/crates/dbx-core/src/db/manticoresearch.rs index b0faec7549..9549214a3f 100644 --- a/crates/dbx-core/src/db/manticoresearch.rs +++ b/crates/dbx-core/src/db/manticoresearch.rs @@ -47,6 +47,7 @@ pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result ObjectInfo { updated_at: get_opt_str(row, "updated_at"), parent_schema: get_opt_str(row, "parent_schema"), parent_name: get_opt_str(row, "parent_name"), + xugu_type_members_expandable: None, } } @@ -3229,6 +3230,7 @@ fn table_infos_to_objects( updated_at: meta.and_then(|meta| meta.updated_at.clone()), parent_schema: table.parent_schema, parent_name: table.parent_name, + xugu_type_members_expandable: None, } }) .collect() @@ -5375,6 +5377,7 @@ mod tests { updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, } } diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index 0201be6402..c17426c69f 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -131,6 +131,7 @@ pub async fn list_objects(pool: &mysql_async::Pool, schema: &str) -> Result Result, parent_schema: row.try_get::<_, Option>(5).ok().flatten().filter(|s| !s.is_empty()), parent_name: row.try_get::<_, Option>(6).ok().flatten().filter(|s| !s.is_empty()), signature: row.try_get::<_, Option>(7).ok().flatten(), + xugu_type_members_expandable: None, }) .collect()) } diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index 95a4e28604..ab924cbb7c 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -19,6 +19,7 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result, updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }) .collect()) } diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 8beeb96f42..80fa011fb9 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -2324,6 +2324,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result< updated_at: row.get::(3).map(|value| value.to_string()), parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }) .collect()) } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 9c2fc9b73b..d6e193e036 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -2388,6 +2388,7 @@ async fn external_driver_presto_like_objects( updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, + xugu_type_members_expandable: None, }) .collect()) } @@ -3241,6 +3242,7 @@ for line in sys.stdin: updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, } } @@ -4139,6 +4141,7 @@ for line in sys.stdin: updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }, super::db::ObjectInfo { name: "ORDERS_VIEW".to_string(), @@ -4151,6 +4154,7 @@ for line in sys.stdin: updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }, super::db::ObjectInfo { name: "REFRESH_ORDERS".to_string(), @@ -4163,6 +4167,7 @@ for line in sys.stdin: updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }, ]; @@ -4844,6 +4849,7 @@ async fn list_objects_once( updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, + xugu_type_members_expandable: None, }) .collect(), )) @@ -7102,6 +7108,7 @@ async fn oracle_agent_list_objects( updated_at: None, parent_schema: None, parent_name: None, + xugu_type_members_expandable: None, }) }) .collect(); diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index b9a0c387af..eda6f1d2cf 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -76,6 +76,8 @@ pub struct ObjectInfo { pub updated_at: Option, pub parent_schema: Option, pub parent_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xugu_type_members_expandable: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -579,11 +581,14 @@ mod tests { #[test] fn list_objects_payload_preserves_optional_validity() { let objects: Vec = - serde_json::from_str(r#"[{"name":"TRG_AUDIT","object_type":"TRIGGER","schema":"APP","valid":false}]"#) + serde_json::from_str( + r#"[{"name":"TRG_AUDIT","object_type":"TRIGGER","schema":"APP","valid":false},{"name":"ORDER_TYPE","object_type":"TYPE","schema":"APP","xugu_type_members_expandable":true}]"#, + ) .unwrap(); assert_eq!(objects[0].valid, Some(false)); assert_eq!(objects[0].object_type, "TRIGGER"); + assert_eq!(objects[1].xugu_type_members_expandable, Some(true)); } #[test] diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 4298c29e98..6678a40b84 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -255,6 +255,7 @@ pub async fn list_objects( updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, + xugu_type_members_expandable: None, }) .collect::>() } else { diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 8e22c2ce17..4448a552ec 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -240,6 +240,7 @@ pub async fn list_objects( updated_at: None, parent_schema: table.parent_schema, parent_name: table.parent_name, + xugu_type_members_expandable: None, }) .collect()); }