feat(xugu): expose package members in schema tree

This commit is contained in:
Elias
2026-08-10 17:05:54 +08:00
committed by GitHub
parent 37f13018d3
commit ad9075e18f
15 changed files with 922 additions and 28 deletions
+10
View File
@@ -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":
+463
View File
@@ -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 &copy
}
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())
}
+106
View File
@@ -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)
}
}
@@ -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"
@@ -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;
}
@@ -558,6 +558,7 @@ const canExpand = computed(() =>
canTreeNodeShowExpander({
type: activeNode.value.type,
childCount: activeNode.value.children?.length ?? 0,
explicitContainer: activeNode.value.children !== undefined,
}),
);
@@ -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 },
@@ -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,
});
});
});
@@ -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"]);
});
});
@@ -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;
}
@@ -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";
@@ -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<string>();
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;
}
@@ -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 }]);
+59 -2
View File
@@ -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<void> {
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<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle"]);
@@ -7224,6 +7280,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadTables,
loadTableForLocate,
loadObjectGroupChildren,
loadXuguPackageMembers,
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
+4
View File
@@ -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;