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}