Skip to main content

nodedb_array/schema/validation/
dims.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Dimension-list validation.
4//!
5//! Rules:
6//! - At least one dim.
7//! - Dim names are unique within the schema.
8//! - Each dim's domain bound variant matches its declared `DimType`.
9//! - For ordered numeric dims, `lo <= hi` (lexicographic for strings).
10
11use std::collections::HashSet;
12
13use crate::error::{ArrayError, ArrayResult};
14use crate::schema::dim_spec::{DimSpec, DimType};
15use crate::types::domain::DomainBound;
16
17pub fn check(array: &str, dims: &[DimSpec]) -> ArrayResult<()> {
18    if dims.is_empty() {
19        return Err(ArrayError::InvalidSchema {
20            array: array.to_string(),
21            detail: "at least one dimension is required".to_string(),
22        });
23    }
24    let mut seen = HashSet::with_capacity(dims.len());
25    for d in dims {
26        if !seen.insert(d.name.as_str()) {
27            return Err(ArrayError::InvalidDim {
28                array: array.to_string(),
29                dim: d.name.clone(),
30                detail: "duplicate dimension name".to_string(),
31            });
32        }
33        check_bounds_match_type(array, d)?;
34        check_bound_order(array, d)?;
35    }
36    Ok(())
37}
38
39fn check_bounds_match_type(array: &str, d: &DimSpec) -> ArrayResult<()> {
40    let ok = matches!(
41        (&d.dtype, &d.domain.lo, &d.domain.hi),
42        (DimType::Int64, DomainBound::Int64(_), DomainBound::Int64(_))
43            | (
44                DimType::Float64,
45                DomainBound::Float64(_),
46                DomainBound::Float64(_),
47            )
48            | (
49                DimType::TimestampMs,
50                DomainBound::TimestampMs(_),
51                DomainBound::TimestampMs(_),
52            )
53            | (
54                DimType::String,
55                DomainBound::String(_),
56                DomainBound::String(_),
57            )
58    );
59    if !ok {
60        return Err(ArrayError::InvalidDim {
61            array: array.to_string(),
62            dim: d.name.clone(),
63            detail: "domain bound variant does not match declared dtype".to_string(),
64        });
65    }
66    Ok(())
67}
68
69fn check_bound_order(array: &str, d: &DimSpec) -> ArrayResult<()> {
70    let ordered = match (&d.domain.lo, &d.domain.hi) {
71        (DomainBound::Int64(lo), DomainBound::Int64(hi)) => lo <= hi,
72        (DomainBound::Float64(lo), DomainBound::Float64(hi)) => {
73            !lo.is_nan() && !hi.is_nan() && lo <= hi
74        }
75        (DomainBound::TimestampMs(lo), DomainBound::TimestampMs(hi)) => lo <= hi,
76        (DomainBound::String(lo), DomainBound::String(hi)) => lo <= hi,
77        _ => true,
78    };
79    if !ordered {
80        return Err(ArrayError::InvalidDim {
81            array: array.to_string(),
82            dim: d.name.clone(),
83            detail: "domain lo > hi (or non-finite float bound)".to_string(),
84        });
85    }
86    Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::types::domain::Domain;
93
94    fn int_dim(name: &str, lo: i64, hi: i64) -> DimSpec {
95        DimSpec::new(
96            name,
97            DimType::Int64,
98            Domain::new(DomainBound::Int64(lo), DomainBound::Int64(hi)),
99        )
100    }
101
102    #[test]
103    fn rejects_empty_dim_list() {
104        assert!(check("a", &[]).is_err());
105    }
106
107    #[test]
108    fn rejects_duplicate_dim_names() {
109        let dims = vec![int_dim("x", 0, 10), int_dim("x", 0, 10)];
110        assert!(check("a", &dims).is_err());
111    }
112
113    #[test]
114    fn rejects_lo_greater_than_hi() {
115        let dims = vec![int_dim("x", 10, 0)];
116        assert!(check("a", &dims).is_err());
117    }
118
119    #[test]
120    fn rejects_bound_type_mismatch() {
121        let d = DimSpec::new(
122            "x",
123            DimType::Int64,
124            Domain::new(DomainBound::Float64(0.0), DomainBound::Float64(10.0)),
125        );
126        assert!(check("a", &[d]).is_err());
127    }
128
129    #[test]
130    fn rejects_nan_float_bound() {
131        let d = DimSpec::new(
132            "x",
133            DimType::Float64,
134            Domain::new(DomainBound::Float64(f64::NAN), DomainBound::Float64(1.0)),
135        );
136        assert!(check("a", &[d]).is_err());
137    }
138
139    #[test]
140    fn accepts_well_formed_dims() {
141        let dims = vec![int_dim("chrom", 0, 24), int_dim("pos", 0, 300_000_000)];
142        assert!(check("a", &dims).is_ok());
143    }
144}