diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index a315fa0ffb..984394bf4d 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -45,6 +45,12 @@ SELECT s.SCHEMA_NAME, q.SEQ_NAME FROM ALL_SEQUENCES q JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID WHERE q.IS_SYS = FALSE` +const xuguCatalogSynonymSelectSQL = ` +SELECT s.SCHEMA_NAME, y.SYNO_NAME, t.SCHEMA_NAME AS TARGET_SCHEMA, y.TARG_NAME +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 t ON t.DB_ID = y.DB_ID AND t.SCHEMA_ID = y.TARG_SCHE_ID +WHERE y.IS_PUBLIC = FALSE` const xuguPrimaryKeyColumnsSQL = ` SELECT c.DEFINE FROM ALL_CONSTRAINTS c @@ -431,6 +437,13 @@ type xuguSequenceMetadata struct { Comment any } +type xuguCatalogSynonym struct { + Schema string + Name string + TargetSchema sql.NullString + TargetName string +} + // xuguIndexKey preserves the catalog spelling and SQL semantics of an index // key. In particular, an index key can be a normal identifier, an identifier // with ASC/DESC ordering, or an arbitrary expression such as LOWER("CODE"). @@ -1639,6 +1652,12 @@ JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND q.IS_SYS = FALSE UNION ALL +SELECT y.SYNO_NAME AS OBJECT_NAME, 'SYNONYM' AS OBJECT_TYPE, NULL AS COMMENTS, y.VALID +FROM ALL_SYNONYMS y +JOIN ALL_SCHEMAS s ON s.DB_ID = y.DB_ID AND s.SCHEMA_ID = y.SCHEMA_ID +WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) + AND y.IS_PUBLIC = FALSE +UNION ALL SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID FROM ALL_TYPES u JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID @@ -1652,7 +1671,7 @@ WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_NAME", "OBJECT_TYPE", - []any{schema, schema, schema, schema, schema, schema, schema, schema, schema}, + []any{schema, schema, schema, schema, schema, schema, schema, schema, schema, schema}, constraints, ) } @@ -1717,7 +1736,7 @@ func normalizedXuguObjectTypes(values []string) []string { normalized = "TABLE" case "VIEW": normalized = "VIEW" - case "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY": + case "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY": // Already normalized. default: continue @@ -2026,6 +2045,9 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an if strings.EqualFold(strings.TrimSpace(objectType), "SEQUENCE") { return s.getSequenceSource(schema, name) } + if strings.EqualFold(strings.TrimSpace(objectType), "SYNONYM") { + return s.getSynonymSource(schema, name) + } var err error schema, err = s.normalizeSchema(schema) if err != nil { @@ -2233,6 +2255,115 @@ 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. +func (s *server) getSynonymSource(schema, name string) (map[string]any, error) { + synonym, err := s.resolveCatalogSynonym(schema, name) + if err != nil { + return nil, err + } + if strings.TrimSpace(synonym.TargetName) == "" { + return nil, fmt.Errorf("synonym target is missing: %s.%s", synonym.Schema, synonym.Name) + } + + var builder strings.Builder + 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)) + builder.WriteByte('.') + } + builder.WriteString(quoteIdentifier(synonym.TargetName)) + builder.WriteString(";") + + return map[string]any{ + "name": synonym.Name, + "object_type": "SYNONYM", + "schema": synonym.Schema, + "source": builder.String(), + }, nil +} + +// resolveCatalogSynonym applies exact matching before a case-insensitive +// fallback. This preserves quoted identifiers and refuses an ambiguous lookup +// instead of silently generating DDL for a different alias. +func (s *server) resolveCatalogSynonym(schema, name string) (xuguCatalogSynonym, error) { + schema = strings.TrimSpace(schema) + name = strings.TrimSpace(name) + if schema == "" { + current, err := s.currentSchema() + if err != nil { + return xuguCatalogSynonym{}, err + } + schema = current + } + if name == "" { + return xuguCatalogSynonym{}, errors.New("synonym name is required") + } + + candidates, err := s.catalogSynonymCandidates(xuguCatalogSynonymQuery(schema, name, false)) + if err != nil { + return xuguCatalogSynonym{}, err + } + if len(candidates) == 0 { + candidates, err = s.catalogSynonymCandidates(xuguCatalogSynonymQuery(schema, name, true)) + if err != nil { + return xuguCatalogSynonym{}, err + } + } + return selectXuguCatalogSynonym(schema, name, candidates) +} + +func xuguCatalogSynonymQuery(schema, name string, caseInsensitive bool) string { + schemaExpr := quoteStringLiteral(schema) + nameExpr := quoteStringLiteral(name) + if caseInsensitive { + schemaExpr = quoteStringLiteral(strings.ToUpper(schema)) + nameExpr = quoteStringLiteral(strings.ToUpper(name)) + return xuguCatalogSynonymSelectSQL + "\n AND UPPER(s.SCHEMA_NAME) = " + schemaExpr + + "\n AND UPPER(y.SYNO_NAME) = " + nameExpr + } + return xuguCatalogSynonymSelectSQL + "\n AND s.SCHEMA_NAME = " + schemaExpr + + "\n AND y.SYNO_NAME = " + nameExpr +} + +func (s *server) catalogSynonymCandidates(query string) ([]xuguCatalogSynonym, error) { + rows, err := s.queryRows(strings.TrimSpace(query), nil) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + + var candidates []xuguCatalogSynonym + for rows.Next() { + var candidate xuguCatalogSynonym + if err := rows.Scan(&candidate.Schema, &candidate.Name, &candidate.TargetSchema, &candidate.TargetName); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func selectXuguCatalogSynonym(schema, name string, candidates []xuguCatalogSynonym) (xuguCatalogSynonym, error) { + for _, candidate := range candidates { + if candidate.Schema == schema && candidate.Name == name { + return candidate, nil + } + } + if len(candidates) == 1 { + return candidates[0], nil + } + if len(candidates) == 0 { + return xuguCatalogSynonym{}, fmt.Errorf("synonym not found: %s.%s", schema, name) + } + return xuguCatalogSynonym{}, fmt.Errorf("synonym name is ambiguous: %s.%s; specify the catalog's exact case", schema, name) +} + func xuguSequenceNumber(value any) string { return strings.TrimSpace(xuguString(value)) } diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 60013796bb..e4023621c0 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -756,25 +756,35 @@ func TestXuguListObjectsQueryRejectsUnsupportedObjectTypes(t *testing.T) { t.Fatalf("unsupported object type should produce empty-result predicate:\n%s", query.SQL) } - wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", 10, 0} + wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", 10, 0} assertArgs(t, query.Args, wantArgs) } func TestXuguListObjectsQueryIncludesProgrammableObjects(t *testing.T) { query := xuguListObjectsQuery("APP", metadataListConstraints{ - ObjectTypes: []string{"procedure", "function", "package", "package-body", "trigger", "sequence", "type", "type-body"}, + ObjectTypes: []string{"procedure", "function", "package", "package-body", "trigger", "sequence", "synonym", "type", "type-body"}, }) - for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_TYPES", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?)"} { + for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_SYNONYMS", "y.IS_PUBLIC = FALSE", "ALL_TYPES", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?,?)"} { if !strings.Contains(query.SQL, want) { t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL) } } - wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "PROCEDURE", "SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"} + wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "PROCEDURE", "SEQUENCE", "SYNONYM", "TRIGGER", "TYPE", "TYPE_BODY"} assertArgs(t, query.Args, wantArgs) } +func TestXuguListObjectsQueryKeepsPublicSynonymsOutOfSchemaGroups(t *testing.T) { + query := xuguListObjectsQuery("SYSDBA", metadataListConstraints{ObjectTypes: []string{"SYNONYM"}}) + for _, want := range []string{"FROM ALL_SYNONYMS y", "y.IS_PUBLIC = FALSE", "OBJECT_TYPE IN (?)"} { + if !strings.Contains(query.SQL, want) { + t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL) + } + } + assertArgs(t, query.Args, []any{"SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYSDBA", "SYNONYM"}) +} + func TestXuguListObjectsQueryExcludesSystemSequences(t *testing.T) { query := xuguListObjectsQuery("APP", metadataListConstraints{ ObjectTypes: []string{"sequence"}, @@ -825,6 +835,30 @@ func TestGetSequenceSourceReconstructsExecutableDDL(t *testing.T) { } } +func TestGetSynonymSourceReconstructsPrivateQuotedDDL(t *testing.T) { + db, err := sql.Open("xugu-test-synonym-source", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + source, err := s.getObjectSource("SYSDBA", "dbxSynonymReplayCase", "SYNONYM") + if err != nil { + t.Fatalf("get synonym source: %v", err) + } + if source["schema"] != "SYSDBA" || source["name"] != "dbxSynonymReplayCase" { + t.Fatalf("synonym source must preserve catalog spelling: %#v", source) + } + + ddl, _ := source["source"].(string) + want := "CREATE SYNONYM \"SYSDBA\".\"dbxSynonymReplayCase\"\nFOR \"AppSchema\".\"tbUserProfile\";" + if ddl != want { + t.Fatalf("synonym DDL = %q, want %q", ddl, want) + } +} + func TestRenderXuguSequenceDDLUsesNoCacheAndNoCycle(t *testing.T) { ddl := renderXuguSequenceDDL(xuguSequenceMetadata{ Schema: "APP", Name: "SEQ_DEFAULTS", Current: int64(1), Minimum: int64(1), @@ -1399,6 +1433,22 @@ func TestSelectXuguCatalogSequenceNamePrefersExactCaseAndRejectsAmbiguity(t *tes } } +func TestSelectXuguCatalogSynonymPrefersExactCaseAndRejectsAmbiguity(t *testing.T) { + candidates := []xuguCatalogSynonym{ + {Schema: "SYSDBA", Name: "dbxSynonym", TargetName: "TB_A"}, + {Schema: "SYSDBA", Name: "DBXSYNONYM", TargetName: "TB_B"}, + } + + synonym, err := selectXuguCatalogSynonym("SYSDBA", "dbxSynonym", candidates) + if err != nil || synonym.Name != "dbxSynonym" || synonym.TargetName != "TB_A" { + t.Fatalf("exact-case selection = (%#v, %v), want quoted catalog synonym", synonym, err) + } + + if _, err := selectXuguCatalogSynonym("SYSDBA", "DbxSynonym", candidates); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("mixed-case ambiguous selection error = %v, want ambiguity error", err) + } +} + func TestCatalogTableLookupQueriesAvoidCaseFoldingBoundParameters(t *testing.T) { exact := xuguCatalogTableNameQuery("S'CHEMA", "MiX'ed", false) if strings.Contains(strings.ToUpper(exact), "UPPER(") { @@ -1441,6 +1491,25 @@ func TestCatalogSequenceLookupQueriesPreferExactIdentifiers(t *testing.T) { } } +func TestCatalogSynonymLookupQueriesPreferExactPrivateIdentifiers(t *testing.T) { + exact := xuguCatalogSynonymQuery("App'Schema", "syn'MixedCase", false) + if strings.Contains(strings.ToUpper(exact), "UPPER(") { + t.Fatalf("exact synonym lookup must not case-fold identifiers:\n%s", exact) + } + for _, fragment := range []string{"y.IS_PUBLIC = FALSE", "s.SCHEMA_NAME = 'App''Schema'", "y.SYNO_NAME = 'syn''MixedCase'"} { + if !strings.Contains(exact, fragment) { + t.Fatalf("exact synonym lookup missing %q:\n%s", fragment, exact) + } + } + + folded := xuguCatalogSynonymQuery("App'Schema", "syn'MixedCase", true) + for _, fragment := range []string{"y.IS_PUBLIC = FALSE", "UPPER(s.SCHEMA_NAME) = 'APP''SCHEMA'", "UPPER(y.SYNO_NAME) = 'SYN''MIXEDCASE'"} { + if !strings.Contains(folded, fragment) { + t.Fatalf("case-insensitive synonym lookup missing %q:\n%s", fragment, folded) + } + } +} + func TestTableDDLCatalogQueriesUseExactIdentifiers(t *testing.T) { queries := map[string]string{ "primary key": xuguPrimaryKeyColumnsSQL, @@ -1722,6 +1791,7 @@ func init() { sql.Register("xugu-test-table-ddl", &xuguTableDDLDriver{}) sql.Register("xugu-test-show-result", &xuguShowResultDriver{}) sql.Register("xugu-test-sequence-source", &xuguSequenceSourceDriver{}) + sql.Register("xugu-test-synonym-source", &xuguSynonymSourceDriver{}) } type xuguShowResultDriver struct{} @@ -1821,6 +1891,33 @@ func (c *xuguSequenceSourceConn) QueryContext(_ context.Context, query string, _ }, nil } +type xuguSynonymSourceDriver struct{} + +func (d *xuguSynonymSourceDriver) Open(name string) (driver.Conn, error) { + return &xuguSynonymSourceConn{}, nil +} + +type xuguSynonymSourceConn struct{} + +func (c *xuguSynonymSourceConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguSynonymSourceConn) Close() error { return nil } +func (c *xuguSynonymSourceConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } +func (c *xuguSynonymSourceConn) 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 = FALSE") { + return nil, fmt.Errorf("unexpected synonym source query: %s", query) + } + if strings.Contains(upper, "UPPER(") || !strings.Contains(query, "s.SCHEMA_NAME = 'SYSDBA'") || !strings.Contains(query, "y.SYNO_NAME = 'dbxSynonymReplayCase'") { + 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"}}, + }, nil +} + type xuguTableDDLDriver struct{} func (d *xuguTableDDLDriver) Open(name string) (driver.Conn, error) { diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index c4f4bb4e14..dafa9f905b 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -180,9 +180,9 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => { .catch(() => {}); }); -const searchableObjectGroupTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]); +const searchableObjectGroupTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]); const simpleObjectParentTypes = new Set(["database", "schema", "linked-server-schema"]); -const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body", "load-more"]); +const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "sequence", "synonym", "package", "package-body", "type", "type-body", "load-more"]); function isSimpleObjectSearchParent(node: TreeNode): boolean { return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim()); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 04497582d1..5be59ec458 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -492,6 +492,7 @@ const groupTypes: Set = new Set([ "group-procedures", "group-functions", "group-sequences", + "group-synonyms", "group-packages", "group-types", "group-partitions", diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 997c923fd7..663bd11ea4 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -14,6 +14,7 @@ import { TableProperties, Key, Link, + Link2, Zap, ListTree, FileCode, @@ -277,6 +278,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Braces, colorClass: "text-amber-500" }; case "sequence": return { icon: ListTree, colorClass: "text-emerald-500" }; + case "synonym": + return { icon: Link2, colorClass: "text-sky-500" }; case "package": return { icon: Package, colorClass: "text-cyan-500" }; case "package-body": @@ -297,6 +300,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: Braces, colorClass: "text-amber-500" }; case "group-sequences": return { icon: ListTree, colorClass: "text-emerald-500" }; + case "group-synonyms": + return { icon: Link2, colorClass: "text-sky-500" }; case "group-packages": return { icon: Package, colorClass: "text-cyan-500" }; case "group-types": @@ -1178,7 +1183,15 @@ function onKeydown(event: KeyboardEvent) { { expect(sidebarObjectKindsForDatabase("dameng")).toContain("MATERIALIZED_VIEW"); }); + it("exposes synonyms for Xugu only", () => { + expect(sidebarObjectKindsForDatabase("xugu")).toContain("SYNONYM"); + expect(sidebarObjectKindsForDatabase("postgres")).not.toContain("SYNONYM"); + }); + it("exposes only tables for HBase namespaces", () => { expect(sidebarObjectKindsForDatabase("hbase")).toEqual(["TABLE"]); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts index 403cf53b2d..844a562f41 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts @@ -25,10 +25,10 @@ function mysqlConnection(): ConnectionConfig { } as ConnectionConfig; } -function objectGroup(type: "group-triggers" | "group-types", id: string): TreeNode { +function objectGroup(type: "group-triggers" | "group-synonyms" | "group-types", id: string): TreeNode { return { id, - label: type === "group-triggers" ? "tree.triggers" : "tree.types", + label: type === "group-triggers" ? "tree.triggers" : type === "group-synonyms" ? "tree.synonyms" : "tree.types", type, connectionId: "mysql-1", database: "app", @@ -70,23 +70,28 @@ describe("sidebar object-group routing", () => { setActivePinia(createPinia()); }); - it("uses listObjects for schema-level trigger and type groups, including empty results", async () => { + it("uses listObjects for schema-level trigger, synonym, and type groups, including empty results", async () => { const listObjects = vi.fn<() => Promise>().mockResolvedValue([]); const listTriggers = vi.fn<() => Promise>().mockResolvedValue([]); const { connection, store } = await createStore({ listObjects, listTriggers }); const triggerGroup = objectGroup("group-triggers", `${connection.id}:app:app:__triggers`); + const synonymGroup = objectGroup("group-synonyms", `${connection.id}:app:app:__synonyms`); const typeGroup = objectGroup("group-types", `${connection.id}:app:app:__types`); - store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup, typeGroup] }]; + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup, synonymGroup, typeGroup] }]; const storedTriggerGroup = store.treeNodes[0].children![0]; - const storedTypeGroup = store.treeNodes[0].children![1]; + const storedSynonymGroup = store.treeNodes[0].children![1]; + const storedTypeGroup = store.treeNodes[0].children![2]; await loadSidebarObjectGroup(storedTriggerGroup, store); + await loadSidebarObjectGroup(storedSynonymGroup, store); await loadSidebarObjectGroup(storedTypeGroup, store); expect(listObjects).toHaveBeenNthCalledWith(1, connection.id, "app", "app", ["TRIGGER"], undefined, 11, 0); - expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "app", "app", ["TYPE", "TYPE_BODY"], undefined, 11, 0); + expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "app", "app", ["SYNONYM"], undefined, 11, 0); + expect(listObjects).toHaveBeenNthCalledWith(3, connection.id, "app", "app", ["TYPE", "TYPE_BODY"], undefined, 11, 0); expect(listTriggers).not.toHaveBeenCalled(); expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); + expect(storedSynonymGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); expect(storedTypeGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts new file mode 100644 index 0000000000..b39c6e29e9 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/treeNodeClick.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { objectSourceKindForTreeNode, treeNodeRowAction } from "@/lib/sidebar/treeNodeClick"; + +describe("treeNodeClick", () => { + it("opens synonym nodes as synonym source", () => { + expect(objectSourceKindForTreeNode("synonym")).toBe("SYNONYM"); + expect(treeNodeRowAction("synonym", false)).toBe("open-source"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index 549ec6aa4c..d14fd39793 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -109,6 +109,16 @@ describe("programmable database objects", () => { expect.arrayContaining([expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }), expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true }), expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true })]), ); }); + + it("groups Xugu private synonyms as source objects", () => { + const objects: ObjectInfo[] = [{ name: "SYN_SHOP_USERS", object_type: "SYNONYM", schema: "SYSDBA", valid: true }]; + + const groups = buildGroupedObjectTreeNodes({ ...context, schema: "SYSDBA", objects }); + const synonymGroup = groups.find((node) => node.type === "group-synonyms"); + + expect(synonymGroup).toEqual(expect.objectContaining({ objectCount: 1, label: "tree.synonyms" })); + expect(synonymGroup?.children).toEqual([expect.objectContaining({ type: "synonym", objectName: "SYN_SHOP_USERS", valid: true })]); + }); }); describe("PostgreSQL table hierarchy", () => { diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index 8d58c54325..5e08c1fec1 100644 --- a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts @@ -1,6 +1,6 @@ import type { DatabaseType } from "@/types/database"; -export type SidebarObjectKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type SidebarObjectKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface DatabaseObjectCapabilities { sidebarObjects: SidebarObjectKind[]; @@ -16,7 +16,7 @@ const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUN const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"]; const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"]; const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"]; -const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"]; +const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"]; const DATABASE_TYPE_OBJECTS = new Map([ // postgres @@ -91,6 +91,7 @@ export function normalizeSidebarObjectKind(type: string): SidebarObjectKind { if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW"; if (value.includes("VIEW")) return "VIEW"; if (value.includes("SEQ")) return "SEQUENCE"; + if (value.includes("SYNONYM")) return "SYNONYM"; if (value.includes("PROC")) return "PROCEDURE"; if (value.includes("FUNC")) return "FUNCTION"; return "TABLE"; diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index 3d2157f40e..57101b8be3 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -7,6 +7,7 @@ const leafTypes: Set = new Set([ "trigger", "procedure", "function", + "synonym", "package", "package-body", "type", diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 4383ad87c1..1bf36f4df3 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -10,10 +10,10 @@ const dataNodeTypes = new Set(["table", "view", "materialized_view const documentBrowserNodeTypes = new Set(["mongo-collection", "mongo-bucket"]); const toggleLeafNodeTypes = new Set(["redis-db", "mq-tenant", "etcd-root", "etcd-dashboard", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]); const objectBrowserNodeTypes = new Set(["database", "schema", "object-browser"]); -const sourceNodeTypes = new Set(["materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body"]); +const sourceNodeTypes = new Set(["materialized_view", "procedure", "function", "trigger", "sequence", "synonym", "package", "package-body", "type", "type-body"]); const savedSqlNodeTypes = new Set(["saved-sql-file"]); const tableChildGroupNodeTypes = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-constraints", "group-partitions", "group-table-partitions", "group-table-subpartitions"]); -const databaseChildGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]); +const databaseChildGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]); export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null { if (type === "view") return "VIEW"; @@ -22,6 +22,7 @@ export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKin if (type === "function") return "FUNCTION"; if (type === "trigger") return "TRIGGER"; if (type === "sequence") return "SEQUENCE"; + if (type === "synonym") return "SYNONYM"; if (type === "package") return "PACKAGE"; if (type === "package-body") return "PACKAGE_BODY"; if (type === "type") return "TYPE"; diff --git a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts index 9852ab38ce..036b55cde3 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts @@ -14,6 +14,7 @@ const treeGroupNodeTypes = new Set([ "group-procedures", "group-functions", "group-sequences", + "group-synonyms", "group-packages", "group-types", "group-partitions", diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index 33e1624bfa..e24b7a219f 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -1,5 +1,5 @@ import type { Component } from "vue"; -import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue"; +import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Gauge, Key, Link, Link2, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue"; import type { ColumnInfo, TreeNode } from "@/types/database"; export type TreeNodeIconInfo = { @@ -87,6 +87,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: Braces, colorClass: "text-amber-500" }; case "sequence": return { icon: ListTree, colorClass: "text-emerald-500" }; + case "synonym": + return { icon: Link2, colorClass: "text-sky-500" }; case "package": return { icon: Package, colorClass: "text-cyan-500" }; case "package-body": @@ -103,6 +105,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: Braces, colorClass: "text-amber-500" }; case "group-sequences": return { icon: ListTree, colorClass: "text-emerald-500" }; + case "group-synonyms": + return { icon: Link2, colorClass: "text-sky-500" }; case "group-packages": return { icon: Package, colorClass: "text-cyan-500" }; case "group-partitions": diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 9fbab239d2..3da9d473b2 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -550,7 +550,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch for (const obj of objects) { const objectType = normalizeObjectType(obj.object_type); - if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"].includes(objectType)) { + if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"].includes(objectType)) { continue; } @@ -606,6 +606,7 @@ function simpleObjectNodeType(objectType: DatabaseObjectTreeKind): TreeNodeType if (objectType === "FUNCTION") return "function"; if (objectType === "TRIGGER") return "trigger"; if (objectType === "SEQUENCE") return "sequence"; + if (objectType === "SYNONYM") return "synonym"; if (objectType === "PACKAGE_BODY") return "package-body"; if (objectType === "PACKAGE") return "package"; if (objectType === "TYPE_BODY") return "type-body"; @@ -661,6 +662,13 @@ const groupDefs: Array<{ nodeType: "group-sequences", childType: "sequence", }, + { + key: "__synonyms", + label: "tree.synonyms", + objectTypes: ["SYNONYM"], + nodeType: "group-synonyms", + childType: "synonym", + }, { key: "__packages", label: "tree.packages", @@ -677,7 +685,7 @@ const groupDefs: Array<{ }, ]; -const objectGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]); +const objectGroupNodeTypes = new Set(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]); export function buildObjectGroupPlaceholderNodes({ nodeId, connectionId, database, schema, objectTypes }: { nodeId: string; connectionId: string; database: string; schema?: string; objectTypes: DatabaseObjectTreeKind[] }): TreeNode[] { const supported = new Set(objectTypes); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index aa8ec6c4b6..4d56ef1c51 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -393,7 +393,7 @@ export interface TableInfo { parent_name?: string | null; } -export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface ObjectInfo { name: string; @@ -415,7 +415,7 @@ export interface ObjectStatistics { total_bytes?: number | null; } -export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface ObjectSource { name: string; @@ -713,6 +713,7 @@ export type TreeNodeType = | "type" | "type-body" | "sequence" + | "synonym" | "package" | "package-body" | "group-columns" @@ -729,6 +730,7 @@ export type TreeNodeType = | "group-functions" | "group-types" | "group-sequences" + | "group-synonyms" | "group-packages" | "group-partitions" | "group-extensions"