1use 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
12const FID_COLUMN: &str = "fid";
14
15#[derive(Debug)]
17pub enum StoreError {
18 Database(rusqlite::Error),
20 NotAGeoPackage,
22 Geometry(String),
24 UnknownClass(String),
26 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#[derive(Debug, Clone, PartialEq)]
66pub enum Attribute {
67 Real(f64),
69 Integer(i64),
71 Text(String),
73}
74
75impl Attribute {
76 #[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#[derive(Debug, Clone)]
104pub struct Feature {
105 pub class: String,
107 pub fid: i64,
109 pub geometry: Geometry<f64>,
111 attributes: HashMap<String, Attribute>,
112}
113
114impl Feature {
115 #[must_use]
117 pub fn attribute(&self, acronym: &str) -> Option<&Attribute> {
118 self.attributes.get(acronym)
119 }
120
121 #[must_use]
123 pub fn number(&self, acronym: &str) -> Option<f64> {
124 self.attribute(acronym).and_then(Attribute::as_f64)
125 }
126
127 #[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 #[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#[derive(Debug, Clone)]
158struct TableInfo {
159 name: String,
161 geometry_column: String,
163 index: Option<String>,
165}
166
167#[derive(Debug)]
172pub struct ChartStore {
173 connection: Connection,
174 tables: HashMap<String, TableInfo>,
175 path: PathBuf,
176}
177
178impl ChartStore {
179 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 #[must_use]
203 pub fn path(&self) -> &Path {
204 &self.path
205 }
206
207 #[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 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 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 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 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 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 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
357fn 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
369fn 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 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}