From ea9a27ce116d714e18aa42ef768e365ebdb3cbb8 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:24:01 +0800 Subject: [PATCH] feat(xugu): expose public synonyms in an isolated scope --- agents/drivers/xugu/main.go | 147 +++++++-- agents/drivers/xugu/main_test.go | 289 +++++++++++++++++- .../drivers/xugu/synonym_scope_live_test.go | 138 +++++++++ agents/metadata-constraint-coverage.tsv | 1 - .../src/components/sidebar/ConnectionTree.vue | 2 +- .../sidebar/SidebarTreeRuntimeHost.vue | 20 +- .../xuguPublicSynonymActions.spec.ts | 28 ++ .../__tests__/xuguPublicSynonyms.spec.ts | 25 ++ .../src/lib/sidebar/xuguPublicSynonyms.ts | 19 ++ .../connectionStore.defaultSchema.spec.ts | 35 +++ apps/desktop/src/stores/connectionStore.ts | 13 +- 11 files changed, 678 insertions(+), 39 deletions(-) create mode 100644 agents/drivers/xugu/synonym_scope_live_test.go create mode 100644 apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts create mode 100644 apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index a125d90ba6..64a06c567c 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -28,11 +28,26 @@ const defaultMaxRows = 1000 const defaultXuguPort = 5138 const legacyAgentSessionID = "__legacy__" const maxAgentSessions = 256 + +// xuguPublicSynonymScope is a protocol-only namespace for database-global +// synonyms. It is deliberately not a real schema name (and must never be +// interpreted as one by metadata queries). +const xuguPublicSynonymScope = "\x00DBX_XUGU_PUBLIC_SYNONYMS" const xuguListDatabasesSQL = ` SELECT DB_NAME FROM ALL_DATABASES ORDER BY DB_NAME` const xuguListSchemasSQL = ` +SELECT s.SCHEMA_NAME AS SCHEMA_NAME, FALSE AS IS_PUBLIC_SCOPE +FROM ALL_SCHEMAS s +WHERE s.DB_ID = CURRENT_DB_ID +UNION +SELECT '' AS SCHEMA_NAME, TRUE AS IS_PUBLIC_SCOPE +FROM ALL_SYNONYMS y +WHERE y.DB_ID = CURRENT_DB_ID + AND y.IS_PUBLIC = TRUE +ORDER BY IS_PUBLIC_SCOPE, SCHEMA_NAME` +const xuguListSchemasFallbackSQL = ` SELECT SCHEMA_NAME FROM ALL_SCHEMAS WHERE DB_ID = CURRENT_DB_ID @@ -48,12 +63,12 @@ JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND q.IS_SYS = FALSE` const xuguCatalogSynonymSelectSQL = ` -SELECT s.SCHEMA_NAME, y.SYNO_NAME, t.SCHEMA_NAME AS TARGET_SCHEMA, y.TARG_NAME +SELECT s.SCHEMA_NAME, + y.SYNO_NAME, t.SCHEMA_NAME AS TARGET_SCHEMA, y.TARG_NAME, y.IS_PUBLIC FROM ALL_SYNONYMS y -JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID +LEFT JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID LEFT JOIN ALL_SCHEMAS t ON t.DB_ID = y.DB_ID AND t.SCHEMA_ID = y.TARG_SCHE_ID -WHERE s.DB_ID = CURRENT_DB_ID - AND y.IS_PUBLIC = FALSE` +WHERE y.DB_ID = CURRENT_DB_ID` const xuguPrimaryKeyColumnsSQL = ` SELECT c.DEFINE FROM ALL_CONSTRAINTS c @@ -457,6 +472,7 @@ type xuguCatalogSynonym struct { Name string TargetSchema sql.NullString TargetName string + Public bool } // xuguIndexKey preserves the catalog spelling and SQL semantics of an index @@ -1689,21 +1705,60 @@ func xuguDSNValue(dsn string, key string) string { func (s *server) listSchemas() ([]string, error) { rows, err := s.queryRows(xuguListSchemasSQL, nil) if err != nil { - if fallback := strings.ToUpper(strings.TrimSpace(s.params.Username)); fallback != "" && isXuguMetadataUnavailableError(err) { - return []string{fallback}, nil + rows, err = s.queryRows(xuguListSchemasFallbackSQL, nil) + if err != nil { + if fallback := strings.ToUpper(strings.TrimSpace(s.params.Username)); fallback != "" && isXuguMetadataUnavailableError(err) { + return []string{fallback}, nil + } + return nil, err } - return nil, err + return s.scanXuguSchemaRows(rows, false) } + return s.scanXuguSchemaRows(rows, true) +} + +func (s *server) scanXuguSchemaRows(rows *sql.Rows, includesPublicScope bool) ([]string, error) { defer s.closeRows(rows) var result []string for rows.Next() { - var schema string - if err := rows.Scan(&schema); err != nil { + var schema sql.NullString + var publicScope bool + var err error + if includesPublicScope { + err = rows.Scan(&schema, &publicScope) + } else { + err = rows.Scan(&schema) + } + if err != nil { return nil, err } - result = append(result, schema) + if publicScope { + result = append(result, xuguPublicSynonymScope) + } else if schema.Valid && schema.String != "" { + result = append(result, schema.String) + } } - return emptyIfNil(result), rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return dedupeXuguSchemaNames(result), nil +} + +func dedupeXuguSchemaNames(names []string) []string { + seen := make(map[string]struct{}, len(names)) + result := make([]string, 0, len(names)) + for _, name := range names { + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + result = append(result, name) + } + return emptyIfNil(result) +} + +func isXuguPublicSynonymScope(schema string) bool { + return strings.TrimSpace(schema) == xuguPublicSynonymScope } func (s *server) currentSchema() (string, error) { @@ -2160,6 +2215,24 @@ WHERE s.DB_ID = CURRENT_DB_ID } func xuguListObjectsQuery(schema string, constraints metadataListConstraints) xuguMetadataListQuery { + // Public synonyms have no owning schema in Xugu. They are queried only + // through the reserved protocol scope; a real schema named GUEST remains + // a normal private-synonym namespace. + publicSynonymScope := isXuguPublicSynonymScope(schema) + synonymSQL := ` +SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE +FROM ALL_SYNONYMS y +WHERE y.DB_ID = CURRENT_DB_ID + AND y.IS_PUBLIC = TRUE` + if !publicSynonymScope { + synonymSQL = ` +SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE +FROM ALL_SYNONYMS y +JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID +WHERE y.DB_ID = CURRENT_DB_ID + AND UPPER(s.SCHEMA_NAME) = UPPER(?) + AND y.IS_PUBLIC = FALSE` + } type objectSource struct { objectTypes []string sql string @@ -2211,13 +2284,7 @@ JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID WHERE s.DB_ID = CURRENT_DB_ID AND UPPER(s.SCHEMA_NAME) = UPPER(?) AND q.IS_SYS = FALSE`}, - {objectTypes: []string{"SYNONYM"}, sql: ` -SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE -FROM ALL_SYNONYMS y -JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID -WHERE s.DB_ID = CURRENT_DB_ID - AND UPPER(s.SCHEMA_NAME) = UPPER(?) - AND y.IS_PUBLIC = FALSE`}, + {objectTypes: []string{"SYNONYM"}, sql: synonymSQL}, {objectTypes: []string{"TYPE"}, sql: ` SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID, CASE WHEN u.UDT_TYPE = 1001 THEN TRUE ELSE FALSE END AS XUGU_TYPE_MEMBERS_EXPANDABLE @@ -2263,7 +2330,9 @@ WHERE s.DB_ID = CURRENT_DB_ID baseArgs := make([]any, 0, len(baseSources)) for _, source := range baseSources { baseSQLParts = append(baseSQLParts, source.sql) - baseArgs = append(baseArgs, schema) + if !(publicSynonymScope && len(source.objectTypes) == 1 && source.objectTypes[0] == "SYNONYM") { + baseArgs = append(baseArgs, schema) + } } return xuguConstrainedMetadataListQuery( strings.Join(baseSQLParts, "\nUNION ALL\n"), @@ -2929,9 +2998,9 @@ func renderXuguSequenceDDL(sequence xuguSequenceMetadata) string { return builder.String() } -// getSynonymSource reconstructs private synonym DDL from ALL_SYNONYMS. Public -// synonyms deliberately remain outside schema groups: their catalog rows have -// no owning schema and showing them in every schema would duplicate objects. +// getSynonymSource reconstructs synonym DDL from ALL_SYNONYMS. Public +// synonyms are exposed in the reserved database-global scope and therefore +// use PUBLIC syntax without a schema qualifier. func (s *server) getSynonymSource(schema, name string) (map[string]any, error) { synonym, err := s.resolveCatalogSynonym(schema, name) if err != nil { @@ -2942,10 +3011,15 @@ func (s *server) getSynonymSource(schema, name string) (map[string]any, error) { } var builder strings.Builder - builder.WriteString("CREATE SYNONYM ") - builder.WriteString(quoteIdentifier(synonym.Schema)) - builder.WriteByte('.') - builder.WriteString(quoteIdentifier(synonym.Name)) + if synonym.Public { + builder.WriteString("CREATE PUBLIC SYNONYM ") + builder.WriteString(quoteIdentifier(synonym.Name)) + } else { + builder.WriteString("CREATE SYNONYM ") + builder.WriteString(quoteIdentifier(synonym.Schema)) + builder.WriteByte('.') + builder.WriteString(quoteIdentifier(synonym.Name)) + } builder.WriteString("\nFOR ") if targetSchema := strings.TrimSpace(synonym.TargetSchema.String); targetSchema != "" { builder.WriteString(quoteIdentifier(targetSchema)) @@ -2996,13 +3070,20 @@ func (s *server) resolveCatalogSynonym(schema, name string) (xuguCatalogSynonym, func xuguCatalogSynonymQuery(schema, name string, caseInsensitive bool) string { schemaExpr := quoteStringLiteral(schema) nameExpr := quoteStringLiteral(name) + publicScope := isXuguPublicSynonymScope(schema) if caseInsensitive { - schemaExpr = quoteStringLiteral(strings.ToUpper(schema)) nameExpr = quoteStringLiteral(strings.ToUpper(name)) - return xuguCatalogSynonymSelectSQL + "\n AND UPPER(s.SCHEMA_NAME) = " + schemaExpr + + if publicScope { + return xuguCatalogSynonymSelectSQL + "\n AND y.IS_PUBLIC = TRUE\n AND UPPER(y.SYNO_NAME) = " + nameExpr + } + schemaExpr = quoteStringLiteral(strings.ToUpper(schema)) + return xuguCatalogSynonymSelectSQL + "\n AND y.IS_PUBLIC = FALSE\n AND UPPER(s.SCHEMA_NAME) = " + schemaExpr + "\n AND UPPER(y.SYNO_NAME) = " + nameExpr } - return xuguCatalogSynonymSelectSQL + "\n AND s.SCHEMA_NAME = " + schemaExpr + + if publicScope { + return xuguCatalogSynonymSelectSQL + "\n AND y.IS_PUBLIC = TRUE\n AND y.SYNO_NAME = " + nameExpr + } + return xuguCatalogSynonymSelectSQL + "\n AND y.IS_PUBLIC = FALSE\n AND s.SCHEMA_NAME = " + schemaExpr + "\n AND y.SYNO_NAME = " + nameExpr } @@ -3016,9 +3097,15 @@ func (s *server) catalogSynonymCandidates(query string) ([]xuguCatalogSynonym, e var candidates []xuguCatalogSynonym for rows.Next() { var candidate xuguCatalogSynonym - if err := rows.Scan(&candidate.Schema, &candidate.Name, &candidate.TargetSchema, &candidate.TargetName); err != nil { + var schema sql.NullString + if err := rows.Scan(&schema, &candidate.Name, &candidate.TargetSchema, &candidate.TargetName, &candidate.Public); err != nil { return nil, err } + if candidate.Public { + candidate.Schema = xuguPublicSynonymScope + } else { + candidate.Schema = schema.String + } candidates = append(candidates, candidate) } return candidates, rows.Err() diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index e23c19da8c..6c33731054 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -711,11 +711,94 @@ func TestSchemaListingSQLUsesLowPrivilegeDictionary(t *testing.T) { if !strings.Contains(sqlText, "ALL_SCHEMAS") || strings.Contains(sqlText, "SYS_SCHEMAS") { t.Fatalf("schema listing should query low-privilege ALL_SCHEMAS, got: %s", xuguListSchemasSQL) } + if !strings.Contains(sqlText, "ALL_SYNONYMS") || !strings.Contains(sqlText, "IS_PUBLIC_SCOPE") { + t.Fatalf("schema listing should expose public synonyms in the same query: %s", xuguListSchemasSQL) + } if !strings.Contains(sqlText, "DB_ID = CURRENT_DB_ID") { t.Fatalf("schema listing must be scoped to the selected database: %s", xuguListSchemasSQL) } } +func TestXuguListSchemasExposesPublicScopeWithoutGUESTCollision(t *testing.T) { + db, err := sql.Open("xugu-test-schema-listing", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + cases := []struct { + name string + realGuest bool + realReserved bool + public bool + want []string + }{ + {name: "private only", want: []string{"APP_TEST", "SYSDBA"}}, + {name: "public without real guest", public: true, want: []string{"APP_TEST", "SYSDBA", xuguPublicSynonymScope}}, + {name: "public with real guest", realGuest: true, public: true, want: []string{"APP_TEST", "GUEST", "SYSDBA", xuguPublicSynonymScope}}, + {name: "public with former reserved schema", realReserved: true, public: true, want: []string{"APP_TEST", "__DBX_XUGU_PUBLIC_SYNONYMS__", "SYSDBA", xuguPublicSynonymScope}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + xuguSchemaListingState.Lock() + xuguSchemaListingState.realGuest = tc.realGuest + xuguSchemaListingState.realReserved = tc.realReserved + xuguSchemaListingState.public = tc.public + xuguSchemaListingState.combinedUnavailable = false + xuguSchemaListingState.queryCount = 0 + xuguSchemaListingState.Unlock() + + s := newServer() + s.db = db + got, err := s.listSchemas() + if err != nil { + t.Fatalf("listSchemas() error: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("listSchemas() = %v, want %v", got, tc.want) + } + xuguSchemaListingState.Lock() + queryCount := xuguSchemaListingState.queryCount + xuguSchemaListingState.Unlock() + if queryCount != 1 { + t.Fatalf("listSchemas() made %d metadata requests, want 1", queryCount) + } + }) + } +} + +func TestXuguListSchemasFallsBackWhenCombinedQueryIsUnavailable(t *testing.T) { + db, err := sql.Open("xugu-test-schema-listing", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + xuguSchemaListingState.Lock() + xuguSchemaListingState.realGuest = true + xuguSchemaListingState.realReserved = false + xuguSchemaListingState.public = true + xuguSchemaListingState.combinedUnavailable = true + xuguSchemaListingState.queryCount = 0 + xuguSchemaListingState.Unlock() + + s := newServer() + s.db = db + got, err := s.listSchemas() + if err != nil { + t.Fatalf("listSchemas() error: %v", err) + } + if want := []string{"APP_TEST", "GUEST", "SYSDBA"}; !reflect.DeepEqual(got, want) { + t.Fatalf("listSchemas() = %v, want %v", got, want) + } + xuguSchemaListingState.Lock() + queryCount := xuguSchemaListingState.queryCount + xuguSchemaListingState.Unlock() + if queryCount != 2 { + t.Fatalf("fallback listSchemas() made %d metadata requests, want 2", queryCount) + } +} + func TestXuguMetadataQueriesAreCurrentDatabaseScoped(t *testing.T) { queries := map[string]string{ "schemas": xuguListSchemasSQL, @@ -1131,6 +1214,46 @@ func TestAvailableXuguObjectTypesRespectsConstraints(t *testing.T) { } } +func TestXuguListObjectsQueryExposesPublicSynonymsOnlyInReservedScope(t *testing.T) { + query := xuguListObjectsQuery(xuguPublicSynonymScope, metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + upper := strings.ToUpper(query.SQL) + for _, want := range []string{"FROM ALL_SYNONYMS Y", "Y.IS_PUBLIC = TRUE", "OBJECT_TYPE IN (?)"} { + if !strings.Contains(upper, want) { + t.Fatalf("public synonym query is missing %q:\n%s", want, query.SQL) + } + } + synonymStart := strings.Index(upper, "SELECT Y.SYNO_NAME") + synonymEnd := strings.Index(upper[synonymStart:], "UNION ALL") + if synonymStart < 0 { + t.Fatalf("public synonym branch could not be isolated:\n%s", query.SQL) + } + if synonymEnd < 0 { + synonymEnd = len(upper) - synonymStart + } + synonymBranch := upper[synonymStart : synonymStart+synonymEnd] + if strings.Contains(synonymBranch, "JOIN ALL_SCHEMAS") || strings.Contains(synonymBranch, "UPPER(S.SCHEMA_NAME)") { + t.Fatalf("public synonym query must not require an owning schema:\n%s", query.SQL) + } + assertArgs(t, query.Args, []any{"SYNONYM"}) + + private := xuguListObjectsQuery("SYSDBA", metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + privateUpper := strings.ToUpper(private.SQL) + if !strings.Contains(privateUpper, "Y.IS_PUBLIC = FALSE") || !strings.Contains(privateUpper, "JOIN ALL_SCHEMAS") { + t.Fatalf("private synonym query must remain schema-scoped:\n%s", private.SQL) + } +} + +func TestXuguListObjectsQueryKeepsRealGuestSchemaPrivate(t *testing.T) { + query := xuguListObjectsQuery("GUEST", metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + upper := strings.ToUpper(query.SQL) + if !strings.Contains(upper, "Y.IS_PUBLIC = FALSE") || !strings.Contains(upper, "JOIN ALL_SCHEMAS") { + t.Fatalf("real GUEST schema must use the private synonym query: %s", query.SQL) + } + if strings.Contains(upper, "Y.IS_PUBLIC = TRUE") { + t.Fatalf("real GUEST schema must not use the public synonym scope: %s", query.SQL) + } +} + func TestXuguListObjectsQueryExcludesSystemSequences(t *testing.T) { query := xuguListObjectsQuery("APP", metadataListConstraints{ ObjectTypes: []string{"sequence"}, @@ -1208,6 +1331,72 @@ func TestGetSynonymSourceReconstructsPrivateQuotedDDL(t *testing.T) { } } +func TestGetSynonymSourceReconstructsPublicDDLWithoutSyntheticSchema(t *testing.T) { + db, err := sql.Open("xugu-test-public-synonym-source", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + source, err := s.getObjectSource(xuguPublicSynonymScope, "DbxPublicMixed", "SYNONYM") + if err != nil { + t.Fatalf("get public synonym source: %v", err) + } + if source["schema"] != xuguPublicSynonymScope || source["name"] != "DbxPublicMixed" { + t.Fatalf("public synonym source must preserve synthetic scope and catalog spelling: %#v", source) + } + ddl, _ := source["source"].(string) + want := "CREATE PUBLIC SYNONYM \"DbxPublicMixed\"\nFOR \"SYSDBA\".\"SHOP_USERS\";" + if ddl != want { + t.Fatalf("public synonym DDL = %q, want %q", ddl, want) + } +} + +func TestXuguCatalogSynonymQueryUsesReservedPublicScope(t *testing.T) { + exact := strings.ToUpper(xuguCatalogSynonymQuery(xuguPublicSynonymScope, "DbxPublicMixed", false)) + if !strings.Contains(exact, "Y.IS_PUBLIC = TRUE") || !strings.Contains(exact, "Y.SYNO_NAME = 'DBXPUBLICMIXED'") { + t.Fatalf("exact public synonym lookup must be global and exact:\n%s", exact) + } + if strings.Contains(exact, "S.SCHEMA_NAME =") { + t.Fatalf("exact public synonym lookup must not require an owning schema:\n%s", exact) + } + + folded := strings.ToUpper(xuguCatalogSynonymQuery(xuguPublicSynonymScope, "dbxpublicmixed", true)) + if !strings.Contains(folded, "Y.IS_PUBLIC = TRUE") || !strings.Contains(folded, "UPPER(Y.SYNO_NAME) = 'DBXPUBLICMIXED'") { + t.Fatalf("case-insensitive public synonym lookup must remain global:\n%s", folded) + } + if strings.Contains(folded, "S.SCHEMA_NAME =") { + t.Fatalf("case-insensitive public synonym lookup must not require an owning schema:\n%s", folded) + } +} + +func TestXuguCatalogSynonymQueryTreatsRealGuestAsPrivate(t *testing.T) { + query := strings.ToUpper(xuguCatalogSynonymQuery("GUEST", "DbxPrivateMixed", false)) + if !strings.Contains(query, "Y.IS_PUBLIC = FALSE") || !strings.Contains(query, "S.SCHEMA_NAME = 'GUEST'") { + t.Fatalf("real GUEST schema must use private exact lookup: %s", query) + } + if strings.Contains(query, "AND Y.IS_PUBLIC = TRUE") { + t.Fatalf("real GUEST schema must not use public lookup: %s", query) + } +} + +func TestSelectXuguCatalogSynonymDisambiguatesPrivateAndPublicSameName(t *testing.T) { + candidates := []xuguCatalogSynonym{ + {Schema: "GUEST", Name: "SharedAlias", TargetSchema: sql.NullString{String: "GUEST", Valid: true}, TargetName: "PRIVATE_TARGET", Public: false}, + {Schema: xuguPublicSynonymScope, Name: "SharedAlias", TargetSchema: sql.NullString{String: "SYSDBA", Valid: true}, TargetName: "PUBLIC_TARGET", Public: true}, + } + private, err := selectXuguCatalogSynonym("GUEST", "SharedAlias", candidates) + if err != nil || private.Public || private.TargetName != "PRIVATE_TARGET" { + t.Fatalf("private same-name synonym resolved incorrectly: %#v, err=%v", private, err) + } + public, err := selectXuguCatalogSynonym(xuguPublicSynonymScope, "SharedAlias", candidates) + if err != nil || !public.Public || public.TargetName != "PUBLIC_TARGET" { + t.Fatalf("public same-name synonym resolved incorrectly: %#v, err=%v", public, err) + } +} + func TestRenderXuguSequenceDDLUsesNoCacheAndNoCycle(t *testing.T) { ddl := renderXuguSequenceDDL(xuguSequenceMetadata{ Schema: "APP", Name: "SEQ_DEFAULTS", Current: int64(1), Minimum: int64(1), @@ -2204,10 +2393,77 @@ func init() { sql.Register("xugu-test-show-result", &xuguShowResultDriver{}) sql.Register("xugu-test-sequence-source", &xuguSequenceSourceDriver{}) sql.Register("xugu-test-synonym-source", &xuguSynonymSourceDriver{}) + sql.Register("xugu-test-public-synonym-source", &xuguPublicSynonymSourceDriver{}) sql.Register("xugu-test-permission-metadata", &xuguPermissionMetadataDriver{}) sql.Register("xugu-test-fallback-errors", &xuguFallbackErrorDriver{}) sql.Register("xugu-test-eof", &xuguEOFDriver{}) sql.Register("xugu-test-trigger-details", &xuguTriggerDetailsDriver{}) + sql.Register("xugu-test-schema-listing", &xuguSchemaListingDriver{}) +} + +var xuguSchemaListingState struct { + sync.Mutex + realGuest bool + realReserved bool + public bool + combinedUnavailable bool + queryCount int +} + +type xuguSchemaListingDriver struct{} + +func (d *xuguSchemaListingDriver) Open(name string) (driver.Conn, error) { + return &xuguSchemaListingConn{}, nil +} + +type xuguSchemaListingConn struct{} + +func (c *xuguSchemaListingConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguSchemaListingConn) Close() error { return nil } +func (c *xuguSchemaListingConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } +func (c *xuguSchemaListingConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + upper := strings.ToUpper(query) + xuguSchemaListingState.Lock() + realGuest, realReserved := xuguSchemaListingState.realGuest, xuguSchemaListingState.realReserved + public, combinedUnavailable := xuguSchemaListingState.public, xuguSchemaListingState.combinedUnavailable + xuguSchemaListingState.queryCount++ + xuguSchemaListingState.Unlock() + switch { + case strings.Contains(upper, "FROM ALL_SCHEMAS") && strings.Contains(upper, "FROM ALL_SYNONYMS"): + if combinedUnavailable { + return nil, errors.New("combined public synonym scope query is unavailable") + } + values := [][]driver.Value{{"APP_TEST"}} + if realGuest { + values = append(values, []driver.Value{"GUEST"}) + } + if realReserved { + values = append(values, []driver.Value{"__DBX_XUGU_PUBLIC_SYNONYMS__"}) + } + values = append(values, []driver.Value{"SYSDBA"}) + combinedValues := make([][]driver.Value, 0, len(values)+1) + for _, value := range values { + combinedValues = append(combinedValues, []driver.Value{value[0], false}) + } + if public { + combinedValues = append(combinedValues, []driver.Value{"", true}) + } + return &xuguStaticRows{columns: []string{"SCHEMA_NAME", "IS_PUBLIC_SCOPE"}, values: combinedValues}, nil + case strings.Contains(upper, "FROM ALL_SCHEMAS"): + values := [][]driver.Value{{"APP_TEST"}} + if realGuest { + values = append(values, []driver.Value{"GUEST"}) + } + if realReserved { + values = append(values, []driver.Value{"__DBX_XUGU_PUBLIC_SYNONYMS__"}) + } + values = append(values, []driver.Value{"SYSDBA"}) + return &xuguStaticRows{columns: []string{"SCHEMA_NAME"}, values: values}, nil + default: + return nil, fmt.Errorf("unexpected schema listing query: %s", query) + } } type xuguEOFDriver struct{} @@ -2656,8 +2912,37 @@ func (c *xuguSynonymSourceConn) QueryContext(_ context.Context, query string, _ return nil, fmt.Errorf("synonym resolution must prioritize exact catalog identifiers: %s", query) } return &xuguStaticRows{ - columns: []string{"SCHEMA_NAME", "SYNO_NAME", "TARGET_SCHEMA", "TARG_NAME"}, - values: [][]driver.Value{{"SYSDBA", "dbxSynonymReplayCase", "AppSchema", "tbUserProfile"}}, + columns: []string{"SCHEMA_NAME", "SYNO_NAME", "TARGET_SCHEMA", "TARG_NAME", "IS_PUBLIC"}, + values: [][]driver.Value{{"SYSDBA", "dbxSynonymReplayCase", "AppSchema", "tbUserProfile", false}}, + }, nil +} + +type xuguPublicSynonymSourceDriver struct{} + +func (d *xuguPublicSynonymSourceDriver) Open(name string) (driver.Conn, error) { + return &xuguPublicSynonymSourceConn{}, nil +} + +type xuguPublicSynonymSourceConn struct{} + +func (c *xuguPublicSynonymSourceConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguPublicSynonymSourceConn) Close() error { return nil } +func (c *xuguPublicSynonymSourceConn) Begin() (driver.Tx, error) { + return nil, errors.New("not supported") +} +func (c *xuguPublicSynonymSourceConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + upper := strings.ToUpper(query) + if !strings.Contains(upper, "FROM ALL_SYNONYMS") || !strings.Contains(upper, "Y.IS_PUBLIC = TRUE") { + return nil, fmt.Errorf("unexpected public synonym source query: %s", query) + } + if strings.Contains(upper, "S.SCHEMA_NAME =") || strings.Contains(upper, "UPPER(") || !strings.Contains(query, "y.SYNO_NAME = 'DbxPublicMixed'") { + return nil, fmt.Errorf("public synonym resolution must use the global exact lookup: %s", query) + } + return &xuguStaticRows{ + columns: []string{"SCHEMA_NAME", "SYNO_NAME", "TARGET_SCHEMA", "TARG_NAME", "IS_PUBLIC"}, + values: [][]driver.Value{{nil, "DbxPublicMixed", "SYSDBA", "SHOP_USERS", true}}, }, nil } diff --git a/agents/drivers/xugu/synonym_scope_live_test.go b/agents/drivers/xugu/synonym_scope_live_test.go new file mode 100644 index 0000000000..30da164d75 --- /dev/null +++ b/agents/drivers/xugu/synonym_scope_live_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// TestLivePublicSynonymScope exercises the complete discovery/list/source +// protocol against a real Xugu database. It is opt-in because public CI does +// not provide a Xugu service. Set XUGU_LIVE_TEST=1 and the XUGU_LIVE_* +// connection variables to run it. +func TestLivePublicSynonymScope(t *testing.T) { + if os.Getenv("XUGU_LIVE_TEST") != "1" { + t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings to run the XuguDB integration test") + } + params := connectParams{ + Host: os.Getenv("XUGU_LIVE_HOST"), + Port: parsePort(os.Getenv("XUGU_LIVE_PORT")), + Database: os.Getenv("XUGU_LIVE_DATABASE"), + Username: os.Getenv("XUGU_LIVE_USERNAME"), + Password: os.Getenv("XUGU_LIVE_PASSWORD"), + } + if params.Host == "" || params.Database == "" || params.Username == "" || params.Password == "" { + t.Fatal("XUGU_LIVE_HOST, XUGU_LIVE_DATABASE, XUGU_LIVE_USERNAME, and XUGU_LIVE_PASSWORD are required") + } + + s := newServer() + db, err := openDB(params) + if err != nil { + t.Fatal(err) + } + s.db = db + s.params = params + s.currentDatabase = params.Database + defer s.disconnect() + + // A real GUEST schema verifies that the database-global scope does not + // replace or hide an ordinary schema in discovery. The unit matrix below + // covers the same-name private/public routing independently of permissions + // available in the live account. + schemas, err := s.listSchemas() + if err != nil { + t.Fatal(err) + } + if !containsXuguName(schemas, "GUEST") { + t.Skip("the live database has no real GUEST schema") + } + + const privateTable = "DBX_PUBLIC_SYNONYM_SCOPE_PRIVATE_T" + const publicTable = "DBX_PUBLIC_SYNONYM_SCOPE_PUBLIC_T" + const privateName = "DBX_PUBLIC_SYNONYM_SCOPE_PRIVATE_ALIAS" + const publicName = "DBX_PUBLIC_SYNONYM_SCOPE_PUBLIC_ALIAS" + privateScope := params.Username + cleanup := []string{ + "DROP PUBLIC SYNONYM " + publicName, + "DROP SYNONYM \"" + privateScope + "\".\"" + privateName + "\"", + "DROP TABLE " + publicTable, + "DROP TABLE " + privateTable, + } + for _, statement := range cleanup { + _ = s.execWithReconnect(statement) + } + defer func() { + for _, statement := range cleanup { + _ = s.execWithReconnect(statement) + } + }() + + for _, statement := range []string{ + "CREATE TABLE " + privateTable + " (ID INTEGER)", + "CREATE TABLE " + publicTable + " (ID INTEGER)", + "CREATE OR REPLACE SYNONYM \"" + privateScope + "\".\"" + privateName + "\" FOR \"SYSDBA\".\"" + privateTable + "\"", + "CREATE OR REPLACE PUBLIC SYNONYM " + publicName + " FOR " + publicTable, + } { + if err := s.execWithReconnect(statement); err != nil { + t.Fatal(err) + } + } + + listedSchemas, err := s.listSchemas() + if err != nil { + t.Fatal(err) + } + if !containsXuguName(listedSchemas, "GUEST") || !containsXuguName(listedSchemas, xuguPublicSynonymScope) { + t.Fatalf("schema discovery lost one of the independent scopes: %v", listedSchemas) + } + + privateObjects, err := s.listObjects(privateScope, metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + if err != nil { + t.Fatal(err) + } + publicObjects, err := s.listObjects(xuguPublicSynonymScope, metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + if err != nil { + t.Fatal(err) + } + if !containsXuguObject(privateObjects, privateName) { + t.Fatalf("private scope did not expose the expected synonym: %#v", privateObjects) + } + if !containsXuguObject(publicObjects, publicName) { + t.Fatalf("public scope did not expose the expected synonym: %#v", publicObjects) + } + + privateSource, err := s.getObjectSource(privateScope, privateName, "SYNONYM") + if err != nil { + t.Fatal(err) + } + publicSource, err := s.getObjectSource(xuguPublicSynonymScope, publicName, "SYNONYM") + if err != nil { + t.Fatal(err) + } + privateDDL := privateSource["source"].(string) + publicDDL := publicSource["source"].(string) + if !strings.HasPrefix(privateDDL, "CREATE SYNONYM") || !strings.Contains(privateDDL, privateTable) { + t.Fatalf("private synonym source used the wrong DDL: %#v", privateSource) + } + if !strings.HasPrefix(publicDDL, "CREATE PUBLIC SYNONYM") || !strings.Contains(publicDDL, publicTable) { + t.Fatalf("public synonym source used the wrong DDL: %#v", publicSource) + } +} + +func containsXuguName(names []string, want string) bool { + for _, name := range names { + if strings.EqualFold(name, want) { + return true + } + } + return false +} + +func containsXuguObject(objects []objectInfo, want string) bool { + for _, object := range objects { + if object.Name == want { + return true + } + } + return false +} diff --git a/agents/metadata-constraint-coverage.tsv b/agents/metadata-constraint-coverage.tsv index 2010fb3838..e4b890588e 100644 --- a/agents/metadata-constraint-coverage.tsv +++ b/agents/metadata-constraint-coverage.tsv @@ -9,7 +9,6 @@ gbase8a native-pushdown java-sql Uses information_schema with type, filter, stab gbase8s native-pushdown java-sql Uses systables with type, filter, stable order, and SKIP/FIRST. goldendb native-pushdown java-sql Uses information_schema with type, filter, stable order, and LIMIT/OFFSET. h2 native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and LIMIT/OFFSET. -hive intentional-fallback java-sql Uses Hive metadata paths where stable server-side fuzzy paging is not portable, common constraints filter locally. hive-go shared-fallback native-go Uses HiveServer2 metadata APIs, then applies stable filtering and paging in the native agent with a SHOW TABLES fallback. informix native-pushdown java-sql Uses systables/sysprocedures with type, filter, stable order, and SKIP/FIRST. iotdb shared-fallback native-go Uses SHOW DEVICES or SHOW TABLES DETAILS for Tree/Table metadata, then applies stable filtering and paging in the native agent. diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 680994f1e3..74cc00c110 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -1226,7 +1226,7 @@ function findDatabaseNode(nodes: TreeNode[], connId: string, database: string): function findSchemaNode(nodes: TreeNode[], connId: string, database: string, schema: string): TreeNode | null { for (const node of nodes) { - if (node.type === "schema" && node.connectionId === connId && sameTreeName(node.database, database) && sameTreeName(node.label, schema)) { + if (node.type === "schema" && node.connectionId === connId && sameTreeName(node.database, database) && sameTreeName(node.schema || node.label, schema)) { return node; } if (node.children) { diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index cec334bf05..c4dd973fd2 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -73,6 +73,7 @@ import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisible import { objectTypesForGroupNode } from "@/lib/table/tableTree"; import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; import { isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; +import { isXuguPublicSynonymTreeNode } from "@/lib/sidebar/xuguPublicSynonyms"; import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates"; import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint"; @@ -2578,7 +2579,7 @@ const canCreateSchema = computed(() => { const canDropSchema = computed(() => { const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; const dbType = effectiveDatabaseTypeForConnection(config); - return activeNode.value.type === "schema" && !isSqlServerLinkedNode(activeNode.value) && (usesTreeSchemaMode(dbType) || dbType === "dameng") && !connectionUsesDatabaseObjectTreeMode(config); + return activeNode.value.type === "schema" && !isXuguPublicSynonymTreeNode(config?.db_type, activeNode.value.type, activeNode.value.schema) && !isSqlServerLinkedNode(activeNode.value) && (usesTreeSchemaMode(dbType) || dbType === "dameng") && !connectionUsesDatabaseObjectTreeMode(config); }); const canEditSchemaComment = computed(() => { @@ -3228,6 +3229,9 @@ async function confirmCreateSchema() { } function dropSchema() { + const node = sidebarDangerTarget.value ?? activeNode.value; + const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined; + if (isXuguPublicSynonymTreeNode(config?.db_type, node.type, node.schema)) return; void refreshDropSchemaPreviewSql(); showDropSchemaConfirm.value = true; } @@ -3236,8 +3240,9 @@ async function confirmDropSchema() { const node = sidebarDangerTarget.value ?? activeNode.value; if (!node.connectionId || !node.database) return; try { - await connectionStore.ensureConnected(node.connectionId); const config = connectionStore.getConfig(node.connectionId); + if (isXuguPublicSynonymTreeNode(config?.db_type, node.type, node.schema)) return; + await connectionStore.ensureConnected(node.connectionId); const dbType = effectiveDatabaseTypeForConnection(config); let dropExecutionSchema: string | undefined; if (dbType === "dameng") { @@ -4309,6 +4314,17 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { const { node, items } = context; // 4. Database / Schema if (node.type === "database" || node.type === "schema") { + if (isXuguPublicSynonymTreeNode(currentDatabaseType(), node.type, node.schema)) { + items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + items.push({ label: "", separator: true }); + items.push({ + label: t("contextMenu.refreshChildren"), + action: refresh, + icon: RefreshCw, + shortcut: shortcutRefresh, + }); + return true; + } if (currentDatabaseType() === "hbase") { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); if (canCreateTable.value) { diff --git a/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts b/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts new file mode 100644 index 0000000000..da44198d7f --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const runtimeSource = readFileSync(fileURLToPath(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url)), "utf8"); + +function functionSource(name: string): string { + const start = runtimeSource.indexOf(`function ${name}(`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + const next = runtimeSource.indexOf("\nfunction ", start + 1); + return runtimeSource.slice(start, next === -1 ? undefined : next); +} + +describe("Xugu public synonym actions", () => { + it("returns a read-only schema menu before default and destructive actions", () => { + const menu = functionSource("buildDatabaseSidebarMenu"); + const readOnlyGuard = menu.indexOf("isXuguPublicSynonymTreeNode"); + + expect(readOnlyGuard).toBeGreaterThan(-1); + expect(readOnlyGuard).toBeLessThan(menu.indexOf("contextMenu.setDefaultSchema")); + expect(readOnlyGuard).toBeLessThan(menu.indexOf("contextMenu.dropSchema")); + }); + + it("guards both the drop request and confirmed execution", () => { + expect(functionSource("dropSchema")).toContain("isXuguPublicSynonymTreeNode"); + expect(functionSource("confirmDropSchema")).toContain("isXuguPublicSynonymTreeNode"); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts new file mode 100644 index 0000000000..15b588ccce --- /dev/null +++ b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isXuguPublicSynonymScope, isXuguPublicSynonymTreeNode, XUGU_PUBLIC_SYNONYM_SCOPE, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; + +describe("Xugu public synonym scope", () => { + it("uses an independent protocol identity", () => { + expect(XUGU_PUBLIC_SYNONYM_SCOPE).not.toBe("GUEST"); + expect(XUGU_PUBLIC_SYNONYM_SCOPE.startsWith("\u0000")).toBe(true); + expect(isXuguPublicSynonymScope(XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(true); + expect(isXuguPublicSynonymScope("GUEST")).toBe(false); + expect(isXuguPublicSynonymScope("__DBX_XUGU_PUBLIC_SYNONYMS__")).toBe(false); + }); + + it("only replaces the reserved key for display", () => { + expect(xuguSchemaDisplayName(XUGU_PUBLIC_SYNONYM_SCOPE)).toBe("Public synonyms"); + expect(xuguSchemaDisplayName("GUEST")).toBe("GUEST"); + expect(xuguSchemaDisplayName("AppSchema")).toBe("AppSchema"); + }); + + it("identifies only the synthetic Xugu schema tree node", () => { + expect(isXuguPublicSynonymTreeNode("xugu", "schema", XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(true); + expect(isXuguPublicSynonymTreeNode("xugu", "schema", "__DBX_XUGU_PUBLIC_SYNONYMS__")).toBe(false); + expect(isXuguPublicSynonymTreeNode("postgres", "schema", XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(false); + expect(isXuguPublicSynonymTreeNode("xugu", "database", XUGU_PUBLIC_SYNONYM_SCOPE)).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts new file mode 100644 index 0000000000..0957937659 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts @@ -0,0 +1,19 @@ +/** + * Xugu public synonyms do not belong to a user schema. The agent exposes + * them through this reserved protocol scope so the schema tree can keep a + * real schema named GUEST independent from database-global aliases. + */ +export const XUGU_PUBLIC_SYNONYM_SCOPE = "\u0000DBX_XUGU_PUBLIC_SYNONYMS"; +export const XUGU_PUBLIC_SYNONYM_SCOPE_LABEL = "Public synonyms"; + +export function isXuguPublicSynonymScope(schema: string | null | undefined): boolean { + return schema === XUGU_PUBLIC_SYNONYM_SCOPE; +} + +export function xuguSchemaDisplayName(schema: string): string { + return isXuguPublicSynonymScope(schema) ? XUGU_PUBLIC_SYNONYM_SCOPE_LABEL : schema; +} + +export function isXuguPublicSynonymTreeNode(databaseType: string | null | undefined, nodeType: string, schema: string | null | undefined): boolean { + return databaseType === "xugu" && nodeType === "schema" && isXuguPublicSynonymScope(schema); +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts index ffd540c1fb..40cffee244 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ConnectionConfig, TreeNode } from "@/types/database"; +import { XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; function installLocalStorage() { const data = new Map(); @@ -24,6 +25,18 @@ function postgresConnection(): ConnectionConfig { }; } +function xuguConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "xugu-1", + name: "Xugu", + db_type: "xugu", + port: 5138, + username: "SYSDBA", + database: "SYSTEM", + }; +} + describe("connectionStore default schema", () => { beforeEach(() => { vi.resetModules(); @@ -86,4 +99,26 @@ describe("connectionStore default schema", () => { expect(schemaLabels()).toEqual(["archive", "public"]); expect(saveConnections).toHaveBeenLastCalledWith([expect.objectContaining({ id: connection.id, default_schema: undefined })]); }); + + it("does not persist the synthetic Xugu public-synonym scope as a default schema", async () => { + const saveConnections = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections, + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = xuguConnection(); + store.addEphemeralConnection(connection); + + await store.setDefaultSchema(connection.id, XUGU_PUBLIC_SYNONYM_SCOPE); + + expect(store.getConfig(connection.id)?.default_schema).toBeUndefined(); + expect(saveConnections).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index fa9290b862..cb05edd46b 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -113,6 +113,7 @@ import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVer import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; +import { isXuguPublicSynonymScope, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; @@ -2623,7 +2624,7 @@ export const useConnectionStore = defineStore("connection", () => { async function setDefaultSchema(connectionId: string, schema: string) { const config = getConfig(connectionId); const defaultSchema = schema.trim(); - if (!config || !defaultSchema || config.default_schema === defaultSchema) return; + if (!config || !defaultSchema || config.default_schema === defaultSchema || (config.db_type === "xugu" && isXuguPublicSynonymScope(defaultSchema))) return; await updateConnection({ ...config, default_schema: defaultSchema, @@ -3947,7 +3948,7 @@ export const useConnectionStore = defineStore("connection", () => { if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const showSystemSchemas = config?.show_system_schemas === true; - const cacheVersion = ownerAwareMetadataCacheVersion(config, "schemas-v3"); + const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "xugu" ? "schemas-v4" : "schemas-v3"); const cacheKey = schemaCacheKey(connectionId, database, cacheVersion, showSystemSchemas ? "show-system" : "hide-system"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); @@ -3966,13 +3967,19 @@ export const useConnectionStore = defineStore("connection", () => { { showSystemSchemas }, ), ); + // The public-synonym scope is a protocol namespace, not a user + // schema. Keep it discoverable even when a visible-schema filter is + // configured, while preserving the raw key for object routing. + if (config?.db_type === "xugu" && schemas.some((schema) => isXuguPublicSynonymScope(schema.name))) { + visibleSchemaNames.add(XUGU_PUBLIC_SYNONYM_SCOPE); + } const children: TreeNode[] = schemas .filter((schema) => visibleSchemaNames.has(schema.name)) .map((schema) => { const s = schema.name; return { id: `${connectionId}:${database}:${s}`, - label: s, + label: config?.db_type === "xugu" ? xuguSchemaDisplayName(s) : s, type: "schema" as const, connectionId, database,