Skip to main content

navcore_enc_store/
store.rs

1//! Opening a chart GeoPackage and pulling features out of it.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use geo::{Geometry, Rect};
8use geozero::ToGeo;
9use geozero::wkb::GpkgWkb;
10use rusqlite::{Connection, OpenFlags, types::ValueRef};
11
12/// Columns that are not chart attributes and are handled on their own.
13const FID_COLUMN: &str = "fid";
14
15/// Anything that can go wrong reading a chart.
16#[derive(Debug)]
17pub enum StoreError {
18    /// The database could not be opened or a query failed.
19    Database(rusqlite::Error),
20    /// The file opened, but it is not a GeoPackage.
21    NotAGeoPackage,
22    /// A geometry blob could not be read.
23    Geometry(String),
24    /// The chart carries no table for that object class.
25    UnknownClass(String),
26    /// A table or column name that cannot be quoted safely into SQL. Never
27    /// expected from a file written by the pipeline; refused rather than
28    /// interpolated.
29    UnsafeName(String),
30}
31
32impl fmt::Display for StoreError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Database(error) => write!(f, "chart database: {error}"),
36            Self::NotAGeoPackage => write!(f, "not a GeoPackage: no gpkg_contents table"),
37            Self::Geometry(detail) => write!(f, "unreadable geometry: {detail}"),
38            Self::UnknownClass(class) => write!(f, "chart has no {class} table"),
39            Self::UnsafeName(name) => write!(f, "refusing to query a table named {name:?}"),
40        }
41    }
42}
43
44impl std::error::Error for StoreError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::Database(error) => Some(error),
48            _ => None,
49        }
50    }
51}
52
53impl From<rusqlite::Error> for StoreError {
54    fn from(error: rusqlite::Error) -> Self {
55        Self::Database(error)
56    }
57}
58
59/// One value of one S-57 attribute.
60///
61/// Kept as read rather than coerced on the way in: the source charts carry
62/// depths as numbers in some cells and as text in others, and `-H` appears
63/// in a depth field as a drying-height marker. Deciding what a value means
64/// is the caller's job, and it needs to see what was actually there.
65#[derive(Debug, Clone, PartialEq)]
66pub enum Attribute {
67    /// A floating point value.
68    Real(f64),
69    /// An integer value.
70    Integer(i64),
71    /// Text, including numbers that were stored as text.
72    Text(String),
73}
74
75impl Attribute {
76    /// The value as a number, if it is one.
77    ///
78    /// Text is parsed, so a depth stored as `"3.5"` reads the same as one
79    /// stored as `3.5`. A non-numeric marker such as `-H` gives `None`,
80    /// which callers must treat as "unknown", never as zero.
81    #[must_use]
82    pub fn as_f64(&self) -> Option<f64> {
83        match self {
84            Self::Real(value) => Some(*value),
85            #[allow(clippy::cast_precision_loss)]
86            Self::Integer(value) => Some(*value as f64),
87            Self::Text(text) => text.trim().parse().ok(),
88        }
89    }
90}
91
92impl fmt::Display for Attribute {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Self::Real(value) => write!(f, "{value}"),
96            Self::Integer(value) => write!(f, "{value}"),
97            Self::Text(text) => write!(f, "{text}"),
98        }
99    }
100}
101
102/// One charted object.
103#[derive(Debug, Clone)]
104pub struct Feature {
105    /// S-57 object class acronym, upper case: `DEPARE`, `LNDARE`.
106    pub class: String,
107    /// Its identity within its table, for pointing back at it.
108    pub fid: i64,
109    /// Its geometry, in longitude and latitude.
110    pub geometry: Geometry<f64>,
111    attributes: HashMap<String, Attribute>,
112}
113
114impl Feature {
115    /// One attribute by its S-57 acronym, upper case.
116    #[must_use]
117    pub fn attribute(&self, acronym: &str) -> Option<&Attribute> {
118        self.attributes.get(acronym)
119    }
120
121    /// One attribute read as a number, if it is present and numeric.
122    #[must_use]
123    pub fn number(&self, acronym: &str) -> Option<f64> {
124        self.attribute(acronym).and_then(Attribute::as_f64)
125    }
126
127    /// A feature assembled by hand, for testing the rules without a chart
128    /// behind them.
129    #[cfg(test)]
130    pub(crate) fn for_test(
131        class: &str,
132        geometry: Geometry<f64>,
133        attributes: &[(&str, Attribute)],
134    ) -> Self {
135        Self {
136            class: class.to_owned(),
137            fid: 1,
138            geometry,
139            attributes: attributes
140                .iter()
141                .map(|(name, value)| ((*name).to_owned(), value.clone()))
142                .collect(),
143        }
144    }
145
146    /// The object's name, if it has one.
147    #[must_use]
148    pub fn name(&self) -> Option<&str> {
149        match self.attribute("OBJNAM") {
150            Some(Attribute::Text(name)) if !name.is_empty() => Some(name),
151            _ => None,
152        }
153    }
154}
155
156/// What the chart holds for one object class.
157#[derive(Debug, Clone)]
158struct TableInfo {
159    /// Table name as written in the file, which the pipeline lower-cases.
160    name: String,
161    /// The geometry column, named by the GeoPackage itself.
162    geometry_column: String,
163    /// The R-Tree index over that column, when the file carries one.
164    index: Option<String>,
165}
166
167/// A chart, open for reading.
168///
169/// Opened read-only and kept that way. This crate performs no writes;
170/// routes and tracks are stored in a separate writable database.
171#[derive(Debug)]
172pub struct ChartStore {
173    connection: Connection,
174    tables: HashMap<String, TableInfo>,
175    path: PathBuf,
176}
177
178impl ChartStore {
179    /// Opens a chart GeoPackage.
180    ///
181    /// # Errors
182    ///
183    /// If the file cannot be opened, or is not a GeoPackage.
184    pub fn open(path: &Path) -> Result<Self, StoreError> {
185        let connection = Connection::open_with_flags(
186            path,
187            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
188        )?;
189
190        let mut store = Self {
191            connection,
192            tables: HashMap::new(),
193            path: path.to_path_buf(),
194        };
195        store.tables = store.read_tables()?;
196        Ok(store)
197    }
198
199    /// The path this chart was opened from. `ChartStore` does not
200    /// expose its connection, so a caller deriving a related path (a
201    /// cache file, a log entry) has no other way to obtain it.
202    #[must_use]
203    pub fn path(&self) -> &Path {
204        &self.path
205    }
206
207    /// The object classes this chart carries, upper case and sorted.
208    #[must_use]
209    pub fn object_classes(&self) -> Vec<String> {
210        let mut classes: Vec<String> = self.tables.keys().cloned().collect();
211        classes.sort();
212        classes
213    }
214
215    /// Every feature of one class whose bounding box meets `bounds`.
216    ///
217    /// A bounding-box result, not an exact one: the R-Tree narrows
218    /// candidates, and the caller performs the precise geometry test.
219    /// The index stays plain SQL and the geometry test stays in Rust,
220    /// so reading the chart file requires no SQLite extension.
221    ///
222    /// # Errors
223    ///
224    /// If the class is not in this chart, or a row cannot be read.
225    pub fn features_in(&self, class: &str, bounds: Rect<f64>) -> Result<Vec<Feature>, StoreError> {
226        let class = class.to_ascii_uppercase();
227        let table = self
228            .tables
229            .get(&class)
230            .ok_or_else(|| StoreError::UnknownClass(class.clone()))?;
231
232        let sql = match &table.index {
233            Some(index) => format!(
234                "SELECT f.* FROM {} f JOIN {} r ON f.{FID_COLUMN} = r.id \
235                 WHERE r.maxx >= ?1 AND r.minx <= ?2 AND r.maxy >= ?3 AND r.miny <= ?4",
236                quote(&table.name)?,
237                quote(index)?,
238            ),
239            // A chart written without a spatial index still has to be
240            // readable; it is merely slow, and saying so is better than
241            // refusing to answer.
242            None => format!("SELECT f.* FROM {} f", quote(&table.name)?),
243        };
244
245        let mut statement = self.connection.prepare(&sql)?;
246        let columns: Vec<String> = statement
247            .column_names()
248            .into_iter()
249            .map(str::to_owned)
250            .collect();
251
252        let rows = statement.query_map(
253            (
254                bounds.min().x,
255                bounds.max().x,
256                bounds.min().y,
257                bounds.max().y,
258            ),
259            |row| {
260                let mut fid = 0;
261                let mut geometry_blob: Option<Vec<u8>> = None;
262                let mut attributes = HashMap::new();
263
264                for (index, name) in columns.iter().enumerate() {
265                    if name.eq_ignore_ascii_case(FID_COLUMN) {
266                        fid = row.get(index)?;
267                        continue;
268                    }
269                    if name.eq_ignore_ascii_case(&table.geometry_column) {
270                        geometry_blob = row.get(index)?;
271                        continue;
272                    }
273                    if let Some(value) = attribute_of(row.get_ref(index)?) {
274                        attributes.insert(name.to_ascii_uppercase(), value);
275                    }
276                }
277
278                Ok((fid, geometry_blob, attributes))
279            },
280        )?;
281
282        let mut features = Vec::new();
283        for row in rows {
284            let (fid, blob, attributes) = row?;
285            // A feature with no geometry is not a place; it cannot be in the
286            // way of anything, and reporting it as a danger with no position
287            // would be worse than passing over it.
288            let Some(blob) = blob else { continue };
289            let geometry = GpkgWkb(blob)
290                .to_geo()
291                .map_err(|error| StoreError::Geometry(format!("{class} fid {fid}: {error}")))?;
292
293            features.push(Feature {
294                class: class.clone(),
295                fid,
296                geometry,
297                attributes,
298            });
299        }
300        Ok(features)
301    }
302
303    /// Reads what the GeoPackage says about itself.
304    fn read_tables(&self) -> Result<HashMap<String, TableInfo>, StoreError> {
305        let has_contents: bool = self
306            .connection
307            .query_row(
308                "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'gpkg_contents'",
309                [],
310                |row| row.get::<_, i64>(0),
311            )
312            .map(|count| count > 0)?;
313        if !has_contents {
314            return Err(StoreError::NotAGeoPackage);
315        }
316
317        let mut statement = self.connection.prepare(
318            "SELECT c.table_name, g.column_name FROM gpkg_contents c \
319             JOIN gpkg_geometry_columns g ON g.table_name = c.table_name \
320             WHERE c.data_type = 'features'",
321        )?;
322        let rows = statement.query_map([], |row| {
323            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
324        })?;
325
326        let mut tables = HashMap::new();
327        for row in rows {
328            let (name, geometry_column) = row?;
329            let index = self.index_for(&name, &geometry_column)?;
330            // Keyed by the S-57 acronym in upper case: the pipeline writes
331            // table names lower-cased, and nobody asking for DEPARE should
332            // have to know that.
333            tables.insert(
334                name.to_ascii_uppercase(),
335                TableInfo {
336                    name,
337                    geometry_column,
338                    index,
339                },
340            );
341        }
342        Ok(tables)
343    }
344
345    /// The R-Tree table for one geometry column, if the chart has one.
346    fn index_for(&self, table: &str, geometry_column: &str) -> Result<Option<String>, StoreError> {
347        let candidate = format!("rtree_{table}_{geometry_column}");
348        let exists: i64 = self.connection.query_row(
349            "SELECT count(*) FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?1",
350            [&candidate],
351            |row| row.get(0),
352        )?;
353        Ok((exists > 0).then_some(candidate))
354    }
355}
356
357/// Turns one SQLite value into an attribute, dropping nulls.
358fn attribute_of(value: ValueRef<'_>) -> Option<Attribute> {
359    match value {
360        ValueRef::Null => None,
361        ValueRef::Integer(number) => Some(Attribute::Integer(number)),
362        ValueRef::Real(number) => Some(Attribute::Real(number)),
363        ValueRef::Text(bytes) | ValueRef::Blob(bytes) => {
364            Some(Attribute::Text(String::from_utf8_lossy(bytes).into_owned()))
365        }
366    }
367}
368
369/// Quotes an identifier read from the chart file.
370///
371/// Table and column names originate from the ingest pipeline, not from
372/// user input. A name containing a quote character would break out of
373/// the SQL string it is interpolated into, so such names are rejected
374/// rather than escaped.
375fn quote(name: &str) -> Result<String, StoreError> {
376    if name.contains('"') || name.contains('\0') {
377        return Err(StoreError::UnsafeName(name.to_owned()));
378    }
379    Ok(format!("\"{name}\""))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn a_depth_reads_the_same_whether_it_was_stored_as_a_number_or_as_text() {
388        assert_eq!(Attribute::Real(3.5).as_f64(), Some(3.5));
389        assert_eq!(Attribute::Text("3.5".into()).as_f64(), Some(3.5));
390        assert_eq!(Attribute::Integer(3).as_f64(), Some(3.0));
391    }
392
393    #[test]
394    fn a_drying_height_marker_is_unknown_and_not_zero() {
395        // "-H" in a depth field means the rock dries. Read as 0.0 it would
396        // be shallower than every safety contour, which is accidentally
397        // safe; read as a number at all it would be a lie.
398        assert_eq!(Attribute::Text("-H".into()).as_f64(), None);
399        assert_eq!(Attribute::Text(String::new()).as_f64(), None);
400    }
401
402    #[test]
403    fn a_name_that_could_break_out_of_the_query_is_refused() {
404        assert!(quote("depare").is_ok());
405        assert!(matches!(
406            quote("dep\"are"),
407            Err(StoreError::UnsafeName(_))
408        ));
409    }
410}