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}