Skip to main content

geonative_core/
schema.rs

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