diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 12df56f3b7..7280940a9a 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -232,6 +232,7 @@ type completionAssistantCandidate struct { ParentName *string `json:"parent_name"` Comment *string `json:"comment"` DataType *string `json:"data_type"` + Signature *string `json:"signature"` } type completionAssistantResponse struct { @@ -1845,6 +1846,10 @@ func (s *server) oracleCompletionValidSynonymTargets(targets []oracleCompletionS } func (s *server) completionAssistantRoutines(request completionAssistantRequest, preferredSchema string, limit int) (completionAssistantResponse, error) { + if strings.TrimSpace(request.ParentName) != "" { + return s.completionAssistantPackageRoutines(request, preferredSchema, limit) + } + query := oracleCompletionRoutinesQuery(request, preferredSchema, limit+1) rows, err := s.queryRows(query.SQL, query.Args) if err != nil { @@ -1886,6 +1891,176 @@ func (s *server) completionAssistantRoutines(request completionAssistantRequest, return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete}, nil } +type oraclePackageRoutineRow struct { + owner string + parentName string + name string + objectID int64 + subprogramID int64 + position sql.NullInt64 + sequence sql.NullInt64 + argumentName sql.NullString + inOut sql.NullString + dataType sql.NullString + typeOwner sql.NullString + typeName sql.NullString + typeSubname sql.NullString + dataLength sql.NullInt64 + dataPrecision sql.NullInt64 + dataScale sql.NullInt64 +} + +type oraclePackageRoutine struct { + owner string + parentName string + name string + isFunction bool + returnType string + argumentList []string +} + +func (s *server) completionAssistantPackageRoutines(request completionAssistantRequest, preferredSchema string, limit int) (completionAssistantResponse, error) { + query := oracleCompletionPackageRoutinesQuery(request, preferredSchema) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return completionAssistantResponse{}, err + } + defer s.closeRows(rows) + + packageRows := make([]oraclePackageRoutineRow, 0) + for rows.Next() { + var row oraclePackageRoutineRow + if err := rows.Scan( + &row.owner, + &row.parentName, + &row.name, + &row.objectID, + &row.subprogramID, + &row.position, + &row.sequence, + &row.argumentName, + &row.inOut, + &row.dataType, + &row.typeOwner, + &row.typeName, + &row.typeSubname, + &row.dataLength, + &row.dataPrecision, + &row.dataScale, + ); err != nil { + return completionAssistantResponse{}, err + } + packageRows = append(packageRows, row) + } + if err := rows.Err(); err != nil { + return completionAssistantResponse{}, err + } + return oracleCompletionPackageCandidates(packageRows, request.Database, limit), nil +} + +func oracleCompletionPackageCandidates(rows []oraclePackageRoutineRow, database string, limit int) completionAssistantResponse { + routines := make([]oraclePackageRoutine, 0) + routineIndexes := make(map[string]int) + for _, row := range rows { + key := fmt.Sprintf("%s\x00%d\x00%d", row.owner, row.objectID, row.subprogramID) + index, found := routineIndexes[key] + if !found { + index = len(routines) + routineIndexes[key] = index + routines = append(routines, oraclePackageRoutine{owner: row.owner, parentName: row.parentName, name: row.name}) + } + routine := &routines[index] + if !row.position.Valid { + continue + } + dataType := oracleCompletionArgumentDataType(row) + if row.position.Int64 == 0 { + routine.isFunction = true + routine.returnType = dataType + continue + } + argument := oracleCompletionArgumentSignature(row, dataType) + if argument != "" { + routine.argumentList = append(routine.argumentList, argument) + } + } + + incomplete := len(routines) > limit + if incomplete { + routines = routines[:limit] + } + candidates := make([]completionAssistantCandidate, 0, len(routines)) + for _, routine := range routines { + kind := "procedure" + if routine.isFunction { + kind = "function" + } + candidate := completionAssistantCandidate{ + Name: routine.name, + Kind: kind, + Database: stringPointer(database), + Schema: stringPointer(routine.owner), + ParentSchema: stringPointer(routine.owner), + ParentName: stringPointer(routine.parentName), + Signature: stringPointer(strings.Join(routine.argumentList, ", ")), + } + if routine.returnType != "" { + candidate.DataType = stringPointer(routine.returnType) + } + candidates = append(candidates, candidate) + } + return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete} +} + +func oracleCompletionArgumentSignature(row oraclePackageRoutineRow, dataType string) string { + parts := make([]string, 0, 3) + if row.argumentName.Valid { + parts = append(parts, strings.TrimSpace(row.argumentName.String)) + } + if row.inOut.Valid { + direction := strings.Join(strings.Fields(strings.ReplaceAll(row.inOut.String, "/", " ")), " ") + if direction != "" { + parts = append(parts, direction) + } + } + if dataType != "" { + parts = append(parts, dataType) + } + return strings.Join(parts, " ") +} + +func oracleCompletionArgumentDataType(row oraclePackageRoutineRow) string { + typeParts := make([]string, 0, 3) + if row.typeOwner.Valid && strings.TrimSpace(row.typeOwner.String) != "" { + typeParts = append(typeParts, strings.TrimSpace(row.typeOwner.String)) + } + if row.typeName.Valid && strings.TrimSpace(row.typeName.String) != "" { + typeParts = append(typeParts, strings.TrimSpace(row.typeName.String)) + } + if row.typeSubname.Valid && strings.TrimSpace(row.typeSubname.String) != "" { + typeParts = append(typeParts, strings.TrimSpace(row.typeSubname.String)) + } + if len(typeParts) > 0 { + return strings.Join(typeParts, ".") + } + + dataType := strings.TrimSpace(row.dataType.String) + switch strings.ToUpper(dataType) { + case "NUMBER", "NUMERIC", "DECIMAL": + if row.dataPrecision.Valid { + if row.dataScale.Valid { + return fmt.Sprintf("%s(%d, %d)", dataType, row.dataPrecision.Int64, row.dataScale.Int64) + } + return fmt.Sprintf("%s(%d)", dataType, row.dataPrecision.Int64) + } + case "CHAR", "VARCHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "RAW": + if row.dataLength.Valid && row.dataLength.Int64 > 0 { + return fmt.Sprintf("%s(%d)", dataType, row.dataLength.Int64) + } + } + return dataType +} + func stringPointer(value string) *string { if value == "" { return nil @@ -1983,31 +2158,11 @@ func oracleCompletionSynonymTargetsQuery(targets []oracleCompletionSynonymTarget func oracleCompletionRoutinesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery { pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode) - args := make([]any, 0, 6) + args := make([]any, 0, 5) baseSQL := ` SELECT o.OWNER, o.OBJECT_NAME, o.OBJECT_TYPE, CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME FROM ALL_OBJECTS o WHERE o.OBJECT_TYPE IN ('FUNCTION', 'PROCEDURE', 'PACKAGE')` - if parentName := strings.ToUpper(strings.TrimSpace(request.ParentName)); parentName != "" { - args = append(args, parentName) - parentParam := len(args) - baseSQL = fmt.Sprintf(` -SELECT p.OWNER, - p.PROCEDURE_NAME AS OBJECT_NAME, - CASE WHEN EXISTS ( - SELECT 1 - FROM ALL_ARGUMENTS a - WHERE a.OWNER = p.OWNER - AND a.OBJECT_ID = p.OBJECT_ID - AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID - AND a.POSITION = 0 - ) THEN 'FUNCTION' ELSE 'PROCEDURE' END AS OBJECT_TYPE, - p.OBJECT_NAME AS PARENT_NAME -FROM ALL_PROCEDURES p -WHERE p.OBJECT_TYPE = 'PACKAGE' - AND p.PROCEDURE_NAME IS NOT NULL - AND p.OBJECT_NAME = :%d`, parentParam) - } args = append(args, pattern) nameParam := len(args) @@ -2038,6 +2193,52 @@ WHERE p.OBJECT_TYPE = 'PACKAGE' } } +func oracleCompletionPackageRoutinesQuery(request completionAssistantRequest, preferredSchema string) oracleMetadataListQuery { + owner := strings.TrimSpace(request.ParentSchema) + if owner == "" { + owner = strings.TrimSpace(request.Schema) + } + if owner == "" { + owner = preferredSchema + } + parentName := strings.TrimSpace(request.ParentName) + pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode) + args := []any{owner, parentName, pattern} + return oracleMetadataListQuery{ + SQL: fmt.Sprintf(`SELECT p.OWNER, + p.OBJECT_NAME AS PARENT_NAME, + p.PROCEDURE_NAME AS OBJECT_NAME, + p.OBJECT_ID, + p.SUBPROGRAM_ID, + a.POSITION, + a.SEQUENCE, + a.ARGUMENT_NAME, + a.IN_OUT, + a.DATA_TYPE, + a.TYPE_OWNER, + a.TYPE_NAME, + a.TYPE_SUBNAME, + a.DATA_LENGTH, + a.DATA_PRECISION, + a.DATA_SCALE +FROM ALL_PROCEDURES p +LEFT JOIN ALL_ARGUMENTS a + ON a.OWNER = p.OWNER + AND a.OBJECT_ID = p.OBJECT_ID + AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID + AND a.DATA_LEVEL = 0 +WHERE p.OBJECT_TYPE = 'PACKAGE' + AND p.PROCEDURE_NAME IS NOT NULL + AND p.OWNER = :1 + AND p.OBJECT_NAME = :2 + AND %s +ORDER BY p.PROCEDURE_NAME, + p.SUBPROGRAM_ID, + NVL(a.SEQUENCE, 0)`, oracleCompletionNamePredicate("p.PROCEDURE_NAME", 3, request.CaseSensitive)), + Args: args, + } +} + func oracleCompletionNamePredicate(column string, parameter int, caseSensitive bool) string { if caseSensitive { return fmt.Sprintf("%s LIKE :%d ESCAPE '\\'", column, parameter) diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 87b73275d4..ff0e49babd 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -1706,13 +1706,13 @@ func TestOracleCompletionTablesQueryScopesExplicitSchema(t *testing.T) { } func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) { - query := oracleCompletionRoutinesQuery(completionAssistantRequest{ + query := oracleCompletionPackageRoutinesQuery(completionAssistantRequest{ Schema: "HR", ParentSchema: "HR", ParentName: "PAYROLL", ObjectKinds: []string{"routine"}, Mask: "CALC", - }, "HR", 200) + }, "HR") sqlText := strings.ToUpper(query.SQL) if !strings.Contains(sqlText, "ALL_PROCEDURES") || !strings.Contains(sqlText, "ALL_ARGUMENTS") { @@ -1721,14 +1721,84 @@ func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) { if strings.Contains(sqlText, "ALL_SOURCE") || strings.Contains(sqlText, "PACKAGE BODY") { t.Fatalf("package completion must not expose private package body source: %s", query.SQL) } - if !strings.Contains(sqlText, "P.OBJECT_NAME = :1") || !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE UPPER(:2)") || !strings.Contains(sqlText, "AND OWNER = :3") { + if !strings.Contains(sqlText, "P.OWNER = :1") || !strings.Contains(sqlText, "P.OBJECT_NAME = :2") || !strings.Contains(sqlText, "UPPER(P.PROCEDURE_NAME) LIKE UPPER(:3)") { t.Fatalf("package completion should scope package and owner: %s", query.SQL) } - if len(query.Args) != 6 || query.Args[0] != "PAYROLL" || query.Args[1] != "CALC%" || query.Args[2] != "HR" { + if !strings.Contains(sqlText, "A.OBJECT_ID = P.OBJECT_ID") || !strings.Contains(sqlText, "A.SUBPROGRAM_ID = P.SUBPROGRAM_ID") || !strings.Contains(sqlText, "A.DATA_LEVEL = 0") { + t.Fatalf("package completion should join complete top-level argument metadata: %s", query.SQL) + } + if strings.Contains(sqlText, "ROWNUM") { + t.Fatalf("package completion must not truncate argument rows before overload grouping: %s", query.SQL) + } + wantArgs := []any{"HR", "PAYROLL", "CALC%"} + if !reflect.DeepEqual(query.Args, wantArgs) { t.Fatalf("unexpected package completion args: %#v", query.Args) } } +func TestOracleCompletionPackageCandidatesPreserveOverloadsAndReturnTypes(t *testing.T) { + rows := []oraclePackageRoutineRow{ + { + owner: "HR", parentName: "PAYROLL", name: "CALCULATE", objectID: 42, subprogramID: 1, + position: sql.NullInt64{Int64: 1, Valid: true}, sequence: sql.NullInt64{Int64: 1, Valid: true}, + argumentName: sql.NullString{String: "P_VALUE", Valid: true}, inOut: sql.NullString{String: "IN", Valid: true}, dataType: sql.NullString{String: "NUMBER", Valid: true}, + }, + { + owner: "HR", parentName: "PAYROLL", name: "CALCULATE", objectID: 42, subprogramID: 2, + position: sql.NullInt64{Int64: 1, Valid: true}, sequence: sql.NullInt64{Int64: 1, Valid: true}, + argumentName: sql.NullString{String: "P_VALUE", Valid: true}, inOut: sql.NullString{String: "IN", Valid: true}, dataType: sql.NullString{String: "VARCHAR2", Valid: true}, + }, + { + owner: "HR", parentName: "PAYROLL", name: "ITEM_COUNT", objectID: 42, subprogramID: 3, + position: sql.NullInt64{Int64: 0, Valid: true}, sequence: sql.NullInt64{Int64: 1, Valid: true}, dataType: sql.NullString{String: "NUMBER", Valid: true}, + }, + { + owner: "HR", parentName: "PAYROLL", name: "ITEM_COUNT", objectID: 42, subprogramID: 3, + position: sql.NullInt64{Int64: 1, Valid: true}, sequence: sql.NullInt64{Int64: 2, Valid: true}, + argumentName: sql.NullString{String: "P_ACTIVE", Valid: true}, inOut: sql.NullString{String: "IN/OUT", Valid: true}, dataType: sql.NullString{String: "NUMBER", Valid: true}, + }, + } + + response := oracleCompletionPackageCandidates(rows, "XE", 10) + if response.Incomplete { + t.Fatal("complete package metadata should not be marked incomplete") + } + if len(response.Candidates) != 3 { + t.Fatalf("overloads should remain distinct candidates: %#v", response.Candidates) + } + want := []struct { + name string + kind string + signature string + returnType string + }{ + {name: "CALCULATE", kind: "procedure", signature: "P_VALUE IN NUMBER"}, + {name: "CALCULATE", kind: "procedure", signature: "P_VALUE IN VARCHAR2"}, + {name: "ITEM_COUNT", kind: "function", signature: "P_ACTIVE IN OUT NUMBER", returnType: "NUMBER"}, + } + for index, expected := range want { + candidate := response.Candidates[index] + if candidate.Name != expected.name || candidate.Kind != expected.kind || candidate.Signature == nil || *candidate.Signature != expected.signature { + t.Fatalf("candidate %d = %#v, want name=%q kind=%q signature=%q", index, candidate, expected.name, expected.kind, expected.signature) + } + if expected.returnType == "" { + if candidate.DataType != nil { + t.Fatalf("candidate %d return type = %#v, want nil", index, candidate.DataType) + } + } else if candidate.DataType == nil || *candidate.DataType != expected.returnType { + t.Fatalf("candidate %d return type = %#v, want %q", index, candidate.DataType, expected.returnType) + } + if candidate.ParentSchema == nil || *candidate.ParentSchema != "HR" || candidate.ParentName == nil || *candidate.ParentName != "PAYROLL" { + t.Fatalf("candidate %d lost package identity: %#v", index, candidate) + } + } + + limited := oracleCompletionPackageCandidates(rows, "XE", 2) + if !limited.Incomplete || len(limited.Candidates) != 2 { + t.Fatalf("member limit should apply after overload grouping: %#v", limited) + } +} + func TestOracleCompletionLikePatternSupportsPrefixAndContains(t *testing.T) { if got := oracleCompletionLikePattern(`A_%`, "prefix"); got != `A\_\%%` { t.Fatalf("prefix pattern = %q", got) diff --git a/agents/drivers/xugu/package_members.go b/agents/drivers/xugu/package_members.go index 1a0ba9d8d8..43de0ae606 100644 --- a/agents/drivers/xugu/package_members.go +++ b/agents/drivers/xugu/package_members.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "unicode" + "unicode/utf8" ) const xuguPackageSpecSQL = ` @@ -201,8 +202,8 @@ func stringPointerOrNil(value string) *string { if value == "" { return nil } - copy := value - return © + result := value + return &result } func parseXuguPackageMembers(spec string) []xuguPackageMember { @@ -212,20 +213,21 @@ func parseXuguPackageMembers(spec string) []xuguPackageMember { index = next continue } - switch spec[index] { + current, width := utf8.DecodeRuneInString(spec[index:]) + switch current { case '(': parenDepth++ - index++ + index += width continue case ')': if parenDepth > 0 { parenDepth-- } - index++ + index += width continue } - if parenDepth != 0 || !isXuguIdentifierStart(rune(spec[index])) { - index++ + if parenDepth != 0 || !isXuguIdentifierStart(current) { + index += width continue } wordStart := index @@ -277,8 +279,9 @@ func xuguFunctionReturnType(fragment string) string { index = next continue } - if !isXuguIdentifierStart(rune(fragment[index])) { - index++ + current, width := utf8.DecodeRuneInString(fragment[index:]) + if !isXuguIdentifierStart(current) { + index += width continue } start := index @@ -298,7 +301,8 @@ func findXuguDeclarationEnd(value string, start int) int { index = next continue } - switch value[index] { + current, width := utf8.DecodeRuneInString(value[index:]) + switch current { case '(': depth++ case ')': @@ -310,7 +314,7 @@ func findXuguDeclarationEnd(value string, start int) int { return index } } - index++ + index += width } return -1 } @@ -322,7 +326,8 @@ func findXuguClosingParen(value string, open int) (int, bool) { index = next continue } - switch value[index] { + current, width := utf8.DecodeRuneInString(value[index:]) + switch current { case '(': depth++ case ')': @@ -331,7 +336,7 @@ func findXuguClosingParen(value string, open int) (int, bool) { return index, true } } - index++ + index += width } return 0, false } @@ -342,21 +347,24 @@ func readXuguSQLIdentifier(value string, index int) (string, int, bool) { } if value[index] == '"' { var builder strings.Builder - for cursor := index + 1; cursor < len(value); cursor++ { - if value[cursor] != '"' { - builder.WriteByte(value[cursor]) + for cursor := index + 1; cursor < len(value); { + current, width := utf8.DecodeRuneInString(value[cursor:]) + if current != '"' { + builder.WriteRune(current) + cursor += width continue } if cursor+1 < len(value) && value[cursor+1] == '"' { builder.WriteByte('"') - cursor++ + cursor += 2 continue } return builder.String(), cursor + 1, true } return "", index, false } - if !isXuguIdentifierStart(rune(value[index])) { + current, _ := utf8.DecodeRuneInString(value[index:]) + if !isXuguIdentifierStart(current) { return "", index, false } next := scanXuguIdentifier(value, index) @@ -364,8 +372,12 @@ func readXuguSQLIdentifier(value string, index int) (string, int, bool) { } func scanXuguIdentifier(value string, index int) int { - for index < len(value) && isXuguIdentifierPart(rune(value[index])) { - index++ + for index < len(value) { + current, width := utf8.DecodeRuneInString(value[index:]) + if !isXuguIdentifierPart(current) { + break + } + index += width } return index } @@ -380,8 +392,9 @@ func isXuguIdentifierPart(value rune) bool { func skipXuguSQLSpaceAndComments(value string, index int) int { for index < len(value) { - if unicode.IsSpace(rune(value[index])) { - index++ + current, width := utf8.DecodeRuneInString(value[index:]) + if unicode.IsSpace(current) { + index += width continue } if next, ok := skipXuguSQLComment(value, index); ok { @@ -401,12 +414,14 @@ func skipXuguSQLLiteralOrComment(value string, index int) (int, bool) { return index, false } quote := value[index] - for cursor := index + 1; cursor < len(value); cursor++ { - if value[cursor] != quote { + for cursor := index + 1; cursor < len(value); { + current, width := utf8.DecodeRuneInString(value[cursor:]) + if current != rune(quote) { + cursor += width continue } if cursor+1 < len(value) && value[cursor+1] == quote { - cursor++ + cursor += 2 continue } return cursor + 1, true @@ -442,9 +457,10 @@ func compactXuguSQLFragment(value string) string { index = next continue } - if unicode.IsSpace(rune(value[index])) { + current, width := utf8.DecodeRuneInString(value[index:]) + if unicode.IsSpace(current) { spacePending = builder.Len() > 0 - index++ + index += width continue } if spacePending && builder.Len() > 0 { @@ -456,8 +472,8 @@ func compactXuguSQLFragment(value string) string { index = next continue } - builder.WriteByte(value[index]) - index++ + builder.WriteRune(current) + index += width } return strings.TrimSpace(builder.String()) } diff --git a/agents/drivers/xugu/package_members_test.go b/agents/drivers/xugu/package_members_test.go index bb2abf3513..f3e2677f31 100644 --- a/agents/drivers/xugu/package_members_test.go +++ b/agents/drivers/xugu/package_members_test.go @@ -57,6 +57,21 @@ END;` } } +func TestParseXuguPackageMembersHandlesUnicodeIdentifiers(t *testing.T) { + spec := `CREATE PACKAGE 中文包 AS +PROCEDURE 处理订单(订单号 IN VARCHAR, 数量 IN NUMERIC); +FUNCTION 统计数量(分类 IN VARCHAR) RETURN NUMERIC; +END;` + got := parseXuguPackageMembers(spec) + want := []xuguPackageMember{ + {Name: "处理订单", Kind: "PROCEDURE", Signature: "订单号 IN VARCHAR, 数量 IN NUMERIC"}, + {Name: "统计数量", Kind: "FUNCTION", Signature: "分类 IN VARCHAR", ReturnType: "NUMERIC"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected Unicode package members:\n got: %#v\nwant: %#v", got, want) + } +} + func TestParseXuguPackageMembersIgnoresBodyOnlyDeclarations(t *testing.T) { spec := `CREATE PACKAGE pkg AS PROCEDURE public_proc(p_value INT); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 5d7164607e..d489efad7c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -91,6 +91,7 @@ import { supportsTableTruncate, supportsTableStructureEditing, supportsTransfer, + supportsPackageMemberExpansion, usesTreeSchemaMode, isSingleDatabase, } from "@/lib/database/databaseCapabilities"; @@ -622,8 +623,8 @@ async function toggle() { return; } - if (node.type === "package" && node.connectionId && connectionStore.getConfig(node.connectionId)?.db_type === "xugu") { - await connectionStore.loadXuguPackageMembers(node); + if (node.type === "package" && node.connectionId && supportsPackageMemberExpansion(currentDatabaseType())) { + await connectionStore.loadPackageMembers(node); emitNodeToggled(node, wasExpanded); return; } diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts index 275ef9df2d..4198c5b550 100644 --- a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -13,6 +13,9 @@ const connectionStore = { canUseLoadedTreeNodeToggle: vi.fn(() => true), releaseCollapsedTreeNodeChildren: vi.fn(), getConfig: vi.fn(() => ({ db_type: "mysql" })), + loadPackageMembers: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), }; vi.mock("@/stores/connectionStore", () => ({ @@ -41,6 +44,7 @@ afterEach(() => { connectionStore.treeNodes = []; connectionStore.sidebarSearchQuery = ""; vi.clearAllMocks(); + connectionStore.getConfig.mockReturnValue({ db_type: "mysql" }); }); describe("SidebarTreeRuntimeHost expansion", () => { @@ -78,4 +82,38 @@ describe("SidebarTreeRuntimeHost expansion", () => { expect(toggled).toHaveBeenCalledWith(renderedGroup, false); expect(liveGroup.isExpanded).toBe(false); }); + + it("loads Oracle package members through the shared expansion path", async () => { + const packageNode: TreeNode = { + id: "oracle:XE:APP:package:BUSINESS_API", + label: "BUSINESS_API", + type: "package", + connectionId: "oracle", + database: "XE", + schema: "APP", + isExpanded: false, + children: [], + }; + connectionStore.treeNodes = [packageNode]; + connectionStore.getConfig.mockReturnValue({ db_type: "oracle" }); + + const host = ref | null>(null); + const toggled = vi.fn(); + const app = createApp( + defineComponent({ + setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: packageNode, depth: 0, onNodeToggled: toggled }), + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + host.value?.toggleNode(packageNode); + await vi.waitFor(() => expect(connectionStore.loadPackageMembers).toHaveBeenCalledWith(packageNode)); + + expect(packageNode.isExpanded).toBe(true); + expect(toggled).toHaveBeenCalledWith(packageNode, true); + }); }); diff --git a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts index 586f44cff9..1224d7c8f6 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeSidebarObjectKind, sidebarObjectKindsForDatabase } from "@/lib/database/databaseObjectCapabilities"; +import { normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion } from "@/lib/database/databaseObjectCapabilities"; describe("databaseObjectCapabilities", () => { it("exposes supported programmable objects for Dameng", () => { @@ -11,6 +11,13 @@ describe("databaseObjectCapabilities", () => { expect(sidebarObjectKindsForDatabase("postgres")).not.toContain("SYNONYM"); }); + it("expands package members only for implemented database paths", () => { + expect(supportsPackageMemberExpansion("oracle")).toBe(true); + expect(supportsPackageMemberExpansion("xugu")).toBe(true); + expect(supportsPackageMemberExpansion("dameng")).toBe(false); + expect(supportsPackageMemberExpansion("opengauss")).toBe(false); + }); + it("exposes only tables for HBase namespaces", () => { expect(sidebarObjectKindsForDatabase("hbase")).toEqual(["TABLE"]); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts similarity index 85% rename from apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts rename to apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts index 63505d1848..6249852160 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/packageMembers.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; -import { buildXuguPackageMemberNodes, markXuguPackageNodesExpandable } from "@/lib/sidebar/xuguPackageMembers"; +import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; function packageNode(): TreeNode { return { @@ -15,10 +15,10 @@ function packageNode(): TreeNode { }; } -describe("Xugu package member tree", () => { +describe("package member tree", () => { it("marks package specifications as expandable without changing other objects", () => { const nodes: TreeNode[] = [packageNode(), { id: "body", label: "business_api", type: "package-body" }, { id: "proc", label: "standalone", type: "procedure" }]; - const result = markXuguPackageNodesExpandable(nodes); + const result = markPackageNodesExpandable(nodes); expect(result[0]?.children).toEqual([]); expect(result[1]?.children).toBeUndefined(); @@ -32,7 +32,7 @@ describe("Xugu package member tree", () => { { name: "lookup", kind: "function", signature: null, data_type: "VARCHAR" }, { name: "ignored_table", kind: "table" }, ]; - const result = buildXuguPackageMemberNodes(packageNode(), candidates); + const result = buildPackageMemberNodes(packageNode(), candidates); expect(result.map((node) => [node.type, node.label])).toEqual([ ["procedure", "calculate(p_value IN INT)"], @@ -50,7 +50,7 @@ describe("Xugu package member tree", () => { { name: "MIXEDCASE", kind: "function", signature: "p INT" }, { name: "", kind: "procedure" }, ]; - const result = buildXuguPackageMemberNodes(packageNode(), candidates); + const result = buildPackageMemberNodes(packageNode(), candidates); expect(result.map((node) => node.objectName)).toEqual(["MixedCase", "MIXEDCASE"]); }); diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index cfdda896f8..bd0e494c05 100644 --- a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts @@ -18,6 +18,7 @@ const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZ const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"]; const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]; const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"]; +const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set(["oracle", "xugu"]); const DATABASE_TYPE_OBJECTS = new Map([ // postgres @@ -82,6 +83,10 @@ export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObj return DATABASE_TYPE_OBJECTS.get(dbType) ?? [...ROUTINE_OBJECTS]; } +export function supportsPackageMemberExpansion(dbType?: DatabaseType): boolean { + return !!dbType && PACKAGE_MEMBER_EXPANSION_DATABASES.has(dbType); +} + export function normalizeSidebarObjectKind(type: string): SidebarObjectKind { const value = type.toUpperCase(); const normalized = value.replace(/[\s-]+/g, "_"); diff --git a/apps/desktop/src/lib/sidebar/xuguPackageMembers.ts b/apps/desktop/src/lib/sidebar/packageMembers.ts similarity index 85% rename from apps/desktop/src/lib/sidebar/xuguPackageMembers.ts rename to apps/desktop/src/lib/sidebar/packageMembers.ts index 27ffbddaa3..ec5f292afb 100644 --- a/apps/desktop/src/lib/sidebar/xuguPackageMembers.ts +++ b/apps/desktop/src/lib/sidebar/packageMembers.ts @@ -1,10 +1,10 @@ import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; -export function markXuguPackageNodesExpandable(nodes: TreeNode[]): TreeNode[] { +export function markPackageNodesExpandable(nodes: TreeNode[]): TreeNode[] { return nodes.map((node) => (node.type === "package" ? { ...node, children: node.children ?? [] } : node)); } -export function buildXuguPackageMemberNodes(packageNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { +export function buildPackageMemberNodes(packageNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { const parentName = packageNode.objectName || packageNode.label; const parentSchema = packageNode.schema; const seen = new Set(); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 76f6be8446..19a9af6ae2 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -178,7 +178,7 @@ describe("connectionStore metadata loading", () => { store.connectedIds = new Set([connection.id]); store.treeNodes = [packageNode]; - await store.loadXuguPackageMembers(packageNode); + await store.loadPackageMembers(packageNode); expect(completionAssistantSearch).toHaveBeenCalledWith({ connection_id: connection.id, @@ -200,6 +200,84 @@ describe("connectionStore metadata loading", () => { expect(packageNode.children?.every((child) => child.parentName === "business_api")).toBe(true); }, 15000); + it("loads Oracle package overloads through the shared package member path", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "CALCULATE", kind: "procedure", signature: "P_VALUE IN NUMBER" }, + { name: "CALCULATE", kind: "procedure", signature: "P_VALUE IN VARCHAR2" }, + { name: "ITEM_COUNT", kind: "function", data_type: "NUMBER" }, + ], + incomplete: false, + fallback_used: false, + }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = oracleConnection(); + const packageNode: TreeNode = { + id: "oracle-1:XE:APP:package:BUSINESS_API", + label: "BUSINESS_API", + type: "package", + connectionId: connection.id, + database: "XE", + schema: "APP", + children: [], + isExpanded: false, + }; + store.connections = [connection]; + store.connectedIds = new Set([connection.id]); + store.treeNodes = [packageNode]; + + await store.loadPackageMembers(packageNode); + + expect(completionAssistantSearch).toHaveBeenCalledWith({ + connection_id: connection.id, + database: "XE", + schema: "APP", + object_kinds: ["routine"], + mask: "", + case_sensitive: true, + global_search: false, + max_results: 1000, + search_in_comments: false, + search_in_definitions: false, + parent_schema: "APP", + parent_name: "BUSINESS_API", + match_mode: "prefix", + }); + expect(packageNode.isExpanded).toBe(true); + expect(packageNode.children?.map((child) => child.label)).toEqual(["CALCULATE(P_VALUE IN NUMBER)", "CALCULATE(P_VALUE IN VARCHAR2)", "ITEM_COUNT"]); + }, 15000); + + it("does not query package members for unsupported databases", async () => { + const completionAssistantSearch = vi.fn(); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = { ...xuguConnection(), id: "dameng-1", db_type: "dameng" } as ConnectionConfig; + const packageNode: TreeNode = { + id: "dameng-1:APP:package:BUSINESS_API", + label: "BUSINESS_API", + type: "package", + connectionId: connection.id, + database: "APP", + schema: "APP", + children: [], + isExpanded: false, + }; + store.connections = [connection]; + store.connectedIds = new Set([connection.id]); + + await store.loadPackageMembers(packageNode); + + expect(completionAssistantSearch).not.toHaveBeenCalled(); + expect(packageNode.isExpanded).toBe(false); + }, 15000); + it("loads missing database roots only for connected sidebar search targets", async () => { const checkConnectionHealth = vi.fn().mockResolvedValue(undefined); const listDatabases = vi.fn().mockResolvedValue([{ name: "dajia", comment: null }]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 432864157b..481a927b89 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -62,7 +62,7 @@ import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } fr import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; -import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; +import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect"; import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; @@ -111,7 +111,7 @@ import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVer import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; -import { buildXuguPackageMemberNodes, markXuguPackageNodesExpandable } from "@/lib/sidebar/xuguPackageMembers"; +import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator"; import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; @@ -1606,7 +1606,8 @@ export const useConnectionStore = defineStore("connection", () => { }); const refreshedGroup = grouped.find((group) => group.type === options.node.type); const children = refreshedGroup?.children ?? []; - return options.node.connectionId && getConfig(options.node.connectionId)?.db_type === "xugu" ? markXuguPackageNodesExpandable(children) : children; + const databaseType = options.node.connectionId ? effectiveDatabaseTypeForConnection(getConfig(options.node.connectionId)) : undefined; + return supportsPackageMemberExpansion(databaseType) ? markPackageNodesExpandable(children) : children; } function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] { @@ -1870,8 +1871,8 @@ export const useConnectionStore = defineStore("connection", () => { schema: options.effectiveSchema, objects: supplementalObjects, }); - if (getConfig(options.connectionId)?.db_type === "xugu") { - supplementalChildren = markXuguPackageNodesExpandable(supplementalChildren); + if (supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(options.connectionId)))) { + supplementalChildren = markPackageNodesExpandable(supplementalChildren); } if (supplementalChildren.length === 0) return; if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return; @@ -5618,8 +5619,9 @@ export const useConnectionStore = defineStore("connection", () => { }); } - async function loadXuguPackageMembers(node: TreeNode, options?: LoadTreeOptions): Promise { - if (node.type !== "package" || !node.connectionId || !node.database || getConfig(node.connectionId)?.db_type !== "xugu") return; + 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 connectionId = node.connectionId; const database = node.database; const schema = node.schema; @@ -5629,7 +5631,7 @@ export const useConnectionStore = defineStore("connection", () => { try { await runTreeMetadataLoad( { - kind: "xugu-package-members", + kind: "package-members", connectionId, database, schema, @@ -5656,7 +5658,7 @@ export const useConnectionStore = defineStore("connection", () => { }); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; - setChildren(targetNode, buildXuguPackageMemberNodes(targetNode, response.candidates)); + setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates)); targetNode.isExpanded = true; }, options, @@ -7280,7 +7282,7 @@ export const useConnectionStore = defineStore("connection", () => { loadTables, loadTableForLocate, loadObjectGroupChildren, - loadXuguPackageMembers, + loadPackageMembers, loadMoreObjectGroupChildren, loadAllObjectGroupChildren, loadTableGroups,