Skip to main content

chartml_core/spec/
transform.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
4#[serde(rename_all = "camelCase")]
5pub struct TransformSpec {
6    pub sql: Option<SqlSpec>,
7    pub aggregate: Option<AggregateSpec>,
8    pub forecast: Option<ForecastSpec>,
9}
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
12#[serde(untagged)]
13pub enum SqlSpec {
14    Single(String),
15    Multiple(Vec<String>),
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct ForecastSpec {
21    pub timestamp: String,
22    pub value: String,
23    pub horizon: Option<u64>,
24    pub confidence_level: Option<f64>,
25    pub model: Option<String>,
26    #[serde(default)]
27    pub group_by: Option<Vec<String>>,
28}
29
30#[derive(Debug, Clone, Deserialize, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct AggregateSpec {
33    #[serde(default)]
34    pub dimensions: Vec<Dimension>,
35    #[serde(default)]
36    pub measures: Vec<Measure>,
37    pub filters: Option<FilterGroup>,
38    pub sort: Option<Vec<SortSpec>>,
39    pub limit: Option<u64>,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43#[serde(untagged)]
44pub enum Dimension {
45    Simple(String),
46    Detailed(DimensionSpec),
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct DimensionSpec {
52    pub column: String,
53    pub name: Option<String>,
54    #[serde(rename = "type")]
55    pub dim_type: Option<String>,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Measure {
61    pub column: Option<String>,
62    pub aggregation: Option<String>,
63    pub name: String,
64    pub expression: Option<String>,
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct FilterGroup {
70    pub combinator: Option<String>,
71    pub rules: Vec<FilterRule>,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct FilterRule {
77    pub field: String,
78    pub operator: String,
79    pub value: Option<serde_json::Value>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct SortSpec {
85    pub field: String,
86    pub direction: Option<String>,
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_backward_compat_aggregate_only() {
95        let yaml = r#"
96aggregate:
97  dimensions:
98    - region
99  measures:
100    - column: amount
101      aggregation: sum
102      name: totalAmount
103  limit: 10
104"#;
105        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
106        assert!(spec.aggregate.is_some());
107        assert!(spec.sql.is_none());
108        assert!(spec.forecast.is_none());
109        let agg = spec.aggregate.unwrap();
110        assert_eq!(agg.dimensions.len(), 1);
111        assert_eq!(agg.measures.len(), 1);
112        assert_eq!(agg.limit, Some(10));
113    }
114
115    #[test]
116    fn test_sql_single_string() {
117        let yaml = r#"
118sql: "SELECT * FROM sales WHERE year = 2024"
119"#;
120        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
121        assert!(spec.aggregate.is_none());
122        assert!(spec.forecast.is_none());
123        match spec.sql.unwrap() {
124            SqlSpec::Single(s) => assert_eq!(s, "SELECT * FROM sales WHERE year = 2024"),
125            other => panic!("Expected SqlSpec::Single, got {:?}", other),
126        }
127    }
128
129    #[test]
130    fn test_sql_multiple_strings() {
131        let yaml = r#"
132sql:
133  - "SELECT * FROM sales"
134  - "WHERE year = 2024"
135  - "ORDER BY revenue DESC"
136"#;
137        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
138        match spec.sql.unwrap() {
139            SqlSpec::Multiple(v) => {
140                assert_eq!(v.len(), 3);
141                assert_eq!(v[0], "SELECT * FROM sales");
142                assert_eq!(v[1], "WHERE year = 2024");
143                assert_eq!(v[2], "ORDER BY revenue DESC");
144            }
145            other => panic!("Expected SqlSpec::Multiple, got {:?}", other),
146        }
147    }
148
149    #[test]
150    fn test_forecast_all_fields() {
151        let yaml = r#"
152forecast:
153  timestamp: date
154  value: revenue
155  horizon: 60
156  confidenceLevel: 0.99
157  model: arima
158  groupBy:
159    - region
160    - category
161"#;
162        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
163        assert!(spec.sql.is_none());
164        assert!(spec.aggregate.is_none());
165        let forecast = spec.forecast.unwrap();
166        assert_eq!(forecast.timestamp, "date");
167        assert_eq!(forecast.value, "revenue");
168        assert_eq!(forecast.horizon, Some(60));
169        assert_eq!(forecast.confidence_level, Some(0.99));
170        assert_eq!(forecast.model, Some("arima".to_string()));
171        let groups = forecast.group_by.unwrap();
172        assert_eq!(groups.len(), 2);
173        assert_eq!(groups[0], "region");
174        assert_eq!(groups[1], "category");
175    }
176
177    #[test]
178    fn test_forecast_required_fields_only() {
179        let yaml = r#"
180forecast:
181  timestamp: date
182  value: revenue
183"#;
184        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
185        let forecast = spec.forecast.unwrap();
186        assert_eq!(forecast.timestamp, "date");
187        assert_eq!(forecast.value, "revenue");
188        assert!(forecast.horizon.is_none());
189        assert!(forecast.confidence_level.is_none());
190        assert!(forecast.model.is_none());
191        assert!(forecast.group_by.is_none());
192    }
193
194    #[test]
195    fn test_all_three_fields() {
196        let yaml = r#"
197sql: "SELECT * FROM sales"
198aggregate:
199  dimensions:
200    - region
201  measures:
202    - column: amount
203      aggregation: sum
204      name: totalAmount
205forecast:
206  timestamp: date
207  value: totalAmount
208  horizon: 30
209"#;
210        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
211        match spec.sql.unwrap() {
212            SqlSpec::Single(s) => assert_eq!(s, "SELECT * FROM sales"),
213            other => panic!("Expected SqlSpec::Single, got {:?}", other),
214        }
215        let agg = spec.aggregate.unwrap();
216        assert_eq!(agg.dimensions.len(), 1);
217        assert_eq!(agg.measures.len(), 1);
218        let forecast = spec.forecast.unwrap();
219        assert_eq!(forecast.timestamp, "date");
220        assert_eq!(forecast.value, "totalAmount");
221        assert_eq!(forecast.horizon, Some(30));
222    }
223}