Skip to main content

stmo_cli/
models.rs

1#![allow(clippy::missing_errors_doc)]
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
6where
7    T: Deserialize<'de>,
8    D: Deserializer<'de>,
9{
10    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
11}
12
13fn deserialize_viz_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
14where
15    D: Deserializer<'de>,
16{
17    let value: Option<u64> = Option::deserialize(deserializer)?;
18    Ok(value.filter(|&id| id != 0))
19}
20
21fn default_width() -> u32 {
22    1
23}
24
25fn deserialize_interval<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
26where
27    D: Deserializer<'de>,
28{
29    use serde::de::Error;
30    match Option::<serde_json::Value>::deserialize(deserializer)? {
31        None | Some(serde_json::Value::Null) => Ok(None),
32        Some(serde_json::Value::Number(n)) => n
33            .as_u64()
34            .ok_or_else(|| D::Error::custom("interval is not a valid u64"))
35            .map(Some),
36        Some(serde_json::Value::String(s)) => s.parse::<u64>().map_err(D::Error::custom).map(Some),
37        Some(other) => Err(D::Error::custom(format!(
38            "unexpected interval value: {other}"
39        ))),
40    }
41}
42
43fn deserialize_null_as_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
44where
45    D: Deserializer<'de>,
46{
47    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
48}
49
50#[derive(Debug, Serialize, Deserialize, Clone)]
51pub struct Query {
52    pub id: u64,
53    pub name: String,
54    pub description: Option<String>,
55    #[serde(rename = "query")]
56    pub sql: String,
57    pub data_source_id: u64,
58    #[serde(default)]
59    pub user: Option<QueryUser>,
60    pub schedule: Option<Schedule>,
61    pub options: QueryOptions,
62    #[serde(default)]
63    pub visualizations: Vec<Visualization>,
64    pub tags: Option<Vec<String>>,
65    pub is_archived: bool,
66    pub is_draft: bool,
67    pub updated_at: String,
68    pub created_at: String,
69}
70
71#[derive(Debug, Serialize, Clone)]
72pub struct CreateQuery {
73    pub name: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub description: Option<String>,
76    #[serde(rename = "query")]
77    pub sql: String,
78    pub data_source_id: u64,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub schedule: Option<Schedule>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub options: Option<QueryOptions>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub tags: Option<Vec<String>>,
85    pub is_archived: bool,
86    pub is_draft: bool,
87}
88
89#[derive(Debug, Serialize, Deserialize, Clone)]
90pub struct QueryUser {
91    pub id: u64,
92    pub name: String,
93    pub email: String,
94}
95
96#[derive(Debug, Serialize, Deserialize, Clone)]
97pub struct QueryOptions {
98    #[serde(default)]
99    pub parameters: Vec<Parameter>,
100}
101
102#[derive(Debug, Serialize, Deserialize, Clone)]
103pub struct Parameter {
104    pub name: String,
105    pub title: String,
106    #[serde(rename = "type")]
107    pub param_type: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub value: Option<serde_json::Value>,
110    #[serde(rename = "enumOptions", skip_serializing_if = "Option::is_none")]
111    pub enum_options: Option<String>,
112    #[serde(rename = "queryId", skip_serializing_if = "Option::is_none")]
113    pub query_id: Option<u64>,
114    #[serde(rename = "multiValuesOptions", skip_serializing_if = "Option::is_none")]
115    pub multi_values_options: Option<MultiValuesOptions>,
116}
117
118#[derive(Debug, Serialize, Deserialize, Clone)]
119pub struct MultiValuesOptions {
120    #[serde(rename = "prefix", skip_serializing_if = "Option::is_none")]
121    pub prefix: Option<String>,
122    #[serde(rename = "suffix", skip_serializing_if = "Option::is_none")]
123    pub suffix: Option<String>,
124    #[serde(rename = "separator", skip_serializing_if = "Option::is_none")]
125    pub separator: Option<String>,
126    #[serde(rename = "quoteCharacter", skip_serializing_if = "Option::is_none")]
127    pub quote_character: Option<String>,
128}
129
130#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
131pub struct Schedule {
132    #[serde(default, deserialize_with = "deserialize_interval")]
133    pub interval: Option<u64>,
134    pub time: Option<String>,
135    pub day_of_week: Option<String>,
136    pub until: Option<String>,
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone)]
140pub struct Visualization {
141    pub id: u64,
142    pub name: String,
143    #[serde(rename = "type")]
144    pub viz_type: String,
145    pub options: serde_json::Value,
146    pub description: Option<String>,
147}
148
149#[derive(Debug, Serialize, Clone)]
150pub struct CreateVisualization {
151    pub query_id: u64,
152    pub name: String,
153    #[serde(rename = "type")]
154    pub viz_type: String,
155    pub options: serde_json::Value,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub description: Option<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize)]
161pub struct QueriesResponse {
162    pub results: Vec<Query>,
163    pub count: u64,
164    pub page: u64,
165    pub page_size: u64,
166}
167
168#[derive(Debug, Serialize, Deserialize, Clone)]
169pub struct VisualizationMetadata {
170    #[serde(
171        default,
172        deserialize_with = "deserialize_viz_id",
173        skip_serializing_if = "Option::is_none"
174    )]
175    pub id: Option<u64>,
176    pub name: String,
177    #[serde(rename = "type")]
178    pub viz_type: String,
179    pub options: serde_json::Value,
180    pub description: Option<String>,
181}
182
183impl From<&Visualization> for VisualizationMetadata {
184    fn from(v: &Visualization) -> Self {
185        Self {
186            id: Some(v.id),
187            name: v.name.clone(),
188            viz_type: v.viz_type.clone(),
189            options: v.options.clone(),
190            description: v.description.clone(),
191        }
192    }
193}
194
195#[derive(Debug, Serialize, Deserialize)]
196pub struct QueryMetadata {
197    pub id: u64,
198    pub name: String,
199    pub description: Option<String>,
200    pub data_source_id: u64,
201    #[serde(default)]
202    pub user_id: Option<u64>,
203    pub schedule: Option<Schedule>,
204    pub options: QueryOptions,
205    pub visualizations: Vec<VisualizationMetadata>,
206    pub tags: Option<Vec<String>>,
207}
208
209#[derive(Debug, Serialize, Deserialize, Clone)]
210pub struct QuerySnippet {
211    pub id: u64,
212    pub trigger: String,
213    pub description: Option<String>,
214    pub snippet: String,
215    #[serde(default)]
216    pub user: Option<QueryUser>,
217    pub updated_at: String,
218    pub created_at: String,
219}
220
221#[derive(Debug, Serialize, Clone)]
222pub struct CreateQuerySnippet {
223    pub trigger: String,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub description: Option<String>,
226    pub snippet: String,
227}
228
229#[derive(Debug, Serialize, Deserialize)]
230pub struct SnippetMetadata {
231    pub id: u64,
232    pub trigger: String,
233    pub description: Option<String>,
234}
235
236#[derive(Debug, Serialize, Deserialize, Clone)]
237#[allow(dead_code)]
238pub struct User {
239    pub id: u64,
240    pub name: String,
241    pub email: String,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub profile_image_url: Option<String>,
244}
245
246#[derive(Debug, Serialize, Deserialize, Clone)]
247pub struct DataSource {
248    pub id: u64,
249    pub name: String,
250    #[serde(rename = "type")]
251    pub ds_type: String,
252    pub syntax: Option<String>,
253    pub description: Option<String>,
254    pub paused: u8,
255    pub pause_reason: Option<String>,
256    pub view_only: bool,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub queue_name: Option<String>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub scheduled_queue_name: Option<String>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub groups: Option<serde_json::Value>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub options: Option<serde_json::Value>,
265}
266
267#[derive(Debug, Serialize, Deserialize)]
268pub struct DataSourceSchema {
269    pub schema: Vec<SchemaTable>,
270}
271
272#[derive(Debug, Serialize, Deserialize)]
273pub struct SchemaTable {
274    pub name: String,
275    pub columns: Vec<SchemaColumn>,
276}
277
278#[derive(Debug, Serialize, Deserialize)]
279pub struct SchemaColumn {
280    pub name: String,
281    #[serde(rename = "type")]
282    pub column_type: String,
283}
284
285#[derive(Debug, Serialize, Deserialize)]
286pub struct RefreshRequest {
287    pub max_age: u64,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
290}
291
292#[derive(Debug, Serialize, Deserialize)]
293pub struct AdhocRefreshRequest {
294    #[serde(rename = "query")]
295    pub sql: String,
296    pub data_source_id: u64,
297    pub max_age: u64,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
303pub struct JobResponse {
304    pub job: Job,
305}
306
307#[derive(Debug, Serialize, Deserialize)]
308pub struct Job {
309    pub id: String,
310    pub status: u8,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub query_result_id: Option<u64>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub error: Option<String>,
315}
316
317#[derive(Debug, Serialize, Deserialize)]
318pub struct QueryResultResponse {
319    pub query_result: QueryResult,
320}
321
322#[derive(Debug, Serialize, Deserialize)]
323pub struct QueryResult {
324    pub id: u64,
325    pub data: QueryResultData,
326    pub runtime: f64,
327    pub retrieved_at: String,
328}
329
330#[derive(Debug, Serialize, Deserialize)]
331pub struct QueryResultData {
332    pub columns: Vec<Column>,
333    pub rows: Vec<serde_json::Value>,
334}
335
336#[derive(Debug, Serialize, Deserialize)]
337pub struct Column {
338    pub name: String,
339    #[serde(rename = "type")]
340    pub type_name: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub friendly_name: Option<String>,
343}
344
345#[derive(Debug, Clone, Copy)]
346pub enum JobStatus {
347    Pending = 1,
348    Started = 2,
349    Success = 3,
350    Failure = 4,
351    Cancelled = 5,
352}
353
354impl JobStatus {
355    pub fn from_u8(status: u8) -> anyhow::Result<Self> {
356        match status {
357            1 => Ok(Self::Pending),
358            2 => Ok(Self::Started),
359            3 => Ok(Self::Success),
360            4 => Ok(Self::Failure),
361            5 => Ok(Self::Cancelled),
362            _ => Err(anyhow::anyhow!("Invalid job status: {status}")),
363        }
364    }
365}
366
367#[derive(Debug, Serialize, Deserialize)]
368pub struct Dashboard {
369    pub id: u64,
370    pub name: String,
371    pub slug: String,
372    pub user_id: u64,
373    pub is_archived: bool,
374    pub is_draft: bool,
375    #[serde(rename = "dashboard_filters_enabled")]
376    pub filters_enabled: bool,
377    pub tags: Vec<String>,
378    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
379    pub widgets: Vec<Widget>,
380}
381
382#[derive(Debug, Serialize)]
383pub struct CreateDashboard {
384    pub name: String,
385}
386
387#[derive(Debug, Serialize, Deserialize)]
388pub struct Widget {
389    pub id: u64,
390    pub dashboard_id: u64,
391    pub width: u32,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub visualization_id: Option<u64>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub visualization: Option<WidgetVisualization>,
396    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
397    pub text: String,
398    pub options: WidgetOptions,
399}
400
401#[derive(Debug, Serialize, Deserialize)]
402pub struct WidgetVisualization {
403    pub id: u64,
404    pub name: String,
405    pub query: VisualizationQuery,
406}
407
408#[derive(Debug, Serialize, Deserialize)]
409pub struct VisualizationQuery {
410    pub id: u64,
411    pub name: String,
412}
413
414#[derive(Debug, Serialize, Deserialize, Clone)]
415pub struct WidgetOptions {
416    pub position: WidgetPosition,
417    #[serde(
418        default,
419        skip_serializing_if = "Option::is_none",
420        rename = "parameterMappings"
421    )]
422    pub parameter_mappings: Option<serde_json::Value>,
423}
424
425#[derive(Debug, Serialize, Deserialize, Clone)]
426pub struct WidgetPosition {
427    pub col: u32,
428    pub row: u32,
429    #[serde(rename = "sizeX")]
430    pub size_x: u32,
431    #[serde(rename = "sizeY")]
432    pub size_y: u32,
433}
434
435#[derive(Debug, Serialize, Deserialize)]
436pub struct DashboardMetadata {
437    pub id: u64,
438    pub name: String,
439    pub slug: String,
440    pub user_id: u64,
441    pub is_draft: bool,
442    pub is_archived: bool,
443    #[serde(rename = "dashboard_filters_enabled")]
444    pub filters_enabled: bool,
445    pub tags: Vec<String>,
446    pub widgets: Vec<WidgetMetadata>,
447}
448
449#[derive(Debug, Serialize, Deserialize)]
450pub struct WidgetMetadata {
451    pub id: u64,
452    #[serde(default = "default_width")]
453    pub width: u32,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub visualization_id: Option<u64>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub query_id: Option<u64>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub visualization_name: Option<String>,
460    #[serde(default, skip_serializing_if = "String::is_empty")]
461    pub text: String,
462    pub options: WidgetOptions,
463}
464
465#[derive(Debug, Deserialize)]
466pub struct DashboardsResponse {
467    pub results: Vec<DashboardSummary>,
468    pub count: u64,
469}
470
471#[derive(Debug, Deserialize)]
472pub struct DashboardSummary {
473    #[allow(dead_code)]
474    pub id: u64,
475    pub name: String,
476    #[allow(dead_code)]
477    pub slug: String,
478    pub is_draft: bool,
479    pub is_archived: bool,
480}
481
482#[derive(Debug, Serialize)]
483pub struct CreateWidget {
484    pub dashboard_id: u64,
485    pub visualization_id: Option<u64>,
486    pub text: String,
487    pub width: u32,
488    pub options: WidgetOptions,
489}
490
491#[must_use]
492pub fn build_dashboard_level_parameter_mappings(parameters: &[Parameter]) -> serde_json::Value {
493    let mut mappings = serde_json::Map::new();
494    for param in parameters {
495        mappings.insert(
496            param.name.clone(),
497            serde_json::json!({
498                "mapTo": param.name,
499                "name": param.name,
500                "title": "",
501                "type": "dashboard-level",
502                "value": null,
503            }),
504        );
505    }
506    serde_json::Value::Object(mappings)
507}
508
509#[cfg(test)]
510#[allow(clippy::missing_errors_doc)]
511#[allow(clippy::unnecessary_literal_unwrap)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_job_status_from_u8_valid() {
517        assert!(matches!(JobStatus::from_u8(1).unwrap(), JobStatus::Pending));
518        assert!(matches!(JobStatus::from_u8(2).unwrap(), JobStatus::Started));
519        assert!(matches!(JobStatus::from_u8(3).unwrap(), JobStatus::Success));
520        assert!(matches!(JobStatus::from_u8(4).unwrap(), JobStatus::Failure));
521        assert!(matches!(
522            JobStatus::from_u8(5).unwrap(),
523            JobStatus::Cancelled
524        ));
525    }
526
527    #[test]
528    fn test_job_status_from_u8_invalid() {
529        assert!(JobStatus::from_u8(0).is_err());
530        assert!(JobStatus::from_u8(6).is_err());
531        assert!(JobStatus::from_u8(255).is_err());
532
533        let err = JobStatus::from_u8(10).unwrap_err();
534        assert!(err.to_string().contains("Invalid job status"));
535    }
536
537    #[test]
538    fn test_query_serialization() {
539        let query = Query {
540            id: 1,
541            name: "Test Query".to_string(),
542            description: None,
543            sql: "SELECT * FROM table".to_string(),
544            data_source_id: 63,
545            user: None,
546            schedule: None,
547            options: QueryOptions { parameters: vec![] },
548            visualizations: vec![],
549            tags: None,
550            is_archived: false,
551            is_draft: false,
552            updated_at: "2026-01-21".to_string(),
553            created_at: "2026-01-21".to_string(),
554        };
555
556        let json = serde_json::to_string(&query).unwrap();
557        assert!(json.contains("\"query\":"));
558        assert!(json.contains("SELECT * FROM table"));
559    }
560
561    #[test]
562    fn test_query_metadata_deserialization() {
563        let yaml = r"
564id: 100064
565name: Test Query
566description: null
567data_source_id: 63
568user_id: 530
569schedule: null
570options:
571  parameters:
572    - name: project
573      title: project
574      type: enum
575      value:
576        - try
577      enumOptions: |
578        try
579        autoland
580visualizations: []
581tags:
582  - bug 1840828
583";
584
585        let metadata: QueryMetadata = serde_yaml::from_str(yaml).unwrap();
586        assert_eq!(metadata.id, 100_064);
587        assert_eq!(metadata.name, "Test Query");
588        assert_eq!(metadata.data_source_id, 63);
589        assert_eq!(metadata.options.parameters.len(), 1);
590        assert_eq!(metadata.options.parameters[0].name, "project");
591    }
592
593    #[test]
594    fn test_query_snippet_deserialization() {
595        let json = r#"{
596            "id": 31,
597            "trigger": "reviewbot_e2e_action_ctcs",
598            "description": "Action-task gap compression CTEs",
599            "snippet": "action_tasks AS (\n    SELECT 1\n)",
600            "user": {
601                "id": 530,
602                "name": "jlorenzo@mozilla.com",
603                "email": "jlorenzo@mozilla.com"
604            },
605            "updated_at": "2026-06-24T09:13:26.873Z",
606            "created_at": "2026-06-24T09:13:26.873Z"
607        }"#;
608
609        let snippet: QuerySnippet = serde_json::from_str(json).unwrap();
610        assert_eq!(snippet.id, 31);
611        assert_eq!(snippet.trigger, "reviewbot_e2e_action_ctcs");
612        assert_eq!(
613            snippet.description,
614            Some("Action-task gap compression CTEs".to_string())
615        );
616        assert!(snippet.snippet.contains("action_tasks AS"));
617        assert_eq!(snippet.user.unwrap().email, "jlorenzo@mozilla.com");
618    }
619
620    #[test]
621    fn test_query_snippet_missing_user_deserialization() {
622        let json = r#"{
623            "id": 12,
624            "trigger": "days_ago",
625            "description": "",
626            "snippet": "(CURRENT_DATE - INTERVAL '$1' DAY)",
627            "updated_at": "2018-04-05T19:36:28.831Z",
628            "created_at": "2018-04-05T19:09:55.265Z"
629        }"#;
630
631        let snippet: QuerySnippet = serde_json::from_str(json).unwrap();
632        assert_eq!(snippet.id, 12);
633        assert!(snippet.user.is_none());
634    }
635
636    #[test]
637    fn test_create_query_snippet_serialization() {
638        let create = CreateQuerySnippet {
639            trigger: "reviewbot_push_duration".to_string(),
640            description: Some("Corrected push duration formula".to_string()),
641            snippet: "TIMESTAMP_DIFF(a, b, SECOND)".to_string(),
642        };
643
644        let json = serde_json::to_string(&create).unwrap();
645        assert!(json.contains("\"trigger\":\"reviewbot_push_duration\""));
646        assert!(json.contains("\"snippet\":\"TIMESTAMP_DIFF(a, b, SECOND)\""));
647        assert!(!json.contains("\"id\""));
648    }
649
650    #[test]
651    fn test_create_query_snippet_omits_none_description() {
652        let create = CreateQuerySnippet {
653            trigger: "trig".to_string(),
654            description: None,
655            snippet: "SELECT 1".to_string(),
656        };
657
658        let json = serde_json::to_string(&create).unwrap();
659        assert!(!json.contains("description"));
660    }
661
662    #[test]
663    fn test_snippet_metadata_yaml_roundtrip() {
664        let yaml = r"
665id: 31
666trigger: reviewbot_e2e_action_ctcs
667description: Action-task gap compression CTEs
668";
669
670        let metadata: SnippetMetadata = serde_yaml::from_str(yaml).unwrap();
671        assert_eq!(metadata.id, 31);
672        assert_eq!(metadata.trigger, "reviewbot_e2e_action_ctcs");
673        assert_eq!(
674            metadata.description,
675            Some("Action-task gap compression CTEs".to_string())
676        );
677
678        let reserialized = serde_yaml::to_string(&metadata).unwrap();
679        let roundtripped: SnippetMetadata = serde_yaml::from_str(&reserialized).unwrap();
680        assert_eq!(roundtripped.id, metadata.id);
681        assert_eq!(roundtripped.trigger, metadata.trigger);
682    }
683
684    #[test]
685    fn test_datasource_deserialization() {
686        let json = r#"{
687            "id": 63,
688            "name": "Test DB",
689            "type": "bigquery",
690            "description": null,
691            "syntax": "sql",
692            "paused": 0,
693            "pause_reason": null,
694            "view_only": false,
695            "queue_name": "queries",
696            "scheduled_queue_name": "scheduled_queries",
697            "groups": {},
698            "options": {}
699        }"#;
700
701        let ds: DataSource = serde_json::from_str(json).unwrap();
702        assert_eq!(ds.id, 63);
703        assert_eq!(ds.name, "Test DB");
704        assert_eq!(ds.ds_type, "bigquery");
705        assert_eq!(ds.syntax, Some("sql".to_string()));
706        assert_eq!(ds.description, None);
707        assert_eq!(ds.paused, 0);
708        assert!(!ds.view_only);
709        assert_eq!(ds.queue_name, Some("queries".to_string()));
710    }
711
712    #[test]
713    fn test_datasource_with_nulls() {
714        let json = r#"{
715            "id": 10,
716            "name": "Minimal DB",
717            "type": "pg",
718            "description": "Test description",
719            "syntax": null,
720            "paused": 1,
721            "pause_reason": "Maintenance",
722            "view_only": true,
723            "queue_name": null,
724            "scheduled_queue_name": null,
725            "groups": null,
726            "options": null
727        }"#;
728
729        let ds: DataSource = serde_json::from_str(json).unwrap();
730        assert_eq!(ds.id, 10);
731        assert_eq!(ds.name, "Minimal DB");
732        assert_eq!(ds.ds_type, "pg");
733        assert_eq!(ds.description, Some("Test description".to_string()));
734        assert_eq!(ds.syntax, None);
735        assert_eq!(ds.paused, 1);
736        assert_eq!(ds.pause_reason, Some("Maintenance".to_string()));
737        assert!(ds.view_only);
738        assert_eq!(ds.queue_name, None);
739    }
740
741    #[test]
742    fn test_datasource_schema_deserialization() {
743        let json = r#"{
744            "schema": [
745                {
746                    "name": "table1",
747                    "columns": [
748                        {"name": "col1", "type": "STRING"},
749                        {"name": "col2", "type": "INTEGER"}
750                    ]
751                },
752                {
753                    "name": "table2",
754                    "columns": [{"name": "id", "type": "INTEGER"}]
755                }
756            ]
757        }"#;
758
759        let schema: DataSourceSchema = serde_json::from_str(json).unwrap();
760        assert_eq!(schema.schema.len(), 2);
761        assert_eq!(schema.schema[0].name, "table1");
762        assert_eq!(schema.schema[0].columns.len(), 2);
763        assert_eq!(schema.schema[0].columns[0].name, "col1");
764        assert_eq!(schema.schema[0].columns[0].column_type, "STRING");
765        assert_eq!(schema.schema[1].name, "table2");
766        assert_eq!(schema.schema[1].columns.len(), 1);
767    }
768
769    #[test]
770    fn test_schema_table_structure() {
771        let json = r#"{
772            "name": "users",
773            "columns": [
774                {"name": "id", "type": "INTEGER"},
775                {"name": "name", "type": "STRING"},
776                {"name": "email", "type": "STRING"}
777            ]
778        }"#;
779
780        let table: SchemaTable = serde_json::from_str(json).unwrap();
781        assert_eq!(table.name, "users");
782        assert_eq!(table.columns.len(), 3);
783        assert_eq!(table.columns[0].name, "id");
784        assert_eq!(table.columns[0].column_type, "INTEGER");
785        assert_eq!(table.columns[1].name, "name");
786        assert_eq!(table.columns[1].column_type, "STRING");
787        assert_eq!(table.columns[2].name, "email");
788        assert_eq!(table.columns[2].column_type, "STRING");
789    }
790
791    #[test]
792    fn test_datasource_serialization() {
793        let ds = DataSource {
794            id: 123,
795            name: "My DB".to_string(),
796            ds_type: "mysql".to_string(),
797            syntax: Some("sql".to_string()),
798            description: Some("Test".to_string()),
799            paused: 0,
800            pause_reason: None,
801            view_only: false,
802            queue_name: Some("queries".to_string()),
803            scheduled_queue_name: None,
804            groups: None,
805            options: None,
806        };
807
808        let json = serde_json::to_string(&ds).unwrap();
809        assert!(json.contains("\"id\":123"));
810        assert!(json.contains("\"name\":\"My DB\""));
811        assert!(json.contains("\"type\":\"mysql\""));
812        assert!(json.contains("\"syntax\":\"sql\""));
813    }
814
815    #[test]
816    fn test_dashboard_deserialization() {
817        let json = r#"{
818            "id": 2570,
819            "name": "Test Dashboard",
820            "slug": "test-dashboard",
821            "user_id": 530,
822            "is_archived": false,
823            "is_draft": false,
824            "dashboard_filters_enabled": true,
825            "tags": ["tag1", "tag2"],
826            "widgets": []
827        }"#;
828
829        let dashboard: Dashboard = serde_json::from_str(json).unwrap();
830        assert_eq!(dashboard.id, 2570);
831        assert_eq!(dashboard.name, "Test Dashboard");
832        assert_eq!(dashboard.slug, "test-dashboard");
833        assert_eq!(dashboard.user_id, 530);
834        assert!(!dashboard.is_archived);
835        assert!(!dashboard.is_draft);
836        assert!(dashboard.filters_enabled);
837        assert_eq!(dashboard.tags, vec!["tag1", "tag2"]);
838        assert_eq!(dashboard.widgets.len(), 0);
839    }
840
841    #[test]
842    fn test_dashboard_with_widgets() {
843        let json = r##"{
844            "id": 2570,
845            "name": "Test Dashboard",
846            "slug": "test-dashboard",
847            "user_id": 530,
848            "is_archived": false,
849            "is_draft": false,
850            "dashboard_filters_enabled": false,
851            "tags": [],
852            "widgets": [
853                {
854                    "id": 75035,
855                    "dashboard_id": 2570,
856                    "width": 1,
857                    "text": "# Test Widget",
858                    "options": {
859                        "position": {
860                            "col": 0,
861                            "row": 0,
862                            "sizeX": 6,
863                            "sizeY": 2
864                        }
865                    }
866                },
867                {
868                    "id": 75029,
869                    "dashboard_id": 2570,
870                    "width": 1,
871                    "visualization_id": 279588,
872                    "visualization": {
873                        "id": 279588,
874                        "name": "Total MAU",
875                        "query": {
876                            "id": 114049,
877                            "name": "MAU Query"
878                        }
879                    },
880                    "text": "",
881                    "options": {
882                        "position": {
883                            "col": 3,
884                            "row": 2,
885                            "sizeX": 3,
886                            "sizeY": 8
887                        },
888                        "parameterMappings": {
889                            "channel": {
890                                "name": "channel",
891                                "type": "dashboard-level"
892                            }
893                        }
894                    }
895                }
896            ]
897        }"##;
898
899        let dashboard: Dashboard = serde_json::from_str(json).unwrap();
900        assert_eq!(dashboard.widgets.len(), 2);
901        assert_eq!(dashboard.widgets[0].id, 75035);
902        assert_eq!(dashboard.widgets[0].text, "# Test Widget");
903        assert!(dashboard.widgets[0].visualization_id.is_none());
904        assert_eq!(dashboard.widgets[1].id, 75029);
905        assert_eq!(dashboard.widgets[1].visualization_id, Some(279_588));
906        let viz = dashboard.widgets[1].visualization.as_ref().unwrap();
907        assert_eq!(viz.id, 279_588);
908        assert_eq!(viz.query.id, 114_049);
909    }
910
911    #[test]
912    fn test_widget_position_serde() {
913        let json = r#"{
914            "col": 3,
915            "row": 5,
916            "sizeX": 6,
917            "sizeY": 4
918        }"#;
919
920        let position: WidgetPosition = serde_json::from_str(json).unwrap();
921        assert_eq!(position.col, 3);
922        assert_eq!(position.row, 5);
923        assert_eq!(position.size_x, 6);
924        assert_eq!(position.size_y, 4);
925
926        let serialized = serde_json::to_string(&position).unwrap();
927        assert!(serialized.contains("\"sizeX\":6"));
928        assert!(serialized.contains("\"sizeY\":4"));
929    }
930
931    #[test]
932    fn test_dashboard_metadata_yaml() {
933        let yaml = r"
934id: 2570
935name: Test Dashboard
936slug: test-dashboard
937user_id: 530
938is_draft: false
939is_archived: false
940dashboard_filters_enabled: true
941tags:
942  - tag1
943  - tag2
944widgets:
945  - id: 75035
946    visualization_id: null
947    query_id: null
948    visualization_name: null
949    text: '# Test Widget'
950    options:
951      position:
952        col: 0
953        row: 0
954        sizeX: 6
955        sizeY: 2
956      parameter_mappings: null
957";
958
959        let metadata: DashboardMetadata = serde_yaml::from_str(yaml).unwrap();
960        assert_eq!(metadata.id, 2570);
961        assert_eq!(metadata.name, "Test Dashboard");
962        assert_eq!(metadata.slug, "test-dashboard");
963        assert_eq!(metadata.user_id, 530);
964        assert!(!metadata.is_draft);
965        assert!(!metadata.is_archived);
966        assert!(metadata.filters_enabled);
967        assert_eq!(metadata.tags, vec!["tag1", "tag2"]);
968        assert_eq!(metadata.widgets.len(), 1);
969        assert_eq!(metadata.widgets[0].id, 75035);
970        assert_eq!(metadata.widgets[0].text, "# Test Widget");
971    }
972
973    #[test]
974    fn test_widget_metadata_text_widget() {
975        let yaml = r"
976id: 75035
977visualization_id: null
978query_id: null
979visualization_name: null
980text: '## Section Header'
981options:
982  position:
983    col: 0
984    row: 0
985    sizeX: 6
986    sizeY: 2
987  parameter_mappings: null
988";
989
990        let widget: WidgetMetadata = serde_yaml::from_str(yaml).unwrap();
991        assert_eq!(widget.id, 75035);
992        assert!(widget.visualization_id.is_none());
993        assert!(widget.query_id.is_none());
994        assert!(widget.visualization_name.is_none());
995        assert_eq!(widget.text, "## Section Header");
996        assert_eq!(widget.options.position.col, 0);
997        assert_eq!(widget.options.position.size_x, 6);
998    }
999
1000    #[test]
1001    fn test_widget_metadata_viz_widget() {
1002        let yaml = r"
1003id: 75029
1004visualization_id: 279588
1005query_id: 114049
1006visualization_name: Total MAU
1007text: ''
1008options:
1009  position:
1010    col: 3
1011    row: 2
1012    sizeX: 3
1013    sizeY: 8
1014  parameterMappings:
1015    channel:
1016      name: channel
1017      type: dashboard-level
1018";
1019
1020        let widget: WidgetMetadata = serde_yaml::from_str(yaml).unwrap();
1021        assert_eq!(widget.id, 75029);
1022        assert_eq!(widget.visualization_id, Some(279_588));
1023        assert_eq!(widget.query_id, Some(114_049));
1024        assert_eq!(widget.visualization_name, Some("Total MAU".to_string()));
1025        assert_eq!(widget.text, "");
1026        assert!(widget.options.parameter_mappings.is_some());
1027    }
1028
1029    #[test]
1030    fn test_create_widget_serialization() {
1031        let widget = CreateWidget {
1032            dashboard_id: 2570,
1033            visualization_id: Some(279_588),
1034            text: String::new(),
1035            width: 1,
1036            options: WidgetOptions {
1037                position: WidgetPosition {
1038                    col: 0,
1039                    row: 0,
1040                    size_x: 3,
1041                    size_y: 2,
1042                },
1043                parameter_mappings: None,
1044            },
1045        };
1046
1047        let json = serde_json::to_string(&widget).unwrap();
1048        assert!(json.contains("\"dashboard_id\":2570"));
1049        assert!(json.contains("\"visualization_id\":279588"));
1050        assert!(json.contains("\"sizeX\":3"));
1051        assert!(json.contains("\"sizeY\":2"));
1052    }
1053
1054    #[test]
1055    fn test_create_text_widget_serialization() {
1056        let widget = CreateWidget {
1057            dashboard_id: 2570,
1058            visualization_id: None,
1059            text: "Some text".to_string(),
1060            width: 1,
1061            options: WidgetOptions {
1062                position: WidgetPosition {
1063                    col: 0,
1064                    row: 0,
1065                    size_x: 3,
1066                    size_y: 2,
1067                },
1068                parameter_mappings: None,
1069            },
1070        };
1071
1072        let json = serde_json::to_string(&widget).unwrap();
1073        assert!(json.contains("\"visualization_id\":null"));
1074    }
1075
1076    #[test]
1077    fn test_dashboards_response() {
1078        let json = r#"{
1079            "results": [
1080                {
1081                    "id": 2570,
1082                    "name": "Dashboard 1",
1083                    "slug": "dashboard-1",
1084                    "is_draft": false,
1085                    "is_archived": false
1086                },
1087                {
1088                    "id": 2558,
1089                    "name": "Dashboard 2",
1090                    "slug": "dashboard-2",
1091                    "is_draft": true,
1092                    "is_archived": false
1093                }
1094            ],
1095            "count": 2
1096        }"#;
1097
1098        let response: DashboardsResponse = serde_json::from_str(json).unwrap();
1099        assert_eq!(response.results.len(), 2);
1100        assert_eq!(response.count, 2);
1101        assert_eq!(response.results[0].id, 2570);
1102        assert_eq!(response.results[0].name, "Dashboard 1");
1103        assert_eq!(response.results[0].slug, "dashboard-1");
1104        assert!(!response.results[0].is_draft);
1105        assert!(!response.results[0].is_archived);
1106        assert_eq!(response.results[1].id, 2558);
1107        assert_eq!(response.results[1].slug, "dashboard-2");
1108        assert!(response.results[1].is_draft);
1109    }
1110
1111    #[test]
1112    fn test_build_dashboard_level_parameter_mappings_empty() {
1113        let result = build_dashboard_level_parameter_mappings(&[]);
1114        assert_eq!(result, serde_json::json!({}));
1115    }
1116
1117    #[test]
1118    fn test_build_dashboard_level_parameter_mappings_with_params() {
1119        let params = vec![
1120            Parameter {
1121                name: "channel".to_string(),
1122                title: "Channel".to_string(),
1123                param_type: "enum".to_string(),
1124                value: None,
1125                enum_options: None,
1126                query_id: None,
1127                multi_values_options: None,
1128            },
1129            Parameter {
1130                name: "date".to_string(),
1131                title: "Date".to_string(),
1132                param_type: "date".to_string(),
1133                value: None,
1134                enum_options: None,
1135                query_id: None,
1136                multi_values_options: None,
1137            },
1138        ];
1139
1140        let result = build_dashboard_level_parameter_mappings(&params);
1141
1142        let expected = serde_json::json!({
1143            "channel": {
1144                "mapTo": "channel",
1145                "name": "channel",
1146                "title": "",
1147                "type": "dashboard-level",
1148                "value": null,
1149            },
1150            "date": {
1151                "mapTo": "date",
1152                "name": "date",
1153                "title": "",
1154                "type": "dashboard-level",
1155                "value": null,
1156            },
1157        });
1158
1159        assert_eq!(result, expected);
1160    }
1161
1162    #[test]
1163    fn test_schedule_interval_as_integer() {
1164        let s: Schedule = serde_json::from_str(r#"{"interval": 3600}"#).unwrap();
1165        assert_eq!(s.interval, Some(3600));
1166    }
1167
1168    #[test]
1169    fn test_schedule_interval_as_string() {
1170        let s: Schedule = serde_json::from_str(r#"{"interval": "3600"}"#).unwrap();
1171        assert_eq!(s.interval, Some(3600));
1172    }
1173
1174    #[test]
1175    fn test_schedule_interval_null() {
1176        let s: Schedule = serde_json::from_str(r#"{"interval": null}"#).unwrap();
1177        assert_eq!(s.interval, None);
1178    }
1179
1180    #[test]
1181    fn test_schedule_interval_absent() {
1182        let s: Schedule = serde_json::from_str(r"{}").unwrap();
1183        assert_eq!(s.interval, None);
1184    }
1185
1186    #[test]
1187    fn test_column_type_null() {
1188        let c: Column = serde_json::from_str(
1189            r#"{"name": "revision", "friendly_name": "revision", "type": null}"#,
1190        )
1191        .unwrap();
1192        assert_eq!(c.name, "revision");
1193        assert_eq!(c.type_name, None);
1194    }
1195
1196    #[test]
1197    fn test_column_type_present() {
1198        let c: Column = serde_json::from_str(
1199            r#"{"name": "time", "friendly_name": "time", "type": "datetime"}"#,
1200        )
1201        .unwrap();
1202        assert_eq!(c.type_name, Some("datetime".to_string()));
1203    }
1204}