Skip to main content

oxigdal_gpkg/
multi_geom.rs

1//! Multiple-geometry-column support for GeoPackage feature tables.
2//!
3//! The OGC GeoPackage specification allows a feature table to expose more than
4//! one geometry column, each registered as a separate row in
5//! `gpkg_geometry_columns`.  This module provides types and functions to
6//! discover, inspect, and (via the writer extension) register those additional
7//! columns without touching the existing single-column fast path.
8
9use crate::error::GpkgError;
10use crate::gpkg::GeoPackage;
11
12// ─────────────────────────────────────────────────────────────────────────────
13// GeometryColumnDef
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Describes a single geometry column registered in `gpkg_geometry_columns`.
17///
18/// This mirrors one row of that system table, with the z/m flags decoded into
19/// `bool` fields for ergonomic use.
20#[derive(Debug, Clone, PartialEq)]
21pub struct GeometryColumnDef {
22    /// Name of the feature table that owns this geometry column.
23    pub table_name: String,
24    /// Name of the column inside that feature table (e.g. `"geom"`).
25    pub column_name: String,
26    /// OGC geometry type name stored in `gpkg_geometry_columns`
27    /// (e.g. `"POINT"`, `"MULTIPOLYGON"`).
28    pub geometry_type_name: String,
29    /// Spatial reference system identifier.
30    pub srs_id: i32,
31    /// `true` when the z coordinate rule is 1 (mandatory) or 2 (optional).
32    pub has_z: bool,
33    /// `true` when the m coordinate rule is 1 (mandatory) or 2 (optional).
34    pub has_m: bool,
35}
36
37impl GeometryColumnDef {
38    /// Construct a new [`GeometryColumnDef`] from raw field values.
39    ///
40    /// `z_flag` and `m_flag` are the raw integer values from the
41    /// `gpkg_geometry_columns` table: 0 = prohibited, 1 = mandatory, 2 = optional.
42    pub fn from_raw(
43        table_name: impl Into<String>,
44        column_name: impl Into<String>,
45        geometry_type_name: impl Into<String>,
46        srs_id: i32,
47        z_flag: u8,
48        m_flag: u8,
49    ) -> Self {
50        Self {
51            table_name: table_name.into(),
52            column_name: column_name.into(),
53            geometry_type_name: geometry_type_name.into(),
54            srs_id,
55            has_z: z_flag > 0,
56            has_m: m_flag > 0,
57        }
58    }
59
60    /// Return the raw z flag value suitable for writing to `gpkg_geometry_columns`.
61    ///
62    /// Maps `has_z = true` → 2 (optional) and `has_z = false` → 0 (prohibited).
63    pub fn z_flag(&self) -> u8 {
64        if self.has_z { 2 } else { 0 }
65    }
66
67    /// Return the raw m flag value suitable for writing to `gpkg_geometry_columns`.
68    ///
69    /// Maps `has_m = true` → 2 (optional) and `has_m = false` → 0 (prohibited).
70    pub fn m_flag(&self) -> u8 {
71        if self.has_m { 2 } else { 0 }
72    }
73}
74
75// ─────────────────────────────────────────────────────────────────────────────
76// MultiGeomColumnSet
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// All geometry columns registered for a single feature table.
80///
81/// The first element of `columns` is treated as the *primary* geometry column
82/// (the one used by readers that only support a single geometry column per
83/// table).  Additional elements are secondary columns.
84#[derive(Debug, Clone)]
85pub struct MultiGeomColumnSet {
86    /// Name of the feature table (matches `gpkg_contents.table_name`).
87    pub table_name: String,
88    /// Ordered list of geometry columns; the first is the primary column.
89    pub columns: Vec<GeometryColumnDef>,
90}
91
92impl MultiGeomColumnSet {
93    /// Create a new, empty column set for `table_name`.
94    pub fn new(table_name: impl Into<String>) -> Self {
95        Self {
96            table_name: table_name.into(),
97            columns: Vec::new(),
98        }
99    }
100
101    /// Return a reference to the primary (first) geometry column, or `None`
102    /// when no columns have been registered yet.
103    pub fn primary(&self) -> Option<&GeometryColumnDef> {
104        self.columns.first()
105    }
106
107    /// Search for a geometry column by its column name.
108    ///
109    /// Returns `None` when no column with that name exists in this set.
110    pub fn find_by_name(&self, column_name: &str) -> Option<&GeometryColumnDef> {
111        self.columns.iter().find(|c| c.column_name == column_name)
112    }
113
114    /// Return the number of registered geometry columns.
115    pub fn column_count(&self) -> usize {
116        self.columns.len()
117    }
118
119    /// Return `true` when this table has more than one geometry column.
120    pub fn has_multiple(&self) -> bool {
121        self.columns.len() > 1
122    }
123}
124
125// ─────────────────────────────────────────────────────────────────────────────
126// Public API
127// ─────────────────────────────────────────────────────────────────────────────
128
129/// Load all geometry columns registered for `table_name` in this GeoPackage.
130///
131/// Scans `gpkg_geometry_columns` and returns a [`MultiGeomColumnSet`]
132/// containing every row whose `table_name` column matches the requested name.
133///
134/// Returns `Ok(None)` when no matching rows are found (the table either does
135/// not exist in `gpkg_contents` or has no geometry column registered).
136///
137/// # Errors
138/// Propagates any SQLite B-tree scan errors.
139pub fn load_geometry_columns_for_table(
140    reader: &GeoPackage,
141    table_name: &str,
142) -> Result<Option<MultiGeomColumnSet>, GpkgError> {
143    let all_rows = match reader.scan_table_by_name("gpkg_geometry_columns")? {
144        Some(r) => r,
145        None => return Ok(None),
146    };
147
148    let mut column_set = MultiGeomColumnSet::new(table_name);
149
150    for (_rowid, values) in &all_rows {
151        if values.len() < 6 {
152            continue; // malformed row — skip
153        }
154
155        let row_table_name = cell_to_string(&values[0]);
156        if row_table_name != table_name {
157            continue;
158        }
159
160        let def = decode_geometry_column_row(values);
161        column_set.columns.push(def);
162    }
163
164    if column_set.columns.is_empty() {
165        Ok(None)
166    } else {
167        Ok(Some(column_set))
168    }
169}
170
171/// Load geometry columns for all feature tables present in the GeoPackage.
172///
173/// Returns one [`MultiGeomColumnSet`] per distinct `table_name` value found
174/// in `gpkg_geometry_columns`.  Tables with no registered geometry column are
175/// omitted.  The order of the returned vec follows the B-tree row order of
176/// `gpkg_geometry_columns` (rowid ascending).
177///
178/// # Errors
179/// Propagates any SQLite B-tree scan errors.
180pub fn load_all_geometry_columns(
181    reader: &GeoPackage,
182) -> Result<Vec<MultiGeomColumnSet>, GpkgError> {
183    let all_rows = match reader.scan_table_by_name("gpkg_geometry_columns")? {
184        Some(r) => r,
185        None => return Ok(Vec::new()),
186    };
187
188    // Preserve insertion order while deduplicating by table name.
189    let mut table_order: Vec<String> = Vec::new();
190    let mut table_map: std::collections::HashMap<String, MultiGeomColumnSet> =
191        std::collections::HashMap::new();
192
193    for (_rowid, values) in &all_rows {
194        if values.len() < 6 {
195            continue;
196        }
197
198        let row_table_name = cell_to_string(&values[0]);
199        let def = decode_geometry_column_row(values);
200
201        if let Some(set) = table_map.get_mut(&row_table_name) {
202            set.columns.push(def);
203        } else {
204            table_order.push(row_table_name.clone());
205            let mut set = MultiGeomColumnSet::new(&row_table_name);
206            set.columns.push(def);
207            table_map.insert(row_table_name, set);
208        }
209    }
210
211    Ok(table_order
212        .into_iter()
213        .filter_map(|name| table_map.remove(&name))
214        .collect())
215}
216
217/// Return `true` when the named feature table has more than one geometry column
218/// registered in `gpkg_geometry_columns`.
219///
220/// Returns `false` for tables with exactly one geometry column or for tables
221/// that are not found in `gpkg_geometry_columns`.
222///
223/// # Errors
224/// Propagates any SQLite B-tree scan errors.
225pub fn has_multiple_geometry_columns(
226    reader: &GeoPackage,
227    table_name: &str,
228) -> Result<bool, GpkgError> {
229    match load_geometry_columns_for_table(reader, table_name)? {
230        Some(set) => Ok(set.has_multiple()),
231        None => Ok(false),
232    }
233}
234
235// ─────────────────────────────────────────────────────────────────────────────
236// Private helpers
237// ─────────────────────────────────────────────────────────────────────────────
238
239/// Decode one row from `gpkg_geometry_columns` into a [`GeometryColumnDef`].
240///
241/// Expected column layout (0-based):
242///
243/// | # | column               | type    |
244/// |---|----------------------|---------|
245/// | 0 | `table_name`         | TEXT    |
246/// | 1 | `column_name`        | TEXT    |
247/// | 2 | `geometry_type_name` | TEXT    |
248/// | 3 | `srs_id`             | INTEGER |
249/// | 4 | `z`                  | INTEGER |
250/// | 5 | `m`                  | INTEGER |
251///
252/// Caller must ensure `values.len() >= 6` before calling this function.
253fn decode_geometry_column_row(values: &[crate::btree::CellValue]) -> GeometryColumnDef {
254    let table_name = cell_to_string(&values[0]);
255    let column_name = cell_to_string(&values[1]);
256    let geometry_type_name = cell_to_string(&values[2]);
257    let srs_id = cell_to_i32(&values[3]);
258    let z_flag = cell_to_u8(&values[4]);
259    let m_flag = cell_to_u8(&values[5]);
260
261    GeometryColumnDef::from_raw(
262        table_name,
263        column_name,
264        geometry_type_name,
265        srs_id,
266        z_flag,
267        m_flag,
268    )
269}
270
271// ── Cell-value coercion helpers (local copies to avoid exposing pub(crate)) ──
272
273fn cell_to_string(v: &crate::btree::CellValue) -> String {
274    use crate::btree::CellValue;
275    match v {
276        CellValue::Text(s) => s.clone(),
277        CellValue::Integer(i) => i.to_string(),
278        CellValue::Float(f) => f.to_string(),
279        CellValue::Blob(b) => String::from_utf8_lossy(b).into_owned(),
280        CellValue::Null => String::new(),
281    }
282}
283
284fn cell_to_i32(v: &crate::btree::CellValue) -> i32 {
285    use crate::btree::CellValue;
286    match v {
287        CellValue::Integer(i) => {
288            if *i > i32::MAX as i64 {
289                i32::MAX
290            } else if *i < i32::MIN as i64 {
291                i32::MIN
292            } else {
293                *i as i32
294            }
295        }
296        CellValue::Float(f) => *f as i32,
297        _ => 0,
298    }
299}
300
301fn cell_to_u8(v: &crate::btree::CellValue) -> u8 {
302    use crate::btree::CellValue;
303    match v {
304        CellValue::Integer(i) => {
305            if *i < 0 {
306                0
307            } else if *i > u8::MAX as i64 {
308                u8::MAX
309            } else {
310                *i as u8
311            }
312        }
313        CellValue::Float(f) => {
314            let i = *f as i64;
315            if i < 0 {
316                0
317            } else if i > u8::MAX as i64 {
318                u8::MAX
319            } else {
320                i as u8
321            }
322        }
323        _ => 0,
324    }
325}