mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(xugu): expose package members in schema tree
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user