diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 8a0b4b807..a315fa0ff 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -40,6 +40,11 @@ const xuguCatalogTableNameSelectSQL = ` SELECT s.SCHEMA_NAME, t.TABLE_NAME FROM ALL_TABLES t JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID` +const xuguCatalogSequenceNameSelectSQL = ` +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 xuguPrimaryKeyColumnsSQL = ` SELECT c.DEFINE FROM ALL_CONSTRAINTS c @@ -411,6 +416,21 @@ type xuguIdentityInfo struct { SystemGenerated bool } +// xuguSequenceMetadata contains the ALL_SEQUENCES fields needed to reproduce +// a user-managed sequence. IS_ORDER and VALID are runtime/catalog state and +// do not have CREATE SEQUENCE clauses, so they are intentionally omitted. +type xuguSequenceMetadata struct { + Schema string + Name string + Current any + Minimum any + Maximum any + Step any + Cache any + Cycle any + Comment any +} + // 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"). @@ -1617,6 +1637,7 @@ SELECT q.SEQ_NAME AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS COMMENTS, N FROM ALL_SEQUENCES q 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 u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID FROM ALL_TYPES u @@ -2002,6 +2023,9 @@ func (s *server) listSubpartitions(schema, table string) ([]subpartitionInfo, er } func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) { + if strings.EqualFold(strings.TrimSpace(objectType), "SEQUENCE") { + return s.getSequenceSource(schema, name) + } var err error schema, err = s.normalizeSchema(schema) if err != nil { @@ -2033,6 +2057,186 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an return result, rows.Err() } +// getSequenceSource reconstructs sequence DDL from ALL_SEQUENCES. Unlike +// programmable objects, sequences have no stored DEFINE text. Reconstructing +// the statement avoids DBMS_METADATA permissions and matches the approach used +// by the Xugu DBeaver extension. +func (s *server) getSequenceSource(schema, name string) (map[string]any, error) { + catalogSchema, catalogName, err := s.resolveCatalogSequenceName(schema, name) + if err != nil { + return nil, err + } + rows, err := s.queryRows(xuguSequenceMetadataQuery(catalogSchema, catalogName), nil) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + + result := map[string]any{"name": catalogName, "object_type": "SEQUENCE", "schema": catalogSchema, "source": "", "editable": false} + if !rows.Next() { + return result, rows.Err() + } + var sequence xuguSequenceMetadata + if err := rows.Scan( + &sequence.Schema, &sequence.Name, + &sequence.Current, &sequence.Minimum, &sequence.Maximum, &sequence.Step, + &sequence.Cache, &sequence.Cycle, &sequence.Comment, + ); err != nil { + return nil, err + } + result["name"] = sequence.Name + result["schema"] = sequence.Schema + result["source"] = renderXuguSequenceDDL(sequence) + return result, rows.Err() +} + +// resolveCatalogSequenceName follows the same exact-case-first policy used by +// table DDL export. A case-insensitive lookup is only safe when it resolves to +// one catalog object; selecting the first row would export a different quoted +// sequence when catalog objects differ only by case. +func (s *server) resolveCatalogSequenceName(schema, name string) (string, string, error) { + schema = strings.TrimSpace(schema) + name = strings.TrimSpace(name) + if schema == "" { + current, err := s.currentSchema() + if err != nil { + return "", "", err + } + schema = current + } + if name == "" { + return "", "", errors.New("sequence name is required") + } + candidates, err := s.catalogSequenceNameCandidates(xuguCatalogSequenceNameQuery(schema, name, false)) + if err != nil { + return "", "", err + } + if len(candidates) > 0 { + return selectXuguCatalogSequenceName(schema, name, candidates) + } + + candidates, err = s.catalogSequenceNameCandidates(xuguCatalogSequenceNameQuery(schema, name, true)) + if err != nil { + return "", "", err + } + return selectXuguCatalogSequenceName(schema, name, candidates) +} + +func xuguSequenceMetadataQuery(schema, name string) string { + return ` +SELECT s.SCHEMA_NAME, q.SEQ_NAME, + q.CURR_VAL, q.MIN_VAL, q.MAX_VAL, q.STEP_VAL, + q.CACHE_VAL, q.IS_CYCLE, q.COMMENTS +FROM ALL_SEQUENCES q +JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID +WHERE s.SCHEMA_NAME = ` + quoteStringLiteral(schema) + ` + AND q.SEQ_NAME = ` + quoteStringLiteral(name) + ` + AND q.IS_SYS = FALSE` +} + +type xuguCatalogSequenceName struct { + Schema string + Name string +} + +func xuguCatalogSequenceNameQuery(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 xuguCatalogSequenceNameSelectSQL + "\n AND UPPER(s.SCHEMA_NAME) = " + schemaExpr + + "\n AND UPPER(q.SEQ_NAME) = " + nameExpr + } + return xuguCatalogSequenceNameSelectSQL + "\n AND s.SCHEMA_NAME = " + schemaExpr + + "\n AND q.SEQ_NAME = " + nameExpr +} + +func (s *server) catalogSequenceNameCandidates(query string) ([]xuguCatalogSequenceName, error) { + rows, err := s.queryRows(strings.TrimSpace(query), nil) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + var candidates []xuguCatalogSequenceName + for rows.Next() { + var candidate xuguCatalogSequenceName + if err := rows.Scan(&candidate.Schema, &candidate.Name); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, err + } + return candidates, nil +} + +func selectXuguCatalogSequenceName(schema, name string, candidates []xuguCatalogSequenceName) (string, string, error) { + for _, candidate := range candidates { + if candidate.Schema == schema && candidate.Name == name { + return candidate.Schema, candidate.Name, nil + } + } + if len(candidates) == 1 { + return candidates[0].Schema, candidates[0].Name, nil + } + if len(candidates) == 0 { + return "", "", fmt.Errorf("sequence not found: %s.%s", schema, name) + } + return "", "", fmt.Errorf("sequence name is ambiguous: %s.%s; specify the catalog's exact case", schema, name) +} + +func renderXuguSequenceDDL(sequence xuguSequenceMetadata) string { + var builder strings.Builder + builder.WriteString("CREATE SEQUENCE ") + builder.WriteString(quoteIdentifier(sequence.Schema)) + builder.WriteByte('.') + builder.WriteString(quoteIdentifier(sequence.Name)) + + if value := xuguSequenceNumber(sequence.Step); value != "" { + builder.WriteString("\n INCREMENT BY ") + builder.WriteString(value) + } + if value := xuguSequenceNumber(sequence.Current); value != "" { + builder.WriteString("\n START WITH ") + builder.WriteString(value) + } + if value := xuguSequenceNumber(sequence.Minimum); value != "" { + builder.WriteString("\n MINVALUE ") + builder.WriteString(value) + } else { + builder.WriteString("\n NOMINVALUE") + } + if value := xuguSequenceNumber(sequence.Maximum); value != "" { + builder.WriteString("\n MAXVALUE ") + builder.WriteString(value) + } else { + builder.WriteString("\n NOMAXVALUE") + } + if value := xuguSequenceNumber(sequence.Cache); value != "" && xuguInt(sequence.Cache) > 1 { + builder.WriteString("\n CACHE ") + builder.WriteString(value) + } else { + builder.WriteString("\n NOCACHE") + } + if truthy(sequence.Cycle) { + builder.WriteString("\n CYCLE") + } else { + builder.WriteString("\n NOCYCLE") + } + if comment := strings.TrimSpace(xuguString(sequence.Comment)); comment != "" { + builder.WriteString("\n COMMENT ") + builder.WriteString(quoteStringLiteral(comment)) + } + builder.WriteString(";") + return builder.String() +} + +func xuguSequenceNumber(value any) string { + return strings.TrimSpace(xuguString(value)) +} + func (s *server) getTableDDL(schema, table string) (string, error) { // Resolve the catalog's stored casing before issuing exact metadata lookups, // so emitted DDL quotes the original names and preserves double-quoted diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 7f1a18ee3..60013796b 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -775,6 +775,71 @@ func TestXuguListObjectsQueryIncludesProgrammableObjects(t *testing.T) { assertArgs(t, query.Args, wantArgs) } +func TestXuguListObjectsQueryExcludesSystemSequences(t *testing.T) { + query := xuguListObjectsQuery("APP", metadataListConstraints{ + ObjectTypes: []string{"sequence"}, + }) + + if !strings.Contains(query.SQL, "q.IS_SYS = FALSE") { + t.Fatalf("sequence lookup must exclude system-managed identity sequences:\n%s", query.SQL) + } +} + +func TestGetSequenceSourceReconstructsExecutableDDL(t *testing.T) { + db, err := sql.Open("xugu-test-sequence-source", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + s := newServer() + s.db = db + source, err := s.getObjectSource("AppSchema", "seqOrderNo", "SEQUENCE") + if err != nil { + t.Fatalf("get sequence source: %v", err) + } + if source["editable"] != false { + t.Fatalf("sequence source must remain read-only: %#v", source) + } + if source["schema"] != "AppSchema" || source["name"] != "seqOrderNo" { + t.Fatalf("sequence source must preserve catalog spelling: %#v", source) + } + + ddl, _ := source["source"].(string) + for _, want := range []string{ + `CREATE SEQUENCE "AppSchema"."seqOrderNo"`, + "INCREMENT BY 10", + "START WITH 500", + "MINVALUE -100", + "MAXVALUE 10000", + "CACHE 20", + "CYCLE", + "COMMENT 'order''s next number'", + } { + if !strings.Contains(ddl, want) { + t.Fatalf("sequence DDL is missing %q:\n%s", want, ddl) + } + } + if !strings.HasSuffix(strings.TrimSpace(ddl), ";") { + t.Fatalf("sequence DDL must end with a statement terminator:\n%s", ddl) + } +} + +func TestRenderXuguSequenceDDLUsesNoCacheAndNoCycle(t *testing.T) { + ddl := renderXuguSequenceDDL(xuguSequenceMetadata{ + Schema: "APP", Name: "SEQ_DEFAULTS", Current: int64(1), Minimum: int64(1), + Maximum: int64(9223372036854775807), Step: int64(1), Cache: int64(1), Cycle: false, + }) + for _, want := range []string{"NOCACHE", "NOCYCLE"} { + if !strings.Contains(ddl, want) { + t.Fatalf("sequence DDL is missing %q:\n%s", want, ddl) + } + } + if strings.Contains(ddl, "NO CYCLE") { + t.Fatalf("sequence DDL must use Xugu's NOCYCLE spelling:\n%s", ddl) + } +} + func TestXuguObjectSourceQuerySupportsSharedObjectKinds(t *testing.T) { for _, objectType := range []string{"TRIGGER", "PACKAGE_BODY", "TYPE", "TYPE_BODY"} { query, _, err := objectSourceQuery("APP", "demo", objectType) @@ -1313,6 +1378,27 @@ func TestSelectXuguCatalogTableNamePrefersExactCaseAndRejectsAmbiguity(t *testin } } +func TestSelectXuguCatalogSequenceNamePrefersExactCaseAndRejectsAmbiguity(t *testing.T) { + candidates := []xuguCatalogSequenceName{ + {Schema: "AppSchema", Name: "seqOrderNo"}, + {Schema: "AppSchema", Name: "SEQORDERNO"}, + } + + schema, name, err := selectXuguCatalogSequenceName("AppSchema", "seqOrderNo", candidates) + if err != nil || schema != "AppSchema" || name != "seqOrderNo" { + t.Fatalf("exact-case selection = (%q, %q, %v), want quoted catalog sequence", schema, name, err) + } + + if _, _, err := selectXuguCatalogSequenceName("AppSchema", "SeqOrderNo", candidates); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("mixed-case ambiguous selection error = %v, want ambiguity error", err) + } + + schema, name, err = selectXuguCatalogSequenceName("appschema", "seq_plain", []xuguCatalogSequenceName{{Schema: "APPSCHEMA", Name: "SEQ_PLAIN"}}) + if err != nil || schema != "APPSCHEMA" || name != "SEQ_PLAIN" { + t.Fatalf("single-candidate fallback = (%q, %q, %v), want catalog spelling", schema, name, err) + } +} + func TestCatalogTableLookupQueriesAvoidCaseFoldingBoundParameters(t *testing.T) { exact := xuguCatalogTableNameQuery("S'CHEMA", "MiX'ed", false) if strings.Contains(strings.ToUpper(exact), "UPPER(") { @@ -1333,6 +1419,28 @@ func TestCatalogTableLookupQueriesAvoidCaseFoldingBoundParameters(t *testing.T) } } +func TestCatalogSequenceLookupQueriesPreferExactIdentifiers(t *testing.T) { + exact := xuguCatalogSequenceNameQuery("App'Schema", "seq'MixedCase", false) + if strings.Contains(strings.ToUpper(exact), "UPPER(") { + t.Fatalf("exact sequence lookup must not case-fold identifiers:\n%s", exact) + } + for _, fragment := range []string{"q.IS_SYS = FALSE", "s.SCHEMA_NAME = 'App''Schema'", "q.SEQ_NAME = 'seq''MixedCase'"} { + if !strings.Contains(exact, fragment) { + t.Fatalf("exact sequence lookup missing %q:\n%s", fragment, exact) + } + } + + folded := xuguCatalogSequenceNameQuery("App'Schema", "seq'MixedCase", true) + if strings.Contains(folded, "UPPER(?)") { + t.Fatalf("case-insensitive sequence lookup must not call UPPER(?) on bound parameters:\n%s", folded) + } + for _, fragment := range []string{"q.IS_SYS = FALSE", "UPPER(s.SCHEMA_NAME) = 'APP''SCHEMA'", "UPPER(q.SEQ_NAME) = 'SEQ''MIXEDCASE'"} { + if !strings.Contains(folded, fragment) { + t.Fatalf("case-insensitive sequence lookup missing %q:\n%s", fragment, folded) + } + } +} + func TestTableDDLCatalogQueriesUseExactIdentifiers(t *testing.T) { queries := map[string]string{ "primary key": xuguPrimaryKeyColumnsSQL, @@ -1613,6 +1721,7 @@ func init() { sql.Register("xugu-test-table-objects", &xuguTableObjectsDriver{}) sql.Register("xugu-test-table-ddl", &xuguTableDDLDriver{}) sql.Register("xugu-test-show-result", &xuguShowResultDriver{}) + sql.Register("xugu-test-sequence-source", &xuguSequenceSourceDriver{}) } type xuguShowResultDriver struct{} @@ -1671,6 +1780,47 @@ func (c *xuguShowResultConn) ExecContext(_ context.Context, query string, _ []dr return nil, fmt.Errorf("SHOW statement was incorrectly sent to ExecContext: %s", query) } +type xuguSequenceSourceDriver struct{} + +func (d *xuguSequenceSourceDriver) Open(name string) (driver.Conn, error) { + return &xuguSequenceSourceConn{}, nil +} + +type xuguSequenceSourceConn struct{} + +func (c *xuguSequenceSourceConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not supported") +} +func (c *xuguSequenceSourceConn) Close() error { return nil } +func (c *xuguSequenceSourceConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") } +func (c *xuguSequenceSourceConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + upper := strings.ToUpper(query) + if !strings.Contains(upper, "FROM ALL_SEQUENCES") || !strings.Contains(upper, "Q.IS_SYS = FALSE") { + return nil, fmt.Errorf("unexpected sequence source query: %s", query) + } + if strings.Contains(upper, "Q.CURR_VAL") { + if strings.Contains(upper, "UPPER(") || !strings.Contains(query, "s.SCHEMA_NAME = 'AppSchema'") || !strings.Contains(query, "q.SEQ_NAME = 'seqOrderNo'") { + return nil, fmt.Errorf("sequence metadata must use exact catalog identifiers: %s", query) + } + return &xuguStaticRows{ + columns: []string{"SCHEMA_NAME", "SEQ_NAME", "CURR_VAL", "MIN_VAL", "MAX_VAL", "STEP_VAL", "CACHE_VAL", "IS_CYCLE", "COMMENTS"}, + values: [][]driver.Value{{"AppSchema", "seqOrderNo", int64(500), int64(-100), int64(10000), int64(10), int64(20), true, "order's next number"}}, + }, nil + } + if strings.Contains(upper, "SELECT S.SCHEMA_NAME, Q.SEQ_NAME") { + if strings.Contains(upper, "UPPER(") || !strings.Contains(query, "s.SCHEMA_NAME = 'AppSchema'") || !strings.Contains(query, "q.SEQ_NAME = 'seqOrderNo'") { + return nil, fmt.Errorf("sequence resolution must prioritize exact catalog identifiers: %s", query) + } + return &xuguStaticRows{ + columns: []string{"SCHEMA_NAME", "SEQ_NAME"}, + values: [][]driver.Value{{"AppSchema", "seqOrderNo"}}, + }, nil + } + return &xuguStaticRows{ + columns: []string{"SCHEMA_NAME", "SEQ_NAME"}, + }, nil +} + type xuguTableDDLDriver struct{} func (d *xuguTableDDLDriver) Open(name string) (driver.Conn, error) {