diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index a1ddb93d56..e9fc7da178 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -33,6 +33,11 @@ const maxAgentSessions = 256 // 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" + +// xuguSchedulerJobScope is a protocol-only namespace for database-scoped +// scheduler jobs. Jobs are not schema objects in Xugu, so keeping them out of +// a user schema prevents an owner from being implied where none exists. +const xuguSchedulerJobScope = "\x00DBX_XUGU_SCHEDULER_JOBS" const xuguListDatabasesSQL = ` SELECT DB_NAME FROM ALL_DATABASES @@ -1726,7 +1731,7 @@ func isXuguMetadataAccessError(err error) bool { catalogObject := false for _, object := range []string{ "DATABASES", "SCHEMAS", "TABLES", "VIEWS", "COLUMNS", "CONSTRAINTS", "INDEXES", - "TRIGGERS", "PARTIS", "SUBPARTIS", "IDX_PARTIS", "IDX_SUBPARTIS", "SEQUENCES", "SYNONYMS", "PROCEDURES", "PACKAGES", "TYPES", + "TRIGGERS", "PARTIS", "SUBPARTIS", "IDX_PARTIS", "IDX_SUBPARTIS", "SEQUENCES", "SYNONYMS", "JOBS", "PROCEDURES", "PACKAGES", "TYPES", } { if strings.Contains(message, "ALL_"+object) || strings.Contains(message, "SYS_"+object) { catalogObject = true @@ -1798,9 +1803,26 @@ func (s *server) listSchemas() ([]string, error) { } return nil, err } - return s.scanXuguSchemaRows(rows, false) + result, scanErr := s.scanXuguSchemaRows(rows, false) + if scanErr != nil { + return nil, scanErr + } + return s.appendXuguSchedulerJobScope(result) } - return s.scanXuguSchemaRows(rows, true) + result, scanErr := s.scanXuguSchemaRows(rows, true) + if scanErr != nil { + return nil, scanErr + } + return s.appendXuguSchedulerJobScope(result) +} + +// appendXuguSchedulerJobScope exposes the database-global scheduler job group. +// Scheduler jobs are discovered from the role-scoped *_JOBS catalog when the +// group is expanded. Keep the group even if that catalog is empty so users can +// distinguish an empty database from an unavailable navigator capability. +func (s *server) appendXuguSchedulerJobScope(schemas []string) ([]string, error) { + schemas = append(schemas, xuguSchedulerJobScope) + return dedupeXuguSchemaNames(schemas), nil } func (s *server) scanXuguSchemaRows(rows *sql.Rows, includesPublicScope bool) ([]string, error) { @@ -1847,6 +1869,10 @@ func isXuguPublicSynonymScope(schema string) bool { return strings.TrimSpace(schema) == xuguPublicSynonymScope } +func isXuguSchedulerJobScope(schema string) bool { + return strings.TrimSpace(schema) == xuguSchedulerJobScope +} + func (s *server) currentSchema() (string, error) { rows, err := s.queryRows(` SELECT s.SCHEMA_NAME @@ -2019,6 +2045,9 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints) if isOnlyXuguObjectType(constraints, "TRIGGER") { return s.listSchemaTriggers(schema, constraints) } + if isXuguSchedulerJobScope(schema) { + return s.listSchedulerJobs(constraints) + } query := xuguListObjectsQuery(schema, constraints) rows, err := s.queryRows(query.SQL, query.Args) if err != nil { @@ -2089,6 +2118,19 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints) return readXuguObjectRows(rows, schema) } +func (s *server) listSchedulerJobs(constraints metadataListConstraints) ([]objectInfo, error) { + query := xuguSchedulerJobsQuery(constraints) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + if isXuguMetadataUnavailableError(err) { + return []objectInfo{}, nil + } + return nil, err + } + defer s.closeRows(rows) + return readXuguObjectRows(rows, xuguSchedulerJobScope) +} + func isOnlyXuguObjectType(constraints metadataListConstraints, objectType string) bool { objectTypes := normalizedXuguObjectTypes(constraints.ObjectTypes) return len(objectTypes) == 1 && objectTypes[0] == objectType @@ -2430,6 +2472,16 @@ WHERE s.DB_ID = CURRENT_DB_ID ) } +func xuguSchedulerJobsQuery(constraints metadataListConstraints) xuguMetadataListQuery { + return xuguConstrainedMetadataListQuery(` +SELECT j.JOB_NAME AS OBJECT_NAME, 'JOB' AS OBJECT_TYPE, j.COMMENTS, + NULL AS VALID, NULL AS XUGU_TYPE_MEMBERS_EXPANDABLE +FROM ALL_JOBS j +WHERE j.DB_ID = CURRENT_DB_ID`, + "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID, XUGU_TYPE_MEMBERS_EXPANDABLE", + "OBJECT_NAME", "OBJECT_TYPE", nil, constraints) +} + func xuguConstrainedMetadataListQuery(baseSQL, selectList, nameColumn, typeColumn string, baseArgs []any, constraints metadataListConstraints) xuguMetadataListQuery { args := append([]any{}, baseArgs...) where := make([]string, 0, 2) @@ -2490,7 +2542,7 @@ func normalizedXuguObjectTypes(values []string) []string { normalized = "TABLE" case "VIEW": normalized = "VIEW" - case "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY": + case "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY", "JOB": // Already normalized. default: continue @@ -2930,6 +2982,13 @@ 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), "JOB") { + result, err := s.getSchedulerJobSource(schema, name) + if err != nil && isXuguMetadataUnavailableError(err) { + return xuguUnavailableObjectSource(schema, name, objectType), nil + } + return result, err + } if strings.EqualFold(strings.TrimSpace(objectType), "SEQUENCE") { result, err := s.getSequenceSource(schema, name) if err != nil && isXuguMetadataAccessError(err) { @@ -2978,6 +3037,188 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an return result, rows.Err() } +type xuguSchedulerJobMetadata struct { + Name string + JobType any + ParameterCount any + Action any + BeginTime any + RepeatInterval any + EndTime any + Enabled any + AutoDrop any + Comments any +} + +const xuguCatalogSchedulerJobNameSelectSQL = ` +SELECT JOB_NAME +FROM ALL_JOBS +WHERE DB_ID = CURRENT_DB_ID` + +// getSchedulerJobSource reconstructs a replayable DBMS_SCHEDULER.CREATE_JOB +// call from the catalog. Xugu stores jobs at database scope rather than under +// a schema, so the returned synthetic scope is retained for sidebar routing. +func (s *server) getSchedulerJobSource(schema, name string) (map[string]any, error) { + if !isXuguSchedulerJobScope(schema) { + return nil, errors.New("scheduler jobs must be read from the scheduler job scope") + } + jobName, err := s.resolveCatalogSchedulerJobName(name) + if err != nil { + return nil, err + } + rows, err := s.queryRows(xuguSchedulerJobMetadataQuery(jobName), nil) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + + result := map[string]any{ + "name": jobName, + "object_type": "JOB", + "schema": xuguSchedulerJobScope, + "source": "", + "editable": false, + } + if !rows.Next() { + return result, rows.Err() + } + var job xuguSchedulerJobMetadata + if err := rows.Scan( + &job.Name, &job.JobType, &job.ParameterCount, &job.Action, &job.BeginTime, + &job.RepeatInterval, &job.EndTime, &job.Enabled, &job.AutoDrop, &job.Comments, + ); err != nil { + return nil, err + } + result["name"] = job.Name + result["source"] = renderXuguSchedulerJobDDL(job) + return result, rows.Err() +} + +func (s *server) resolveCatalogSchedulerJobName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("scheduler job name is required") + } + candidates, err := s.catalogSchedulerJobNameCandidates(xuguCatalogSchedulerJobNameQuery(name, false)) + if err != nil { + return "", err + } + if len(candidates) == 0 { + candidates, err = s.catalogSchedulerJobNameCandidates(xuguCatalogSchedulerJobNameQuery(name, true)) + if err != nil { + return "", err + } + } + for _, candidate := range candidates { + if candidate == name { + return candidate, nil + } + } + if len(candidates) == 1 { + return candidates[0], nil + } + if len(candidates) == 0 { + return "", fmt.Errorf("scheduler job not found: %s", name) + } + return "", fmt.Errorf("scheduler job name is ambiguous: %s; specify the catalog's exact case", name) +} + +func xuguCatalogSchedulerJobNameQuery(name string, caseInsensitive bool) string { + expr := quoteStringLiteral(name) + if caseInsensitive { + expr = quoteStringLiteral(strings.ToUpper(name)) + return xuguCatalogSchedulerJobNameSelectSQL + "\n AND UPPER(JOB_NAME) = " + expr + } + return xuguCatalogSchedulerJobNameSelectSQL + "\n AND JOB_NAME = " + expr +} + +func (s *server) catalogSchedulerJobNameCandidates(query string) ([]string, error) { + rows, err := s.queryRows(strings.TrimSpace(query), nil) + if err != nil { + return nil, err + } + defer s.closeRows(rows) + var result []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + result = append(result, name) + } + return result, rows.Err() +} + +func xuguSchedulerJobMetadataQuery(name string) string { + // Keep DATETIME values on the textual path. The Go driver decodes a raw + // DATETIME catalog value before database/sql can preserve its NULL state; + // TO_CHAR lets SQL NULL pass through as nil instead of becoming a bogus + // time.Time value in the reconstructed CREATE_JOB call. + return ` +SELECT JOB_NAME, JOB_TYPE, JOB_PARAM_NUM, TO_CHAR(JOB_ACTION), + TO_CHAR(BEGIN_T), REPET_INTERVAL, TO_CHAR(END_T), ENABLE, AUTO_DROP, COMMENTS +FROM ALL_JOBS +WHERE DB_ID = CURRENT_DB_ID + AND JOB_NAME = ` + quoteStringLiteral(name) +} + +func xuguNullableSchedulerLiteral(value any) string { + normalized := normalizeValue(value) + if normalized == nil { + return "NULL" + } + // TO_CHAR(NULL) should arrive as nil, but some Xugu versions expose an + // empty string for optional catalog values. Treat both representations as + // SQL NULL so the generated call remains replayable. + if text, ok := normalized.(string); ok && strings.TrimSpace(text) == "" { + return "NULL" + } + return quoteStringLiteral(xuguString(normalized)) +} + +// Xugu stores an END_T/END_DATE value supplied as SQL NULL using a catalog +// sentinel. The native driver exposes that sentinel as an 1816 timestamp when +// the raw DATETIME column is scanned, while TO_CHAR(END_T) exposes the same +// value as 9999-12-31. Neither value is a real user-specified end date; both +// mean that the scheduler job has no end time and must be replayed as NULL. +func xuguNullableSchedulerEndTimeLiteral(value any) string { + normalized := normalizeValue(value) + if normalized == nil { + return "NULL" + } + text := strings.TrimSpace(fmt.Sprint(normalized)) + if text == "" || strings.HasPrefix(text, "1816-03-30") || strings.HasPrefix(text, "9999-12-31") { + return "NULL" + } + return xuguNullableSchedulerLiteral(normalized) +} + +func renderXuguSchedulerJobDDL(job xuguSchedulerJobMetadata) string { + var builder strings.Builder + builder.WriteString("EXEC DBMS_SCHEDULER.CREATE_JOB(\n ") + builder.WriteString(quoteStringLiteral(job.Name)) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerLiteral(job.JobType)) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerLiteral(job.Action)) + builder.WriteString(",\n ") + builder.WriteString(strconv.Itoa(xuguInt(job.ParameterCount))) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerLiteral(job.BeginTime)) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerLiteral(job.RepeatInterval)) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerEndTimeLiteral(job.EndTime)) + builder.WriteString(",\n 'default_class',\n ") + builder.WriteString(strconv.FormatBool(truthy(job.Enabled))) + builder.WriteString(",\n ") + builder.WriteString(strconv.FormatBool(truthy(job.AutoDrop))) + builder.WriteString(",\n ") + builder.WriteString(xuguNullableSchedulerLiteral(job.Comments)) + builder.WriteString("\n);") + return builder.String() +} + func xuguUnavailableObjectSource(schema, name, objectType string) map[string]any { return map[string]any{ "name": name, diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 39ad15c0da..7692905556 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -744,10 +744,10 @@ func TestXuguListSchemasExposesPublicScopeWithoutGUESTCollision(t *testing.T) { 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}}, + {name: "private only", want: []string{"APP_TEST", "SYSDBA", xuguSchedulerJobScope}}, + {name: "public synonyms", public: true, want: []string{"APP_TEST", "SYSDBA", xuguPublicSynonymScope, xuguSchedulerJobScope}}, + {name: "public with real guest", realGuest: true, public: true, want: []string{"APP_TEST", "GUEST", "SYSDBA", xuguPublicSynonymScope, xuguSchedulerJobScope}}, + {name: "public with former reserved schema", realReserved: true, public: true, want: []string{"APP_TEST", "__DBX_XUGU_PUBLIC_SYNONYMS__", "SYSDBA", xuguPublicSynonymScope, xuguSchedulerJobScope}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -799,7 +799,7 @@ func TestXuguListSchemasFallsBackWhenCombinedQueryIsUnavailable(t *testing.T) { if err != nil { t.Fatalf("listSchemas() error: %v", err) } - if want := []string{"APP_TEST", "GUEST", "SYSDBA"}; !reflect.DeepEqual(got, want) { + if want := []string{"APP_TEST", "GUEST", "SYSDBA", xuguSchedulerJobScope}; !reflect.DeepEqual(got, want) { t.Fatalf("listSchemas() = %v, want %v", got, want) } xuguSchemaListingState.Lock() @@ -1482,6 +1482,100 @@ func TestGetSequenceSourceReconstructsExecutableDDL(t *testing.T) { } } +func TestRenderXuguSchedulerJobDDLReconstructsEscapedReplayableCall(t *testing.T) { + ddl := renderXuguSchedulerJobDDL(xuguSchedulerJobMetadata{ + Name: `DBX_JOB_'A`, + JobType: "plsql_block", + ParameterCount: 2, + Action: `BEGIN do_work('x'); END;`, + BeginTime: "2026-08-29 10:15:00", + RepeatInterval: "FREQ=DAILY;INTERVAL=2", + EndTime: nil, + Enabled: true, + AutoDrop: false, + Comments: `owner's scheduled task`, + }) + + for _, want := range []string{ + "EXEC DBMS_SCHEDULER.CREATE_JOB(", + "'DBX_JOB_''A'", + "'plsql_block'", + "'BEGIN do_work(''x''); END;'", + "2", + "'2026-08-29 10:15:00'", + "'FREQ=DAILY;INTERVAL=2'", + "NULL", + "'default_class'", + "true", + "false", + "'owner''s scheduled task'", + ");", + } { + if !strings.Contains(ddl, want) { + t.Fatalf("scheduler DDL is missing %q:\n%s", want, ddl) + } + } +} + +func TestXuguSchedulerJobQueriesRemainInCurrentDatabase(t *testing.T) { + listQuery := xuguSchedulerJobsQuery(metadataListConstraints{ObjectTypes: []string{"JOB"}}) + if !strings.Contains(listQuery.SQL, "DB_ID = CURRENT_DB_ID") || !strings.Contains(listQuery.SQL, "'JOB'") { + t.Fatalf("job list must remain current-database scoped: %s", listQuery.SQL) + } + + metadataQuery := xuguSchedulerJobMetadataQuery("DbxJob") + if !strings.Contains(metadataQuery, "DB_ID = CURRENT_DB_ID") || !strings.Contains(metadataQuery, "JOB_NAME = 'DbxJob'") { + t.Fatalf("job metadata must remain exact-name and current-database scoped: %s", metadataQuery) + } + if !strings.Contains(metadataQuery, "TO_CHAR(BEGIN_T)") || !strings.Contains(metadataQuery, "TO_CHAR(END_T)") { + t.Fatalf("scheduler timestamps must be read as text to preserve SQL NULL values: %s", metadataQuery) + } + + exact := xuguCatalogSchedulerJobNameQuery("DbxJob", false) + folded := xuguCatalogSchedulerJobNameQuery("DbxJob", true) + if strings.Contains(exact, "UPPER(JOB_NAME)") || !strings.Contains(folded, "UPPER(JOB_NAME)") { + t.Fatalf("job source lookup must prefer exact case before folded fallback: exact=%s folded=%s", exact, folded) + } +} + +func TestXuguNullableSchedulerLiteralTreatsEmptyCatalogValueAsNull(t *testing.T) { + if got := xuguNullableSchedulerLiteral(""); got != "NULL" { + t.Fatalf("empty optional scheduler metadata should render as NULL, got %q", got) + } + if got := xuguNullableSchedulerLiteral(" "); got != "NULL" { + t.Fatalf("whitespace-only optional scheduler metadata should render as NULL, got %q", got) + } + if got := xuguNullableSchedulerLiteral("FREQ=DAILY"); got != "'FREQ=DAILY'" { + t.Fatalf("non-empty scheduler metadata should remain quoted, got %q", got) + } +} + +func TestXuguNullableSchedulerEndTimeTreatsCatalogSentinelsAsNull(t *testing.T) { + for _, value := range []any{ + "1816-03-30T05:56:08.065277376Z", + "9999-12-31 23:59:59", + "9999-12-31T23:59:59Z", + } { + if got := xuguNullableSchedulerEndTimeLiteral(value); got != "NULL" { + t.Fatalf("Xugu no-end sentinel %v should render as NULL, got %q", value, got) + } + } + if got := xuguNullableSchedulerEndTimeLiteral("2029-01-01 01:00:00"); got != "'2029-01-01 01:00:00'" { + t.Fatalf("real scheduler end time should remain quoted, got %q", got) + } +} + +func TestXuguSchedulerJobCatalogErrorsDegradeWithoutBreakingSchemaDiscovery(t *testing.T) { + for _, message := range []string{ + "[E5021] 表或视图 ALL_JOBS 不存在", + "permission denied for ALL_JOBS", + } { + if !isXuguMetadataUnavailableError(errors.New(message)) { + t.Fatalf("scheduler catalog error should be treated as optional metadata: %q", message) + } + } +} + func TestGetSynonymSourceReconstructsPrivateQuotedDDL(t *testing.T) { db, err := sql.Open("xugu-test-synonym-source", "") if err != nil { diff --git a/agents/drivers/xugu/scheduler_jobs_live_test.go b/agents/drivers/xugu/scheduler_jobs_live_test.go new file mode 100644 index 0000000000..cb2012d20a --- /dev/null +++ b/agents/drivers/xugu/scheduler_jobs_live_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// TestLiveXuguSchedulerJobs exercises the database-scoped job path end to +// end. It intentionally creates a disabled no-op job so the test does not +// schedule application work. CI has no Xugu service, so this runs only with +// XUGU_LIVE_TEST=1 and the standard XUGU_LIVE_* variables. +func TestLiveXuguSchedulerJobs(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() + + const jobName = "DBX_SCHEDULER_LIVE_TEST_JOB" + cleanup := "EXEC DBMS_SCHEDULER.DROP_JOB('" + jobName + "', FALSE);" + _ = s.execWithReconnect(cleanup) + defer func() { _ = s.execWithReconnect(cleanup) }() + + // Use a real start timestamp and repeat expression, not only NULL defaults. + // The subsequent drop/replay assertion verifies that the catalog values + // generated by the server are valid CREATE_JOB inputs. + create := "EXEC DBMS_SCHEDULER.CREATE_JOB('" + jobName + "', 'plsql_block', 'BEGIN NULL; END;', 0, SYSTIMESTAMP, 'FREQ=DAILY;BYHOUR=2;BYMINUTE=0', NULL, 'default_class', FALSE, FALSE, 'DBX scheduler integration test');" + if err := s.execWithReconnect(create); err != nil { + t.Fatalf("create scheduler job failed: %v", err) + } + + schemas, err := s.listSchemas() + if err != nil { + t.Fatal(err) + } + if !containsXuguName(schemas, xuguSchedulerJobScope) { + t.Fatalf("scheduler scope missing after job creation: %v", schemas) + } + + jobs, err := s.listObjects(xuguSchedulerJobScope, metadataListConstraints{ObjectTypes: []string{"JOB"}}) + if err != nil { + t.Fatal(err) + } + if !containsXuguObject(jobs, jobName) { + t.Fatalf("scheduler job was not listed: %#v", jobs) + } + + source, err := s.getObjectSource(xuguSchedulerJobScope, jobName, "JOB") + if err != nil { + t.Fatal(err) + } + ddl, _ := source["source"].(string) + for _, want := range []string{"EXEC DBMS_SCHEDULER.CREATE_JOB", jobName, "'plsql_block'", "'BEGIN NULL; END;'", "FREQ=DAILY", "FALSE"} { + if !strings.Contains(ddl, want) { + t.Fatalf("reconstructed job DDL is missing %q: %s", want, ddl) + } + } + if strings.Contains(ddl, "1816-03-30") { + t.Fatalf("NULL END_T must not be reconstructed as the Xugu DATETIME sentinel: %s", ddl) + } + if !strings.Contains(ddl, "\n NULL,\n 'default_class'") { + t.Fatalf("NULL END_T must remain a SQL NULL argument in reconstructed DDL: %s", ddl) + } + if err := s.execWithReconnect(cleanup); err != nil { + t.Fatalf("drop scheduler job before replay failed: %v", err) + } + if err := s.execWithReconnect(ddl); err != nil { + t.Fatalf("replay reconstructed scheduler job DDL failed: %v\nDDL:\n%s", err, ddl) + } + replayed, err := s.listObjects(xuguSchedulerJobScope, metadataListConstraints{ObjectTypes: []string{"JOB"}}) + if err != nil { + t.Fatal(err) + } + if !containsXuguObject(replayed, jobName) { + t.Fatalf("scheduler job missing after DDL replay: %#v", replayed) + } + + for _, statement := range []string{ + "EXEC DBMS_SCHEDULER.ENABLE('" + jobName + "');", + "EXEC DBMS_SCHEDULER.RUN_JOB('" + jobName + "', TRUE);", + "EXEC DBMS_SCHEDULER.DISABLE('" + jobName + "', FALSE);", + } { + if err := s.execWithReconnect(statement); err != nil { + t.Fatalf("job lifecycle operation failed (%s): %v", statement, err) + } + } +} diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 68f295e6ae..1ebd5549b0 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -335,9 +335,23 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was }); }); -const searchableObjectGroupTypes = new Set(["group-tables", "group-dolt-system-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-events", "group-sequences", "group-synonyms", "group-packages", "group-types"]); +const searchableObjectGroupTypes = new Set([ + "group-tables", + "group-dolt-system-tables", + "group-views", + "group-materialized-views", + "group-procedures", + "group-functions", + "group-triggers", + "group-events", + "group-sequences", + "group-synonyms", + "group-jobs", + "group-packages", + "group-types", +]); const simpleObjectParentTypes = new Set(["database", "schema", "linked-server-schema"]); -const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "event", "sequence", "synonym", "package", "package-body", "type", "type-body", "load-more"]); +const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "event", "sequence", "synonym", "job", "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 7eb75656d3..bb516c44e6 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -80,7 +80,8 @@ import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout"; 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 { isXuguSyntheticTreeNode } from "@/lib/sidebar/xuguPublicSynonyms"; +import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/database/xuguSchedulerJobSql"; import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates"; import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; import { qualifiedTableName } from "@/lib/table/tableSelectSql"; @@ -649,6 +650,7 @@ const groupTypes: Set = new Set([ "group-functions", "group-sequences", "group-synonyms", + "group-jobs", "group-packages", "group-types", "group-partitions", @@ -2218,7 +2220,7 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false databaseType, signature: sourceNode.signature, }); - const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType); + const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"].includes(resolvedType); if (sourceIsEditable) { queryStore.openObjectSourceTab({ connectionId, @@ -2276,6 +2278,26 @@ async function compileXuguObject() { } } +async function executeXuguSchedulerJobAction(action: XuguSchedulerJobAction) { + const node = activeNode.value; + if (currentDatabaseType() !== "xugu" || node.type !== "job" || !node.connectionId || !node.database) return; + const sql = buildXuguSchedulerJobSql(action, node.objectName || node.label); + if (!sql) return; + if (action === "drop" && !window.confirm(t("xuguSchedulerJob.confirmDrop", { name: node.label }))) return; + try { + await connectionStore.ensureConnected(node.connectionId); + const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); + if (!executed) return; + toast(t("xuguSchedulerJob.actionSuccess", { action: t(`xuguSchedulerJob.${action}`), name: node.label }), 3000); + // A job belongs to the synthetic scheduler-job group. `refreshTreeNode` + // already walks a child back to that group, so this refreshes the list + // after a destructive action without retaining a stale job node. + await connectionStore.refreshTreeNode(node); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + function requestDropObject() { void refreshDropObjectPreviewSql(); showDropObjectConfirm.value = true; @@ -3113,12 +3135,12 @@ 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" && !isXuguPublicSynonymTreeNode(config?.db_type, activeNode.value.type, activeNode.value.schema) && !isSqlServerLinkedNode(activeNode.value) && (usesTreeSchemaMode(dbType) || dbType === "dameng") && !connectionUsesDatabaseObjectTreeMode(config); + return activeNode.value.type === "schema" && !isXuguSyntheticTreeNode(config?.db_type, activeNode.value.type, activeNode.value.schema) && !isSqlServerLinkedNode(activeNode.value) && (usesTreeSchemaMode(dbType) || dbType === "dameng") && !connectionUsesDatabaseObjectTreeMode(config); }); const canEditSchemaComment = computed(() => { const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; - return activeNode.value.type === "schema" && !!activeNode.value.database && !connectionIsEffectivelyReadOnly(config) && supportsSchemaComment(effectiveDatabaseTypeForConnection(config)); + return activeNode.value.type === "schema" && !!activeNode.value.database && !isXuguSyntheticTreeNode(config?.db_type, activeNode.value.type, activeNode.value.schema) && !connectionIsEffectivelyReadOnly(config) && supportsSchemaComment(effectiveDatabaseTypeForConnection(config)); }); const canBatchDropCascade = computed(() => { @@ -3813,7 +3835,7 @@ 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; + if (isXuguSyntheticTreeNode(config?.db_type, node.type, node.schema)) return; void refreshDropSchemaPreviewSql(); showDropSchemaConfirm.value = true; } @@ -3823,7 +3845,7 @@ async function confirmDropSchema() { if (!node.connectionId || !node.database) return; try { const config = connectionStore.getConfig(node.connectionId); - if (isXuguPublicSynonymTreeNode(config?.db_type, node.type, node.schema)) return; + if (isXuguSyntheticTreeNode(config?.db_type, node.type, node.schema)) return; await connectionStore.ensureConnected(node.connectionId); const dbType = effectiveDatabaseTypeForConnection(config); let dropExecutionSchema: string | undefined; @@ -5071,7 +5093,7 @@ 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)) { + if (isXuguSyntheticTreeNode(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({ @@ -5683,6 +5705,22 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { return true; } + if (node.type === "job") { + items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + if (currentDatabaseType() === "xugu") { + items.push({ label: "", separator: true }); + items.push({ label: t("xuguSchedulerJob.enable"), action: () => executeXuguSchedulerJobAction("enable"), icon: Play }); + items.push({ label: t("xuguSchedulerJob.disable"), action: () => executeXuguSchedulerJobAction("disable"), icon: Wrench }); + items.push({ label: t("xuguSchedulerJob.run"), action: () => executeXuguSchedulerJobAction("run"), icon: Play }); + items.push({ label: "", separator: true }); + items.push({ label: t("xuguSchedulerJob.drop"), action: () => executeXuguSchedulerJobAction("drop"), icon: Trash2, variant: "destructive" as const }); + } + items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh }); + return true; + } + if (node.type === "trigger" || node.type === "package" || node.type === "package-body") { if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 8e84adfccf..595c8d31c5 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -78,7 +78,7 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; -import { isXuguPublicSynonymTreeNode } from "@/lib/sidebar/xuguPublicSynonyms"; +import { isXuguPublicSynonymTreeNode, isXuguSchedulerJobTreeNode, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; // --- Drag and Drop --- import { useDragSort } from "@/composables/useDragSort"; import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime"; @@ -239,6 +239,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { case "schema": { const databaseType = node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)) : undefined; if (isXuguPublicSynonymTreeNode(databaseType, node.type, node.schema)) return { icon: Link2, colorClass: "text-sky-500" }; + if (isXuguSchedulerJobTreeNode(databaseType, node.type, node.schema)) return { icon: CalendarClock, colorClass: "text-primary" }; return { icon: FolderOpen, colorClass: "text-sky-400" }; } case "table": @@ -344,6 +345,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ListTree, colorClass: "text-emerald-500" }; case "synonym": return { icon: Link2, colorClass: "text-sky-500" }; + case "job": + return { icon: Clock, colorClass: "text-orange-400" }; case "package": return { icon: Package, colorClass: "text-cyan-500" }; case "package-body": @@ -370,6 +373,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ListTree, colorClass: "text-emerald-500" }; case "group-synonyms": return { icon: Link2, colorClass: "text-sky-500" }; + case "group-jobs": + return { icon: Clock, colorClass: "text-orange-400" }; case "group-packages": return { icon: Package, colorClass: "text-cyan-500" }; case "group-types": @@ -392,6 +397,13 @@ function isGroupLabel(node: TreeNode): boolean { } function displayLabel(node: TreeNode): string { + // Synthetic Xugu scopes are persisted with their reserved protocol value. + // Resolve them at render time as well, so an already-cached tree never + // exposes that implementation detail after the feature is introduced. + if (node.type === "schema" && node.connectionId) { + const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)); + if (databaseType === "xugu") return xuguSchemaDisplayName(node.schema ?? node.label); + } if (node.type === "load-more") return t(node.label); if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); // Use the canonical key for persisted trees created before this label was @@ -1485,6 +1497,7 @@ function onKeydown(event: KeyboardEvent) { node.type === 'group-events' || node.type === 'group-sequences' || node.type === 'group-synonyms' || + node.type === 'group-jobs' || node.type === 'group-packages' || node.type === 'group-types' || node.type === 'group-partitions' || diff --git a/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts b/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts index 624cd076af..719c786f4d 100644 --- a/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/xuguPublicSynonymActions.spec.ts @@ -15,7 +15,7 @@ function functionSource(name: string): string { 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"); + const readOnlyGuard = menu.indexOf("isXuguSyntheticTreeNode"); expect(readOnlyGuard).toBeGreaterThan(-1); expect(readOnlyGuard).toBeLessThan(menu.indexOf("contextMenu.setDefaultSchema")); @@ -23,8 +23,8 @@ describe("Xugu public synonym actions", () => { }); it("guards both the drop request and confirmed execution", () => { - expect(functionSource("dropSchema")).toContain("isXuguPublicSynonymTreeNode"); - expect(functionSource("confirmDropSchema")).toContain("isXuguPublicSynonymTreeNode"); + expect(functionSource("dropSchema")).toContain("isXuguSyntheticTreeNode"); + expect(functionSource("confirmDropSchema")).toContain("isXuguSyntheticTreeNode"); }); it("uses a distinct link icon for the database-global synonym scope", () => { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 82d3f23483..33868dab1c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3362,6 +3362,7 @@ export default { procedures: "Procedures", functions: "Functions", sequences: "Sequences", + schedulerJobs: "Scheduled Jobs", synonyms: "Synonyms", packages: "Packages", types: "Types", @@ -3610,6 +3611,14 @@ export default { applySuccess: "Job operation applied", applyFailed: "Execution failed: {message}", }, + xuguSchedulerJob: { + enable: "Enable", + disable: "Disable", + run: "Run now", + drop: "Drop job", + confirmDrop: 'Drop scheduled job "{name}"? This cannot be undone.', + actionSuccess: '{action} completed for "{name}".', + }, damengUserAdmin: { title: "Dameng Users", unsupported: "This page only supports Dameng database connections.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c5b9005640..24c472b232 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3269,6 +3269,7 @@ export default withEnglishFallback({ procedures: "Procedimientos", functions: "Funciones", sequences: "Secuencias", + schedulerJobs: "Trabajos programados", synonyms: "Sinónimos", packages: "Paquetes", partitions: "Particiones", @@ -7363,6 +7364,14 @@ export default withEnglishFallback({ noAvailable: "Todas las extensiones disponibles ya están instaladas.", noInstalled: "Aún no hay extensiones instaladas.", }, + xuguSchedulerJob: { + enable: "Habilitar", + disable: "Deshabilitar", + run: "Ejecutar ahora", + drop: "Eliminar trabajo", + confirmDrop: '¿Eliminar el trabajo programado "{name}"? Esta acción no se puede deshacer.', + actionSuccess: '{action} completado para "{name}".', + }, sqlFileTree: { title: "Archivos SQL", openFolder: "Abrir carpeta", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 64c2ed296c..2685c74cc4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3267,6 +3267,7 @@ export default withEnglishFallback({ procedures: "Procedure", functions: "Funzioni", sequences: "Sequenze", + schedulerJobs: "Lavori pianificati", synonyms: "Sinonimi", packages: "Pacchetti", partitions: "Partizioni", @@ -7364,6 +7365,14 @@ export default withEnglishFallback({ noAvailable: "Tutte le estensioni disponibili sono installate.", noInstalled: "Nessuna estensione installata.", }, + xuguSchedulerJob: { + enable: "Abilita", + disable: "Disabilita", + run: "Esegui ora", + drop: "Elimina lavoro", + confirmDrop: 'Eliminare il lavoro pianificato "{name}"? Questa azione non può essere annullata.', + actionSuccess: '{action} completato per "{name}".', + }, sqlFileTree: { title: "File SQL", openFolder: "Apri cartella", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 252b28a98d..342bbf0a23 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3291,6 +3291,7 @@ export default withEnglishFallback({ procedures: "プロシージャ", functions: "関数", sequences: "シーケンス", + schedulerJobs: "スケジュール済みジョブ", synonyms: "シノニム", packages: "パッケージ", partitions: "パーティション", @@ -7415,6 +7416,14 @@ export default withEnglishFallback({ noAvailable: "すべての利用可能な拡張機能がインストールされています。", noInstalled: "インストールされている拡張機能はありません。", }, + xuguSchedulerJob: { + enable: "有効化", + disable: "無効化", + run: "今すぐ実行", + drop: "ジョブを削除", + confirmDrop: "スケジュール済みジョブ「{name}」を削除しますか?この操作は元に戻せません。", + actionSuccess: "「{name}」の{action}が完了しました。", + }, sqlFileTree: { title: "SQL ファイル", openFolder: "フォルダを開く", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f29517e2f2..a1b8d29e95 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3145,6 +3145,7 @@ export default withEnglishFallback({ procedures: "프로시저", functions: "함수", sequences: "시퀀스", + schedulerJobs: "예약된 작업", synonyms: "동의어", packages: "패키지", types: "타입", @@ -3171,6 +3172,14 @@ export default withEnglishFallback({ noAvailable: "사용 가능한 모든 확장이 이미 설치되어 있습니다.", noInstalled: "설치된 확장이 없습니다.", }, + xuguSchedulerJob: { + enable: "활성화", + disable: "비활성화", + run: "지금 실행", + drop: "작업 삭제", + confirmDrop: '예약된 작업 "{name}"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + actionSuccess: '"{name}"에 대한 {action}이(가) 완료되었습니다.', + }, processList: { title: "프로세스 목록", sessionCount: "{count}개 세션", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 8688ca4006..02d8be3663 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3269,6 +3269,7 @@ export default withEnglishFallback({ procedures: "Procedimentos", functions: "Funções", sequences: "Sequências", + schedulerJobs: "Trabalhos agendados", synonyms: "Sinônimos", packages: "Pacotes", partitions: "Partições", @@ -7365,6 +7366,14 @@ export default withEnglishFallback({ noAvailable: "Todas as extensões disponíveis já estão instaladas.", noInstalled: "Nenhuma extensão instalada.", }, + xuguSchedulerJob: { + enable: "Habilitar", + disable: "Desabilitar", + run: "Executar agora", + drop: "Excluir trabalho", + confirmDrop: 'Excluir o trabalho agendado "{name}"? Esta ação não pode ser desfeita.', + actionSuccess: '{action} concluído para "{name}".', + }, sqlFileTree: { title: "Arquivos SQL", openFolder: "Abrir pasta", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 16b0acc5ee..84d3f9bd97 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3283,6 +3283,7 @@ export default withEnglishFallback({ procedures: "存储过程", functions: "函数", sequences: "序列", + schedulerJobs: "定时作业", synonyms: "同义词", packages: "包", types: "类型", @@ -3596,6 +3597,14 @@ export default withEnglishFallback({ applySuccess: "作业操作已执行", applyFailed: "执行失败:{message}", }, + xuguSchedulerJob: { + enable: "启用", + disable: "禁用", + run: "立即运行", + drop: "删除作业", + confirmDrop: "确认删除定时作业「{name}」?此操作不可撤销。", + actionSuccess: "已对「{name}」完成{action}。", + }, damengUserAdmin: { title: "达梦用户", unsupported: "当前页面仅支持达梦数据库连接。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4126b72735..f8bb26eb39 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3267,6 +3267,7 @@ export default withEnglishFallback({ procedures: "預存程序", functions: "函式", sequences: "序列", + schedulerJobs: "排程工作", synonyms: "同義詞", packages: "套件", partitions: "分割區", @@ -7353,6 +7354,14 @@ export default withEnglishFallback({ noAvailable: "所有可用擴展均已安裝。", noInstalled: "暫無已安裝的擴展。", }, + xuguSchedulerJob: { + enable: "啟用", + disable: "停用", + run: "立即執行", + drop: "刪除工作", + confirmDrop: "確定要刪除排程工作「{name}」嗎?此操作無法復原。", + actionSuccess: "已完成對「{name}」的{action}。", + }, sqlFileTree: { title: "SQL 檔案", openFolder: "開啟資料夾", diff --git a/apps/desktop/src/lib/__tests__/xuguSchedulerJobSql.spec.ts b/apps/desktop/src/lib/__tests__/xuguSchedulerJobSql.spec.ts new file mode 100644 index 0000000000..441098fcae --- /dev/null +++ b/apps/desktop/src/lib/__tests__/xuguSchedulerJobSql.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { buildXuguSchedulerJobSql } from "@/lib/database/xuguSchedulerJobSql"; + +describe("buildXuguSchedulerJobSql", () => { + it("uses DBMS_SCHEDULER actions and safely quotes the job name", () => { + expect(buildXuguSchedulerJobSql("enable", "nightly'load")).toBe("EXEC DBMS_SCHEDULER.ENABLE('nightly''load');"); + expect(buildXuguSchedulerJobSql("disable", "nightly")).toBe("EXEC DBMS_SCHEDULER.DISABLE('nightly', FALSE);"); + expect(buildXuguSchedulerJobSql("run", "nightly")).toBe("EXEC DBMS_SCHEDULER.RUN_JOB('nightly', TRUE);"); + expect(buildXuguSchedulerJobSql("drop", "nightly")).toBe("EXEC DBMS_SCHEDULER.DROP_JOB('nightly', FALSE);"); + }); + + it("refuses an empty scheduler job name", () => { + expect(buildXuguSchedulerJobSql("enable", " ")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index 42d2b33dcf..4c70af5332 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" | "EVENT" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type SidebarObjectKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "JOB" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface DatabaseObjectCapabilities { sidebarObjects: SidebarObjectKind[]; @@ -168,6 +168,7 @@ export function normalizeSidebarObjectKind(type: string): SidebarObjectKind { if (value.includes("VIEW")) return "VIEW"; if (value.includes("SEQ")) return "SEQUENCE"; if (value.includes("SYNONYM")) return "SYNONYM"; + if (value.includes("JOB")) return "JOB"; if (value.includes("PROC")) return "PROCEDURE"; if (value.includes("FUNC")) return "FUNCTION"; return "TABLE"; diff --git a/apps/desktop/src/lib/database/xuguSchedulerJobSql.ts b/apps/desktop/src/lib/database/xuguSchedulerJobSql.ts new file mode 100644 index 0000000000..5723d67f87 --- /dev/null +++ b/apps/desktop/src/lib/database/xuguSchedulerJobSql.ts @@ -0,0 +1,26 @@ +export type XuguSchedulerJobAction = "enable" | "disable" | "run" | "drop"; + +/** + * Build the DBMS_SCHEDULER statements supported by the first Xugu job + * management slice. Scheduler jobs are database-scoped; names are string + * arguments rather than schema-qualified identifiers. + */ +export function buildXuguSchedulerJobSql(action: XuguSchedulerJobAction, name: string): string | null { + const jobName = name.trim(); + if (!jobName) return null; + const literal = quoteXuguString(jobName); + switch (action) { + case "enable": + return `EXEC DBMS_SCHEDULER.ENABLE(${literal});`; + case "disable": + return `EXEC DBMS_SCHEDULER.DISABLE(${literal}, FALSE);`; + case "run": + return `EXEC DBMS_SCHEDULER.RUN_JOB(${literal}, TRUE);`; + case "drop": + return `EXEC DBMS_SCHEDULER.DROP_JOB(${literal}, FALSE);`; + } +} + +function quoteXuguString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} diff --git a/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts index cc97992814..5c988bb1b2 100644 --- a/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts +++ b/apps/desktop/src/lib/sidebar/__tests__/xuguPublicSynonyms.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isXuguPublicSynonymScope, isXuguPublicSynonymTreeNode, sortXuguSchemaInfos, XUGU_PUBLIC_SYNONYM_SCOPE, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; +import { isXuguPublicSynonymScope, isXuguPublicSynonymTreeNode, isXuguSchedulerJobScope, isXuguSyntheticTreeNode, sortXuguSchemaInfos, XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE, xuguSchemaDisplayName } from "@/lib/sidebar/xuguPublicSynonyms"; import { compareSidebarNames } from "@/lib/database/databaseTree"; describe("Xugu public synonym scope", () => { @@ -35,6 +35,15 @@ describe("Xugu public synonym scope", () => { expect(sortXuguSchemaInfos(schemas, compareSidebarNames).map((schema) => schema.name)).toEqual(["AppSchema", "GUEST", "SYSDBA", XUGU_PUBLIC_SYNONYM_SCOPE]); }); + it("places the database-scoped scheduler root after real schemas and public synonyms", () => { + const schemas = [{ name: XUGU_SCHEDULER_JOB_SCOPE }, { name: "APP_TEST" }, { name: XUGU_PUBLIC_SYNONYM_SCOPE }]; + + expect(isXuguSchedulerJobScope(XUGU_SCHEDULER_JOB_SCOPE)).toBe(true); + expect(xuguSchemaDisplayName(XUGU_SCHEDULER_JOB_SCOPE)).toBe("Scheduled jobs"); + expect(isXuguSyntheticTreeNode("xugu", "schema", XUGU_SCHEDULER_JOB_SCOPE)).toBe(true); + expect(sortXuguSchemaInfos(schemas, compareSidebarNames).map((schema) => schema.name)).toEqual(["APP_TEST", XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE]); + }); + it("does not treat the real GUEST schema as a public-synonym scope", () => { const schemas = [{ name: "GUEST" }, { name: XUGU_PUBLIC_SYNONYM_SCOPE }]; expect(sortXuguSchemaInfos(schemas, compareSidebarNames).map((schema) => schema.name)).toEqual(["GUEST", XUGU_PUBLIC_SYNONYM_SCOPE]); diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 8a7c61a75a..992195b300 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -55,10 +55,24 @@ const objectBrowserNodeTypes = new Set(["database", "schema", "obj export function shouldOpenObjectBrowserOnSingleClick(type: TreeNodeType, enabled: boolean): boolean { return enabled && objectBrowserNodeTypes.has(type); } -const sourceNodeTypes = new Set(["materialized_view", "procedure", "function", "trigger", "event", "sequence", "synonym", "package", "package-body", "type", "type-body"]); +const sourceNodeTypes = new Set(["materialized_view", "procedure", "function", "trigger", "event", "sequence", "synonym", "job", "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-dolt-system-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-events", "group-sequences", "group-synonyms", "group-packages", "group-types"]); +const databaseChildGroupNodeTypes = new Set([ + "group-tables", + "group-dolt-system-tables", + "group-views", + "group-materialized-views", + "group-procedures", + "group-functions", + "group-triggers", + "group-events", + "group-sequences", + "group-synonyms", + "group-jobs", + "group-packages", + "group-types", +]); const displayPathObjectNodeTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "event"]); export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null { @@ -70,6 +84,7 @@ export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKin if (type === "event") return "EVENT"; if (type === "sequence") return "SEQUENCE"; if (type === "synonym") return "SYNONYM"; + if (type === "job") return "JOB"; 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 543e1aa0ad..675a13cc27 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts @@ -17,6 +17,7 @@ const treeGroupNodeTypes = new Set([ "group-functions", "group-sequences", "group-synonyms", + "group-jobs", "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 dbd9136564..cab51de55c 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -112,6 +112,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: ListTree, colorClass: "text-emerald-500" }; case "synonym": return { icon: Link2, colorClass: "text-sky-500" }; + case "job": + return { icon: Clock, colorClass: "text-orange-400" }; case "package": return { icon: Package, colorClass: "text-cyan-500" }; case "package-body": @@ -132,6 +134,8 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: ListTree, colorClass: "text-emerald-500" }; case "group-synonyms": return { icon: Link2, colorClass: "text-sky-500" }; + case "group-jobs": + return { icon: Clock, colorClass: "text-orange-400" }; case "group-packages": return { icon: Package, colorClass: "text-cyan-500" }; case "group-partitions": diff --git a/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts index d1520e6e9c..f8d00ab47a 100644 --- a/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts +++ b/apps/desktop/src/lib/sidebar/xuguPublicSynonyms.ts @@ -5,6 +5,8 @@ */ export const XUGU_PUBLIC_SYNONYM_SCOPE = "\u0000DBX_XUGU_PUBLIC_SYNONYMS"; export const XUGU_PUBLIC_SYNONYM_SCOPE_LABEL = "Public synonyms"; +export const XUGU_SCHEDULER_JOB_SCOPE = "\u0000DBX_XUGU_SCHEDULER_JOBS"; +export const XUGU_SCHEDULER_JOB_SCOPE_LABEL = "Scheduled jobs"; /** * Keep the synthetic public-synonym scope visually separate from real @@ -14,27 +16,48 @@ export const XUGU_PUBLIC_SYNONYM_SCOPE_LABEL = "Public synonyms"; export function sortXuguSchemaInfos(schemas: readonly T[], compareNames: (left: string, right: string) => number): T[] { const realSchemas: T[] = []; const publicSynonymScopes: T[] = []; + const schedulerJobScopes: T[] = []; for (const schema of schemas) { if (isXuguPublicSynonymScope(schema.name)) { publicSynonymScopes.push(schema); + } else if (isXuguSchedulerJobScope(schema.name)) { + schedulerJobScopes.push(schema); } else { realSchemas.push(schema); } } realSchemas.sort((left, right) => compareNames(left.name, right.name)); - return [...realSchemas, ...publicSynonymScopes]; + return [...realSchemas, ...publicSynonymScopes, ...schedulerJobScopes]; } export function isXuguPublicSynonymScope(schema: string | null | undefined): boolean { return schema === XUGU_PUBLIC_SYNONYM_SCOPE; } +export function isXuguSchedulerJobScope(schema: string | null | undefined): boolean { + return schema === XUGU_SCHEDULER_JOB_SCOPE; +} + +export function isXuguSyntheticScope(schema: string | null | undefined): boolean { + return isXuguPublicSynonymScope(schema) || isXuguSchedulerJobScope(schema); +} + export function xuguSchemaDisplayName(schema: string): string { - return isXuguPublicSynonymScope(schema) ? XUGU_PUBLIC_SYNONYM_SCOPE_LABEL : schema; + if (isXuguPublicSynonymScope(schema)) return XUGU_PUBLIC_SYNONYM_SCOPE_LABEL; + if (isXuguSchedulerJobScope(schema)) return XUGU_SCHEDULER_JOB_SCOPE_LABEL; + return schema; } export function isXuguPublicSynonymTreeNode(databaseType: string | null | undefined, nodeType: string, schema: string | null | undefined): boolean { return databaseType === "xugu" && nodeType === "schema" && isXuguPublicSynonymScope(schema); } + +export function isXuguSchedulerJobTreeNode(databaseType: string | null | undefined, nodeType: string, schema: string | null | undefined): boolean { + return databaseType === "xugu" && nodeType === "schema" && isXuguSchedulerJobScope(schema); +} + +export function isXuguSyntheticTreeNode(databaseType: string | null | undefined, nodeType: string, schema: string | null | undefined): boolean { + return databaseType === "xugu" && nodeType === "schema" && isXuguSyntheticScope(schema); +} diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index a250e159fa..43dcb30716 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -774,6 +774,13 @@ const groupDefs: Array<{ nodeType: "group-synonyms", childType: "synonym", }, + { + key: "__jobs", + label: "tree.schedulerJobs", + objectTypes: ["JOB"], + nodeType: "group-jobs", + childType: "job", + }, { key: "__packages", label: "tree.packages", @@ -790,7 +797,7 @@ const groupDefs: Array<{ }, ]; -const objectGroupNodeTypes = new Set(["group-tables", "group-dolt-system-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-events", "group-sequences", "group-synonyms", "group-packages", "group-types"]); +const objectGroupNodeTypes = new Set(["group-tables", "group-dolt-system-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-events", "group-sequences", "group-synonyms", "group-jobs", "group-packages", "group-types"]); export function buildObjectGroupPlaceholderNodes({ nodeId, diff --git a/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts index 40cffee244..bf18ad8e85 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.defaultSchema.spec.ts @@ -1,7 +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"; +import { XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; function installLocalStorage() { const data = new Map(); @@ -100,7 +100,7 @@ describe("connectionStore default schema", () => { 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 () => { + it("does not persist synthetic Xugu scopes as a default schema", async () => { const saveConnections = vi.fn().mockResolvedValue(undefined); vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ @@ -116,9 +116,10 @@ describe("connectionStore default schema", () => { 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(); + for (const scope of [XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE]) { + await store.setDefaultSchema(connection.id, 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 b39ca54b66..4bccacb22e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -148,7 +148,7 @@ import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMiss import { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers"; -import { isXuguPublicSynonymScope, sortXuguSchemaInfos, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; +import { isXuguPublicSynonymScope, isXuguSchedulerJobScope, isXuguSyntheticScope, sortXuguSchemaInfos, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; import { buildPackageMemberNodes, markPackageNodesExpandable, packageMemberGroupOwnerId } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; @@ -1700,11 +1700,14 @@ export const useConnectionStore = defineStore("connection", () => { if (config?.db_type === "xugu" && isXuguPublicSynonymScope(schema)) { return ["SYNONYM"]; } + if (config?.db_type === "xugu" && isXuguSchedulerJobScope(schema)) { + return ["JOB"]; + } return supportedSidebarObjectTypes(config); } function objectTreeCacheVersion(config: ConnectionConfig | undefined, schema: string | undefined, baseVersion: string): string { - const scopedVersion = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema) ? `${baseVersion}-public-synonyms` : baseVersion; + const scopedVersion = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema) ? `${baseVersion}-public-synonyms` : config?.db_type === "xugu" && isXuguSchedulerJobScope(schema) ? `${baseVersion}-scheduler-jobs` : baseVersion; return ownerAwareMetadataCacheVersion(config, scopedVersion); } @@ -3392,7 +3395,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 || (config.db_type === "xugu" && isXuguPublicSynonymScope(defaultSchema))) return; + if (!config || !defaultSchema || config.default_schema === defaultSchema || (config.db_type === "xugu" && isXuguSyntheticScope(defaultSchema))) return; await updateConnection({ ...config, default_schema: defaultSchema, @@ -5043,6 +5046,9 @@ export const useConnectionStore = defineStore("connection", () => { if (config?.db_type === "xugu" && schemas.some((schema) => isXuguPublicSynonymScope(schema.name))) { visibleSchemaNames.add(XUGU_PUBLIC_SYNONYM_SCOPE); } + if (config?.db_type === "xugu" && schemas.some((schema) => isXuguSchedulerJobScope(schema.name))) { + visibleSchemaNames.add(XUGU_SCHEDULER_JOB_SCOPE); + } const children: TreeNode[] = schemas .filter((schema) => visibleSchemaNames.has(schema.name)) .map((schema) => { @@ -5445,6 +5451,7 @@ export const useConnectionStore = defineStore("connection", () => { const config = getConfig(connectionId); const objectTreeProfile = driverProfileObjectTreeProfileForConnection(config); const isPublicSynonymScope = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema); + const isSchedulerJobScope = config?.db_type === "xugu" && isXuguSchedulerJobScope(schema); const baseCacheVersion = objectTreeCacheVersion(config, schema, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8"); const cacheVersion = !simpleObjectDisplay && objectTreeProfile?.cacheKey ? `${baseCacheVersion}:${objectTreeProfile.cacheKey}` : baseCacheVersion; const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion); @@ -5468,7 +5475,7 @@ export const useConnectionStore = defineStore("connection", () => { const nonTableObjectTypes = simpleObjectDisplay ? sidebarObjectTypesForScope(config, schema).filter((objectType) => objectType !== "TABLE") : []; let children: TreeNode[]; let nextObjectCount: number | undefined; - if (simpleObjectDisplay && !isPublicSynonymScope) { + if (simpleObjectDisplay && !isPublicSynonymScope && !isSchedulerJobScope) { const pageSize = sidebarObjectGroupPageSize(); const page = await loadPagedSimpleTableChildren({ nodeId, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 52d23651f8..f20db340a4 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -377,7 +377,7 @@ export interface TableInfo { parent_name?: string | null; } -export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "JOB" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface ObjectInfo { name: string; @@ -406,7 +406,7 @@ export interface ObjectStatistics { total_bytes?: number | null; } -export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; +export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "SYNONYM" | "JOB" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY"; export interface ObjectSource { name: string; @@ -878,6 +878,7 @@ export type TreeNodeType = | "type-member" | "sequence" | "synonym" + | "job" | "package" | "package-body" | "group-columns" @@ -897,6 +898,7 @@ export type TreeNodeType = | "group-types" | "group-sequences" | "group-synonyms" + | "group-jobs" | "group-packages" | "group-partitions" | "group-extensions" diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index f293f091bc..0b503d63ff 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -66,7 +66,7 @@ test("query-tab object source uses canonical identity and honors backend editabi assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTab\(\{/); assert.match(openObjectSourceBody, /raw\.editable !== false/); - assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"\]\.includes\(resolvedType\)/); + assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"\]\.includes\(resolvedType\)/); assert.match(openObjectSourceBody, /objectType: resolvedType/); assert.match(openObjectSourceBody, /signature: node\.signature/); assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true \}\)/);