From ad9075e18f1990ad664ea320c3c9b759e7f65794 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:05:54 +0800 Subject: [PATCH] feat(xugu): expose package members in schema tree --- agents/drivers/xugu/main.go | 10 + agents/drivers/xugu/package_members.go | 463 ++++++++++++++++++ agents/drivers/xugu/package_members_test.go | 106 ++++ .../src/components/sidebar/ConnectionTree.vue | 13 +- .../sidebar/SidebarTreeRuntimeHost.vue | 43 +- .../src/components/sidebar/TreeItem.vue | 1 + .../sidebar/sidebarTreeItemLayout.spec.ts | 7 + .../__tests__/sidebar/treeNodeClick.spec.ts | 46 +- .../sidebar/xuguPackageMembers.spec.ts | 57 +++ .../src/lib/sidebar/sidebarTreeItemLayout.ts | 4 +- apps/desktop/src/lib/sidebar/treeNodeClick.ts | 27 + .../src/lib/sidebar/xuguPackageMembers.ts | 40 ++ .../connectionStore.metadataLoading.spec.ts | 68 +++ apps/desktop/src/stores/connectionStore.ts | 61 ++- apps/desktop/src/types/database.ts | 4 + 15 files changed, 922 insertions(+), 28 deletions(-) create mode 100644 agents/drivers/xugu/package_members.go create mode 100644 agents/drivers/xugu/package_members_test.go create mode 100644 apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/xuguPackageMembers.ts diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 9b09ab7973..0384e18643 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -939,6 +939,16 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any schema := stringParam(params, "schema") result, err := s.listObjects(schema, metadataListConstraintsFromParams(params)) return result, false, err + case "completion_assistant_search_v1": + var request completionAssistantRequest + if err := decodeParams(params, &request); err != nil { + return nil, false, err + } + if err := s.useDatabase(request.Database); err != nil { + return nil, false, err + } + result, err := s.completionAssistantSearch(request) + return result, false, err case "list_data_types": return xuguDataTypes, false, nil case "get_columns": diff --git a/agents/drivers/xugu/package_members.go b/agents/drivers/xugu/package_members.go new file mode 100644 index 0000000000..1a0ba9d8d8 --- /dev/null +++ b/agents/drivers/xugu/package_members.go @@ -0,0 +1,463 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "unicode" +) + +const xuguPackageSpecSQL = ` +SELECT COALESCE(TO_CHAR(p.SPEC), '') +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 s.SCHEMA_NAME = ? + AND p.PACK_NAME = ?` + +const xuguPackageSpecCaseFoldedSQL = ` +SELECT COALESCE(TO_CHAR(p.SPEC), '') +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 UPPER(p.PACK_NAME) = UPPER(?) +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 + Signature string + ReturnType string +} + +func (s *server) completionAssistantSearch(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") + } + parentSchema := strings.TrimSpace(request.ParentSchema) + if parentSchema == "" { + parentSchema = strings.TrimSpace(request.Schema) + } + if parentSchema == "" { + return completionAssistantResponse{}, errors.New("parent_schema is required for Xugu package members") + } + + spec, err := s.xuguPackageSpec(parentSchema, parentName) + if err != nil { + return completionAssistantResponse{}, err + } + members := parseXuguPackageMembers(spec) + allowedKinds := xuguCompletionRoutineKinds(request.ObjectKinds) + filtered := make([]xuguPackageMember, 0, len(members)) + for _, member := range members { + if !allowedKinds[member.Kind] || !xuguCompletionNameMatches(member.Name, request) { + continue + } + filtered = append(filtered, member) + } + + limit := request.MaxResults + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + incomplete := len(filtered) > limit + if incomplete { + filtered = filtered[:limit] + } + + database := strings.TrimSpace(request.Database) + candidates := make([]completionAssistantCandidate, 0, len(filtered)) + for _, member := range filtered { + candidate := completionAssistantCandidate{ + Name: member.Name, + Kind: strings.ToLower(member.Kind), + Database: stringPointerOrNil(database), + Schema: stringPointerOrNil(parentSchema), + ParentSchema: stringPointerOrNil(parentSchema), + ParentName: stringPointerOrNil(parentName), + Signature: stringPointerOrNil(member.Signature), + } + if member.ReturnType != "" { + candidate.DataType = stringPointerOrNil(member.ReturnType) + } + candidates = append(candidates, candidate) + } + return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete, FallbackUsed: false}, nil +} + +func (s *server) xuguPackageSpec(schema, packageName string) (string, error) { + query := func(sqlText string, args ...any) (string, bool, error) { + rows, err := s.queryRows(sqlText, args) + if err != nil { + return "", false, err + } + defer s.closeRows(rows) + if !rows.Next() { + return "", false, rows.Err() + } + var spec sql.NullString + if err := rows.Scan(&spec); err != nil { + return "", false, err + } + return spec.String, true, nil + } + + if spec, found, err := query(xuguPackageSpecSQL, schema, packageName); err != nil || found { + return spec, err + } + if spec, found, err := query(xuguPackageSpecCaseFoldedSQL, schema, packageName, schema, packageName); err != nil { + return "", err + } else if found { + return spec, nil + } + return "", fmt.Errorf("package not found: %s.%s", schema, packageName) +} + +func xuguCompletionRequestsRoutines(kinds []string) bool { + if len(kinds) == 0 { + return false + } + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine", "procedure", "function": + return true + } + } + return false +} + +func xuguCompletionRoutineKinds(kinds []string) map[string]bool { + allowed := map[string]bool{} + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine": + allowed["PROCEDURE"] = true + allowed["FUNCTION"] = true + case "procedure": + allowed["PROCEDURE"] = true + case "function": + allowed["FUNCTION"] = true + } + } + return allowed +} + +func xuguCompletionNameMatches(name string, request completionAssistantRequest) bool { + mask := strings.TrimSpace(request.Mask) + if mask == "" { + return true + } + value := name + if !request.CaseSensitive { + value = strings.ToUpper(value) + mask = strings.ToUpper(mask) + } + if strings.EqualFold(strings.TrimSpace(request.MatchMode), "contains") { + return strings.Contains(value, mask) + } + return strings.HasPrefix(value, mask) +} + +func stringPointerOrNil(value string) *string { + if value == "" { + return nil + } + copy := value + return © +} + +func parseXuguPackageMembers(spec string) []xuguPackageMember { + members := make([]xuguPackageMember, 0) + for index, parenDepth := 0, 0; index < len(spec); { + if next, ok := skipXuguSQLLiteralOrComment(spec, index); ok { + index = next + continue + } + switch spec[index] { + case '(': + parenDepth++ + index++ + continue + case ')': + if parenDepth > 0 { + parenDepth-- + } + index++ + continue + } + if parenDepth != 0 || !isXuguIdentifierStart(rune(spec[index])) { + index++ + continue + } + wordStart := index + index = scanXuguIdentifier(spec, index) + keyword := strings.ToUpper(spec[wordStart:index]) + if keyword != "PROCEDURE" && keyword != "FUNCTION" { + continue + } + member, next, ok := parseXuguPackageMemberDeclaration(spec, index, keyword) + if !ok { + continue + } + members = append(members, member) + index = next + } + return members +} + +func parseXuguPackageMemberDeclaration(spec string, index int, kind string) (xuguPackageMember, int, bool) { + index = skipXuguSQLSpaceAndComments(spec, index) + name, next, ok := readXuguSQLIdentifier(spec, index) + if !ok { + return xuguPackageMember{}, index, false + } + index = skipXuguSQLSpaceAndComments(spec, next) + signature := "" + if index < len(spec) && spec[index] == '(' { + end, ok := findXuguClosingParen(spec, index) + if !ok { + return xuguPackageMember{}, index, false + } + signature = compactXuguSQLFragment(spec[index+1 : end]) + index = end + 1 + } + declarationEnd := findXuguDeclarationEnd(spec, index) + if declarationEnd < 0 { + return xuguPackageMember{}, index, false + } + returnType := "" + if kind == "FUNCTION" { + returnType = xuguFunctionReturnType(spec[index:declarationEnd]) + } + return xuguPackageMember{Name: name, Kind: kind, Signature: signature, ReturnType: returnType}, declarationEnd + 1, true +} + +func xuguFunctionReturnType(fragment string) string { + for index := 0; index < len(fragment); { + if next, ok := skipXuguSQLLiteralOrComment(fragment, index); ok { + index = next + continue + } + if !isXuguIdentifierStart(rune(fragment[index])) { + index++ + continue + } + start := index + index = scanXuguIdentifier(fragment, index) + if !strings.EqualFold(fragment[start:index], "RETURN") { + continue + } + return compactXuguSQLFragment(fragment[skipXuguSQLSpaceAndComments(fragment, index):]) + } + return "" +} + +func findXuguDeclarationEnd(value string, start int) int { + depth := 0 + for index := start; index < len(value); { + if next, ok := skipXuguSQLLiteralOrComment(value, index); ok { + index = next + continue + } + switch value[index] { + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + case ';': + if depth == 0 { + return index + } + } + index++ + } + return -1 +} + +func findXuguClosingParen(value string, open int) (int, bool) { + depth := 0 + for index := open; index < len(value); { + if next, ok := skipXuguSQLLiteralOrComment(value, index); ok { + index = next + continue + } + switch value[index] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return index, true + } + } + index++ + } + return 0, false +} + +func readXuguSQLIdentifier(value string, index int) (string, int, bool) { + if index >= len(value) { + return "", index, false + } + if value[index] == '"' { + var builder strings.Builder + for cursor := index + 1; cursor < len(value); cursor++ { + if value[cursor] != '"' { + builder.WriteByte(value[cursor]) + continue + } + if cursor+1 < len(value) && value[cursor+1] == '"' { + builder.WriteByte('"') + cursor++ + continue + } + return builder.String(), cursor + 1, true + } + return "", index, false + } + if !isXuguIdentifierStart(rune(value[index])) { + return "", index, false + } + next := scanXuguIdentifier(value, index) + return value[index:next], next, true +} + +func scanXuguIdentifier(value string, index int) int { + for index < len(value) && isXuguIdentifierPart(rune(value[index])) { + index++ + } + return index +} + +func isXuguIdentifierStart(value rune) bool { + return value == '_' || unicode.IsLetter(value) +} + +func isXuguIdentifierPart(value rune) bool { + return isXuguIdentifierStart(value) || unicode.IsDigit(value) || value == '$' || value == '#' +} + +func skipXuguSQLSpaceAndComments(value string, index int) int { + for index < len(value) { + if unicode.IsSpace(rune(value[index])) { + index++ + continue + } + if next, ok := skipXuguSQLComment(value, index); ok { + index = next + continue + } + break + } + return index +} + +func skipXuguSQLLiteralOrComment(value string, index int) (int, bool) { + if next, ok := skipXuguSQLComment(value, index); ok { + return next, true + } + if index >= len(value) || (value[index] != '\'' && value[index] != '"') { + return index, false + } + quote := value[index] + for cursor := index + 1; cursor < len(value); cursor++ { + if value[cursor] != quote { + continue + } + if cursor+1 < len(value) && value[cursor+1] == quote { + cursor++ + continue + } + return cursor + 1, true + } + return len(value), true +} + +func skipXuguSQLComment(value string, index int) (int, bool) { + if index+1 >= len(value) { + return index, false + } + if value[index:index+2] == "--" { + if end := strings.IndexByte(value[index+2:], '\n'); end >= 0 { + return index + 2 + end + 1, true + } + return len(value), true + } + if value[index:index+2] == "/*" { + if end := strings.Index(value[index+2:], "*/"); end >= 0 { + return index + 2 + end + 2, true + } + return len(value), true + } + return index, false +} + +func compactXuguSQLFragment(value string) string { + var builder strings.Builder + spacePending := false + for index := 0; index < len(value); { + if next, ok := skipXuguSQLComment(value, index); ok { + spacePending = builder.Len() > 0 + index = next + continue + } + if unicode.IsSpace(rune(value[index])) { + spacePending = builder.Len() > 0 + index++ + continue + } + if spacePending && builder.Len() > 0 { + builder.WriteByte(' ') + } + spacePending = false + if next, ok := skipXuguSQLLiteralOrComment(value, index); ok { + builder.WriteString(value[index:next]) + index = next + continue + } + builder.WriteByte(value[index]) + index++ + } + return strings.TrimSpace(builder.String()) +} diff --git a/agents/drivers/xugu/package_members_test.go b/agents/drivers/xugu/package_members_test.go new file mode 100644 index 0000000000..bb2abf3513 --- /dev/null +++ b/agents/drivers/xugu/package_members_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseXuguPackageMembers(t *testing.T) { + spec := ` +CREATE OR REPLACE PACKAGE "DbxPackage" AS + -- PROCEDURE ignored_comment(p_value INT); + TYPE rec_type IS RECORD ( + procedure_name VARCHAR(40), + function_name VARCHAR(40) + ); + PROCEDURE ping; + PROCEDURE echo_value(p_value IN VARCHAR DEFAULT 'a,b'); + PROCEDURE echo_value( + p_value IN NUMERIC(12, 2), + p_result OUT VARCHAR + ); + FUNCTION total_value( + p_left IN NUMERIC, + p_right IN NUMERIC DEFAULT ROUND(1.25, 1) + ) RETURN NUMERIC(18, 2); + FUNCTION "MixedCase"() RETURN VARCHAR; + value_text VARCHAR := 'FUNCTION fake() RETURN INT;'; +END "DbxPackage";` + + got := parseXuguPackageMembers(spec) + want := []xuguPackageMember{ + {Name: "ping", Kind: "PROCEDURE"}, + {Name: "echo_value", Kind: "PROCEDURE", Signature: "p_value IN VARCHAR DEFAULT 'a,b'"}, + {Name: "echo_value", Kind: "PROCEDURE", Signature: "p_value IN NUMERIC(12, 2), p_result OUT VARCHAR"}, + {Name: "total_value", Kind: "FUNCTION", Signature: "p_left IN NUMERIC, p_right IN NUMERIC DEFAULT ROUND(1.25, 1)", ReturnType: "NUMERIC(18, 2)"}, + {Name: "MixedCase", Kind: "FUNCTION", ReturnType: "VARCHAR"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected package members:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestParseXuguPackageMembersHandlesEscapedIdentifiersAndComments(t *testing.T) { + spec := `CREATE PACKAGE pkg AS +/* FUNCTION hidden RETURN INT; */ +PROCEDURE "a""b"(p_text VARCHAR DEFAULT 'it''s -- not a comment'); +FUNCTION f_value /* inline */ (p_value IN VARCHAR) RETURN /* type */ VARCHAR; +END;` + got := parseXuguPackageMembers(spec) + want := []xuguPackageMember{ + {Name: `a"b`, Kind: "PROCEDURE", Signature: "p_text VARCHAR DEFAULT 'it''s -- not a comment'"}, + {Name: "f_value", Kind: "FUNCTION", Signature: "p_value IN VARCHAR", ReturnType: "VARCHAR"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected escaped 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); +END;` + body := `CREATE PACKAGE BODY pkg AS +PROCEDURE private_proc IS BEGIN NULL; END; +PROCEDURE public_proc(p_value INT) IS BEGIN NULL; END; +END;` + got := parseXuguPackageMembers(spec) + if len(got) != 1 || got[0].Name != "public_proc" { + t.Fatalf("package specification should expose only public members: %#v", got) + } + if strings.Contains(spec, "private_proc") || !strings.Contains(body, "private_proc") { + t.Fatal("invalid test fixture") + } +} + +func TestXuguCompletionRoutineKindsAndFiltering(t *testing.T) { + if !xuguCompletionRequestsRoutines([]string{"table", "function"}) { + t.Fatal("function requests should enable package member lookup") + } + if xuguCompletionRequestsRoutines([]string{"table", "view"}) { + t.Fatal("non-routine requests must keep using the generic completion fallback") + } + allowed := xuguCompletionRoutineKinds([]string{"procedure"}) + if !allowed["PROCEDURE"] || allowed["FUNCTION"] { + t.Fatalf("unexpected procedure filter: %#v", allowed) + } + request := completionAssistantRequest{Mask: "VALUE", CaseSensitive: false, MatchMode: "contains"} + if !xuguCompletionNameMatches("total_value", request) || xuguCompletionNameMatches("ping", request) { + t.Fatal("case-insensitive contains filtering is incorrect") + } +} + +func TestXuguPackageSpecQueriesScopeCurrentDatabaseAndPreferExactIdentity(t *testing.T) { + exact := strings.ToUpper(xuguPackageSpecSQL) + if !strings.Contains(exact, "S.DB_ID = CURRENT_DB_ID") || !strings.Contains(exact, "S.SCHEMA_NAME = ?") || !strings.Contains(exact, "P.PACK_NAME = ?") { + t.Fatalf("exact package lookup must be database-scoped and case-preserving: %s", xuguPackageSpecSQL) + } + fallback := strings.ToUpper(xuguPackageSpecCaseFoldedSQL) + if !strings.Contains(fallback, "UPPER(S.SCHEMA_NAME) = UPPER(?)") || !strings.Contains(fallback, "UPPER(P.PACK_NAME) = UPPER(?)") { + t.Fatalf("case-folded package lookup is incomplete: %s", xuguPackageSpecCaseFoldedSQL) + } + if !strings.Contains(fallback, "CASE WHEN S.SCHEMA_NAME = ? THEN 0") || !strings.Contains(fallback, "CASE WHEN P.PACK_NAME = ? THEN 0") { + t.Fatalf("case-folded lookup must preserve exact-match priority: %s", xuguPackageSpecCaseFoldedSQL) + } +} diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 877eb0934a..4ac2f6280a 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -12,7 +12,7 @@ import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts"; import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut"; -import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick"; +import { copyNameForTreeNode, objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick"; import { copyToClipboard } from "@/lib/common/clipboard"; import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch"; @@ -106,7 +106,8 @@ const sidebarDdlDatabaseType = computed(() => { const connectionId = sidebarDdlTarget.value?.connectionId; return connectionId ? effectiveDatabaseTypeForConnection(store.getConfig(connectionId)) : undefined; }); -const sidebarObjectSourceType = computed(() => (sidebarObjectSourceTarget.value ? objectSourceKindForTreeNode(sidebarObjectSourceTarget.value.node.type) : null)); +const sidebarObjectSourceResolvedTarget = computed(() => (sidebarObjectSourceTarget.value ? objectSourceTargetForTreeNode(sidebarObjectSourceTarget.value.node) : null)); +const sidebarObjectSourceType = computed(() => sidebarObjectSourceResolvedTarget.value?.objectType ?? null); const sidebarObjectSourceDatabaseType = computed(() => { const connectionId = sidebarObjectSourceTarget.value?.node.connectionId; return connectionId ? effectiveDatabaseTypeForConnection(store.getConfig(connectionId)) : undefined; @@ -1336,7 +1337,7 @@ function openSidebarDdlForSelection(): boolean { } function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) { - if (!node.connectionId || !node.database || !objectSourceKindForTreeNode(node.type)) return; + if (!node.connectionId || !node.database || !objectSourceTargetForTreeNode(node)) return; const target = createSidebarActionTarget(node); const requestGeneration = beginSidebarAction(); void store @@ -1980,10 +1981,10 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); v-model:open="sidebarObjectSourceOpen" :connection-id="sidebarObjectSourceTarget.node.connectionId!" :database="sidebarObjectSourceTarget.node.database!" - :schema="sidebarObjectSourceTarget.node.schema" - :name="sidebarObjectSourceTarget.node.objectName || sidebarObjectSourceTarget.node.label" + :schema="sidebarObjectSourceResolvedTarget?.schema" + :name="sidebarObjectSourceResolvedTarget!.name" :relation-name="sidebarObjectSourceTarget.node.tableName" - :signature="sidebarObjectSourceTarget.node.signature" + :signature="sidebarObjectSourceResolvedTarget?.signature" :object-type="sidebarObjectSourceType" :database-type="sidebarObjectSourceDatabaseType" :dialect="sidebarObjectSourceDialect" diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 18582949b8..5d7164607e 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -94,7 +94,7 @@ import { usesTreeSchemaMode, isSingleDatabase, } from "@/lib/database/databaseCapabilities"; -import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; +import { copyNameForTreeNode, isDirectNavigationTreeNode, isDocumentBrowserTreeNode, objectSourceTargetForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick"; import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation"; import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy"; import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts"; @@ -622,6 +622,12 @@ async function toggle() { return; } + if (node.type === "package" && node.connectionId && connectionStore.getConfig(node.connectionId)?.db_type === "xugu") { + await connectionStore.loadXuguPackageMembers(node); + emitNodeToggled(node, wasExpanded); + return; + } + if (node.type === "connection" && node.connectionId) { const config = connectionStore.getConfig(node.connectionId); if (config?.db_type === "redis") { @@ -1674,18 +1680,18 @@ function openObjectSourceDialog(initialEditing: boolean) { if (!node.connectionId || !node.database) return; const connectionId = node.connectionId; const database = node.database; - const objectType = objectSourceKindForTreeNode(node.type); - if (!objectType) return; + const sourceTarget = objectSourceTargetForTreeNode(node); + if (!sourceTarget) return; const openMode = settingsStore.editorSettings.routineSourceOpenMode; if (openMode === "query-tab") { void connectionStore .ensureConnected(connectionId) .then(async () => { connectionStore.activeConnectionId = connectionId; - const schema = node.schema || database; + const schema = sourceTarget.schema || database; const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId)); if (!databaseType) throw new Error("Connection type is unavailable."); - const objectName = node.objectName || node.label; + const objectName = sourceTarget.name; const { raw, editableSource, @@ -1695,7 +1701,7 @@ function openObjectSourceDialog(initialEditing: boolean) { database, schema, name: objectName, - objectType: objectType as any, + objectType: sourceTarget.objectType as any, databaseType, signature: node.signature, }); @@ -4649,14 +4655,15 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { // 8. Procedure / Function / Package if (node.type === "procedure" || node.type === "function") { - if (node.type === "procedure") { + const isPackageMember = node.parentType === "package" && !!node.parentName; + if (node.type === "procedure" && !isPackageMember) { items.push({ label: t("contextMenu.executeProcedure"), action: openProcedureExecution, icon: Play }); } - if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { + if (!isPackageMember && currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); } items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); - if (canRenameObject.value) { + if (!isPackageMember && canRenameObject.value) { items.push({ label: t("contextMenu.renameObject"), action: openRenameObjectDialog, @@ -4666,14 +4673,16 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { } items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); - items.push({ label: "", separator: true }); - items.push({ - label: deleteMenuLabel(node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction")), - action: deleteMenuAction(requestDropObject), - icon: Trash2, - shortcut: shortcutDelete, - variant: "destructive" as const, - }); + if (!isPackageMember) { + items.push({ label: "", separator: true }); + items.push({ + label: deleteMenuLabel(node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction")), + action: deleteMenuAction(requestDropObject), + icon: Trash2, + shortcut: shortcutDelete, + variant: "destructive" as const, + }); + } return true; } diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index c3b8975f31..c4de7617e8 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -558,6 +558,7 @@ const canExpand = computed(() => canTreeNodeShowExpander({ type: activeNode.value.type, childCount: activeNode.value.children?.length ?? 0, + explicitContainer: activeNode.value.children !== undefined, }), ); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts index dc7c534135..7b5dd1c94b 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeItemLayout.spec.ts @@ -27,6 +27,13 @@ describe("sidebar tree item layout", () => { expect(canTreeNodeShowExpander({ type: "consul-overview", childCount: 0 })).toBe(false); }); + it("shows an expander only for package nodes explicitly marked as containers", () => { + expect(canTreeNodeShowExpander({ type: "package", childCount: 0 })).toBe(false); + expect(canTreeNodeShowExpander({ type: "package", childCount: 0, explicitContainer: true })).toBe(true); + expect(canTreeNodeShowExpander({ type: "package-body", childCount: 0, explicitContainer: true })).toBe(false); + expect(canTreeNodeShowExpander({ type: "procedure", 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 93a17dc0d8..d6c1e6b01f 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick"; +import { isDirectNavigationTreeNode, objectSourceKindForTreeNode, objectSourceTargetForTreeNode, shouldActivateTreeNodeOnSingleClick, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick"; describe("treeNodeClick", () => { it("opens synonym nodes as synonym source", () => { @@ -17,4 +17,48 @@ describe("treeNodeClick", () => { expect(treeNodeRowAction("consul-root", false, "double")).toBe("toggle"); expect(treeNodeRowAction("consul-overview", false, "double")).toBe("toggle"); }); + + it("expands package containers while preserving source behavior for leaf packages", () => { + expect(treeNodeRowAction("package", true)).toBe("toggle"); + expect(treeNodeRowAction("package", false)).toBe("open-source"); + }); + + it("routes package members to their owning package body", () => { + expect( + objectSourceTargetForTreeNode({ + id: "pkg:member", + label: "calculate(p_value IN INT)", + type: "function", + objectName: "calculate", + parentName: "business_api", + parentSchema: "app_schema", + parentType: "package", + schema: "ignored_schema", + signature: "p_value IN INT", + }), + ).toEqual({ + name: "business_api", + schema: "app_schema", + objectType: "PACKAGE", + signature: "p_value IN INT", + }); + }); + + it("keeps standalone routine source routing unchanged", () => { + expect(objectSourceTargetForTreeNode({ id: "proc", label: "standalone_proc", type: "procedure", schema: "app" })).toEqual({ + name: "standalone_proc", + schema: "app", + objectType: "PROCEDURE", + signature: undefined, + }); + }); + + it("does not reinterpret unrelated parent metadata as a package member", () => { + expect(objectSourceTargetForTreeNode({ id: "routine", label: "child_routine", type: "function", schema: "app", parentName: "other_parent" })).toEqual({ + name: "child_routine", + schema: "app", + objectType: "FUNCTION", + signature: undefined, + }); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts new file mode 100644 index 0000000000..63505d1848 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/xuguPackageMembers.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; +import { buildXuguPackageMemberNodes, markXuguPackageNodesExpandable } from "@/lib/sidebar/xuguPackageMembers"; + +function packageNode(): TreeNode { + return { + id: "conn:db:schema:package:business_api", + label: "business_api", + type: "package", + objectName: "business_api", + connectionId: "conn", + database: "db", + schema: "app_schema", + valid: false, + }; +} + +describe("Xugu 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); + + expect(result[0]?.children).toEqual([]); + expect(result[1]?.children).toBeUndefined(); + expect(result[2]?.children).toBeUndefined(); + }); + + it("builds public procedure and function children with overload identities", () => { + const candidates: CompletionAssistantCandidate[] = [ + { name: "calculate", kind: "procedure", signature: "p_value IN INT" }, + { name: "calculate", kind: "procedure", signature: "p_value IN VARCHAR" }, + { name: "lookup", kind: "function", signature: null, data_type: "VARCHAR" }, + { name: "ignored_table", kind: "table" }, + ]; + const result = buildXuguPackageMemberNodes(packageNode(), candidates); + + expect(result.map((node) => [node.type, node.label])).toEqual([ + ["procedure", "calculate(p_value IN INT)"], + ["procedure", "calculate(p_value IN VARCHAR)"], + ["function", "lookup"], + ]); + expect(result.every((node) => node.parentName === "business_api" && node.parentSchema === "app_schema" && node.parentType === "package" && node.valid === false)).toBe(true); + expect(new Set(result.map((node) => node.id)).size).toBe(3); + }); + + it("deduplicates exact duplicate metadata without merging case-distinct members", () => { + const candidates: CompletionAssistantCandidate[] = [ + { name: "MixedCase", kind: "function", signature: "p INT" }, + { name: "MixedCase", kind: "function", signature: "p INT" }, + { name: "MIXEDCASE", kind: "function", signature: "p INT" }, + { name: "", kind: "procedure" }, + ]; + const result = buildXuguPackageMemberNodes(packageNode(), candidates); + + expect(result.map((node) => node.objectName)).toEqual(["MixedCase", "MIXEDCASE"]); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index 9cf5b3d608..5d75e70ca5 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -155,8 +155,8 @@ export function canTreeNodeExpand(type: TreeNodeType): boolean { return !leafTypes.has(type); } -export function canTreeNodeShowExpander({ type, childCount }: { type: TreeNodeType; childCount?: number }): boolean { - if (!canTreeNodeExpand(type)) return false; +export function canTreeNodeShowExpander({ type, childCount, explicitContainer = false }: { type: TreeNodeType; childCount?: number; explicitContainer?: boolean }): boolean { + if (!canTreeNodeExpand(type) && !(type === "package" && 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 020264f043..2ec91563ec 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -58,6 +58,32 @@ export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKin return null; } +export interface TreeNodeObjectSourceTarget { + name: string; + schema?: string; + objectType: ObjectSourceKind; + signature?: string; +} + +export function objectSourceTargetForTreeNode(node: TreeNode): TreeNodeObjectSourceTarget | null { + if ((node.type === "procedure" || node.type === "function") && node.parentType === "package" && node.parentName) { + return { + name: node.parentName, + schema: node.parentSchema || node.schema, + objectType: "PACKAGE", + signature: node.signature, + }; + } + const objectType = objectSourceKindForTreeNode(node.type); + if (!objectType) return null; + return { + name: node.objectName || node.label, + schema: node.schema, + objectType, + signature: node.signature, + }; +} + export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean { return documentBrowserNodeTypes.has(type); } @@ -66,6 +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 (sourceNodeTypes.has(type)) return "open-source"; if (toggleLeafNodeTypes.has(type)) return "toggle"; if (canExpand) return "toggle"; diff --git a/apps/desktop/src/lib/sidebar/xuguPackageMembers.ts b/apps/desktop/src/lib/sidebar/xuguPackageMembers.ts new file mode 100644 index 0000000000..27ffbddaa3 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/xuguPackageMembers.ts @@ -0,0 +1,40 @@ +import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; + +export function markXuguPackageNodesExpandable(nodes: TreeNode[]): TreeNode[] { + return nodes.map((node) => (node.type === "package" ? { ...node, children: node.children ?? [] } : node)); +} + +export function buildXuguPackageMemberNodes(packageNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { + const parentName = packageNode.objectName || packageNode.label; + const parentSchema = packageNode.schema; + const seen = new Set(); + const children: TreeNode[] = []; + + for (const candidate of candidates) { + if (candidate.kind !== "procedure" && candidate.kind !== "function") continue; + const name = candidate.name.trim(); + if (!name) continue; + const signature = candidate.signature?.trim() || ""; + const key = `${candidate.kind}\0${name}\0${signature}`; + if (seen.has(key)) continue; + seen.add(key); + children.push({ + id: `${packageNode.id}:member:${candidate.kind}:${name}:${signature}`, + label: signature ? `${name}(${signature})` : name, + type: candidate.kind, + objectName: name, + signature: signature || undefined, + parentName, + parentSchema, + parentType: "package", + valid: packageNode.valid, + connectionId: packageNode.connectionId, + database: packageNode.database, + schema: parentSchema, + isExpanded: false, + children: undefined, + }); + } + + return children; +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 0a7e616ed4..76f6be8446 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -60,6 +60,19 @@ function oracleConnection(): ConnectionConfig { } as ConnectionConfig; } +function xuguConnection(): ConnectionConfig { + return { + id: "xugu-1", + name: "XuguDB", + db_type: "xugu", + host: "127.0.0.1", + port: 5138, + username: "app_user", + password: "", + database: "app_db", + } as ConnectionConfig; +} + function genericJdbcConnection(): ConnectionConfig { return { id: "jdbc-1", @@ -132,6 +145,61 @@ describe("connectionStore metadata loading", () => { setActivePinia(createPinia()); }); + // Dynamic import of the merged connectionStore plus the scoped metadata + // load can exceed Vitest's 5s default on slower CI runners; the assertion + // is deterministic, so allow a wider timeout. + it("loads Xugu package members through the scoped completion endpoint", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "process_item", kind: "procedure", signature: "p_id IN INT" }, + { name: "process_item", kind: "procedure", signature: "p_code IN VARCHAR" }, + { name: "item_count", kind: "function", data_type: "INT" }, + ], + 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 = xuguConnection(); + const packageNode: TreeNode = { + id: "xugu-1:app_db:app_schema:package:business_api", + label: "business_api", + type: "package", + connectionId: connection.id, + database: "app_db", + schema: "app_schema", + children: [], + isExpanded: false, + }; + store.connections = [connection]; + store.connectedIds = new Set([connection.id]); + store.treeNodes = [packageNode]; + + await store.loadXuguPackageMembers(packageNode); + + expect(completionAssistantSearch).toHaveBeenCalledWith({ + connection_id: connection.id, + database: "app_db", + schema: "app_schema", + object_kinds: ["routine"], + mask: "", + case_sensitive: true, + global_search: false, + max_results: 1000, + search_in_comments: false, + search_in_definitions: false, + parent_schema: "app_schema", + parent_name: "business_api", + match_mode: "prefix", + }); + expect(packageNode.isExpanded).toBe(true); + expect(packageNode.children?.map((child) => child.label)).toEqual(["process_item(p_id IN INT)", "process_item(p_code IN VARCHAR)", "item_count"]); + expect(packageNode.children?.every((child) => child.parentName === "business_api")).toBe(true); + }, 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 ba9d4ca89a..432864157b 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -111,6 +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 { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator"; import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; @@ -1604,7 +1605,8 @@ export const useConnectionStore = defineStore("connection", () => { objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))), }); const refreshedGroup = grouped.find((group) => group.type === options.node.type); - return refreshedGroup?.children ?? []; + const children = refreshedGroup?.children ?? []; + return options.node.connectionId && getConfig(options.node.connectionId)?.db_type === "xugu" ? markXuguPackageNodesExpandable(children) : children; } function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] { @@ -1861,13 +1863,16 @@ export const useConnectionStore = defineStore("connection", () => { ); const supplementalObjects = filterSimpleSidebarSupplementalObjects(objects); if (supplementalObjects.length === 0) return; - const supplementalChildren = buildSimpleObjectTreeNodes({ + let supplementalChildren = buildSimpleObjectTreeNodes({ nodeId: options.nodeId, connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, objects: supplementalObjects, }); + if (getConfig(options.connectionId)?.db_type === "xugu") { + supplementalChildren = markXuguPackageNodesExpandable(supplementalChildren); + } if (supplementalChildren.length === 0) return; if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return; const targetNode = treeNodeLoadTarget(options.load); @@ -5613,6 +5618,57 @@ 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; + const connectionId = node.connectionId; + const database = node.database; + const schema = node.schema; + const packageName = node.objectName || node.label; + let load = beginTreeNodeLoad(node); + + try { + await runTreeMetadataLoad( + { + kind: "xugu-package-members", + connectionId, + database, + schema, + nodeKind: node.type, + extra: { packageName }, + }, + async () => { + await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); + const response = await completionAssistantSearch({ + connection_id: connectionId, + database, + schema: schema ?? null, + object_kinds: ["routine"], + mask: "", + case_sensitive: true, + global_search: false, + max_results: 1000, + search_in_comments: false, + search_in_definitions: false, + parent_schema: schema ?? null, + parent_name: packageName, + match_mode: "prefix", + }); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + setChildren(targetNode, buildXuguPackageMemberNodes(targetNode, response.candidates)); + targetNode.isExpanded = true; + }, + options, + ); + } catch (error) { + recordMetadataLoadError(connectionId, error, load); + throw error; + } finally { + finishTreeNodeLoad(load); + } + } + 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"]); @@ -7224,6 +7280,7 @@ export const useConnectionStore = defineStore("connection", () => { loadTables, loadTableForLocate, loadObjectGroupChildren, + loadXuguPackageMembers, loadMoreObjectGroupChildren, loadAllObjectGroupChildren, loadTableGroups, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 30cf2a9d1e..42e6674079 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -890,6 +890,10 @@ export interface TreeNode { tableName?: string; objectName?: string; signature?: string; + /** Owning package/type for nested routine members. */ + parentName?: string; + parentSchema?: string; + parentType?: TreeNodeType; tableType?: string; comment?: string | null; valid?: boolean | null;