Skip to main content

dlin_core/parser/
yaml_schema.rs

1use serde::Deserialize;
2
3/// Top-level schema YAML file (can contain sources, models, snapshots, exposures,
4/// semantic_models, metrics, saved_queries)
5#[derive(Debug, Deserialize, Default)]
6pub struct SchemaFile {
7    #[serde(default)]
8    pub sources: Vec<SourceDefinition>,
9
10    #[serde(default)]
11    pub models: Vec<ModelDefinition>,
12
13    #[serde(default)]
14    pub snapshots: Vec<SnapshotDefinition>,
15
16    #[serde(default)]
17    pub exposures: Vec<ExposureDefinition>,
18
19    #[serde(default)]
20    pub semantic_models: Vec<SemanticModelDefinition>,
21
22    #[serde(default)]
23    pub metrics: Vec<MetricDefinition>,
24
25    #[serde(default)]
26    pub saved_queries: Vec<SavedQueryDefinition>,
27}
28
29#[derive(Debug, Deserialize, Clone)]
30pub struct SourceDefinition {
31    pub name: String,
32    #[serde(default)]
33    pub description: Option<String>,
34    #[serde(default)]
35    pub tables: Vec<SourceTable>,
36}
37
38#[derive(Debug, Deserialize, Clone)]
39pub struct SourceTable {
40    pub name: String,
41    #[serde(default)]
42    pub description: Option<String>,
43    #[serde(default)]
44    pub columns: Vec<ColumnDefinition>,
45}
46
47#[derive(Debug, Deserialize, Clone)]
48pub struct ColumnDefinition {
49    pub name: String,
50    #[serde(default)]
51    pub description: Option<String>,
52    #[serde(default, alias = "data_tests")]
53    pub tests: Vec<TestDefinition>,
54}
55
56/// Tests can be either a string or a map.
57/// Complex variants are deserialized into `serde_json::Value` because serde-saphyr
58/// has no intermediate Value type. This is safe for dbt schema files which use
59/// JSON-compatible YAML.
60#[derive(Debug, Deserialize, Clone)]
61#[serde(untagged)]
62pub enum TestDefinition {
63    Simple(String),
64    Complex(serde_json::Value),
65}
66
67impl TestDefinition {
68    /// Extract the test name from either variant.
69    ///
70    /// - `Simple("not_null")` → `"not_null"`
71    /// - `Complex({"unique": {...}})` → `"unique"`
72    /// - `Complex({"name": "custom", "test_name": "accepted_values", ...})` → `"accepted_values"`
73    pub fn test_name(&self) -> Option<&str> {
74        match self {
75            TestDefinition::Simple(s) => Some(s.as_str()),
76            TestDefinition::Complex(v) => {
77                let obj = v.as_object()?;
78                // Alternative format: {"name": "...", "test_name": "accepted_values", ...}
79                if let Some(tn) = obj.get("test_name").and_then(|v| v.as_str()) {
80                    return Some(tn);
81                }
82                // Standard format: single-key map like {"unique": {...}}
83                // Note: serde_json::Map uses BTreeMap, so keys() is alphabetically ordered.
84                // Skip objects that only have meta-keys (name/config/arguments).
85                for key in obj.keys() {
86                    if !matches!(key.as_str(), "config" | "arguments" | "name") {
87                        return Some(key.as_str());
88                    }
89                }
90                None
91            }
92        }
93    }
94}
95
96/// Normalize a serde_json::Value version field to a canonical string.
97/// JSON integer and float values are normalized without trailing fractional
98/// parts (2.0 → "2"). String values are parsed as i64 when possible (so
99/// "2" normalizes to "2" consistently with a YAML integer `2`); otherwise
100/// the string is returned as-is. This matches dbt-core's int-or-string
101/// version semantics and avoids f64 precision loss on large integers.
102fn version_value_to_str(v: &serde_json::Value) -> String {
103    if let Some(n) = v.as_i64() {
104        return n.to_string();
105    }
106    if let Some(n) = v.as_u64() {
107        return n.to_string();
108    }
109    if let Some(f) = v.as_f64() {
110        // Reached only for JSON floats; serde_json stores integers as i64/u64
111        // (handled above), so NaN/Inf cannot arise from valid JSON input.
112        // dbt-core uses f32 for version comparison, so f64 is already more
113        // precise than the reference implementation.
114        return if f.fract() == 0.0 {
115            (f as i64).to_string()
116        } else {
117            f.to_string()
118        };
119    }
120    if let Some(s) = v.as_str() {
121        if let Ok(n) = s.parse::<i64>() {
122            return n.to_string();
123        }
124        return s.to_string();
125    }
126    v.to_string()
127}
128
129/// A single entry in the `versions:` list of a model definition.
130#[derive(Debug, Deserialize, Clone)]
131pub struct VersionSpec {
132    pub v: serde_json::Value,
133    /// SQL file stem override (defaults to `{model_name}_v{v}`)
134    #[serde(default)]
135    pub defined_in: Option<String>,
136}
137
138impl VersionSpec {
139    /// Return the version number formatted as a string (e.g. `"1"`, `"2"`).
140    pub fn v_str(&self) -> String {
141        version_value_to_str(&self.v)
142    }
143
144    /// Return the SQL file stem for this version.
145    /// Falls back to `{model_name}_v{v}` when `defined_in` is not set.
146    pub fn sql_stem(&self, model_name: &str) -> String {
147        self.defined_in
148            .clone()
149            .unwrap_or_else(|| format!("{}_v{}", model_name, self.v_str()))
150    }
151}
152
153#[derive(Debug, Deserialize, Clone)]
154pub struct ModelDefinition {
155    pub name: String,
156    #[serde(default)]
157    pub description: Option<String>,
158    #[serde(default)]
159    pub columns: Vec<ColumnDefinition>,
160    #[serde(default)]
161    pub config: Option<ModelConfig>,
162    #[serde(default)]
163    pub tags: Vec<String>,
164    /// Model-level tests (not attached to a specific column)
165    #[serde(default, alias = "data_tests")]
166    pub tests: Vec<TestDefinition>,
167    /// Versioned model definitions (dbt v1.5+)
168    #[serde(default)]
169    pub versions: Vec<VersionSpec>,
170    /// Latest version used when ref('name') is called without version= kwarg
171    #[serde(default)]
172    pub latest_version: Option<serde_json::Value>,
173}
174
175impl ModelDefinition {
176    /// Return the version string used for `latest_version`, or derive it from
177    /// the `versions` list when `latest_version` is unset.
178    ///
179    /// Inference mirrors dbt-core: if all versions parse as numbers, use the
180    /// largest; otherwise fall back to the lexicographically greatest string.
181    pub fn resolved_latest_version_str(&self) -> Option<String> {
182        if let Some(lv) = &self.latest_version {
183            return Some(version_value_to_str(lv));
184        }
185        if self.versions.is_empty() {
186            return None;
187        }
188        let strs: Vec<String> = self.versions.iter().map(|v| v.v_str()).collect();
189        let numerics: Vec<i64> = strs.iter().filter_map(|s| s.parse().ok()).collect();
190        if numerics.len() == strs.len() {
191            // All versions are integers: use the largest. i64 is intentionally
192            // used here — dbt-core itself compares via f32 (losing precision
193            // above 2^24 ≈ 16.7M), so i64 is already far more robust than the
194            // reference implementation.
195            numerics.into_iter().max().map(|n| n.to_string())
196        } else {
197            strs.into_iter().max()
198        }
199    }
200}
201
202#[derive(Debug, Deserialize, Clone, Default)]
203pub struct ModelConfig {
204    #[serde(default)]
205    pub materialized: Option<String>,
206    #[serde(default)]
207    pub tags: Vec<String>,
208}
209
210/// YAML-only snapshot definition (dbt v1.9+).
211/// When no `.sql` file exists for the snapshot, the graph node is built from this.
212#[derive(Debug, Deserialize, Clone)]
213pub struct SnapshotDefinition {
214    pub name: String,
215    #[serde(default)]
216    pub description: Option<String>,
217    /// Upstream relation, e.g. `ref('model_name')`.
218    #[serde(default)]
219    pub relation: Option<String>,
220}
221
222#[derive(Debug, Deserialize, Clone)]
223pub struct ExposureDefinition {
224    pub name: String,
225    #[serde(default)]
226    pub description: Option<String>,
227    #[serde(default)]
228    pub label: Option<String>,
229    #[serde(rename = "type", default)]
230    pub exposure_type: Option<String>,
231    #[serde(default)]
232    pub url: Option<String>,
233    #[serde(default)]
234    pub maturity: Option<String>,
235    #[serde(default)]
236    pub depends_on: Vec<String>,
237    #[serde(default)]
238    pub owner: Option<ExposureOwner>,
239}
240
241#[derive(Debug, Deserialize, Clone)]
242pub struct ExposureOwner {
243    pub name: Option<String>,
244    pub email: Option<String>,
245}
246
247/// A semantic model definition (dbt Semantic Layer)
248#[derive(Debug, Deserialize, Clone)]
249pub struct SemanticModelDefinition {
250    pub name: String,
251    #[serde(default)]
252    pub description: Option<String>,
253    #[serde(default)]
254    pub label: Option<String>,
255    /// The upstream dbt model as a ref() string, e.g. "ref('orders')"
256    #[serde(default)]
257    pub model: Option<String>,
258    /// Measure names defined by this semantic model (used to resolve metric edges)
259    #[serde(default)]
260    pub measures: Vec<MeasureDefinition>,
261}
262
263#[derive(Debug, Deserialize, Clone)]
264pub struct MeasureDefinition {
265    pub name: String,
266}
267
268/// A metric definition (dbt Semantic Layer)
269#[derive(Debug, Deserialize, Clone)]
270pub struct MetricDefinition {
271    pub name: String,
272    #[serde(default)]
273    pub description: Option<String>,
274    #[serde(default)]
275    pub label: Option<String>,
276    /// Raw type_params blob — used to extract measure/metric references
277    /// without needing to model the full metric type hierarchy.
278    #[serde(default)]
279    pub type_params: Option<serde_json::Value>,
280    /// Conversion metric: the base metric to track (top-level field, not inside type_params).
281    #[serde(default)]
282    pub base_metric: Option<serde_json::Value>,
283    /// Conversion metric: the conversion event metric (top-level field, not inside type_params).
284    #[serde(default)]
285    pub conversion_metric: Option<serde_json::Value>,
286    /// Cumulative metric: the input metric to accumulate (top-level field, not inside type_params).
287    #[serde(default)]
288    pub input_metric: Option<serde_json::Value>,
289}
290
291impl MetricDefinition {
292    fn name_ref(value: &serde_json::Value) -> Option<&str> {
293        value
294            .as_str()
295            .or_else(|| value.get("name").and_then(|n| n.as_str()))
296    }
297
298    /// Extract all measure names this metric references.
299    ///
300    /// Covers all metric types that reference measures directly:
301    /// - Simple: `type_params.measure`
302    /// - Conversion: `type_params.base_measure`, `type_params.conversion_measure`
303    pub fn measure_refs(&self) -> Vec<&str> {
304        let Some(p) = &self.type_params else {
305            return vec![];
306        };
307        let mut refs = vec![];
308        for field in &["measure", "base_measure", "conversion_measure"] {
309            if let Some(v) = p.get(field)
310                && let Some(name) = Self::name_ref(v)
311            {
312                refs.push(name);
313            }
314        }
315        refs
316    }
317
318    /// Extract metric names this metric depends on (Ratio/Derived/Conversion/Cumulative).
319    pub fn metric_refs(&self) -> Vec<&str> {
320        let mut refs = vec![];
321        // Ratio: numerator / denominator are inside type_params
322        if let Some(p) = &self.type_params {
323            for field in &["numerator", "denominator"] {
324                if let Some(v) = p.get(field)
325                    && let Some(name) = Self::name_ref(v)
326                {
327                    refs.push(name);
328                }
329            }
330            // Derived: input_metrics/metrics: [{name: ...}, ...] or strings
331            for field in &["input_metrics", "metrics"] {
332                if let Some(arr) = p.get(field).and_then(|v| v.as_array()) {
333                    for item in arr {
334                        if let Some(name) = Self::name_ref(item) {
335                            refs.push(name);
336                        }
337                    }
338                }
339            }
340        }
341        // Conversion: base_metric / conversion_metric are top-level metric fields
342        for v in [&self.base_metric, &self.conversion_metric]
343            .into_iter()
344            .flatten()
345        {
346            if let Some(name) = Self::name_ref(v) {
347                refs.push(name);
348            }
349        }
350        // Cumulative: input_metric is a top-level metric field
351        if let Some(v) = &self.input_metric
352            && let Some(name) = Self::name_ref(v)
353        {
354            refs.push(name);
355        }
356        refs
357    }
358}
359
360/// A saved query definition (dbt Semantic Layer)
361#[derive(Debug, Deserialize, Clone)]
362pub struct SavedQueryDefinition {
363    pub name: String,
364    #[serde(default)]
365    pub description: Option<String>,
366    #[serde(default)]
367    pub label: Option<String>,
368    #[serde(default)]
369    pub query_params: Option<SavedQueryQueryParams>,
370}
371
372#[derive(Debug, Deserialize, Clone)]
373pub struct SavedQueryQueryParams {
374    #[serde(default)]
375    pub metrics: Vec<String>,
376}
377
378/// Parse a schema YAML file
379pub fn parse_schema_file(
380    content: &str,
381    path: Option<&std::path::Path>,
382) -> anyhow::Result<SchemaFile> {
383    let location = path
384        .map(|p| p.display().to_string())
385        .unwrap_or_else(|| "<input>".to_string());
386    super::yaml_from_str(content, &location)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_parse_sources() {
395        let yaml = r#"
396sources:
397  - name: raw
398    description: Raw data from the warehouse
399    tables:
400      - name: orders
401        description: Raw orders table
402      - name: customers
403"#;
404        let schema = parse_schema_file(yaml, None).unwrap();
405        assert_eq!(schema.sources.len(), 1);
406        assert_eq!(schema.sources[0].name, "raw");
407        assert_eq!(schema.sources[0].tables.len(), 2);
408        assert_eq!(schema.sources[0].tables[0].name, "orders");
409    }
410
411    #[test]
412    fn test_parse_models_with_data_tests() {
413        let yaml = r#"
414models:
415  - name: stg_orders
416    description: Staged orders
417    columns:
418      - name: order_id
419        data_tests:
420          - not_null
421          - unique
422"#;
423        let schema = parse_schema_file(yaml, None).unwrap();
424        assert_eq!(schema.models.len(), 1);
425        assert_eq!(schema.models[0].name, "stg_orders");
426        assert_eq!(schema.models[0].columns.len(), 1);
427        assert_eq!(schema.models[0].columns[0].tests.len(), 2);
428    }
429
430    #[test]
431    fn test_parse_models_with_legacy_tests_key() {
432        let yaml = r#"
433models:
434  - name: stg_orders
435    columns:
436      - name: order_id
437        tests:
438          - not_null
439          - unique
440"#;
441        let schema = parse_schema_file(yaml, None).unwrap();
442        assert_eq!(schema.models[0].columns[0].tests.len(), 2);
443    }
444
445    #[test]
446    fn test_parse_data_tests_all_formats() {
447        let yaml = r#"
448models:
449  - name: orders
450    columns:
451      - name: order_id
452        data_tests:
453          - not_null
454          - unique:
455              config:
456                where: "order_id > 21"
457      - name: status
458        data_tests:
459          - accepted_values:
460              arguments:
461                values:
462                  - placed
463                  - shipped
464                  - completed
465                  - returned
466              config:
467                severity: warn
468      - name: customer_id
469        data_tests:
470          - relationships:
471              arguments:
472                to: ref('customers')
473                field: id
474          - name: custom_test_name
475            test_name: accepted_values
476            arguments:
477              values:
478                - 1
479                - 2
480                - 3
481            config:
482              where: "order_date = current_date"
483"#;
484        let schema = parse_schema_file(yaml, None).unwrap();
485        let model = &schema.models[0];
486        assert_eq!(model.columns.len(), 3);
487
488        // Simple + map with config
489        assert_eq!(model.columns[0].tests.len(), 2);
490        assert!(
491            matches!(model.columns[0].tests[0], TestDefinition::Simple(ref s) if s == "not_null")
492        );
493        assert!(matches!(
494            model.columns[0].tests[1],
495            TestDefinition::Complex(_)
496        ));
497
498        // accepted_values with arguments + config
499        assert_eq!(model.columns[1].tests.len(), 1);
500        assert!(matches!(
501            model.columns[1].tests[0],
502            TestDefinition::Complex(_)
503        ));
504
505        // relationships + alternative name/test_name format
506        assert_eq!(model.columns[2].tests.len(), 2);
507        assert!(matches!(
508            model.columns[2].tests[0],
509            TestDefinition::Complex(_)
510        ));
511        assert!(matches!(
512            model.columns[2].tests[1],
513            TestDefinition::Complex(_)
514        ));
515    }
516
517    #[test]
518    fn test_parse_exposures() {
519        let yaml = r#"
520exposures:
521  - name: weekly_report
522    description: Weekly business report
523    type: dashboard
524    depends_on:
525      - ref('orders')
526      - ref('customers')
527    owner:
528      name: Data Team
529      email: data@example.com
530"#;
531        let schema = parse_schema_file(yaml, None).unwrap();
532        assert_eq!(schema.exposures.len(), 1);
533        assert_eq!(schema.exposures[0].name, "weekly_report");
534        assert_eq!(schema.exposures[0].depends_on.len(), 2);
535    }
536
537    #[test]
538    fn test_parse_duplicate_mapping_keys() {
539        // Duplicate mapping keys (same key at same level) should be tolerated
540        // with last-value-wins, matching PyYAML behavior.
541        let yaml = r#"
542sources:
543  - name: raw
544    tables:
545      - name: orders
546sources:
547  - name: other
548    tables:
549      - name: users
550"#;
551        let schema = parse_schema_file(yaml, None).unwrap();
552        // Last value wins: "other" source replaces "raw"
553        assert_eq!(schema.sources.len(), 1);
554        assert_eq!(schema.sources[0].name, "other");
555    }
556
557    #[test]
558    fn test_empty_file() {
559        let yaml = "";
560        let schema = parse_schema_file(yaml, None).unwrap();
561        assert!(schema.sources.is_empty());
562        assert!(schema.models.is_empty());
563        assert!(schema.snapshots.is_empty());
564        assert!(schema.exposures.is_empty());
565    }
566
567    #[test]
568    fn test_parse_yaml_only_snapshots() {
569        let yaml = r#"
570snapshots:
571  - name: snap_orders
572    description: Orders snapshot
573    relation: ref('stg_orders')
574  - name: snap_customers
575    relation: ref('stg_customers', version=2)
576  - name: snap_no_relation
577    description: Snapshot without upstream relation
578"#;
579        let schema = parse_schema_file(yaml, None).unwrap();
580        assert_eq!(schema.snapshots.len(), 3);
581        assert_eq!(schema.snapshots[0].name, "snap_orders");
582        assert_eq!(
583            schema.snapshots[0].description.as_deref(),
584            Some("Orders snapshot")
585        );
586        assert_eq!(
587            schema.snapshots[0].relation.as_deref(),
588            Some("ref('stg_orders')")
589        );
590        assert_eq!(schema.snapshots[1].name, "snap_customers");
591        assert_eq!(
592            schema.snapshots[1].relation.as_deref(),
593            Some("ref('stg_customers', version=2)")
594        );
595        assert!(schema.snapshots[2].relation.is_none());
596    }
597
598    #[test]
599    fn test_parse_versioned_model() {
600        let yaml = r#"
601models:
602  - name: my_model
603    description: A versioned model
604    latest_version: 2
605    versions:
606      - v: 1
607      - v: 2
608        defined_in: my_model_custom
609"#;
610        let schema = parse_schema_file(yaml, None).unwrap();
611        assert_eq!(schema.models.len(), 1);
612        let m = &schema.models[0];
613        assert_eq!(m.name, "my_model");
614        assert_eq!(m.versions.len(), 2);
615        assert_eq!(m.versions[0].v_str(), "1");
616        assert_eq!(m.versions[0].sql_stem("my_model"), "my_model_v1");
617        assert_eq!(m.versions[1].v_str(), "2");
618        assert_eq!(m.versions[1].sql_stem("my_model"), "my_model_custom");
619        assert_eq!(m.resolved_latest_version_str().as_deref(), Some("2"));
620    }
621
622    #[test]
623    fn test_versioned_model_infers_latest_from_max_v() {
624        let yaml = r#"
625models:
626  - name: orders
627    versions:
628      - v: 1
629      - v: 3
630      - v: 2
631"#;
632        let schema = parse_schema_file(yaml, None).unwrap();
633        let m = &schema.models[0];
634        assert_eq!(m.resolved_latest_version_str().as_deref(), Some("3"));
635    }
636
637    #[test]
638    fn test_versioned_model_infers_latest_from_quoted_v() {
639        let yaml = r#"
640models:
641  - name: orders
642    versions:
643      - v: "1"
644      - v: "3"
645      - v: "2"
646"#;
647        let schema = parse_schema_file(yaml, None).unwrap();
648        let m = &schema.models[0];
649        assert_eq!(m.resolved_latest_version_str().as_deref(), Some("3"));
650    }
651
652    #[test]
653    fn test_v_str_normalizes_quoted_numeric() {
654        // v: "2" (quoted string) must produce the same ID as v: 2 (YAML integer)
655        // so that v_str() and resolved_latest_version_str() stay consistent.
656        let quoted = VersionSpec {
657            v: serde_json::Value::String("2".to_string()),
658            defined_in: None,
659        };
660        assert_eq!(quoted.v_str(), "2");
661
662        // Quoted integer larger than 1 also normalizes correctly
663        let quoted_large = VersionSpec {
664            v: serde_json::Value::String("10".to_string()),
665            defined_in: None,
666        };
667        assert_eq!(quoted_large.v_str(), "10");
668
669        // Non-numeric string is returned as-is
670        let non_numeric = VersionSpec {
671            v: serde_json::Value::String("alpha".to_string()),
672            defined_in: None,
673        };
674        assert_eq!(non_numeric.v_str(), "alpha");
675
676        // Large integer string must not lose precision through f64 conversion
677        // (9007199254740993 = 2^53 + 1, which f64 cannot represent exactly)
678        let large_int = VersionSpec {
679            v: serde_json::Value::String("9007199254740993".to_string()),
680            defined_in: None,
681        };
682        assert_eq!(large_int.v_str(), "9007199254740993");
683
684        // JSON Number stored as u64 (> i64::MAX) must not lose precision via f64
685        let u64_num = VersionSpec {
686            v: serde_json::Value::Number(serde_json::Number::from(i64::MAX as u64 + 1)),
687            defined_in: None,
688        };
689        assert_eq!(u64_num.v_str(), (i64::MAX as u64 + 1).to_string());
690    }
691
692    #[test]
693    fn test_unversioned_model_has_empty_versions() {
694        let yaml = r#"
695models:
696  - name: plain_model
697    description: Not versioned
698"#;
699        let schema = parse_schema_file(yaml, None).unwrap();
700        let m = &schema.models[0];
701        assert!(m.versions.is_empty());
702        assert!(m.latest_version.is_none());
703        assert!(m.resolved_latest_version_str().is_none());
704    }
705
706    #[test]
707    fn test_test_name_extraction() {
708        // Simple string test
709        let simple = TestDefinition::Simple("not_null".to_string());
710        assert_eq!(simple.test_name(), Some("not_null"));
711
712        // Complex single-key map: {"unique": {"config": ...}}
713        let complex_single = TestDefinition::Complex(serde_json::json!({
714            "unique": {"config": {"where": "id > 0"}}
715        }));
716        assert_eq!(complex_single.test_name(), Some("unique"));
717
718        // Complex with test_name field: {"name": "custom", "test_name": "accepted_values", ...}
719        let complex_named = TestDefinition::Complex(serde_json::json!({
720            "name": "custom_test_name",
721            "test_name": "accepted_values",
722            "arguments": {"values": [1, 2]}
723        }));
724        assert_eq!(complex_named.test_name(), Some("accepted_values"));
725
726        // Complex relationships test
727        let relationships = TestDefinition::Complex(serde_json::json!({
728            "relationships": {"arguments": {"to": "ref('customers')", "field": "id"}}
729        }));
730        assert_eq!(relationships.test_name(), Some("relationships"));
731
732        // Edge case: {"name": "something"} without test_name should return None
733        let name_only = TestDefinition::Complex(serde_json::json!({"name": "something"}));
734        assert_eq!(name_only.test_name(), None);
735    }
736
737    #[test]
738    fn test_parse_semantic_models() {
739        let yaml = r#"
740semantic_models:
741  - name: orders
742    description: Order semantic model
743    model: ref('orders')
744    measures:
745      - name: order_total
746      - name: order_count
747    dimensions:
748      - name: ordered_at
749        type: time
750"#;
751        let schema = parse_schema_file(yaml, None).unwrap();
752        assert_eq!(schema.semantic_models.len(), 1);
753        let sm = &schema.semantic_models[0];
754        assert_eq!(sm.name, "orders");
755        assert_eq!(sm.description.as_deref(), Some("Order semantic model"));
756        assert_eq!(sm.model.as_deref(), Some("ref('orders')"));
757        assert_eq!(sm.measures.len(), 2);
758        assert_eq!(sm.measures[0].name, "order_total");
759        assert_eq!(sm.measures[1].name, "order_count");
760    }
761
762    #[test]
763    fn test_parse_metrics_simple() {
764        let yaml = r#"
765metrics:
766  - name: order_total
767    label: Order Total
768    description: Sum of orders
769    type: simple
770    type_params:
771      measure: order_total
772"#;
773        let schema = parse_schema_file(yaml, None).unwrap();
774        assert_eq!(schema.metrics.len(), 1);
775        let m = &schema.metrics[0];
776        assert_eq!(m.name, "order_total");
777        assert_eq!(m.label.as_deref(), Some("Order Total"));
778        assert_eq!(m.measure_refs(), vec!["order_total"]);
779        assert!(m.metric_refs().is_empty());
780    }
781
782    #[test]
783    fn test_parse_metrics_simple_with_object_measure() {
784        let yaml = r#"
785metrics:
786  - name: order_total
787    type: simple
788    type_params:
789      measure:
790        name: order_total
791        fill_nulls_with: 0
792"#;
793        let schema = parse_schema_file(yaml, None).unwrap();
794        let m = &schema.metrics[0];
795        assert_eq!(m.measure_refs(), vec!["order_total"]);
796        assert!(m.metric_refs().is_empty());
797    }
798
799    #[test]
800    fn test_parse_metrics_ratio() {
801        let yaml = r#"
802metrics:
803  - name: revenue_per_order
804    type: ratio
805    type_params:
806      numerator: revenue
807      denominator: orders
808"#;
809        let schema = parse_schema_file(yaml, None).unwrap();
810        let m = &schema.metrics[0];
811        assert!(m.measure_refs().is_empty());
812        let refs = m.metric_refs();
813        assert!(refs.contains(&"revenue"));
814        assert!(refs.contains(&"orders"));
815    }
816
817    #[test]
818    fn test_parse_metrics_derived_with_input_metrics_and_metrics() {
819        let yaml = r#"
820metrics:
821  - name: pct_change
822    type: derived
823    type_params:
824      input_metrics:
825        - name: revenue
826        - orders
827      metrics:
828        - name: margin
829        - customer_count
830"#;
831        let schema = parse_schema_file(yaml, None).unwrap();
832        let m = &schema.metrics[0];
833        assert!(m.measure_refs().is_empty());
834        let refs = m.metric_refs();
835        assert!(refs.contains(&"revenue"));
836        assert!(refs.contains(&"orders"));
837        assert!(refs.contains(&"margin"));
838        assert!(refs.contains(&"customer_count"));
839    }
840
841    #[test]
842    fn test_parse_metrics_conversion_measure() {
843        let yaml = r#"
844metrics:
845  - name: visitors_who_bought
846    type: conversion
847    type_params:
848      base_measure:
849        name: visitors
850      conversion_measure:
851        name: buyers
852      entity: user
853"#;
854        let schema = parse_schema_file(yaml, None).unwrap();
855        let m = &schema.metrics[0];
856        let refs = m.measure_refs();
857        assert!(
858            refs.contains(&"visitors"),
859            "base_measure should be included"
860        );
861        assert!(
862            refs.contains(&"buyers"),
863            "conversion_measure should be included"
864        );
865        assert!(m.metric_refs().is_empty());
866    }
867
868    #[test]
869    fn test_parse_metrics_conversion_metric() {
870        let yaml = r#"
871metrics:
872  - name: visit_to_purchase
873    type: conversion
874    base_metric: visits
875    conversion_metric:
876      name: purchases
877      filter: "{{ Dimension('user__country') }} = 'US'"
878    entity: user
879    window: 7 days
880"#;
881        let schema = parse_schema_file(yaml, None).unwrap();
882        let m = &schema.metrics[0];
883        assert!(m.measure_refs().is_empty());
884        let refs = m.metric_refs();
885        assert!(
886            refs.contains(&"visits"),
887            "base_metric (string) should be in metric_refs"
888        );
889        assert!(
890            refs.contains(&"purchases"),
891            "conversion_metric (object) should be in metric_refs"
892        );
893    }
894
895    #[test]
896    fn test_parse_metrics_cumulative_input_metric() {
897        let yaml = r#"
898metrics:
899  - name: cumulative_revenue
900    type: cumulative
901    input_metric: revenue
902    window: 1 month
903"#;
904        let schema = parse_schema_file(yaml, None).unwrap();
905        let m = &schema.metrics[0];
906        assert!(m.measure_refs().is_empty());
907        let refs = m.metric_refs();
908        assert!(
909            refs.contains(&"revenue"),
910            "input_metric should be in metric_refs"
911        );
912    }
913
914    #[test]
915    fn test_parse_saved_queries() {
916        let yaml = r#"
917saved_queries:
918  - name: order_metrics
919    description: Key order metrics
920    query_params:
921      metrics:
922        - orders
923        - order_total
924        - food_orders
925"#;
926        let schema = parse_schema_file(yaml, None).unwrap();
927        assert_eq!(schema.saved_queries.len(), 1);
928        let sq = &schema.saved_queries[0];
929        assert_eq!(sq.name, "order_metrics");
930        assert_eq!(sq.description.as_deref(), Some("Key order metrics"));
931        let metrics = sq.query_params.as_ref().unwrap().metrics.as_slice();
932        assert_eq!(metrics, &["orders", "order_total", "food_orders"]);
933    }
934
935    #[test]
936    fn test_parse_full_semantic_layer_yaml() {
937        // Simulates a real jaffle-shop style YAML with all three semantic layer blocks
938        let yaml = r#"
939models:
940  - name: orders
941    description: Orders table
942
943semantic_models:
944  - name: orders
945    model: ref('orders')
946    measures:
947      - name: order_count
948      - name: order_total
949
950metrics:
951  - name: orders
952    type: simple
953    type_params:
954      measure: order_count
955  - name: order_total
956    type: simple
957    type_params:
958      measure: order_total
959
960saved_queries:
961  - name: order_kpis
962    query_params:
963      metrics:
964        - orders
965        - order_total
966"#;
967        let schema = parse_schema_file(yaml, None).unwrap();
968        assert_eq!(schema.models.len(), 1);
969        assert_eq!(schema.semantic_models.len(), 1);
970        assert_eq!(schema.metrics.len(), 2);
971        assert_eq!(schema.saved_queries.len(), 1);
972        assert_eq!(
973            schema.saved_queries[0]
974                .query_params
975                .as_ref()
976                .unwrap()
977                .metrics
978                .len(),
979            2
980        );
981    }
982}