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    #![allow(clippy::unwrap_used)]
92    use super::*;
93
94    #[test]
95    fn test_backward_compat_aggregate_only() {
96        let yaml = r#"
97aggregate:
98  dimensions:
99    - region
100  measures:
101    - column: amount
102      aggregation: sum
103      name: totalAmount
104  limit: 10
105"#;
106        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
107        assert!(spec.aggregate.is_some());
108        assert!(spec.sql.is_none());
109        assert!(spec.forecast.is_none());
110        let agg = spec.aggregate.unwrap();
111        assert_eq!(agg.dimensions.len(), 1);
112        assert_eq!(agg.measures.len(), 1);
113        assert_eq!(agg.limit, Some(10));
114    }
115
116    #[test]
117    fn test_sql_single_string() {
118        let yaml = r#"
119sql: "SELECT * FROM sales WHERE year = 2024"
120"#;
121        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
122        assert!(spec.aggregate.is_none());
123        assert!(spec.forecast.is_none());
124        match spec.sql.unwrap() {
125            SqlSpec::Single(s) => assert_eq!(s, "SELECT * FROM sales WHERE year = 2024"),
126            other => panic!("Expected SqlSpec::Single, got {:?}", other),
127        }
128    }
129
130    #[test]
131    fn test_sql_multiple_strings() {
132        let yaml = r#"
133sql:
134  - "SELECT * FROM sales"
135  - "WHERE year = 2024"
136  - "ORDER BY revenue DESC"
137"#;
138        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
139        match spec.sql.unwrap() {
140            SqlSpec::Multiple(v) => {
141                assert_eq!(v.len(), 3);
142                assert_eq!(v[0], "SELECT * FROM sales");
143                assert_eq!(v[1], "WHERE year = 2024");
144                assert_eq!(v[2], "ORDER BY revenue DESC");
145            }
146            other => panic!("Expected SqlSpec::Multiple, got {:?}", other),
147        }
148    }
149
150    #[test]
151    fn test_forecast_all_fields() {
152        let yaml = r#"
153forecast:
154  timestamp: date
155  value: revenue
156  horizon: 60
157  confidenceLevel: 0.99
158  model: arima
159  groupBy:
160    - region
161    - category
162"#;
163        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
164        assert!(spec.sql.is_none());
165        assert!(spec.aggregate.is_none());
166        let forecast = spec.forecast.unwrap();
167        assert_eq!(forecast.timestamp, "date");
168        assert_eq!(forecast.value, "revenue");
169        assert_eq!(forecast.horizon, Some(60));
170        assert_eq!(forecast.confidence_level, Some(0.99));
171        assert_eq!(forecast.model, Some("arima".to_string()));
172        let groups = forecast.group_by.unwrap();
173        assert_eq!(groups.len(), 2);
174        assert_eq!(groups[0], "region");
175        assert_eq!(groups[1], "category");
176    }
177
178    #[test]
179    fn test_forecast_required_fields_only() {
180        let yaml = r#"
181forecast:
182  timestamp: date
183  value: revenue
184"#;
185        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
186        let forecast = spec.forecast.unwrap();
187        assert_eq!(forecast.timestamp, "date");
188        assert_eq!(forecast.value, "revenue");
189        assert!(forecast.horizon.is_none());
190        assert!(forecast.confidence_level.is_none());
191        assert!(forecast.model.is_none());
192        assert!(forecast.group_by.is_none());
193    }
194
195    #[test]
196    fn test_all_three_fields() {
197        let yaml = r#"
198sql: "SELECT * FROM sales"
199aggregate:
200  dimensions:
201    - region
202  measures:
203    - column: amount
204      aggregation: sum
205      name: totalAmount
206forecast:
207  timestamp: date
208  value: totalAmount
209  horizon: 30
210"#;
211        let spec: TransformSpec = serde_yaml::from_str(yaml).unwrap();
212        match spec.sql.unwrap() {
213            SqlSpec::Single(s) => assert_eq!(s, "SELECT * FROM sales"),
214            other => panic!("Expected SqlSpec::Single, got {:?}", other),
215        }
216        let agg = spec.aggregate.unwrap();
217        assert_eq!(agg.dimensions.len(), 1);
218        assert_eq!(agg.measures.len(), 1);
219        let forecast = spec.forecast.unwrap();
220        assert_eq!(forecast.timestamp, "date");
221        assert_eq!(forecast.value, "totalAmount");
222        assert_eq!(forecast.horizon, Some(30));
223    }
224}