Skip to main content

geonative_core/
schema.rs

1//! Layer schema description. Format readers populate it; format writers
2//! consult it. Fields are an **ordered vector** with name lookup as a
3//! secondary index — DBF, GDB, and Arrow all require defined column order.
4
5use std::collections::HashMap;
6
7use crate::{crs::Crs, geometry::GeometryType, value::ValueType, Error, Result};
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct FieldDef {
11    pub name: String,
12    pub alias: Option<String>,
13    pub ty: ValueType,
14    pub nullable: bool,
15    /// Driver-defined width hint (string max-length, binary cap, …).
16    pub width: Option<u32>,
17}
18
19impl FieldDef {
20    pub fn new(name: impl Into<String>, ty: ValueType, nullable: bool) -> Self {
21        Self {
22            name: name.into(),
23            alias: None,
24            ty,
25            nullable,
26            width: None,
27        }
28    }
29
30    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
31        self.alias = Some(alias.into());
32        self
33    }
34
35    pub fn with_width(mut self, width: u32) -> Self {
36        self.width = Some(width);
37        self
38    }
39}
40
41/// Per-layer geometry column metadata.
42#[derive(Debug, Clone, PartialEq)]
43pub struct GeomField {
44    pub name: String,
45    pub kind: GeometryType,
46    pub has_z: bool,
47    pub has_m: bool,
48    /// xmin, ymin, zmin, xmax, ymax, zmax — `None` if not declared by the source.
49    pub extent: Option<[f64; 6]>,
50}
51
52impl GeomField {
53    pub fn new(name: impl Into<String>, kind: GeometryType) -> Self {
54        Self {
55            name: name.into(),
56            kind,
57            has_z: false,
58            has_m: false,
59            extent: None,
60        }
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct Schema {
66    pub fields: Vec<FieldDef>,
67    pub geometry: Option<GeomField>,
68    pub crs: Crs,
69    name_index: HashMap<String, usize>,
70}
71
72impl Schema {
73    pub fn new(fields: Vec<FieldDef>, geometry: Option<GeomField>, crs: Crs) -> Self {
74        let name_index = fields
75            .iter()
76            .enumerate()
77            .map(|(i, f)| (f.name.clone(), i))
78            .collect();
79        Self {
80            fields,
81            geometry,
82            crs,
83            name_index,
84        }
85    }
86
87    pub fn field(&self, idx: usize) -> Option<&FieldDef> {
88        self.fields.get(idx)
89    }
90
91    pub fn field_index(&self, name: &str) -> Option<usize> {
92        self.name_index.get(name).copied()
93    }
94
95    pub fn field_by_name(&self, name: &str) -> Option<&FieldDef> {
96        self.field_index(name).and_then(|i| self.fields.get(i))
97    }
98
99    pub fn len(&self) -> usize {
100        self.fields.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.fields.is_empty()
105    }
106
107    /// Validate that an attribute row has the right arity and each value's
108    /// type tag matches the schema (nulls pass for nullable fields).
109    pub fn validate_row(&self, values: &[crate::Value]) -> Result<()> {
110        if values.len() != self.fields.len() {
111            return Err(Error::schema(format!(
112                "expected {} values, got {}",
113                self.fields.len(),
114                values.len()
115            )));
116        }
117        for (i, (v, f)) in values.iter().zip(&self.fields).enumerate() {
118            if v.is_null() {
119                if !f.nullable {
120                    return Err(Error::schema(format!(
121                        "field {} ({}) is not nullable but value is null",
122                        i, f.name
123                    )));
124                }
125                continue;
126            }
127            match v.ty() {
128                Some(t) if t == f.ty => {}
129                Some(t) => {
130                    return Err(Error::schema(format!(
131                        "field {} ({}) expected {:?}, got {:?}",
132                        i, f.name, f.ty, t
133                    )))
134                }
135                None => unreachable!("non-null value must have a type"),
136            }
137        }
138        Ok(())
139    }
140}
141
142impl PartialEq for Schema {
143    fn eq(&self, other: &Self) -> bool {
144        self.fields == other.fields && self.geometry == other.geometry && self.crs == other.crs
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::Value;
152
153    fn sample() -> Schema {
154        Schema::new(
155            vec![
156                FieldDef::new("id", ValueType::Int64, false),
157                FieldDef::new("name", ValueType::String, true),
158            ],
159            Some(GeomField::new("geom", GeometryType::Point)),
160            Crs::Epsg(4326),
161        )
162    }
163
164    #[test]
165    fn name_index_lookup() {
166        let s = sample();
167        assert_eq!(s.field_index("id"), Some(0));
168        assert_eq!(s.field_index("name"), Some(1));
169        assert_eq!(s.field_index("missing"), None);
170        assert_eq!(s.field_by_name("id").unwrap().ty, ValueType::Int64);
171    }
172
173    #[test]
174    fn validate_row_happy_path() {
175        let s = sample();
176        s.validate_row(&[Value::Int64(1), Value::String("a".into())])
177            .unwrap();
178        s.validate_row(&[Value::Int64(1), Value::Null]).unwrap();
179    }
180
181    #[test]
182    fn validate_row_rejects_null_in_non_nullable() {
183        let s = sample();
184        assert!(s
185            .validate_row(&[Value::Null, Value::String("a".into())])
186            .is_err());
187    }
188
189    #[test]
190    fn validate_row_rejects_arity_mismatch() {
191        let s = sample();
192        assert!(s.validate_row(&[Value::Int64(1)]).is_err());
193    }
194
195    #[test]
196    fn validate_row_rejects_type_mismatch() {
197        let s = sample();
198        assert!(s
199            .validate_row(&[Value::String("nope".into()), Value::Null])
200            .is_err());
201    }
202}