Skip to main content

razeri_plugin_contract/
task_renderer.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! Task and schedule rendering extensions.
6
7use crate::SqlRenderer;
8
9/// Configuration for rendering a CREATE TASK statement.
10///
11/// Uses primitive types so the contract does not depend on Razeri's internal
12/// task and schedule model.
13#[derive(Debug)]
14pub struct TaskRenderConfig<'a> {
15    /// Task name.
16    pub name: &'a str,
17    /// SQL body to execute.
18    pub body: &'a str,
19    /// Pre-rendered schedule clause (from `render_schedule_*` methods).
20    pub schedule: Option<String>,
21    /// Warehouse / compute resource.
22    pub warehouse: Option<&'a str>,
23    /// Human-readable comment.
24    pub comment: Option<&'a str>,
25    /// Error integration name.
26    pub error_integration: Option<&'a str>,
27    /// WHEN condition expression.
28    pub condition: Option<&'a str>,
29    /// Task dependencies.
30    pub dependencies: Vec<TaskDependencyConfig<'a>>,
31    /// **Whether the task runs on its schedule once it is made.** `false`
32    /// asks for it made and left disabled; an engine that cannot say that
33    /// answers `None` rather than schedule a task its author turned off.
34    pub enabled: bool,
35}
36
37/// A single task dependency for rendering.
38#[derive(Debug)]
39pub struct TaskDependencyConfig<'a> {
40    /// Name of the predecessor task.
41    pub predecessor: &'a str,
42    /// Condition kind: `"on success"`, `"on completion"`, or `"on failure"`.
43    pub condition: &'a str,
44}
45
46/// Render a dependency list as a SQL comment line.
47///
48/// Returns an empty string when `deps` is empty.
49#[must_use]
50pub fn render_dependency_comment(deps: &[TaskDependencyConfig<'_>]) -> String {
51    if deps.is_empty() {
52        return String::new();
53    }
54    let strs: Vec<String> =
55        deps.iter().map(|d| format!("{} ({})", d.predecessor, d.condition)).collect();
56    format!("-- Dependencies: {}", strs.join(", "))
57}
58
59/// Task and schedule rendering for database-native orchestration.
60///
61/// Platform-specific task syntax is implemented by dialect plugins.
62pub trait TaskRenderer: SqlRenderer {
63    // ========================================================================
64    // Schedule rendering
65    // ========================================================================
66
67    /// Render a cron-based schedule clause.
68    fn render_schedule_cron(&self, expression: &str, timezone: Option<&str>) -> String {
69        let tz = timezone.unwrap_or("UTC");
70        format!("-- Schedule: cron {expression} {tz}")
71    }
72
73    /// Render an interval-based schedule clause.
74    fn render_schedule_interval(&self, minutes: u32) -> String {
75        format!("-- Schedule: every {minutes} minutes")
76    }
77
78    /// Render a stream-based schedule trigger.
79    fn render_schedule_stream(&self, stream_name: &str) -> String {
80        format!("-- Stream trigger: {stream_name}")
81    }
82
83    /// Render an event-driven schedule trigger.
84    fn render_schedule_event(&self, source: &str, description: &str) -> String {
85        format!("-- Event-driven: {source} ({description})")
86    }
87
88    // ========================================================================
89    // Task rendering
90    // ========================================================================
91
92    /// **The statement that makes a task run on its schedule**, or `None`
93    /// where this engine has no scheduler of its own.
94    ///
95    /// `None` is the default, and the honest answer for an engine whose
96    /// statements run when they are sent: Razeri then refuses the task by
97    /// name. A task written as its bare statement under a comment would run
98    /// that statement at once, the moment the script ran, rather than on its
99    /// schedule. Answer `None` too for a task whose
100    /// [`enabled`](TaskRenderConfig::enabled) is `false` where your engine
101    /// cannot make one disabled.
102    fn render_create_task(&self, config: &TaskRenderConfig<'_>) -> Option<String> {
103        let _ = config;
104        None
105    }
106
107    /// Render DROP TASK / unschedule.
108    fn render_drop_task(&self, name: &str) -> String {
109        format!("-- Drop task: {name} (platform manages externally)")
110    }
111
112    /// Render ALTER TASK ... RESUME or equivalent enable command.
113    fn render_resume_task(&self, name: &str) -> String {
114        format!("-- Resume task: {name} (no native suspend/resume)")
115    }
116
117    /// Render ALTER TASK ... SUSPEND or equivalent disable command.
118    fn render_suspend_task(&self, name: &str) -> String {
119        format!("-- Suspend task: {name} (no native suspend/resume)")
120    }
121
122    /// **The statement a task runs to load a file into a table** — `COPY
123    /// INTO`, `BULK INSERT`, `LOAD DATA` and their kin — or `None` where this
124    /// engine has no such statement, and the task is refused by name.
125    fn render_bulk_load(&self, table: &str, source: &str, format: &str) -> Option<String> {
126        let _ = (table, source, format);
127        None
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::DataType;
135
136    struct TestDialect;
137
138    impl SqlRenderer for TestDialect {
139        fn render_data_type(&self, _dt: &DataType) -> String {
140            "TEXT".to_string()
141        }
142
143        fn quote_identifier(&self, name: &str) -> String {
144            format!("\"{name}\"")
145        }
146
147        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
148            match schema {
149                Some(s) => format!("\"{s}\".\"{name}\""),
150                None => format!("\"{name}\""),
151            }
152        }
153
154        fn render_current_timestamp(&self) -> String {
155            "CURRENT_TIMESTAMP".to_string()
156        }
157    }
158
159    impl TaskRenderer for TestDialect {}
160
161    #[test]
162    fn task_renderer_defaults_return_comments() {
163        let d = TestDialect;
164
165        // Schedule defaults
166        assert_eq!(
167            d.render_schedule_cron("0 * * * *", Some("UTC")),
168            "-- Schedule: cron 0 * * * * UTC"
169        );
170        assert_eq!(d.render_schedule_interval(15), "-- Schedule: every 15 minutes");
171        assert_eq!(d.render_schedule_stream("my_stream"), "-- Stream trigger: my_stream");
172        assert_eq!(
173            d.render_schedule_event("sns", "order placed"),
174            "-- Event-driven: sns (order placed)"
175        );
176
177        // An engine with no scheduler writes no task: its body would run at
178        // once, rather than on its schedule.
179        let config = TaskRenderConfig {
180            name: "hourly",
181            body: "SELECT 1",
182            schedule: Some("-- Schedule: cron 0 * * * * UTC".to_string()),
183            warehouse: None,
184            comment: Some("Hourly check"),
185            error_integration: None,
186            condition: None,
187            dependencies: vec![],
188            enabled: true,
189        };
190        assert_eq!(d.render_create_task(&config), None);
191
192        // Drop/resume/suspend defaults include task name
193        assert_eq!(
194            d.render_drop_task("hourly"),
195            "-- Drop task: hourly (platform manages externally)"
196        );
197        assert_eq!(
198            d.render_resume_task("hourly"),
199            "-- Resume task: hourly (no native suspend/resume)"
200        );
201        assert_eq!(
202            d.render_suspend_task("hourly"),
203            "-- Suspend task: hourly (no native suspend/resume)"
204        );
205
206        // Bulk load default: none.
207        assert_eq!(d.render_bulk_load("raw_orders", "s3://bucket/orders.csv", "csv"), None);
208    }
209
210    #[test]
211    fn render_dependency_comment_formats_correctly() {
212        let deps = vec![
213            TaskDependencyConfig { predecessor: "task_a", condition: "on success" },
214            TaskDependencyConfig { predecessor: "task_b", condition: "on completion" },
215        ];
216        let result = render_dependency_comment(&deps);
217        assert_eq!(result, "-- Dependencies: task_a (on success), task_b (on completion)");
218    }
219
220    #[test]
221    fn render_dependency_comment_empty_returns_empty() {
222        assert_eq!(render_dependency_comment(&[]), "");
223    }
224}