diff --git a/Cargo.toml b/Cargo.toml index 3eaebbeed..fff04cfe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,9 @@ rustgis-assistant = { path = "crates/assistant" } # foundation thiserror = "2" serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["raw_value"] } +# float_roundtrip: projects must read back the exact coordinates they wrote, or source-edit +# baselines (row hashes) see every reopened row as changed. +serde_json = { version = "1", features = ["raw_value", "float_roundtrip"] } sha2 = "0.11" log = "0.4" # std::time that also works in the browser (std panics on wasm32-unknown-unknown). @@ -77,7 +79,7 @@ arrow-schema = { version = "60", default-features = false } bytes = "1" flatgeobuf = { version = "6", default-features = false } geozero = { version = "0.15", default-features = false, features = ["with-wkb"] } -rusqlite = { version = "0.40", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled", "functions"] } # PostgreSQL/PostGIS, in pure Rust: the client, and TLS through rustls (ring, as ureq has it). postgres = { version = "0.19", default-features = false } tokio-postgres-rustls = { version = "0.14", default-features = false, features = ["ring"] } diff --git a/ROADMAP.md b/ROADMAP.md index 7c87ffdb3..d4c5b0ee8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,8 +96,6 @@ And alongside it: - A histogram for raster stretches; raster attribute tables. ### Data at the source -- Write edits back to GeoPackage and PostGIS (edits stay in the project today). _Coordinate - first._ - GeoLibre opening RustGIS `.rgis` projects, once RustGIS's project format and supported data types settle. RustGIS imports GeoLibre projects; combined colour and size rendering is tracked in [#1017](https://github.com/opengeos/RustGIS/issues/1017). @@ -167,6 +165,8 @@ Open questions rather than plans; discuss before building. - An in-app Python console; an R package. ## Done +- Save edits back to existing GeoPackage and PostGIS tables on desktop, with atomic saves, + conflict detection and discard/reload ([#1041](https://github.com/opengeos/RustGIS/issues/1041)). - Projects that survive a move: local data paths relative to the project and undoable Repair Data Source for layers and tile archives ([#879](https://github.com/opengeos/RustGIS/issues/879)). - Layered workspace, command registry with undo, project files (`.rgis`), `rustgis-cli`, the diff --git a/crates/data/src/source/edits.rs b/crates/data/src/source/edits.rs new file mode 100644 index 000000000..be0c75471 --- /dev/null +++ b/crates/data/src/source/edits.rs @@ -0,0 +1,56 @@ +//! Changes and outcomes shared by editable source tables. + +use crate::Feature; + +/// Edits to save to a table, by its key. +#[derive(Debug, Default)] +pub struct Edits { + /// Rows changed: each as it is now, with the version it was read at. + pub updates: Vec<(Feature, i64)>, + /// Rows deleted: their keys and the versions they were read at. + pub deletes: Vec<(u64, i64)>, + /// New rows. The source assigns their keys according to its insertion policy. + pub inserts: Vec, +} + +impl Edits { + pub fn is_empty(&self) -> bool { + self.updates.is_empty() && self.deletes.is_empty() && self.inserts.is_empty() + } +} + +/// Why edits weren't saved: a row in the database isn't as it was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Conflict { + /// A row changed in the database since it was read. + Changed(u64), + /// A row deleted from the database since it was read. + Deleted(u64), + /// A new row's key is already taken. + Exists(u64), +} + +impl std::fmt::Display for Conflict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Conflict::Changed(k) => write!(f, "row {} was changed in the database since it was read", k.cast_signed()), + Conflict::Deleted(k) => write!(f, "row {} was deleted from the database since it was read", k.cast_signed()), + Conflict::Exists(k) => write!(f, "a row with key {} is already in the database", k.cast_signed()), + } + } +} + +/// What saving edits did. +#[derive(Debug, Default)] +pub struct Saved { + /// Each new row's feature id and the key it was saved with. + pub inserted: Vec<(u64, u64)>, + pub updated: usize, + /// Rows edited only in what the table doesn't hold, left as they are. + pub unchanged: usize, + pub deleted: usize, + /// The version of each row inserted or updated, as saved. + pub versions: Vec<(u64, i64)>, + /// The layer's fields the table has no column for, which weren't saved. + pub skipped_fields: Vec, +} diff --git a/crates/data/src/source/gpkg.rs b/crates/data/src/source/gpkg.rs index 7349a74f8..b21a98650 100644 --- a/crates/data/src/source/gpkg.rs +++ b/crates/data/src/source/gpkg.rs @@ -13,6 +13,8 @@ use rustgis_geom::Extent; use std::path::{Path, PathBuf}; use std::sync::Mutex; +pub mod edit; + /// Rows fetched by id per query. const CHUNK: usize = 500; @@ -378,8 +380,11 @@ fn blob(g: &crate::Geometry, multi_points: bool, srs_id: i32) -> Vec { crate::Geometry::Point(c) if multi_points => &crate::Geometry::MultiPoint(vec![*c]), g => g, }; - let e = g.extent(); - let mut out = Vec::with_capacity(40 + g.coord_count() * 16); + header_blob(&wkb::encode(g), &g.extent(), srs_id) +} + +fn header_blob(wkb: &[u8], e: &Extent, srs_id: i32) -> Vec { + let mut out = Vec::with_capacity(40 + wkb.len()); out.extend_from_slice(b"GP"); out.push(0); if e.is_empty() { @@ -393,7 +398,7 @@ fn blob(g: &crate::Geometry, multi_points: bool, srs_id: i32) -> Vec { out.extend_from_slice(&v.to_le_bytes()); } } - out.extend_from_slice(&wkb::encode(g)); + out.extend_from_slice(wkb); out } @@ -586,20 +591,7 @@ fn build(conn: &Connection, ds: &crate::Dataset, crs: Crs) -> Result<()> { row.push(f.geometry.as_ref().map(|x| Sql::Blob(blob(x, multi_points, srs_id))).unwrap_or(Sql::Null)); } for (k, fd) in ds.fields.iter().enumerate() { - row.push(match (f.attrs.get(k).unwrap_or(&Value::Null), fd.ty) { - (Value::Null, _) => Sql::Null, - (Value::Int(x), _) => Sql::Integer(*x), - (Value::Float(x), _) if x.is_finite() => Sql::Real(*x), - (Value::Float(_), _) => Sql::Null, - (Value::Bool(b), _) => Sql::Integer(i64::from(*b)), - (Value::Text(s), FieldType::Text) => Sql::Text(s.clone()), - (v, ty) => match Value::parse(&v.to_string(), ty) { - Value::Int(x) => Sql::Integer(x), - Value::Float(x) if x.is_finite() => Sql::Real(x), - Value::Bool(b) => Sql::Integer(i64::from(b)), - _ => Sql::Null, - }, - }); + row.push(sql_value(f.attrs.get(k).unwrap_or(&Value::Null), fd.ty)); } st.execute(params_from_iter(row)).map_err(err)?; let e = f.geometry.as_ref().map(|x| x.extent()).unwrap_or(Extent::EMPTY); @@ -660,6 +652,24 @@ fn build(conn: &Connection, ds: &crate::Dataset, crs: Crs) -> Result<()> { Ok(()) } +fn sql_value(v: &Value, ty: FieldType) -> rusqlite::types::Value { + use rusqlite::types::Value as Sql; + match (v, ty) { + (Value::Null, _) => Sql::Null, + (Value::Int(x), _) => Sql::Integer(*x), + (Value::Float(x), _) if x.is_finite() => Sql::Real(*x), + (Value::Float(_), _) => Sql::Null, + (Value::Bool(b), _) => Sql::Integer(i64::from(*b)), + (Value::Text(s), FieldType::Text) => Sql::Text(s.clone()), + (v, ty) => match Value::parse(&v.to_string(), ty) { + Value::Int(x) => Sql::Integer(x), + Value::Float(x) if x.is_finite() => Sql::Real(x), + Value::Bool(b) => Sql::Integer(i64::from(b)), + _ => Sql::Null, + }, + } +} + fn value(v: ValueRef<'_>, ty: FieldType) -> Value { match (v, ty) { (ValueRef::Null, _) => Value::Null, diff --git a/crates/data/src/source/gpkg/edit.rs b/crates/data/src/source/gpkg/edit.rs new file mode 100644 index 000000000..a321c50df --- /dev/null +++ b/crates/data/src/source/gpkg/edit.rs @@ -0,0 +1,1813 @@ +//! Whole-table editing of an existing GeoPackage, with optimistic row versions and atomic saves. + +use super::{CHUNK, GpkgSource, err, header_blob, is_empty_blob, quote, split_blob, sql_value, value}; +use crate::raster::Crs; +use crate::source::SourceInfo; +use crate::source::edits::{Conflict, Edits, Saved}; +use crate::{DataError, Feature, Field, Geometry, Result, Value, wkb}; +use rusqlite::config::DbConfig; +use rusqlite::functions::FunctionFlags; +use rusqlite::types::{Value as Sql, ValueRef}; +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params, params_from_iter}; +use rustgis_geom::Extent; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; +use std::time::Duration; + +/// A whole-table editing session is bounded, unlike a streamed map source. +pub const MAX_EDITABLE: usize = 100_000; + +/// Rows loaded for editing, including geometry-less rows but excluding unreadable geometries. +pub struct Editable { + pub features: Vec, + pub versions: Vec<(u64, i64)>, + /// The durable table marker from the same read transaction as the rows. + pub revision: String, + pub matched: u64, + pub skipped: u64, + pub writable: bool, +} + +/// Stored row versions and snapshots, read in the write transaction before it commits. +#[derive(Debug)] +pub struct SavedRows { + pub changes: Saved, + /// Requested snapshots, followed by final snapshots of trigger-affected resident keys. + /// Attributes follow supplied fields; absent columns retain submitted values when available. + pub features: Vec, + /// Affected known resident keys absent after triggers, in unsigned key order. + pub removed: Vec, + /// Supplied field positions that have no stored column. + pub unstored_fields: Vec, + pub revision: String, +} + +/// An existing feature table; its read connection is never reused for writes. +pub struct EditTable { + src: GpkgSource, + path: PathBuf, + columns: Vec, + geometry_type: String, + srs_id: i64, + z: i64, + m: i64, +} + +impl EditTable { + pub fn open(path: &Path, table: &str) -> Result { + let src = GpkgSource::open(path, Some(table))?; + let (columns, geometry_type, srs_id, z, m) = { + let guard = src.conn.lock().map_err(|_| DataError::Format("GeoPackage connection poisoned".into()))?; + let conn = guard.as_ref().ok_or_else(|| DataError::Format("GeoPackage connection closed".into()))?; + let cols = table_columns(conn, &src.table)?; + let mut keys = cols.iter().filter(|c| c.pk > 0); + let key = keys.next(); + if keys.next().is_some() + || !key.is_some_and(|c| c.ty.trim().eq_ignore_ascii_case("INTEGER") && src.pk.as_deref() == Some(c.name.as_str())) + { + return Err(DataError::Format(format!("{table}: no INTEGER PRIMARY KEY, so its rows can't be told apart to save"))); + } + let (ty, srs, z, m) = conn + .query_row("SELECT geometry_type_name, srs_id, z, m FROM gpkg_geometry_columns WHERE table_name = ?1", [&src.table], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?)) + }) + .map_err(err)?; + (cols.into_iter().map(|c| c.name).collect(), ty.to_ascii_uppercase(), srs, z, m) + }; + Ok(Self { src, path: path.to_path_buf(), columns, geometry_type, srs_id, z, m }) + } + + pub fn info(&self) -> &SourceInfo { + &self.src.info + } + + pub fn crs(&self) -> Option { + (!self.src.reproject.is_lonlat()).then_some(self.src.reproject.crs) + } + + pub fn table(&self) -> &str { + &self.src.table + } + + pub fn key(&self) -> &str { + self.src.pk.as_deref().unwrap_or("") + } + + pub fn geometry_column(&self) -> &str { + &self.src.geom + } + + pub fn srs_id(&self) -> i64 { + self.srs_id + } + + /// The declared geometry type (uppercase), Z and M flags as read when opened. + pub fn declaration(&self) -> (&str, i64, i64) { + (&self.geometry_type, self.z, self.m) + } + + pub fn read_editable(&self, limit: usize) -> Result { + let guard = self.src.conn.lock().map_err(|_| DataError::Format("GeoPackage connection poisoned".into()))?; + let conn = guard.as_ref().ok_or_else(|| DataError::Format("GeoPackage connection closed".into()))?; + // Count and baseline come from the same snapshot, even if another process is writing. + let tx = conn.unchecked_transaction().map_err(err)?; + let matched = tx.query_row(&format!("SELECT COUNT(*) FROM {}", quote(self.table())), [], |r| r.get::<_, i64>(0)).map_err(err)?; + let matched = u64::try_from(matched).map_err(|_| DataError::Format("GeoPackage: negative row count".into()))?; + let (key, geom) = self.positions(&self.columns)?; + let attrs = self + .info() + .fields + .iter() + .map(|f| self.columns.iter().position(|c| c.eq_ignore_ascii_case(&f.name)).ok_or_else(|| self.shape_error())) + .collect::>>()?; + let mut out = Editable { + features: Vec::new(), + versions: Vec::new(), + revision: self.revision(&tx)?, + matched, + skipped: 0, + writable: std::fs::metadata(&self.path).is_ok_and(|m| !m.permissions().readonly()), + }; + { + let mut st = tx + .prepare(&format!( + "SELECT {} FROM {} ORDER BY {} LIMIT ?1", + select_list(&self.columns), + quote(self.table()), + quote(self.key()) + )) + .map_err(err)?; + let mut rows = st.query([limit.clamp(1, MAX_EDITABLE) as i64]).map_err(err)?; + while let Some(row) = rows.next().map_err(err)? { + let id = row.get::<_, i64>(key).map_err(err)?.cast_unsigned(); + let Some(geometry) = self.geometry(row.get_ref(geom).map_err(err)?) else { + out.skipped += 1; + continue; + }; + let mut hash = FNV_OFFSET; + for i in 0..self.columns.len() { + hash_value(&mut hash, row.get_ref(i).map_err(err)?); + } + let attrs = self + .info() + .fields + .iter() + .zip(&attrs) + .map(|(f, &i)| row.get_ref(i).map(|v| value(v, f.ty))) + .collect::>() + .map_err(err)?; + out.versions.push((id, hash.cast_signed())); + out.features.push(Feature { id, geometry, attrs }); + } + } + tx.commit().map_err(err)?; + Ok(out) + } + + /// Returns conflicts without changing anything; every other failure also rolls back the save. + /// `reserved` must include every loaded baseline key, including rows not changed by this save. + /// Pending inserts also require `expected_revision` to match the durable loaded table marker. + /// New positive keys exceed those keys, the table's current maximum and its AUTOINCREMENT + /// history. Stored snapshots and their versions are decoded together before commit; an + /// unreadable or trigger-removed row aborts the whole transaction. + pub fn save( + &self, + edits: &Edits, + fields: &[Field], + reserved: impl Iterator, + expected_revision: &str, + ) -> Result>> { + let mut conn = + Connection::open_with_flags(&self.path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX).map_err(err)?; + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true).map_err(err)?; + conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false).map_err(err)?; + conn.busy_timeout(Duration::from_secs(5)).map_err(err)?; + register_functions(&conn)?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(err)?; + let revision = self.revision(&tx)?; + if !edits.inserts.is_empty() && revision != expected_revision { + return Err(DataError::Format(format!( + "{}: the source table changed since it was loaded; refusing stale inserts, so nothing was saved. Discard Edits loads it again", + self.table() + ))); + } + let (geometry_type, srs_id, z, m) = tx + .query_row( + "SELECT geometry_type_name, srs_id, z, m FROM gpkg_geometry_columns WHERE lower(table_name) = lower(?1)", + [self.table()], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?)), + ) + .map_err(err)?; + if srs_id != self.srs_id { + return Err(DataError::Format(format!( + "{}: its SRS changed since it was loaded, so nothing was saved. Discard Edits loads it again", + self.table() + ))); + } + if !geometry_type.eq_ignore_ascii_case(&self.geometry_type) || z != self.z || m != self.m { + return Err(self.shape_error()); + } + let columns: Vec = table_columns(&tx, self.table())?.into_iter().map(|c| c.name).collect(); + let (key, geom) = self.positions(&columns)?; + let residents: HashMap = reserved + .map(u64::cast_signed) + .chain(edits.updates.iter().map(|(f, _)| f.id.cast_signed())) + .chain(edits.deletes.iter().map(|(k, _)| k.cast_signed())) + .map(|k| (k, false)) + .collect(); + let reserved_high_water = if edits.inserts.is_empty() { 0 } else { residents.keys().copied().max().unwrap_or(0).max(0) }; + // Residents already gone (deleted elsewhere since loading) aren't evicted by this save. + let absent_before = self.missing_keys(&tx, &residents.keys().copied().collect::>())?; + let feature_writes = watch_feature_writes(&tx, self.table(), self.key(), residents)?; + let mut direct_changes = 0_u64; + let mut saved = Saved::default(); + let mut targets = Vec::new(); + let mut snapshot_columns = Vec::new(); + let mut unstored_fields = Vec::new(); + for (i, f) in fields.iter().enumerate() { + if let Some(c) = columns.iter().position(|c| c.eq_ignore_ascii_case(&f.name)) { + snapshot_columns.push((i, c)); + if c != key && c != geom { + targets.push((i, c)); + } else { + saved.skipped_fields.push(f.name.clone()); + } + } else { + saved.skipped_fields.push(f.name.clone()); + unstored_fields.push(i); + } + } + let keys: Vec = + edits.updates.iter().map(|(f, _)| f.id.cast_signed()).chain(edits.deletes.iter().map(|(k, _)| k.cast_signed())).collect(); + let current = self.fetch_rows(&tx, &columns, key, &keys)?; + let mut conflicts = Vec::new(); + for (k, version) in edits.updates.iter().map(|(f, v)| (f.id, *v)).chain(edits.deletes.iter().copied()) { + match current.get(&k.cast_signed()) { + None => conflicts.push(Conflict::Deleted(k)), + Some(row) if fingerprint(row.iter().map(ValueRef::from)) != version => conflicts.push(Conflict::Changed(k)), + Some(_) => {} + } + } + if !conflicts.is_empty() { + return Ok(Err(conflicts)); + } + + let table = quote(self.table()); + let pk = quote(self.key()); + let mut spatial_changed = !edits.inserts.is_empty() || !edits.deletes.is_empty(); + for (f, _) in &edits.updates { + let row = current.get(&f.id.cast_signed()).ok_or_else(|| self.shape_error())?; + let mut sets = Vec::new(); + let mut args = Vec::new(); + for &(i, c) in &targets { + let field = fields.get(i).ok_or_else(|| self.shape_error())?; + let old = row.get(c).ok_or_else(|| self.shape_error())?; + let attr = f.attrs.get(i).unwrap_or(&Value::Null); + if value(ValueRef::from(old), field.ty) != *attr { + sets.push(format!("{} = ?", quote(columns.get(c).ok_or_else(|| self.shape_error())?))); + args.push(sql_value(attr, field.ty)); + } + } + let old = ValueRef::from(row.get(geom).ok_or_else(|| self.shape_error())?); + let geometry_changed = self.geometry(old).ok_or_else(|| self.shape_error())? != f.geometry; + spatial_changed |= geometry_changed; + if geometry_changed { + let blob = self.geometry_blob(f.geometry.as_ref(), Some((f.id, old)))?; + sets.push(format!("{} = ?", quote(self.geometry_column()))); + args.push(blob); + } + if sets.is_empty() { + saved.unchanged += 1; + continue; + } + args.push(Sql::Integer(f.id.cast_signed())); + direct_changes += + tx.execute(&format!("UPDATE {table} SET {} WHERE {pk} = ?", sets.join(", ")), params_from_iter(args)).map_err(err)? as u64; + saved.updated += 1; + } + drop(current); + // Reserve the loaded baseline as well as live and historical keys. A deleted source row + // can still be present in the layer, so SQLite's ordinary rowid allocation is not enough. + let mut high_water = 0; + if !edits.inserts.is_empty() { + high_water = reserved_high_water; + let current_max: Option = tx.query_row(&format!("SELECT max({pk}) FROM {table}"), [], |r| r.get(0)).map_err(err)?; + high_water = high_water.max(current_max.unwrap_or(0)); + let has_sequence: bool = tx + .query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence')", [], |r| r.get(0)) + .map_err(err)?; + if has_sequence { + let sequence: Option = tx + .query_row("SELECT max(seq) FROM sqlite_sequence WHERE lower(name) = lower(?1)", [self.table()], |r| r.get(0)) + .map_err(err)?; + high_water = high_water.max(sequence.unwrap_or(0)); + } + } + for f in &edits.inserts { + let new_key = high_water.checked_add(1).ok_or_else(|| { + DataError::Format(format!("{}: no positive INTEGER PRIMARY KEY remains above the reserved keys", self.table())) + })?; + high_water = new_key; + let mut names = vec![pk.clone()]; + let mut args = vec![Sql::Integer(new_key)]; + if let Some(g) = &f.geometry { + let blob = self.geometry_blob(Some(g), None)?; + names.push(quote(self.geometry_column())); + args.push(blob); + } + for &(i, c) in &targets { + let attr = f.attrs.get(i).unwrap_or(&Value::Null); + if *attr != Value::Null { + let field = fields.get(i).ok_or_else(|| self.shape_error())?; + names.push(quote(columns.get(c).ok_or_else(|| self.shape_error())?)); + args.push(sql_value(attr, field.ty)); + } + } + let sql = format!("INSERT INTO {table} ({}) VALUES ({})", names.join(", "), vec!["?"; names.len()].join(", ")); + direct_changes += tx.execute(&sql, params_from_iter(args)).map_err(err)? as u64; + let key = new_key; + saved.inserted.push((f.id, key.cast_unsigned())); + } + for (k, _) in &edits.deletes { + let deleted = tx.execute(&format!("DELETE FROM {table} WHERE {pk} = ?1"), [k.cast_signed()]).map_err(err)?; + direct_changes += deleted as u64; + } + // Spatial/metadata repair must not itself trigger more feature writes after the snapshot. + feature_writes.allowed.store(false, Ordering::Relaxed); + let affected = feature_writes.affected()?; + // A conflict-REPLACE delete fires no delete trigger (recursive_triggers is off), so a + // resident row it evicted is found only by checking each unmarked resident still exists. + let unmarked: Vec = feature_writes.unmarked()?.into_iter().filter(|k| absent_before.binary_search(k).is_err()).collect(); + let evicted = self.missing_keys(&tx, &unmarked)?; + let mut keys: Vec = edits + .updates + .iter() + .map(|(f, _)| f.id.cast_signed()) + .chain(saved.inserted.iter().map(|(_, k)| k.cast_signed())) + .chain(edits.deletes.iter().map(|(k, _)| k.cast_signed())) + .collect(); + keys.sort_unstable(); + keys.dedup(); + let additional: Vec = affected.iter().copied().filter(|k| keys.binary_search(k).is_err()).collect(); + keys.extend(&additional); + let rows = self.fetch_rows(&tx, &columns, key, &keys)?; + let mut removed: Vec = affected.iter().filter(|k| !rows.contains_key(*k)).chain(&evicted).map(|k| k.cast_unsigned()).collect(); + removed.sort_unstable(); + removed.dedup(); + saved.deleted = edits.deletes.iter().filter(|(k, _)| !rows.contains_key(&k.cast_signed())).count(); + let mut features = Vec::with_capacity(keys.len()); + let originals = edits + .updates + .iter() + .map(|(f, _)| (Some(f), f.id.cast_signed())) + .chain(edits.inserts.iter().zip(&saved.inserted).map(|(f, (_, k))| (Some(f), k.cast_signed()))) + .chain(edits.deletes.iter().filter(|(k, _)| rows.contains_key(&k.cast_signed())).map(|(k, _)| (None, k.cast_signed()))) + .chain(additional.iter().filter(|k| rows.contains_key(*k)).map(|k| (None, *k))); + for (original, k) in originals { + let row = rows.get(&k).ok_or_else(|| DataError::Format(format!("row {k}: it was removed while saving")))?; + let geometry = self + .geometry(ValueRef::from(row.get(geom).ok_or_else(|| self.shape_error())?)) + .ok_or_else(|| DataError::Format(format!("row {k}: its stored geometry can't be read, so nothing was saved")))?; + let mut feature = Feature { id: k.cast_unsigned(), geometry, attrs: vec![Value::Null; fields.len()] }; + if let Some(original) = original { + for &i in &unstored_fields { + if let (Some(target), Some(submitted)) = (feature.attrs.get_mut(i), original.attrs.get(i)) { + target.clone_from(submitted); + } + } + } + for &(i, c) in &snapshot_columns { + let field = fields.get(i).ok_or_else(|| self.shape_error())?; + let attr = feature.attrs.get_mut(i).ok_or_else(|| self.shape_error())?; + *attr = value(ValueRef::from(row.get(c).ok_or_else(|| self.shape_error())?), field.ty); + } + saved.versions.push((k.cast_unsigned(), fingerprint(row.iter().map(ValueRef::from)))); + features.push(feature); + } + // Standard spatial triggers only write the R-tree. A trigger that writes feature rows + // can affect keys outside this edit batch, so those saves need a complete spatial scan. + let rebuild = feature_writes.count.load(Ordering::Relaxed) != direct_changes || !evicted.is_empty(); + let mut written_extent = Extent::EMPTY; + if rebuild { + written_extent = rebuild_spatial(&tx, self.table(), self.key(), self.geometry_column(), self.src.rtree.as_deref())?; + } else { + for k in keys { + let extent = rows + .get(&k) + .map(|row| stored_extent(ValueRef::from(row.get(geom).ok_or_else(|| self.shape_error())?), k, false)) + .transpose()? + .unwrap_or(Extent::EMPTY); + maintain_rtree(&tx, self.src.rtree.as_deref(), k, &extent)?; + } + } + let unknown_bounds: bool = tx + .query_row( + "SELECT min_x IS NULL OR min_y IS NULL OR max_x IS NULL OR max_y IS NULL + FROM gpkg_contents WHERE lower(table_name) = lower(?1)", + [self.table()], + |r| r.get(0), + ) + .map_err(err)?; + if rebuild || spatial_changed || unknown_bounds { + // Ordinary saves keep unreadable rows and index entries untouched, while bounds + // fold every readable native extent once (including untouched rows). + let extent = if rebuild { written_extent } else { readable_bounds(&tx, self.table(), self.geometry_column())? }; + let extent = (!extent.is_empty()).then_some(extent); + tx.execute( + "UPDATE gpkg_contents SET min_x = ?2, min_y = ?3, max_x = ?4, max_y = ?5 WHERE lower(table_name) = lower(?1)", + params![self.table(), extent.map(|e| e.min_x), extent.map(|e| e.min_y), extent.map(|e| e.max_x), extent.map(|e| e.max_y)], + ) + .map_err(err)?; + } + tx.execute( + "UPDATE gpkg_contents SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ', + CASE WHEN julianday(last_change) >= julianday('now') THEN last_change ELSE 'now' END, '+0.001 seconds') + WHERE lower(table_name) = lower(?1)", + [self.table()], + ) + .map_err(err)?; + let revision = self.revision(&tx)?; + tx.commit().map_err(err)?; + Ok(Ok(SavedRows { changes: saved, features, removed, unstored_fields, revision })) + } + + fn revision(&self, conn: &Connection) -> Result { + conn.query_row( + "SELECT last_change FROM gpkg_contents + WHERE lower(table_name) = lower(?1) AND julianday(last_change) IS NOT NULL", + [self.table()], + |r| r.get(0), + ) + .map_err(|e| DataError::Format(format!("{}: missing or invalid GeoPackage last_change: {e}", self.table()))) + } + + fn shape_error(&self) -> DataError { + DataError::Format(format!("{} changed shape since it was loaded; Discard Edits loads it again", self.table())) + } + + fn positions(&self, columns: &[String]) -> Result<(usize, usize)> { + let key = columns.iter().position(|c| c.eq_ignore_ascii_case(self.key())); + let geom = columns.iter().position(|c| c.eq_ignore_ascii_case(self.geometry_column())); + key.zip(geom).ok_or_else(|| self.shape_error()) + } + + // Outer None is unreadable; inner None is a valid NULL or empty geometry. + fn geometry(&self, v: ValueRef<'_>) -> Option> { + match v { + ValueRef::Null => Some(None), + ValueRef::Blob(b) if is_empty_blob(b) => Some(None), + ValueRef::Blob(b) => { + let (_, body) = split_blob(b)?; + self.src.reproject.geometry(wkb::decode(body)?).map(Some) + } + _ => None, + } + } + + fn geometry_blob(&self, g: Option<&Geometry>, row: Option<(u64, ValueRef<'_>)>) -> Result { + // Check before returning NULL: removing geometry must not silently drop existing Z/M. + let has_zm = row.is_some_and(|(_, v)| match v { + ValueRef::Blob(b) => split_blob(b).is_some_and(|(_, body)| wkb::has_zm(body)), + _ => false, + }); + if self.z == 1 || self.m == 1 || has_zm { + let message = match row { + Some((k, _)) => format!( + "row {}: its geometry has Z or M values, which RustGIS doesn't keep, so its geometry can't be saved (its attributes can)", + k.cast_signed() + ), + None => format!( + "{} needs Z or M values, which RustGIS doesn't keep, so new features with a geometry can't be saved", + self.table() + ), + }; + return Err(DataError::Format(message)); + } + let Some(g) = g else { + return Ok(Sql::Null); + }; + let native = self.src.reproject.to_native(g).ok_or_else(|| { + let id = row.map(|(k, _)| k.cast_signed().to_string()).unwrap_or_else(|| "new".into()); + DataError::Format(format!("row {id}: its geometry can't be converted to {}", self.src.reproject.label())) + })?; + let body = match (self.geometry_type.as_str(), &native) { + ("POINT", Geometry::Point(_) | Geometry::MultiPoint(_)) + | ("LINESTRING", Geometry::Line(_)) + | ("POLYGON", Geometry::Polygon(_)) => wkb::encode_single(&native).ok_or_else(|| { + DataError::Format(format!("{}'s {} column holds one part; a feature has several", self.table(), self.geometry_type)) + })?, + ("MULTIPOINT", Geometry::Point(c)) => wkb::encode(&Geometry::MultiPoint(vec![*c])), + ("MULTIPOINT", Geometry::MultiPoint(_)) | ("MULTILINESTRING", Geometry::Line(_)) | ("MULTIPOLYGON", Geometry::Polygon(_)) => { + wkb::encode(&native) + } + ("GEOMETRY", _) => wkb::encode_single(&native).unwrap_or_else(|| wkb::encode(&native)), + ("POINT" | "LINESTRING" | "POLYGON" | "MULTIPOINT" | "MULTILINESTRING" | "MULTIPOLYGON", _) => { + return Err(DataError::Format(format!( + "{}'s {} column can't hold this {:?} geometry", + self.table(), + self.geometry_type, + native.kind() + ))); + } + // The model has no collection, curve or surface variant to preserve these types. + _ => return Err(DataError::Format(format!("{}'s {} geometry can't be written by RustGIS", self.table(), self.geometry_type))), + }; + let srs = i32::try_from(self.srs_id) + .map_err(|_| DataError::Format(format!("{}: srs_id {} doesn't fit a GeoPackage geometry header", self.table(), self.srs_id)))?; + let extent = native.extent(); + Ok(Sql::Blob(header_blob(&body, &extent, srs))) + } + + /// Keys among `keys` with no row now, read through the key column only. + fn missing_keys(&self, conn: &Connection, keys: &[i64]) -> Result> { + let mut present = std::collections::HashSet::with_capacity(keys.len()); + for chunk in keys.chunks(CHUNK) { + let pk = quote(self.key()); + let mut st = conn + .prepare(&format!("SELECT {pk} FROM {} WHERE {pk} IN ({})", quote(self.table()), vec!["?"; chunk.len()].join(","))) + .map_err(err)?; + let mut rows = st.query(params_from_iter(chunk.iter())).map_err(err)?; + while let Some(row) = rows.next().map_err(err)? { + present.insert(row.get::<_, i64>(0).map_err(err)?); + } + } + let mut missing: Vec = keys.iter().copied().filter(|k| !present.contains(k)).collect(); + missing.sort_unstable(); + Ok(missing) + } + + fn fetch_rows(&self, conn: &Connection, columns: &[String], key: usize, keys: &[i64]) -> Result>> { + let mut out = HashMap::new(); + for chunk in keys.chunks(CHUNK) { + let mut st = conn + .prepare(&format!( + "SELECT {} FROM {} WHERE {} IN ({})", + select_list(columns), + quote(self.table()), + quote(self.key()), + vec!["?"; chunk.len()].join(",") + )) + .map_err(err)?; + let mut rows = st.query(params_from_iter(chunk.iter())).map_err(err)?; + while let Some(row) = rows.next().map_err(err)? { + let k = row.get::<_, i64>(key).map_err(err)?; + let vals = (0..columns.len()).map(|i| row.get(i)).collect::>>().map_err(err)?; + out.insert(k, vals); + } + } + Ok(out) + } +} + +struct FeatureWrites { + count: AtomicU64, + allowed: AtomicBool, + residents: Mutex>, +} + +impl FeatureWrites { + fn affected(&self) -> Result> { + let residents = self.residents.lock().map_err(|_| DataError::Format("GeoPackage write observer poisoned".into()))?; + let mut keys: Vec = residents.iter().filter_map(|(&k, &changed)| changed.then_some(k)).collect(); + keys.sort_unstable(); + Ok(keys) + } + + /// Residents no write was observed on. + fn unmarked(&self) -> Result> { + let residents = self.residents.lock().map_err(|_| DataError::Format("GeoPackage write observer poisoned".into()))?; + Ok(residents.iter().filter_map(|(&k, &changed)| (!changed).then_some(k)).collect()) + } +} + +fn watch_feature_writes(conn: &Connection, table: &str, key: &str, residents: HashMap) -> Result> { + let state = Arc::new(FeatureWrites { count: AtomicU64::new(0), allowed: AtomicBool::new(true), residents: Mutex::new(residents) }); + let observed = state.clone(); + conn.create_scalar_function("rustgis_feature_write", 2, FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_INNOCUOUS, move |ctx| { + if !observed.allowed.load(Ordering::Relaxed) { + return Err(rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other( + "metadata or spatial-index triggers cannot change feature rows after the saved snapshot", + )))); + } + observed.count.fetch_add(1, Ordering::Relaxed); + let old = ctx.get::>(0)?; + let new = ctx.get::>(1)?; + let mut residents = observed + .residents + .lock() + .map_err(|_| rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other("GeoPackage write observer poisoned"))))?; + for key in old.into_iter().chain(new) { + if let Some(changed) = residents.get_mut(&key) { + *changed = true; + } + } + Ok(0_i64) + }) + .map_err(err)?; + // Connection-local observers see file-owned triggers without growing beyond resident keys. + let table = quote(table); + let key = quote(key); + for (event, old, new) in [ + ("INSERT", "NULL".to_string(), format!("NEW.{key}")), + ("UPDATE", format!("OLD.{key}"), format!("NEW.{key}")), + ("DELETE", format!("OLD.{key}"), "NULL".to_string()), + ] { + conn.execute_batch(&format!( + "CREATE TEMP TRIGGER rustgis_observe_{event} AFTER {event} ON main.{table} + BEGIN SELECT rustgis_feature_write({old}, {new}); END;" + )) + .map_err(err)?; + } + Ok(state) +} + +fn stored_extent(value: ValueRef<'_>, key: i64, strict: bool) -> Result { + match value { + ValueRef::Null => Ok(Extent::EMPTY), + ValueRef::Blob(bytes) if is_empty_blob(bytes) => Ok(Extent::EMPTY), + value => { + let extent = if strict { + match value { + ValueRef::Blob(bytes) => split_blob(bytes) + .and_then(|(envelope, body)| wkb::decode(body).map(|geometry| envelope.unwrap_or_else(|| geometry.extent()))), + _ => None, + } + } else { + native_extent(value) + }; + extent.ok_or_else(|| DataError::Format(format!("row {key}: its stored geometry can't be read, so nothing was saved"))) + } + } +} + +fn readable_bounds(conn: &Connection, table: &str, geometry: &str) -> Result { + let mut statement = conn.prepare(&format!("SELECT {} FROM {}", quote(geometry), quote(table))).map_err(err)?; + let mut rows = statement.query([]).map_err(err)?; + let mut bounds = Extent::EMPTY; + while let Some(row) = rows.next().map_err(err)? { + if let Some(extent) = native_extent(row.get_ref(0).map_err(err)?) { + bounds = bounds.union(&extent); + } + } + Ok(bounds) +} + +fn rebuild_spatial(conn: &Connection, table: &str, key: &str, geometry: &str, rtree: Option<&str>) -> Result { + let mut index = if let Some(rtree) = rtree { + conn.execute(&format!("DELETE FROM {}", quote(rtree)), []).map_err(err)?; + Some(conn.prepare(&format!("INSERT INTO {} VALUES (?1, ?2, ?3, ?4, ?5)", quote(rtree))).map_err(err)?) + } else { + None + }; + let mut statement = conn.prepare(&format!("SELECT {}, {} FROM {}", quote(key), quote(geometry), quote(table))).map_err(err)?; + let mut rows = statement.query([]).map_err(err)?; + let mut bounds = Extent::EMPTY; + while let Some(row) = rows.next().map_err(err)? { + let key = row.get::<_, i64>(0).map_err(err)?; + let extent = stored_extent(row.get_ref(1).map_err(err)?, key, true)?; + if !extent.is_empty() { + if let Some(index) = &mut index { + index.execute(params![key, extent.min_x, extent.max_x, extent.min_y, extent.max_y]).map_err(err)?; + } + bounds = bounds.union(&extent); + } + } + Ok(bounds) +} + +struct Column { + name: String, + ty: String, + pk: i64, +} + +fn table_columns(conn: &Connection, table: &str) -> Result> { + let mut st = conn.prepare(&format!("PRAGMA table_info({})", quote(table))).map_err(err)?; + st.query_map([], |r| Ok(Column { name: r.get(1)?, ty: r.get::<_, Option>(2)?.unwrap_or_default(), pk: r.get(5)? })) + .map_err(err)? + .collect::>() + .map_err(err) +} + +fn select_list(columns: &[String]) -> String { + columns.iter().map(|c| quote(c)).collect::>().join(", ") +} + +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x100_0000_01b3; + +fn fingerprint<'a>(vals: impl Iterator>) -> i64 { + let mut hash = FNV_OFFSET; + for v in vals { + hash_value(&mut hash, v); + } + hash.cast_signed() +} + +fn hash_value(hash: &mut u64, v: ValueRef<'_>) { + let mut bytes = |b: &[u8]| { + for x in b { + *hash = (*hash ^ u64::from(*x)).wrapping_mul(FNV_PRIME); + } + }; + match v { + ValueRef::Null => bytes(&[0]), + ValueRef::Integer(v) => { + bytes(&[1]); + bytes(&v.to_le_bytes()); + } + ValueRef::Real(v) => { + bytes(&[2]); + bytes(&v.to_bits().to_le_bytes()); + } + ValueRef::Text(v) => { + bytes(&[3]); + bytes(&(v.len() as u64).to_le_bytes()); + bytes(v); + } + ValueRef::Blob(v) => { + bytes(&[4]); + bytes(&(v.len() as u64).to_le_bytes()); + bytes(v); + } + } +} + +fn native_extent(v: ValueRef<'_>) -> Option { + let ValueRef::Blob(b) = v else { + return None; + }; + if is_empty_blob(b) { + return None; + } + let (envelope, body) = split_blob(b)?; + envelope.or_else(|| wkb::decode(body).map(|g| g.extent())).filter(|e| !e.is_empty()) +} + +fn register_functions(conn: &Connection) -> Result<()> { + let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS; + conn.create_scalar_function("ST_IsEmpty", 1, flags, |ctx| { + Ok(match ctx.get_raw(0) { + ValueRef::Blob(b) => Some(i64::from(is_empty_blob(b))), + _ => None, + }) + }) + .map_err(err)?; + for (name, index) in [("ST_MinX", 0), ("ST_MaxX", 1), ("ST_MinY", 2), ("ST_MaxY", 3)] { + conn.create_scalar_function(name, 1, flags, move |ctx| { + Ok(native_extent(ctx.get_raw(0)).map(|e| match index { + 0 => e.min_x, + 1 => e.max_x, + 2 => e.min_y, + _ => e.max_y, + })) + }) + .map_err(err)?; + } + Ok(()) +} + +fn maintain_rtree(conn: &Connection, rtree: Option<&str>, key: i64, extent: &Extent) -> Result<()> { + let Some(rt) = rtree else { + return Ok(()); + }; + if extent.is_empty() { + conn.execute(&format!("DELETE FROM {} WHERE id = ?1", quote(rt)), [key]).map_err(err)?; + } else { + conn.execute( + &format!("INSERT OR REPLACE INTO {} VALUES (?1, ?2, ?3, ?4, ?5)", quote(rt)), + params![key, extent.min_x, extent.max_x, extent.min_y, extent.max_y], + ) + .map_err(err)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustgis_geom::Coord; + + const REVISION: &str = "2026-01-01T00:00:00.000Z"; + + #[test] + fn gpkg_insert_revision_is_durable_and_strictly_advances() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + // A future timestamp forces the strictly-monotonic branch even on rapid saves. + conn.execute("UPDATE gpkg_contents SET last_change = '2099-01-01T00:00:00.000Z'", []).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let loaded = table.read_editable(MAX_EDITABLE).unwrap(); + let edits = Edits { inserts: vec![Feature { id: 100, geometry: Some(line(4.0)), attrs: vec![] }], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [1].into_iter(), &loaded.revision).unwrap().unwrap(); + assert!(saved.revision > loaded.revision); + drop(table); + // An older serialized project cannot repeat an insert, even in a new editing session. + let table = EditTable::open(&path, "roads").unwrap(); + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &loaded.revision).unwrap_err(); + assert!(error.to_string().contains("refusing stale inserts"), "{error}"); + assert_eq!(conn.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + assert_eq!(table.read_editable(MAX_EDITABLE).unwrap().revision, saved.revision); + let mut revision = saved.revision; + for _ in 0..8 { + // Identical geometries/attributes are legitimate new features, not duplicate rows. + let saved = table.save(&edits, &table.info().fields, [1].into_iter(), &revision).unwrap().unwrap(); + assert!(saved.revision > revision); + assert_eq!(table.read_editable(MAX_EDITABLE).unwrap().revision, saved.revision); + revision = saved.revision; + } + assert_eq!(conn.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 10); + conn.execute_batch( + "CREATE TRIGGER reject_marker BEFORE UPDATE OF last_change ON gpkg_contents + BEGIN SELECT RAISE(ABORT, 'marker refused'); END;", + ) + .unwrap(); + let before = table.read_editable(MAX_EDITABLE).unwrap(); + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &revision).unwrap_err(); + assert!(error.to_string().contains("marker refused"), "{error}"); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.revision, before.revision); + assert_eq!(after.features, before.features); + assert_eq!(after.versions, before.versions); + } + + #[test] + fn gpkg_requires_valid_revision_metadata() { + for mutation in + ["UPDATE gpkg_contents SET last_change = NULL", "UPDATE gpkg_contents SET last_change = 'invalid'", "DELETE FROM gpkg_contents"] + { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + let loaded = table.read_editable(MAX_EDITABLE).unwrap(); + Connection::open(&path).unwrap().execute(mutation, []).unwrap(); + assert!(table.read_editable(MAX_EDITABLE).is_err(), "{mutation}"); + let edits = Edits { inserts: vec![Feature { id: 100, geometry: None, attrs: vec![] }], ..Default::default() }; + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &loaded.revision).unwrap_err(); + assert!(error.to_string().contains("missing or invalid"), "{mutation}: {error}"); + assert_eq!(Connection::open(&path).unwrap().query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + } + } + + #[test] + fn gpkg_rechecks_geometry_declaration_inside_write_transaction() { + for (declaration, message) in [ + ("srs_id = 3857", "SRS changed"), + ("geometry_type_name = 'POINT'", "changed shape"), + ("z = 1", "changed shape"), + ("m = 1", "changed shape"), + ] { + for spatial in [false, true] { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + let before = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = before.features[0].clone(); + changed.attrs[0] = Value::Text("must not save".into()); + if spatial { + changed.geometry = Some(line(8.0)); + } + let edits = Edits { updates: vec![(changed, before.versions[0].1)], ..Default::default() }; + Connection::open(&path).unwrap().execute(&format!("UPDATE gpkg_geometry_columns SET {declaration}"), []).unwrap(); + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &before.revision).unwrap_err(); + assert!(error.to_string().contains(message), "{declaration}: {error}"); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.features, before.features); + assert_eq!(after.versions, before.versions); + assert_eq!(after.revision, before.revision); + } + } + } + + #[test] + fn gpkg_unknown_bounds_include_untouched_native_geometries() { + for untouched in [false, true] { + for bounds in ["NULL, NULL, NULL, NULL", "NULL, 0, 2, 3"] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute("UPDATE roads SET geom = NULL", []).unwrap(); + conn.execute(&format!("UPDATE gpkg_contents SET (min_x, min_y, max_x, max_y) = ({bounds})"), []).unwrap(); + if untouched { + let g = line(-30.0); + conn.execute( + "INSERT INTO roads (fid, geom) VALUES (2, ?1)", + [header_blob(&wkb::encode_single(&g).unwrap(), &g.extent(), 4326)], + ) + .unwrap(); + } + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let edits = Edits { inserts: vec![Feature { id: 100, geometry: Some(line(10.0)), attrs: vec![] }], ..Default::default() }; + table.save(&edits, &table.info().fields, read.versions.iter().map(|(k, _)| *k), &read.revision).unwrap().unwrap(); + let bounds: (f64, f64, f64, f64) = conn + .query_row("SELECT min_x, min_y, max_x, max_y FROM gpkg_contents", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + }) + .unwrap(); + assert_eq!(bounds, (if untouched { -30.0 } else { 10.0 }, 2.0, 11.0, 3.0)); + } + } + } + + #[test] + fn gpkg_final_trigger_geometries_repair_all_rtree_coverage() { + for coverage in ["none", "partial", "full"] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id, minx, maxx, miny, maxy); + INSERT INTO rtree_roads_geom VALUES (1, 1, 2, 2, 3); + CREATE TABLE moves (kind TEXT, geom BLOB);", + ) + .unwrap(); + for (kind, x) in [("insert", 60.0), ("update", 90.0), ("delete", 40.0)] { + let g = line(x); + conn.execute( + "INSERT INTO moves VALUES (?1, ?2)", + params![kind, header_blob(&wkb::encode_single(&g).unwrap(), &g.extent(), 4326)], + ) + .unwrap(); + } + if coverage == "full" { + conn.execute_batch( + "CREATE TRIGGER spatial_update AFTER UPDATE OF geom ON roads BEGIN + DELETE FROM rtree_roads_geom WHERE id = OLD.fid; + INSERT INTO rtree_roads_geom SELECT NEW.fid, ST_MinX(NEW.geom), ST_MaxX(NEW.geom), ST_MinY(NEW.geom), ST_MaxY(NEW.geom) + WHERE NEW.geom IS NOT NULL AND NOT ST_IsEmpty(NEW.geom); + END; + CREATE TRIGGER spatial_insert AFTER INSERT ON roads BEGIN + INSERT OR REPLACE INTO rtree_roads_geom SELECT NEW.fid, ST_MinX(NEW.geom), ST_MaxX(NEW.geom), ST_MinY(NEW.geom), ST_MaxY(NEW.geom) + WHERE NEW.geom IS NOT NULL AND NOT ST_IsEmpty(NEW.geom); + END;" + ).unwrap(); + } + if coverage != "none" { + conn.execute_batch( + "CREATE TRIGGER spatial_delete AFTER DELETE ON roads BEGIN + DELETE FROM rtree_roads_geom WHERE id = OLD.fid; + END;", + ) + .unwrap(); + } + conn.execute_batch( + "CREATE TRIGGER move_insert AFTER INSERT ON roads WHEN NEW.name = 'move' BEGIN + UPDATE roads SET geom = (SELECT geom FROM moves WHERE kind = 'insert') WHERE fid = NEW.fid; + END; + CREATE TRIGGER move_update AFTER UPDATE OF name ON roads WHEN NEW.name = 'move' BEGIN + UPDATE roads SET geom = (SELECT geom FROM moves WHERE kind = 'update') WHERE fid = NEW.fid; + END;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = read.features[0].clone(); + // Attribute-only submitted update; its trigger moves the stored geometry. + changed.attrs[0] = Value::Text("move".into()); + let edits = Edits { + updates: vec![(changed, read.versions[0].1)], + inserts: vec![Feature { id: 100, geometry: Some(line(4.0)), attrs: vec![Value::Text("move".into()), Value::Null] }], + ..Default::default() + }; + let saved = table.save(&edits, &table.info().fields, [1].into_iter(), &read.revision).unwrap().unwrap(); + assert_eq!(saved.features[0].geometry, Some(line(90.0)), "{coverage}"); + assert_eq!(saved.features[1].geometry, Some(line(60.0)), "{coverage}"); + for (x, expected) in [(90.5, vec![1_i64]), (60.5, vec![2]), (4.5, vec![]), (1.5, vec![])] { + let mut st = conn.prepare("SELECT id FROM rtree_roads_geom WHERE minx <= ?1 AND maxx >= ?1 ORDER BY id").unwrap(); + let keys = st.query_map([x], |r| r.get::<_, i64>(0)).unwrap().collect::>>().unwrap(); + assert_eq!(keys, expected, "{coverage}: {x}"); + } + let bounds: (f64, f64, f64, f64) = conn + .query_row("SELECT min_x, min_y, max_x, max_y FROM gpkg_contents", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap(); + assert_eq!(bounds, (60.0, 2.0, 91.0, 3.0), "{coverage}"); + conn.execute_batch( + "CREATE TRIGGER keep_deleted AFTER DELETE ON roads WHEN OLD.fid = 1 BEGIN + INSERT INTO roads (fid, geom, name) SELECT OLD.fid, geom, 'kept' FROM moves WHERE kind = 'delete'; + END;", + ) + .unwrap(); + let edits = Edits { deletes: saved.changes.versions, ..Default::default() }; + let restored = table.save(&edits, &table.info().fields, [1, 2].into_iter(), &saved.revision).unwrap().unwrap(); + assert_eq!(restored.changes.deleted, 1); + assert_eq!(restored.features[0].id, 1); + assert_eq!(restored.features[0].geometry, Some(line(40.0))); + assert_eq!(restored.features[0].attrs[0], Value::Text("kept".into())); + assert_eq!(restored.changes.versions, table.read_editable(MAX_EDITABLE).unwrap().versions); + let index: (i64, f64, f64) = + conn.query_row("SELECT id, minx, maxx FROM rtree_roads_geom", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap(); + assert_eq!(index, (1, 40.0, 41.0), "{coverage}"); + assert_eq!(conn.query_row("SELECT count(*) FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + let bounds: (f64, f64, f64, f64) = conn + .query_row("SELECT min_x, min_y, max_x, max_y FROM gpkg_contents", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap(); + assert_eq!(bounds, (40.0, 2.0, 41.0, 3.0), "{coverage}"); + } + } + + #[test] + fn gpkg_cross_row_triggers_rebuild_spatial_state_transactionally() { + use crate::source::{FeatureSource, Request}; + for indexed in [false, true] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + let other = line(4.0); + conn.execute( + "INSERT INTO roads (fid, geom, name) VALUES (2, ?1, 'other'), (3, ?1, 'deleted')", + [header_blob(&wkb::encode_single(&other).unwrap(), &other.extent(), 4326)], + ) + .unwrap(); + if indexed { + conn.execute_batch( + "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id,minx,maxx,miny,maxy); + INSERT INTO rtree_roads_geom VALUES(1,1,2,2,3),(2,4,5,2,3),(3,4,5,2,3);", + ) + .unwrap(); + } + let moved = line(30.0); + let inserted = line(60.0); + conn.execute_batch(&format!( + "CREATE TRIGGER move_other AFTER UPDATE OF name ON roads WHEN NEW.fid = 1 BEGIN + UPDATE roads SET geom = x'{}' WHERE fid = 2; + INSERT INTO roads (fid,geom,name) VALUES (7,x'{}','trigger inserted'); + DELETE FROM roads WHERE fid = 3; + END;", + header_blob(&wkb::encode_single(&moved).unwrap(), &moved.extent(), 4326) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + header_blob(&wkb::encode_single(&inserted).unwrap(), &inserted.extent(), 4326) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + )) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let loaded = table.read_editable(MAX_EDITABLE).unwrap(); + let mut feature = loaded.features[0].clone(); + feature.attrs[0] = Value::Text("move other rows".into()); + let edits = Edits { updates: vec![(feature, loaded.versions[0].1)], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [1, 2, 3].into_iter(), &loaded.revision).unwrap().unwrap(); + assert_eq!(saved.features.iter().map(|f| f.id).collect::>(), vec![1, 2]); + assert_eq!(saved.features[1].geometry, Some(moved.clone())); + assert_eq!(saved.changes.versions.iter().map(|(k, _)| *k).collect::>(), vec![1, 2]); + assert_eq!(saved.removed, vec![3]); + assert_eq!(saved.changes.deleted, 0, "trigger-only deletion is not a submitted deletion"); + let source = GpkgSource::open(&path, Some("roads")).unwrap(); + for (x, expected) in [(30.5, vec![2]), (60.5, vec![7]), (4.5, vec![])] { + let batch = source.read(&Request { extent: Some(Extent::new(x, 1.0, x + 0.1, 4.0)), limit: 100 }).unwrap(); + assert_eq!(batch.features.iter().map(|f| f.id).collect::>(), expected, "indexed={indexed}"); + } + let bounds: (f64, f64, f64, f64) = conn + .query_row("SELECT min_x, min_y, max_x, max_y FROM gpkg_contents", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap(); + assert_eq!(bounds, (1.0, 2.0, 61.0, 3.0), "indexed={indexed}"); + if indexed { + let keys = conn + .prepare("SELECT id FROM rtree_roads_geom ORDER BY id") + .unwrap() + .query_map([], |r| r.get::<_, i64>(0)) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(keys, vec![1, 2, 7]); + } + } + } + + #[test] + fn gpkg_observes_both_primary_keys_without_duplicate_resident_snapshots() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "INSERT INTO roads(fid,name) VALUES(-2,'negative'),(2,'positive'); + CREATE TRIGGER replace_resident_key AFTER UPDATE OF name ON roads WHEN NEW.fid=1 BEGIN + DELETE FROM roads WHERE fid=-2; + UPDATE roads SET fid=-2,name='trigger result',lanes=9 WHERE fid=2; + UPDATE roads SET lanes=10 WHERE fid=-2; + INSERT INTO roads(fid,name) VALUES(-3,'outside scope'); + END;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let loaded = table.read_editable(MAX_EDITABLE).unwrap(); + let mut feature = loaded.features.iter().find(|f| f.id == 1).unwrap().clone(); + feature.attrs[0] = Value::Text("change residents".into()); + let version = loaded.versions.iter().find(|(k, _)| *k == 1).unwrap().1; + let edits = Edits { updates: vec![(feature, version)], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [(-2_i64).cast_unsigned(), 2].into_iter(), &loaded.revision).unwrap().unwrap(); + assert_eq!(saved.features.iter().map(|f| f.id.cast_signed()).collect::>(), vec![1, -2]); + assert_eq!(saved.changes.versions.iter().map(|(k, _)| k.cast_signed()).collect::>(), vec![1, -2]); + assert_eq!(saved.removed, vec![2]); + let negative = saved.features[1].clone(); + let lanes = table.info().fields.iter().position(|f| f.name == "lanes").unwrap(); + assert_eq!(negative.attrs[lanes], Value::Int(10)); + let mut next = negative; + next.attrs[0] = Value::Text("next".into()); + let edits = Edits { updates: vec![(next, saved.changes.versions[1].1)], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [1, (-2_i64).cast_unsigned()].into_iter(), &saved.revision).unwrap().unwrap(); + assert_eq!(saved.changes.updated, 1); + let stored: (String, i64) = + conn.query_row("SELECT name,lanes FROM roads WHERE fid=-2", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(stored, ("next".into(), 10)); + } + + #[test] + fn gpkg_bounds_shrink_on_geometry_edits_and_deletes_and_clear_when_empty() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + let g = line(30.0); + conn.execute("INSERT INTO roads (fid, geom) VALUES (2, ?1)", [header_blob(&wkb::encode_single(&g).unwrap(), &g.extent(), 4326)]) + .unwrap(); + conn.execute("UPDATE gpkg_contents SET min_x=-100, min_y=-100, max_x=100, max_y=100", []).unwrap(); + let bounds = || { + conn.query_row("SELECT min_x,min_y,max_x,max_y FROM gpkg_contents", [], |r| { + Ok((r.get::<_, Option>(0)?, r.get::<_, Option>(1)?, r.get::<_, Option>(2)?, r.get::<_, Option>(3)?)) + }) + .unwrap() + }; + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut moved = read.features[1].clone(); + moved.geometry = Some(line(-10.0)); + let edits = Edits { updates: vec![(moved, read.versions[1].1)], ..Default::default() }; + table.save(&edits, &table.info().fields, [1, 2].into_iter(), &read.revision).unwrap().unwrap(); + assert_eq!(bounds(), (Some(-10.0), Some(2.0), Some(2.0), Some(3.0))); + for (key, expected) in [(2, (Some(1.0), Some(2.0), Some(2.0), Some(3.0))), (1, (None, None, None, None))] { + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let version = read.versions.iter().find(|(id, _)| *id == key).unwrap().1; + let edits = Edits { deletes: vec![(key, version)], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [1, 2].into_iter(), &read.revision).unwrap().unwrap(); + assert_eq!(saved.changes.deleted, 1); + assert_eq!(bounds(), expected); + } + assert_eq!(conn.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + } + + #[test] + fn gpkg_unreadable_untouched_geometry_refuses_trigger_rebuild_without_partial_writes() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "INSERT INTO roads (fid, geom, name, lanes) VALUES (2,x'00','unreadable',2); + CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id,minx,maxx,miny,maxy); + INSERT INTO rtree_roads_geom VALUES(1,1,2,2,3),(2,5,6,2,3); + CREATE TRIGGER modify_other AFTER UPDATE OF name ON roads WHEN NEW.fid=1 + BEGIN UPDATE roads SET lanes=7 WHERE fid=2; END;", + ) + .unwrap(); + // A valid XY envelope must not hide an unreadable WKB body during a strict rebuild. + let unreadable = header_blob(&[0], &Extent::new(5.0, 2.0, 6.0, 3.0), 4326); + conn.execute("UPDATE roads SET geom = ?1 WHERE fid = 2", [&unreadable]).unwrap(); + let index = || { + conn.prepare("SELECT id,minx,maxx,miny,maxy FROM rtree_roads_geom ORDER BY id") + .unwrap() + .query_map([], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, f64>(1)?, r.get::<_, f64>(2)?, r.get::<_, f64>(3)?, r.get::<_, f64>(4)?)) + }) + .unwrap() + .collect::>>() + .unwrap() + }; + let before_index = index(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(read.skipped, 1); + let mut feature = read.features[0].clone(); + feature.attrs[0] = Value::Text("must roll back".into()); + let edits = Edits { updates: vec![(feature, read.versions[0].1)], ..Default::default() }; + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &read.revision).unwrap_err(); + assert!(error.to_string().contains("stored geometry can't be read"), "{error}"); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.features, read.features); + assert_eq!(after.versions, read.versions); + assert_eq!(after.revision, read.revision); + assert_eq!(index(), before_index); + let bad: (Vec, i64) = conn.query_row("SELECT geom, lanes FROM roads WHERE fid=2", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(bad, (unreadable, 2)); + let bounds: (f64, f64, f64, f64) = conn + .query_row("SELECT min_x,min_y,max_x,max_y FROM gpkg_contents", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap(); + assert_eq!(bounds, (0.0, 0.0, 2.0, 3.0)); + } + + #[test] + fn gpkg_conflict_replace_evictions_are_removed_but_earlier_external_deletes_are_not() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + // INSERT OR REPLACE on a unique column deletes row 2 without firing a delete trigger. + conn.execute_batch( + "INSERT INTO roads (fid, name) VALUES (2, 'two'), (3, 'three'); + CREATE UNIQUE INDEX roads_name ON roads(name); + CREATE TRIGGER evict AFTER UPDATE OF name ON roads WHEN NEW.fid = 1 + BEGIN INSERT OR REPLACE INTO roads (fid, name) VALUES (9, 'two'); END;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(read.features.iter().map(|f| f.id).collect::>(), vec![1, 2, 3]); + // Deleted by someone else before this save: not this save's eviction. + conn.execute("DELETE FROM roads WHERE fid = 3", []).unwrap(); + let mut changed = read.features[0].clone(); + changed.attrs[0] = Value::Text("changed".into()); + let edits = Edits { updates: vec![(changed, read.versions[0].1)], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, read.versions.iter().map(|(k, _)| *k), &read.revision).unwrap().unwrap(); + assert_eq!(conn.query_row("SELECT count(*) FROM roads WHERE fid = 2", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + assert_eq!(saved.removed, vec![2], "the evicted resident row is reported; the earlier external delete isn't"); + assert!(saved.features.iter().all(|f| f.id != 9), "an unknown trigger-inserted row must not join the loaded scope"); + } + + #[test] + fn gpkg_metadata_triggers_cannot_change_features_after_spatial_repair() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TRIGGER alter_after_snapshot AFTER UPDATE OF last_change ON gpkg_contents + BEGIN UPDATE roads SET name = 'late mutation'; END;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let loaded = table.read_editable(MAX_EDITABLE).unwrap(); + let mut feature = loaded.features[0].clone(); + feature.attrs[0] = Value::Text("must roll back".into()); + let edits = Edits { updates: vec![(feature, loaded.versions[0].1)], ..Default::default() }; + assert!(table.save(&edits, &table.info().fields, [1].into_iter(), &loaded.revision).is_err()); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.features, loaded.features); + assert_eq!(after.versions, loaded.versions); + assert_eq!(after.revision, loaded.revision); + } + + fn line(x: f64) -> Geometry { + Geometry::Line(vec![vec![Coord::new(x, 2.0), Coord::new(x + 1.0, 3.0)]]) + } + + fn fixture() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("roads.gpkg"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE gpkg_spatial_ref_sys (srs_id INTEGER PRIMARY KEY, organization TEXT, organization_coordsys_id INTEGER); + INSERT INTO gpkg_spatial_ref_sys VALUES (4326, 'EPSG', 4326); + CREATE TABLE gpkg_contents (table_name TEXT PRIMARY KEY, last_change TEXT, min_x DOUBLE, min_y DOUBLE, max_x DOUBLE, max_y DOUBLE); + INSERT INTO gpkg_contents VALUES ('roads', '2026-01-01T00:00:00.000Z', 0, 0, 2, 3); + CREATE TABLE gpkg_geometry_columns (table_name TEXT, column_name TEXT, geometry_type_name TEXT, srs_id INTEGER, z INTEGER, m INTEGER); + INSERT INTO gpkg_geometry_columns VALUES ('roads', 'geom', 'LINESTRING', 4326, 0, 0); + CREATE TABLE roads (fid INTEGER PRIMARY KEY, geom LINESTRING, name TEXT DEFAULT 'default', lanes INTEGER DEFAULT 2, photo BLOB); + CREATE TABLE other (v TEXT); + INSERT INTO other VALUES ('keep');", + ).unwrap(); + let g = line(1.0); + conn.execute( + "INSERT INTO roads VALUES (1, ?1, 'old', 2, x'00ff')", + [header_blob(&wkb::encode_single(&g).unwrap(), &g.extent(), 4326)], + ) + .unwrap(); + (dir, path) + } + + fn blob_at(path: &Path, id: i64) -> Vec { + Connection::open(path).unwrap().query_row("SELECT geom FROM roads WHERE fid = ?1", [id], |r| r.get(0)).unwrap() + } + + fn update(table: &EditTable, geometry: Option) -> Edits { + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut f = read.features[0].clone(); + f.geometry = geometry; + Edits { updates: vec![(f, read.versions[0].1)], ..Default::default() } + } + + #[test] + fn gpkg_single_lines_and_transaction_rollback() { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "ROADS").unwrap(); + let saved = table.save(&update(&table, Some(line(4.0))), &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap(); + assert_eq!(saved.changes.updated, 1); + let bytes = blob_at(&path, 1); + let (_, body) = split_blob(&bytes).unwrap(); + assert_eq!(body.get(1..5), Some(2u32.to_le_bytes().as_slice())); + assert_eq!(wkb::decode(body), Some(line(4.0))); + assert_eq!(saved.changes.versions, table.read_editable(MAX_EDITABLE).unwrap().versions); + let part = vec![Coord::new(5.0, 2.0), Coord::new(6.0, 3.0)]; + let multipart = Geometry::Line(vec![part.clone(), part]); + assert!( + table + .save(&update(&table, Some(multipart)), &table.info().fields, std::iter::empty(), &saved.revision) + .unwrap_err() + .to_string() + .contains("holds one part") + ); + assert_eq!(blob_at(&path, 1), bytes); + + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TRIGGER no_bad BEFORE INSERT ON roads WHEN NEW.name = 'bad' BEGIN SELECT RAISE(ABORT, 'bad name'); END;", + ) + .unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = read.features[0].clone(); + changed.attrs[0] = Value::Text("partial".into()); + let new = Feature { id: 100, geometry: Some(line(7.0)), attrs: vec![Value::Text("bad".into()), Value::Null] }; + let edits = Edits { updates: vec![(changed, read.versions[0].1)], inserts: vec![new], ..Default::default() }; + let before: String = conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get(0)).unwrap(); + assert!(table.save(&edits, &table.info().fields, std::iter::empty(), &read.revision).unwrap_err().to_string().contains("bad name")); + assert_eq!(conn.query_row("SELECT name FROM roads WHERE fid = 1", [], |r| r.get::<_, String>(0)).unwrap(), "old"); + assert_eq!(conn.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), before); + assert_eq!(blob_at(&path, 1), bytes); + } + + #[test] + fn gpkg_conflicts_fingerprint_unexposed_columns() { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + let edits = update(&table, Some(line(4.0))); + let conn = Connection::open(&path).unwrap(); + conn.execute("UPDATE roads SET photo = x'01ff' WHERE fid = 1", []).unwrap(); + assert_eq!( + table.save(&edits, &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap_err(), + vec![Conflict::Changed(1)] + ); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), REVISION); + conn.execute("DELETE FROM roads WHERE fid = 1", []).unwrap(); + assert_eq!( + table.save(&edits, &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap_err(), + vec![Conflict::Deleted(1)] + ); + assert_ne!( + fingerprint([ValueRef::Text(b"a"), ValueRef::Text(b"bc")].into_iter()), + fingerprint([ValueRef::Text(b"ab"), ValueRef::Text(b"c")].into_iter()) + ); + assert_ne!(fingerprint([ValueRef::Text(b"1")].into_iter()), fingerprint([ValueRef::Blob(b"1")].into_iter())); + assert_ne!(fingerprint([ValueRef::Integer(1)].into_iter()), fingerprint([ValueRef::Real(1.0)].into_iter())); + } + + #[test] + fn gpkg_editable_keeps_null_and_empty_but_skips_bad_rows() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute( + "INSERT INTO roads (fid, geom) VALUES (2, NULL), (3, ?1), (4, x'47500003'), (5, 'not geometry')", + [header_blob(&[], &Extent::EMPTY, 4326)], + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!((read.matched, read.skipped, read.features.len()), (5, 2, 3)); + assert_eq!(read.versions.iter().map(|(k, _)| *k).collect::>(), vec![1, 2, 3]); + assert!(read.features[1..].iter().all(|f| f.geometry.is_none())); + assert_eq!(table.read_editable(0).unwrap().features.len(), 1); + let edits = Edits { deletes: read.versions, ..Default::default() }; + assert_eq!(table.save(&edits, &table.info().fields, std::iter::empty(), &read.revision).unwrap().unwrap().changes.deleted, 3); + assert_eq!(conn.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + assert_eq!(conn.query_row("SELECT geom FROM roads WHERE fid = 4", [], |r| r.get::<_, Vec>(0)).unwrap(), b"GP\0\x03"); + } + + #[test] + fn gpkg_zm_refuses_geometry_removal_but_allows_attributes() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + // Optional Z with an actual ISO XYZ line: the read geometry deliberately drops Z. + conn.execute("UPDATE gpkg_geometry_columns SET z = 2", []).unwrap(); + let mut body = vec![1]; + body.extend_from_slice(&1002u32.to_le_bytes()); + body.extend_from_slice(&2u32.to_le_bytes()); + for v in [1.0_f64, 2.0, 9.0, 2.0, 3.0, 10.0] { + body.extend_from_slice(&v.to_le_bytes()); + } + let original = header_blob(&body, &line(1.0).extent(), 4326); + conn.execute("UPDATE roads SET geom = ?1 WHERE fid = 1", [&original]).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + for g in [None, Some(line(6.0))] { + assert!( + table + .save(&update(&table, g), &table.info().fields, std::iter::empty(), REVISION) + .unwrap_err() + .to_string() + .contains("Z or M") + ); + assert_eq!(blob_at(&path, 1), original); + } + let mut edits = update(&table, Some(line(1.0))); + edits.updates[0].0.attrs[0] = Value::Text("attributes only".into()); + let saved = table.save(&edits, &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap(); + assert_eq!(saved.changes.updated, 1); + assert_eq!(blob_at(&path, 1), original); + conn.execute("UPDATE gpkg_geometry_columns SET z = 1", []).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let edits = Edits { inserts: vec![Feature { id: 100, geometry: Some(line(4.0)), attrs: vec![] }], ..Default::default() }; + assert!( + table.save(&edits, &table.info().fields, std::iter::empty(), &saved.revision).unwrap_err().to_string().contains("needs Z or M") + ); + let edits = Edits { inserts: vec![Feature { id: 100, geometry: None, attrs: vec![] }], ..Default::default() }; + assert_eq!( + table.save(&edits, &table.info().fields, std::iter::empty(), &saved.revision).unwrap().unwrap().changes.inserted, + vec![(100, 2)] + ); + } + + #[test] + fn gpkg_untriggered_rtree_metadata_and_generated_keys() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id, minx, maxx, miny, maxy); + INSERT INTO rtree_roads_geom VALUES (1, 1, 2, 2, 3);", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let saved = table.save(&update(&table, Some(line(10.0))), &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap(); + let envelope: (f64, f64, f64, f64) = conn + .query_row("SELECT minx, maxx, miny, maxy FROM rtree_roads_geom WHERE id = 1", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + }) + .unwrap(); + assert_eq!(envelope, (10.0, 11.0, 2.0, 3.0)); + assert_eq!(conn.query_row("SELECT max_x FROM gpkg_contents", [], |r| r.get::<_, f64>(0)).unwrap(), 11.0); + assert_ne!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), REVISION); + table.save(&update(&table, None), &table.info().fields, std::iter::empty(), &saved.revision).unwrap().unwrap(); + assert_eq!(conn.query_row("SELECT count(*) FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let edits = Edits { + deletes: read.versions, + inserts: vec![Feature { id: 100, geometry: Some(line(20.0)), attrs: vec![] }], + ..Default::default() + }; + let saved = table.save(&edits, &table.info().fields, std::iter::empty(), &read.revision).unwrap().unwrap(); + assert_eq!(saved.changes.inserted, vec![(100, 2)]); + assert_eq!(saved.changes.deleted, 1); + assert_eq!(conn.query_row("SELECT id FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + assert_eq!(conn.query_row("SELECT name FROM roads", [], |r| r.get::<_, String>(0)).unwrap(), "default"); + assert_eq!(conn.query_row("SELECT v FROM other", [], |r| r.get::<_, String>(0)).unwrap(), "keep"); + } + + #[test] + fn gpkg_innocuous_functions_work_from_untrusted_triggers() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id, minx, maxx, miny, maxy); + INSERT INTO rtree_roads_geom VALUES (1, 1, 2, 2, 3); + CREATE TRIGGER spatial_update AFTER UPDATE OF geom ON roads + BEGIN DELETE FROM rtree_roads_geom WHERE id = OLD.fid; + INSERT INTO rtree_roads_geom SELECT NEW.fid, ST_MinX(NEW.geom), ST_MaxX(NEW.geom), ST_MinY(NEW.geom), ST_MaxY(NEW.geom) + WHERE NEW.geom IS NOT NULL AND NOT ST_IsEmpty(NEW.geom); + END;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let saved = table.save(&update(&table, Some(line(8.0))), &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap(); + assert_eq!(conn.query_row("SELECT maxx FROM rtree_roads_geom", [], |r| r.get::<_, f64>(0)).unwrap(), 9.0); + table.save(&update(&table, None), &table.info().fields, std::iter::empty(), &saved.revision).unwrap().unwrap(); + assert_eq!(conn.query_row("SELECT count(*) FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + register_functions(&conn).unwrap(); + for sql in ["SELECT ST_IsEmpty(NULL)", "SELECT ST_MinX('text')", "SELECT ST_MaxY(x'00')"] { + assert_eq!(conn.query_row(sql, [], |r| r.get::<_, Option>(0)).unwrap(), None); + } + let raw = wkb::encode_single(&line(5.0)).unwrap(); + assert_eq!(conn.query_row("SELECT ST_MaxX(?1)", [raw], |r| r.get::<_, f64>(0)).unwrap(), 6.0); + } + + #[test] + fn gpkg_native_reprojection_writes_native_envelopes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mercator.gpkg"); + let crs = Crs::from_epsg(3857).unwrap(); + let mut ds = crate::Dataset::new("roads", crate::GeometryType::Polyline, vec![]); + ds.push(Some(line(1_000_000.0)), vec![]); + super::super::write_in(&ds, &path, crs).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + assert_eq!(table.crs(), Some(crs)); + let mut edits = update(&table, None); + let original = table.read_editable(MAX_EDITABLE).unwrap().features[0].geometry.clone().unwrap(); + let moved = original.map_coords(&|c| Coord::new(c.x + 1.0, c.y + 1.0)); + let expected = table.src.reproject.to_native(&moved).unwrap(); + edits.updates[0].0.geometry = Some(moved.clone()); + let revision = table.read_editable(MAX_EDITABLE).unwrap().revision; + table.save(&edits, &[], std::iter::empty(), &revision).unwrap().unwrap(); + let stored = blob_at(&path, 1); + let (envelope, body) = split_blob(&stored).unwrap(); + assert_eq!(envelope, Some(expected.extent())); + assert_eq!(wkb::decode(body), Some(expected.clone())); + let conn = Connection::open(&path).unwrap(); + let minx: f64 = conn.query_row("SELECT minx FROM rtree_roads_geom WHERE id = 1", [], |r| r.get(0)).unwrap(); + assert!((minx - expected.extent().min_x).abs() < 1.0); + let reloaded = table.read_editable(MAX_EDITABLE).unwrap().features[0].geometry.clone().unwrap(); + let mut expected_coords = Vec::new(); + moved.for_each_coord(|c| expected_coords.push(c)); + let mut actual_coords = Vec::new(); + reloaded.for_each_coord(|c| actual_coords.push(c)); + assert!(expected_coords.iter().zip(actual_coords).all(|(a, b)| (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6)); + } + + #[test] + fn gpkg_fields_match_by_name_and_unstored_edits_are_unchanged() { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let fields = vec![ + Field::new("missing", crate::FieldType::Text), + Field::new("NAME", crate::FieldType::Text), + Field::new("fid", crate::FieldType::Integer), + ]; + let mut f = read.features[0].clone(); + f.attrs = vec![Value::Text("not saved".into()), Value::Text("old".into()), Value::Int(999)]; + let mut edits = Edits { updates: vec![(f, read.versions[0].1)], ..Default::default() }; + let saved = table.save(&edits, &fields, std::iter::empty(), &read.revision).unwrap().unwrap(); + assert_eq!((saved.changes.unchanged, saved.changes.updated), (1, 0)); + assert_eq!(saved.changes.skipped_fields, vec!["missing", "fid"]); + let mut expected = edits.updates[0].0.clone(); + expected.attrs[2] = Value::Int(1); + assert_eq!(saved.features, vec![expected]); + edits.updates[0].0.attrs[1] = Value::Text("new".into()); + assert_eq!(table.save(&edits, &fields, std::iter::empty(), &saved.revision).unwrap().unwrap().changes.updated, 1); + let conn = Connection::open(&path).unwrap(); + let stored: (i64, String, i64, Vec) = + conn.query_row("SELECT fid, name, lanes, photo FROM roads", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))).unwrap(); + assert_eq!(stored, (1, "new".into(), 2, vec![0, 255])); + conn.execute("ALTER TABLE roads RENAME COLUMN geom TO shape", []).unwrap(); + assert!(table.save(&Edits::default(), &fields, std::iter::empty(), REVISION).unwrap_err().to_string().contains("changed shape")); + } + + #[test] + fn gpkg_saved_snapshots_include_defaults_triggers_and_supplied_field_order() { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + let conn = Connection::open(&path).unwrap(); + // This column appears after open: snapshots must use the transaction's column list. + conn.execute_batch( + "ALTER TABLE roads ADD COLUMN added TEXT DEFAULT 'added-default'; + CREATE TRIGGER stored_insert AFTER INSERT ON roads BEGIN + UPDATE roads SET name = upper(NEW.name), lanes = NEW.lanes + 5, + geom = (SELECT geom FROM roads WHERE fid = 1) WHERE fid = NEW.fid; + END; + CREATE TRIGGER stored_update AFTER UPDATE OF name ON roads + WHEN NEW.name != upper(NEW.name) BEGIN + UPDATE roads SET name = upper(NEW.name), lanes = NEW.lanes + 1, geom = NULL WHERE fid = NEW.fid; + END;", + ) + .unwrap(); + let fields = vec![ + Field::new("lanes", crate::FieldType::Integer), + Field::new("local", crate::FieldType::Text), + Field::new("NAME", crate::FieldType::Text), + Field::new("added", crate::FieldType::Text), + Field::new("fid", crate::FieldType::Integer), + ]; + let version = EditTable::open(&path, "roads").unwrap().read_editable(MAX_EDITABLE).unwrap().versions[0].1; + let unchanged = Feature { + id: 1, + geometry: Some(line(1.0)), + attrs: vec![ + Value::Int(2), + Value::Text("kept".into()), + Value::Text("old".into()), + Value::Text("added-default".into()), + Value::Int(999), + ], + }; + let inserted = Feature { + id: 100, + geometry: None, + attrs: vec![Value::Null, Value::Text("insert-only".into()), Value::Null, Value::Null, Value::Int(999)], + }; + let edits = Edits { updates: vec![(unchanged, version)], inserts: vec![inserted], ..Default::default() }; + let saved = table.save(&edits, &fields, [1].into_iter(), REVISION).unwrap().unwrap(); + assert_eq!((saved.changes.unchanged, saved.changes.updated), (1, 0)); + assert_eq!(saved.changes.inserted, vec![(100, 2)]); + assert_eq!(saved.features.len(), 2); + assert_eq!(saved.unstored_fields, vec![1]); + assert_eq!(saved.features[0].attrs[1], Value::Text("kept".into())); + assert_eq!(saved.features[0].attrs[4], Value::Int(1)); + assert_eq!( + saved.features[1], + Feature { + id: 2, + geometry: Some(line(1.0)), + attrs: vec![ + Value::Int(7), + Value::Text("insert-only".into()), + Value::Text("DEFAULT".into()), + Value::Text("added-default".into()), + Value::Int(2), + ], + } + ); + let reopened = EditTable::open(&path, "roads").unwrap(); + assert_eq!(saved.changes.versions, reopened.read_editable(MAX_EDITABLE).unwrap().versions); + + let mut changed = saved.features[1].clone(); + changed.attrs[2] = Value::Text("edited".into()); + changed.geometry = Some(line(8.0)); + let edits = Edits { updates: vec![(changed, saved.changes.versions[1].1)], ..Default::default() }; + let saved = table.save(&edits, &fields, [1, 2].into_iter(), &saved.revision).unwrap().unwrap(); + assert_eq!(saved.changes.updated, 1); + assert_eq!(saved.features[0].geometry, None); + assert_eq!(saved.features[0].attrs[0], Value::Int(8)); + assert_eq!(saved.features[0].attrs[1], Value::Text("insert-only".into())); + assert_eq!(saved.features[0].attrs[2], Value::Text("EDITED".into())); + let read = reopened.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(saved.changes.versions, vec![read.versions[1]]); + // Reusing the returned snapshot and version must not reset trigger-produced values. + let edits = Edits { updates: vec![(saved.features[0].clone(), saved.changes.versions[0].1)], ..Default::default() }; + let saved_again = table.save(&edits, &fields, [1, 2].into_iter(), &saved.revision).unwrap().unwrap(); + assert_eq!((saved_again.changes.unchanged, saved_again.changes.updated), (1, 0)); + assert_eq!(saved_again.features, saved.features); + } + + #[test] + fn gpkg_unreadable_or_removed_stored_rows_roll_back() { + for operation in ["INSERT", "UPDATE"] { + for (action, message) in [ + ("UPDATE roads SET geom = x'00' WHERE fid = NEW.fid", "stored geometry can't be read"), + ("DELETE FROM roads WHERE fid = NEW.fid", "removed while saving"), + ] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch(&format!( + "CREATE TRIGGER break_stored AFTER {operation} ON roads WHEN NEW.name = 'broken' + BEGIN {action}; END;" + )) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = read.features[0].clone(); + changed.attrs[0] = Value::Text(if operation == "UPDATE" { "broken" } else { "partial" }.into()); + let mut edits = Edits { updates: vec![(changed, read.versions[0].1)], ..Default::default() }; + if operation == "INSERT" { + edits.inserts.push(Feature { + id: 100, + geometry: Some(line(4.0)), + attrs: vec![Value::Text("broken".into()), Value::Null], + }); + } + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &read.revision).unwrap_err(); + assert!(error.to_string().contains(message), "{operation}: {error}"); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.features, read.features); + assert_eq!(after.versions, read.versions); + assert_eq!(after.matched, 1); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), read.revision); + } + } + } + + #[test] + fn gpkg_reserves_loaded_keys_after_external_highest_key_deletion() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("INSERT INTO roads (fid) VALUES (-1), (2), (3)").unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + conn.execute("DELETE FROM roads WHERE fid = 3", []).unwrap(); + let edits = Edits { + inserts: vec![Feature { id: 999, geometry: None, attrs: vec![Value::Text("new".into()), Value::Null] }], + ..Default::default() + }; + let saved = table.save(&edits, &table.info().fields, read.versions.iter().map(|(k, _)| *k), &read.revision).unwrap().unwrap(); + assert_eq!(saved.changes.inserted, vec![(999, 4)]); + assert_eq!(saved.features[0].id, 4); + assert_eq!(conn.query_row("SELECT count(*) FROM roads WHERE fid IN (3, 999)", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + assert_eq!(saved.changes.versions, vec![table.read_editable(MAX_EDITABLE).unwrap().versions[3]]); + } + + #[test] + fn gpkg_generated_keys_respect_autoincrement_history() { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "ALTER TABLE roads RENAME TO old_roads; + CREATE TABLE roads (fid INTEGER PRIMARY KEY AUTOINCREMENT, geom LINESTRING, name TEXT DEFAULT 'default', lanes INTEGER DEFAULT 2, photo BLOB); + INSERT INTO roads SELECT * FROM old_roads; + DROP TABLE old_roads; + INSERT INTO roads (fid) VALUES (100); + DELETE FROM roads WHERE fid = 100;", + ) + .unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let edits = Edits { inserts: vec![Feature { id: 999, geometry: None, attrs: vec![] }], ..Default::default() }; + let saved = table.save(&edits, &table.info().fields, [70].into_iter(), REVISION).unwrap().unwrap(); + assert_eq!(saved.changes.inserted, vec![(999, 101)]); + assert_eq!(saved.features[0].id, 101); + assert_eq!(conn.query_row("SELECT seq FROM sqlite_sequence WHERE name = 'roads'", [], |r| r.get::<_, i64>(0)).unwrap(), 101); + } + + #[test] + fn gpkg_key_exhaustion_rolls_back_updates_and_earlier_inserts() { + for high_water_source in ["table", "reserved", "sequence"] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + if high_water_source == "sequence" { + conn.execute_batch( + "ALTER TABLE roads RENAME TO old_roads; + CREATE TABLE roads (fid INTEGER PRIMARY KEY AUTOINCREMENT, geom LINESTRING, name TEXT DEFAULT 'default', lanes INTEGER DEFAULT 2, photo BLOB); + INSERT INTO roads SELECT * FROM old_roads; + DROP TABLE old_roads;", + ) + .unwrap(); + } + if high_water_source != "reserved" { + conn.execute("INSERT INTO roads (fid) VALUES (?1)", [i64::MAX - 1]).unwrap(); + if high_water_source == "sequence" { + conn.execute("DELETE FROM roads WHERE fid = ?1", [i64::MAX - 1]).unwrap(); + } + } + let table = EditTable::open(&path, "roads").unwrap(); + let before = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = before.features[0].clone(); + changed.attrs[0] = Value::Text("partial".into()); + let edits = Edits { + updates: vec![(changed, before.versions[0].1)], + inserts: vec![Feature { id: 100, geometry: None, attrs: vec![] }, Feature { id: 101, geometry: None, attrs: vec![] }], + ..Default::default() + }; + let reserved = if high_water_source == "reserved" { (i64::MAX - 1).cast_unsigned() } else { 1 }; + let error = table.save(&edits, &table.info().fields, [reserved].into_iter(), &before.revision).unwrap_err(); + assert!(error.to_string().contains("no positive INTEGER PRIMARY KEY"), "{high_water_source}: {error}"); + let after = table.read_editable(MAX_EDITABLE).unwrap(); + assert_eq!(after.features, before.features, "{high_water_source}"); + assert_eq!(after.versions, before.versions, "{high_water_source}"); + assert_eq!(after.matched, before.matched, "{high_water_source}"); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), before.revision); + if high_water_source == "sequence" { + assert_eq!( + conn.query_row("SELECT seq FROM sqlite_sequence WHERE name = 'roads'", [], |r| r.get::<_, i64>(0)).unwrap(), + i64::MAX - 1 + ); + } + } + } + + #[test] + fn gpkg_declared_geometry_mismatches_roll_back_updates_and_inserts() { + for (ty, geometry) in [ + ("POINT", line(4.0)), + ("LINESTRING", Geometry::Point(Coord::new(4.0, 5.0))), + ("POLYGON", line(4.0)), + ("MULTIPOINT", line(4.0)), + ("MULTILINESTRING", Geometry::Point(Coord::new(4.0, 5.0))), + ("MULTIPOLYGON", line(4.0)), + ("GEOMETRYCOLLECTION", Geometry::Point(Coord::new(4.0, 5.0))), + ] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute("UPDATE gpkg_geometry_columns SET geometry_type_name = ?1", [ty]).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + let original = blob_at(&path, 1); + assert!( + table.save(&update(&table, Some(geometry.clone())), &table.info().fields, std::iter::empty(), REVISION).is_err(), + "{ty}" + ); + assert_eq!(blob_at(&path, 1), original, "{ty}"); + let read = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = read.features[0].clone(); + changed.attrs[0] = Value::Text("must roll back".into()); + let inserted = Feature { id: 99, geometry: Some(geometry), attrs: vec![Value::Text("new".into()), Value::Null] }; + let edits = Edits { updates: vec![(changed, read.versions[0].1)], inserts: vec![inserted], ..Default::default() }; + assert!(table.save(&edits, &table.info().fields, std::iter::empty(), &read.revision).is_err(), "{ty}"); + let stored: (i64, String) = conn.query_row("SELECT count(*), name FROM roads", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(stored, (1, "old".into()), "{ty}"); + assert_eq!(blob_at(&path, 1), original, "{ty}"); + } + } + + #[test] + fn gpkg_geometry_writes_keep_the_declared_single_or_multi_type() { + let point = Coord::new(4.0, 5.0); + let polygon = Geometry::Polygon(vec![vec![vec![point, Coord::new(5.0, 5.0), Coord::new(5.0, 6.0), point]]]); + for (ty, geometry, wkb_type) in [ + ("POINT", Geometry::MultiPoint(vec![point]), 1_u32), + ("LINESTRING", line(4.0), 2), + ("POLYGON", polygon.clone(), 3), + ("MULTIPOINT", Geometry::Point(point), 4), + ("MULTILINESTRING", line(4.0), 5), + ("MULTIPOLYGON", polygon, 6), + ("GEOMETRY", Geometry::Point(point), 1), + ] { + let (_dir, path) = fixture(); + Connection::open(&path).unwrap().execute("UPDATE gpkg_geometry_columns SET geometry_type_name = ?1", [ty]).unwrap(); + let table = EditTable::open(&path, "roads").unwrap(); + table.save(&update(&table, Some(geometry)), &table.info().fields, std::iter::empty(), REVISION).unwrap().unwrap(); + let bytes = blob_at(&path, 1); + let (_, body) = split_blob(&bytes).unwrap(); + assert_eq!(body.get(1..5), Some(wkb_type.to_le_bytes().as_slice()), "{ty}"); + } + } + + #[test] + fn gpkg_rejects_non_integer_and_composite_primary_keys() { + for schema in [ + "CREATE TABLE roads (fid INT PRIMARY KEY, geom BLOB, name TEXT)", + "CREATE TABLE roads (fid INTEGER, geom BLOB, name TEXT, PRIMARY KEY (fid, name))", + "CREATE TABLE roads (fid INTEGER, geom BLOB, name TEXT)", + ] { + let (_dir, path) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("DROP TABLE roads").unwrap(); + conn.execute_batch(schema).unwrap(); + let result = EditTable::open(&path, "roads"); + assert!(matches!(result, Err(DataError::Format(e)) if e.contains("no INTEGER PRIMARY KEY"))); + } + } +} diff --git a/crates/data/src/source/mod.rs b/crates/data/src/source/mod.rs index d787d7250..0635d8287 100644 --- a/crates/data/src/source/mod.rs +++ b/crates/data/src/source/mod.rs @@ -10,6 +10,7 @@ pub mod credentials; #[cfg(not(target_arch = "wasm32"))] pub mod duckdb; +pub mod edits; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod external; pub mod fgb; @@ -596,6 +597,18 @@ impl Reproject { ok.then_some(out) } + /// A longitude/latitude geometry in the source's coordinates. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn to_native(&self, g: &Geometry) -> Option { + let out = match &self.t { + Some(t) => g.map_coords(&|c| t.from_lonlat(c).unwrap_or(Coord::new(f64::NAN, f64::NAN))), + None => g.clone(), + }; + let mut ok = true; + out.for_each_coord(|c| ok &= c.is_finite()); + ok.then_some(out) + } + /// A native extent in longitude/latitude. pub(crate) fn extent_to_lonlat(&self, e: &Extent) -> Extent { match &self.t { diff --git a/crates/data/src/source/postgis.rs b/crates/data/src/source/postgis.rs index a6f202485..f2e7abfd0 100644 --- a/crates/data/src/source/postgis.rs +++ b/crates/data/src/source/postgis.rs @@ -11,6 +11,7 @@ //! What a server sends is hostile input: geometry is decoded by [`wkb`], reads are bounded by //! `LIMIT`, and statements time out. +use super::edits::{Conflict, Edits, Saved}; use super::{Batch, Family, FeatureSource, Reproject, Request, SourceInfo, clamp_limit, in_request, stride}; use crate::raster::Crs; use crate::{DataError, Feature, Field, FieldType, GeometryType, Result, Value, wkb}; @@ -1081,61 +1082,6 @@ impl PostgisSource { // --------------------------------------------------------------------------------------------- // Saving edits -/// Edits to save to a table ([`PostgisSource::save`]), by its key. -#[derive(Debug, Default)] -pub struct Edits { - /// Rows changed: each as it is now, with the version it was read at. - pub updates: Vec<(Feature, i64)>, - /// Rows deleted: their keys and the versions they were read at. - pub deletes: Vec<(u64, i64)>, - /// New rows. Each gets its key field's value when that is a whole number, else the table's - /// default key when it has one, else its feature id. - pub inserts: Vec, -} - -impl Edits { - pub fn is_empty(&self) -> bool { - self.updates.is_empty() && self.deletes.is_empty() && self.inserts.is_empty() - } -} - -/// Why edits weren't saved: a row in the database isn't as it was read. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Conflict { - /// A row changed in the database since it was read. - Changed(u64), - /// A row deleted from the database since it was read. - Deleted(u64), - /// A new row's key is already taken. - Exists(u64), -} - -impl std::fmt::Display for Conflict { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Keys keep their sign (feature ids hold their bits). - match self { - Conflict::Changed(k) => write!(f, "row {} was changed in the database since it was read", k.cast_signed()), - Conflict::Deleted(k) => write!(f, "row {} was deleted from the database since it was read", k.cast_signed()), - Conflict::Exists(k) => write!(f, "a row with key {} is already in the database", k.cast_signed()), - } - } -} - -/// What saving edits did. -#[derive(Debug, Default)] -pub struct Saved { - /// Each new row's feature id and the key it was saved with. - pub inserted: Vec<(u64, u64)>, - pub updated: usize, - /// Rows edited only in what the table doesn't hold, left as they are. - pub unchanged: usize, - pub deleted: usize, - /// The version of each row inserted or updated, as saved. - pub versions: Vec<(u64, i64)>, - /// The layer's fields the table has no column for, which weren't saved. - pub skipped_fields: Vec, -} - /// A column a value is written to: the layer's field, its type in the table, and the field of /// the rows as read that holds its value. struct Target { diff --git a/crates/data/src/wkb.rs b/crates/data/src/wkb.rs index d2d007074..55fa53d36 100644 --- a/crates/data/src/wkb.rs +++ b/crates/data/src/wkb.rs @@ -66,6 +66,51 @@ pub fn encode(g: &Geometry) -> Vec { out } +/// Encodes a geometry only when it fits a single-type ISO WKB column. +pub fn encode_single(g: &Geometry) -> Option> { + let (ty, count) = match g { + Geometry::Point(_) => return Some(encode(g)), + Geometry::MultiPoint(v) if v.len() == 1 => return v.first().map(|c| encode(&Geometry::Point(*c))), + Geometry::Line(p) if p.len() == 1 => (2_u32, p.first()?.len()), + Geometry::Polygon(p) if p.len() == 1 => (3_u32, p.first()?.len()), + _ => return None, + }; + let mut out = Vec::with_capacity(wkb_size(g).checked_sub(9)?); + out.push(1); + out.extend_from_slice(&ty.to_le_bytes()); + out.extend_from_slice(&u32::try_from(count).ok()?.to_le_bytes()); + match g { + Geometry::Line(p) => { + for c in p.first()? { + out.extend_from_slice(&c.x.to_le_bytes()); + out.extend_from_slice(&c.y.to_le_bytes()); + } + } + Geometry::Polygon(p) => { + for r in p.first()? { + out.extend_from_slice(&u32::try_from(r.len()).ok()?.to_le_bytes()); + for c in r { + out.extend_from_slice(&c.x.to_le_bytes()); + out.extend_from_slice(&c.y.to_le_bytes()); + } + } + } + _ => return None, + } + Some(out) +} + +/// Whether a WKB header carries Z or M dimensions. +pub fn has_zm(bytes: &[u8]) -> bool { + let Some(raw) = bytes.get(1..5).and_then(|b| <[u8; 4]>::try_from(b).ok()) else { return false }; + let ty = match bytes.first() { + Some(0) => u32::from_be_bytes(raw), + Some(1) => u32::from_le_bytes(raw), + _ => return false, + }; + ty & 0xC000_0000 != 0 || (ty & 0x0FFF_FFFF) / 1000 > 0 +} + /// Encoded width: 16 bytes per coordinate, 4 per count and 5 per geometry head. fn wkb_size(g: &Geometry) -> usize { let ring = |r: &[Coord]| 4 + 16 * r.len(); @@ -270,6 +315,38 @@ mod tests { Geometry::Polygon(vec![vec![r]]) } + #[test] + fn single_columns_keep_their_type_and_reject_multiple_parts() { + let line = vec![Coord::new(0.0, 0.0), Coord::new(1.0, 1.0)]; + for (g, ty) in [(Geometry::Point(Coord::new(2.0, 3.0)), 1_u32), (Geometry::Line(vec![line.clone()]), 2), (square(), 3)] { + let bytes = encode_single(&g).unwrap(); + assert_eq!(bytes.get(1..5), Some(ty.to_le_bytes().as_slice())); + assert_eq!(decode(&bytes), Some(g)); + } + let point = Coord::new(2.0, 3.0); + assert_eq!(encode_single(&Geometry::MultiPoint(vec![point])), Some(encode(&Geometry::Point(point)))); + assert_eq!(encode_single(&Geometry::MultiPoint(vec![point, point])), None); + assert_eq!(encode_single(&Geometry::Line(vec![line.clone(), line])), None); + assert_eq!(encode_single(&Geometry::Polygon(vec![vec![], vec![]])), None); + } + + #[test] + fn dimension_headers_detect_iso_and_ewkb_in_both_byte_orders() { + for (ty, expected) in + [(1_u32, false), (1002, true), (2003, true), (3001, true), (0x8000_0001, true), (0x4000_0001, true), (0x2000_0001, false)] + { + for little in [false, true] { + let mut bytes = vec![u8::from(little)]; + bytes.extend_from_slice(&if little { ty.to_le_bytes() } else { ty.to_be_bytes() }); + assert_eq!(has_zm(&bytes), expected); + for n in 0..5 { + assert!(!has_zm(&bytes[..n])); + } + } + } + assert!(!has_zm(&[7, 255, 255, 255, 255])); + } + #[test] fn round_trips() { for g in [ diff --git a/crates/engine/src/cmd/gpkg.rs b/crates/engine/src/cmd/gpkg.rs new file mode 100644 index 000000000..b4a89114f --- /dev/null +++ b/crates/engine/src/cmd/gpkg.rs @@ -0,0 +1,317 @@ +//! Desktop editing of one existing local GeoPackage feature table, saved atomically. + +#[cfg(not(target_arch = "wasm32"))] +use super::{f, ps}; +use crate::{CmdResult, CommandSpec as C, Session}; +#[cfg(not(target_arch = "wasm32"))] +use rustgis_data::source::gpkg::edit::{EditTable, MAX_EDITABLE}; +#[cfg(not(target_arch = "wasm32"))] +use rustgis_map::source::versions_of; +use serde_json::{Value, json}; + +pub fn commands() -> Vec { + vec![ + C::new("gpkg.addLayer", "Edit GeoPackage Table", "Home › Add › Add Data", add_layer).params( + r#"{"path": "a GeoPackage file on this computer", "table"?: "its feature table (needed when it has several)", "limit"?: "the most rows (default and most: 100000)", "name"?: string}"#, + ), + C::new("gpkg.pendingEdits", "GeoPackage Edits", "Edit › Manage Edits", pending_edits).view() + .params(r#"{"layer"?: "a layer added with gpkg.addLayer (default: the active one)"}"#), + C::new("gpkg.saveEdits", "Save to GeoPackage", "Edit › Manage Edits", save_edits) + .params(r#"{"layer"?: "a layer added with gpkg.addLayer (default: the active one)"}"#), + C::new("gpkg.discardEdits", "Discard GeoPackage Edits", "Edit › Manage Edits", discard_edits) + .params(r#"{"layer"?: "a layer added with gpkg.addLayer (default: the active one)"}"#), + ] +} + +fn gpkg_layer(s: &Session, p: &Value) -> Result<(u32, String, rustgis_map::source::GpkgEdit), String> { + let id = s.layer_id(p)?; + let l = s.map().layer(id).ok_or("no such layer")?; + let edit = l + .gpkg + .clone() + .ok_or_else(|| format!("{}: not a GeoPackage layer loaded for editing (Home › Add Data › Edit GeoPackage Table…)", l.name))?; + Ok((id, l.name.clone(), edit)) +} + +fn pending_edits(s: &mut Session, p: &Value) -> CmdResult { + let (id, name, edit) = gpkg_layer(s, p)?; + let l = s.map().layer(id).ok_or("no such layer")?; + let c = edit.versions.changes(&l.dataset.features); + Ok(json!({"layer": name, "file": edit.path, "table": edit.table, + "inserts": c.inserts.len(), "updates": c.updates.len(), "deletes": c.deletes.len(), "writable": edit.writable})) +} + +fn add_layer(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p); + Err("GeoPackage tables are edited in the desktop app".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::path::Path; + let path = ps(p, "path").map(str::trim).filter(|v| !v.is_empty()).ok_or("path is required")?; + let path = Path::new(path); + if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("gpkg")) { + return Err(format!("{}: not a GeoPackage (.gpkg)", path.display())); + } + if !path.is_file() { + return Err(format!("{}: no such file", path.display())); + } + let path = std::path::absolute(path).map_err(|e| format!("{}: {e}", path.display()))?; + let file = path.file_name().unwrap_or_default().to_string_lossy(); + let err = |e: rustgis_data::DataError| format!("{file}: {e}"); + let table = match ps(p, "table").map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => t.to_string(), + None => { + let tables = rustgis_data::source::layers(&path).map_err(err)?; + if tables.len() != 1 { + return Err(format!("{file}: {} feature tables ({}); choose one with table", tables.len(), tables.join(", "))); + } + tables.first().cloned().ok_or_else(|| format!("{file}: no feature tables"))? + } + }; + let src = EditTable::open(&path, &table).map_err(err)?; + let limit = f(p, "limit").map_or(MAX_EDITABLE, |n| n.clamp(1.0, MAX_EDITABLE as f64) as usize); + let editable = src.read_editable(limit).map_err(err)?; + let name = ps(p, "name").map(str::trim).filter(|v| !v.is_empty()).unwrap_or(src.table()).to_string(); + let info = src.info(); + let mut ds = rustgis_data::Dataset::new(name.clone(), info.geometry_type, info.fields.clone()); + ds.crs = src.crs(); + let path = path.to_string_lossy().into_owned(); + ds.source = Some(path.clone()); + let n = editable.features.len(); + let read = editable.versions.iter().copied().collect(); + let versions = versions_of(&editable.features, &Default::default(), &read); + ds.features = editable.features.into(); + for note in &info.notes { + s.notify(format!("{name}: {note}"), false); + } + if (n as u64) < editable.matched { + s.notify( + format!("{name}: loaded {n} of {} rows; the others aren't edited, and saving leaves them as they are", editable.matched), + false, + ); + } + if editable.skipped > 0 { + s.notify( + format!( + "{name}: {} rows with a geometry RustGIS can't read weren't loaded; saving leaves them as they are", + editable.skipped + ), + false, + ); + } + if !editable.writable { + s.notify(format!("{name}: the file is read-only, so edits can't be saved to it"), false); + } + let empty_before = s.map().layers.is_empty(); + let id = super::layer::add_dataset(s, ds); + if let Some(l) = s.map_mut().layer_mut(id) { + let (geometry_type, z, m) = src.declaration(); + l.gpkg = Some(rustgis_map::source::GpkgEdit { + path, + table: src.table().to_string(), + key: src.key().to_string(), + geometry: src.geometry_column().to_string(), + srs_id: src.srs_id(), + geometry_type: geometry_type.to_string(), + z, + m, + revision: editable.revision, + writable: editable.writable, + limit, + versions, + }); + } + if empty_before && let Some(e) = s.map().layer(id).map(|l| l.extent()).filter(|e| !e.is_empty()) { + s.zoom_to(&e); + } + Ok(json!({"layer": id, "features": n, "matched": editable.matched, "skipped": editable.skipped, "writable": editable.writable})) + } +} + +fn save_edits(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p); + Err("GeoPackage tables are edited in the desktop app".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::path::Path; + let (id, name, edit) = gpkg_layer(s, p)?; + let l = s.map().layer(id).ok_or("no such layer")?; + let changes = edit.versions.changes(&l.dataset.features); + let fields = l.dataset.fields.clone(); + if changes.is_empty() { + s.notify(format!("{name}: no edits to save"), false); + return Ok(json!({"saved": false, "inserted": 0, "updated": 0, "deleted": 0})); + } + let table = &edit.table; + if s.saved_over.iter().any(|v| v.shares(&edit.versions)) { + return Err(format!( + "{name}: the layer is back before an edit saved to {table} (undone), so nothing was saved. Discard Edits loads the rows as they are now" + )); + } + let path = Path::new(&edit.path); + if !path.is_file() { + return Err(format!("{name}: {} isn't there any more, so nothing was saved", edit.path)); + } + if std::fs::metadata(path).is_ok_and(|m| m.permissions().readonly()) { + return Err(format!("{name}: {} is read-only, so nothing was saved", edit.path)); + } + let err = |e: rustgis_data::DataError| format!("{name}: nothing was saved to {table}: {e}"); + let src = EditTable::open(path, table).map_err(err)?; + // `src` is opened just now, so its declaration is compared with the one loaded; the save + // transaction then compares `src` with the table again before writing. + let (geometry_type, z, m) = src.declaration(); + if !src.key().eq_ignore_ascii_case(&edit.key) + || !src.geometry_column().eq_ignore_ascii_case(&edit.geometry) + || src.srs_id() != edit.srs_id + || !geometry_type.eq_ignore_ascii_case(&edit.geometry_type) + || z != edit.z + || m != edit.m + { + return Err(format!("{name}: {table} changed shape since it was loaded, so nothing was saved. Discard Edits loads it again")); + } + let rows = changes.insert_rows; + let edits = rustgis_data::source::edits::Edits { updates: changes.updates, deletes: changes.deletes, inserts: changes.inserts }; + let saved_rows = match src.save(&edits, &fields, edit.versions.iter().map(|(key, _)| key), &edit.revision).map_err(err)? { + Ok(saved) => saved, + Err(conflicts) => { + let mut said: Vec = conflicts.iter().take(20).map(ToString::to_string).collect(); + if conflicts.len() > 20 { + said.push(format!("{} more", conflicts.len() - 20)); + } + return Err(format!( + "{name}: nothing was saved to {table} in {}, {} conflicts: {}. Discard Edits loads the rows as they are now", + edit.path, + conflicts.len(), + said.join("; ") + )); + } + }; + let saved = saved_rows.changes; + let mut stored: std::collections::HashMap<_, _> = saved_rows.features.into_iter().map(|f| (f.id, f)).collect(); + let versions = saved.versions.iter().copied().collect(); + if let Some(l) = s.map_mut().layer_mut(id) { + for (row, (_, key)) in rows.iter().zip(&saved.inserted) { + if let Some(f) = l.dataset.features.get_mut(*row) { + f.id = *key; + } + } + let mut geometry_changed = false; + if !saved_rows.removed.is_empty() { + let selected: std::collections::HashSet<_> = + l.selection.iter().filter_map(|&row| l.dataset.features.get(row).map(|f| f.id)).collect(); + let before = l.dataset.features.len(); + l.dataset.features.retain(|f| saved_rows.removed.binary_search(&f.id).is_err()); + if l.dataset.features.len() != before { + geometry_changed = true; + l.selection = + l.dataset.features.iter().enumerate().filter_map(|(row, f)| selected.contains(&f.id).then_some(row)).collect(); + } + } + for feature in l.dataset.features.iter_mut() { + if let Some(mut row) = stored.remove(&feature.id) { + for &i in &saved_rows.unstored_fields { + if let (Some(stored), Some(local)) = (row.attrs.get_mut(i), feature.attrs.get_mut(i)) { + std::mem::swap(stored, local); + } + } + geometry_changed |= row.geometry != feature.geometry; + *feature = row; + } + } + for (key, _) in &edits.deletes { + if let Some(row) = stored.remove(key) { + l.dataset.features.push(row); + geometry_changed = true; + } + } + if geometry_changed { + l.touch_geometry(); + } + let baseline = versions_of(&l.dataset.features, &edit.versions, &versions); + if let Some(g) = &mut l.gpkg { + g.versions = baseline; + g.revision = saved_rows.revision; + } + } + s.saved_over.retain(rustgis_map::source::Versions::held_elsewhere); + s.saved_over.push(edit.versions); + let canonical = std::fs::canonicalize(path).ok(); + for map in &mut s.project.maps { + for l in map.layers.iter_mut() { + if let Some(source) = &mut l.source + && source.table.as_deref().is_none_or(|t| t.eq_ignore_ascii_case(table)) + && source.path.as_deref().is_some_and(|p| match (canonical.as_ref(), std::fs::canonicalize(p).ok()) { + (Some(a), Some(b)) => *a == b, + _ => p == edit.path, + }) + { + source.reset(); + } + } + } + s.notify( + format!( + "{name}: saved to {table} in {}: {} new, {} changed, {} deleted", + edit.path, + saved.inserted.len(), + saved.updated, + saved.deleted + ), + false, + ); + if !saved.skipped_fields.is_empty() { + s.notify(format!("{name}: {table} has no column for {}, so it wasn't saved", saved.skipped_fields.join(", ")), false); + } + Ok(json!({"saved": true, "inserted": saved.inserted.len(), "updated": saved.updated, "deleted": saved.deleted, + "unchanged": saved.unchanged, "keys": saved.inserted.iter().map(|(_, k)| k.cast_signed()).collect::>(), "skippedFields": saved.skipped_fields})) + } +} + +fn discard_edits(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p); + Err("GeoPackage tables are edited in the desktop app".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let (id, name, edit) = gpkg_layer(s, p)?; + let err = |e: rustgis_data::DataError| format!("{name}: {e}"); + let src = EditTable::open(std::path::Path::new(&edit.path), &edit.table).map_err(err)?; + let editable = src.read_editable(if edit.limit == 0 { MAX_EDITABLE } else { edit.limit }).map_err(err)?; + let read = editable.versions.iter().copied().collect(); + let n = editable.features.len(); + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + let geometry_type = src.info().geometry_type; + if l.dataset.geometry_type != geometry_type { + l.renderer = rustgis_map::Renderer::Single(rustgis_map::Symbol::default_for(geometry_type, id)); + } + l.dataset.geometry_type = geometry_type; + l.dataset.crs = src.crs(); + l.dataset.fields = src.info().fields.clone(); + l.dataset.features = editable.features.into(); + l.selection.clear(); + l.touch_geometry(); + let baseline = versions_of(&l.dataset.features, &Default::default(), &read); + if let Some(g) = &mut l.gpkg { + g.versions = baseline; + g.writable = editable.writable; + g.key = src.key().to_string(); + g.geometry = src.geometry_column().to_string(); + g.srs_id = src.srs_id(); + let (geometry_type, z, m) = src.declaration(); + g.geometry_type = geometry_type.to_string(); + g.z = z; + g.m = m; + g.revision = editable.revision; + } + s.notify(format!("{name}: edits discarded; {n} rows loaded again from {}", edit.table), false); + Ok(json!({"features": n, "matched": editable.matched})) + } +} diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index 3645ad270..95b35e4bd 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -530,6 +530,9 @@ fn template(p: &Project) -> Project { l.dataset.features.clear(); l.selection.clear(); l.dataset.source = None; + // A feature-less template has no loaded rows to save back to a source. + l.postgis = None; + l.gpkg = None; if let Some(rl) = &mut l.raster { rl.embedded = None; } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 808235e6a..f7a44063f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1500,6 +1500,10 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { .map(|l| { // (Sharing the rows: `Features` is copy-on-write, replaced just below.) let mut layer = l.clone(); + layer.renew_instance_id(); + // Simplified sketches aren't the source's rows: saving them would overwrite it. + layer.gpkg = None; + layer.postgis = None; layer.dataset.features = l .dataset .features diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6a91db2c3..9a7038ea6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod embeddings; pub mod geocode; pub mod geolibre; pub mod gp; +pub mod gpkg; pub mod graphics; pub mod group; pub mod history; @@ -103,6 +104,7 @@ pub fn all() -> Vec { v.extend(share::commands()); v.extend(sql::commands()); v.extend(pg::commands()); + v.extend(gpkg::commands()); v.extend(plugins::commands()); v.extend(sharing::commands()); v.extend(stac::commands()); diff --git a/crates/engine/src/cmd/navigate.rs b/crates/engine/src/cmd/navigate.rs index f690d92d1..79de67e9e 100644 --- a/crates/engine/src/cmd/navigate.rs +++ b/crates/engine/src/cmd/navigate.rs @@ -270,6 +270,7 @@ fn duplicate_map(s: &mut Session, p: &Value) -> CmdResult { m.name = super::copy_name(ps(p, "name"), &m.name, |n| s.project.maps.iter().any(|x| x.name.eq_ignore_ascii_case(n))); m.label_lock = None; for l in &mut m.layers { + l.renew_instance_id(); l.selection.clear(); } let name = m.name.clone(); diff --git a/crates/engine/src/cmd/pg.rs b/crates/engine/src/cmd/pg.rs index 931599011..597868350 100644 --- a/crates/engine/src/cmd/pg.rs +++ b/crates/engine/src/cmd/pg.rs @@ -65,29 +65,10 @@ fn postgis_layer(s: &Session, p: &Value) -> Result<(u32, String, rustgis_map::so Ok((id, l.name.clone(), edit)) } -#[cfg(not(target_arch = "wasm32"))] -/// The rows of `features` that are loaded rows, with their versions and what they hold now: -/// the new baseline after a save (`saved`: the versions written) or a load. -fn versions_of( - features: &[rustgis_data::Feature], - old: &rustgis_map::source::Versions, - saved: &std::collections::HashMap, -) -> rustgis_map::source::Versions { - use rustgis_map::source::{RowVersion, row_hash}; - let mut seen = std::collections::HashSet::new(); - features - .iter() - .filter_map(|f| { - let version = saved.get(&f.id).copied().or_else(|| old.get(f.id).map(|r| r.version))?; - seen.insert(f.id).then(|| (f.id, RowVersion { version, hash: row_hash(f) })) - }) - .collect() -} - fn pending_edits(s: &mut Session, p: &Value) -> CmdResult { let (id, name, edit) = postgis_layer(s, p)?; let l = s.map().layer(id).ok_or("no such layer")?; - let c = edit.changes(&l.dataset.features); + let c = edit.versions.changes(&l.dataset.features); Ok(serde_json::json!({ "layer": name, "table": edit.origin.table, @@ -109,7 +90,7 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { use rustgis_data::source::{duckdb::Database, postgis}; let (id, name, edit) = postgis_layer(s, p)?; let l = s.map().layer(id).ok_or("no such layer")?; - let changes = edit.changes(&l.dataset.features); + let changes = edit.versions.changes(&l.dataset.features); let fields = l.dataset.fields.clone(); if changes.is_empty() { s.notify(format!("{name}: no edits to save"), false); @@ -118,7 +99,7 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { let table = edit.origin.table.clone(); // Undone past a save: the database holds those edits already, and its new rows would be // inserted again. - if s.pg_saved_over.iter().any(|v| v.shares(&edit.versions)) { + if s.saved_over.iter().any(|v| v.shares(&edit.versions)) { return Err(format!( "{name}: the layer is back before an edit saved to {table} (undone), so nothing was saved. Discard Edits loads the rows as they are now" )); @@ -139,7 +120,7 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { let t = postgis::find_table(&conn, &table).map_err(err)?; let src = postgis::PostgisSource::open(conn, t, edit.origin.family, edit.origin.crs.as_deref()).map_err(err)?; let rows = changes.insert_rows.clone(); - let edits = postgis::Edits { updates: changes.updates, deletes: changes.deletes, inserts: changes.inserts }; + let edits = rustgis_data::source::edits::Edits { updates: changes.updates, deletes: changes.deletes, inserts: changes.inserts }; let saved = match src.save(&edits, &fields).map_err(err)? { Ok(saved) => saved, Err(conflicts) => { @@ -167,14 +148,14 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { } } } - let baseline = versions_of(&l.dataset.features, &edit.versions, &versions); + let baseline = rustgis_map::source::versions_of(&l.dataset.features, &edit.versions, &versions); if let Some(pg) = l.postgis.as_mut() { pg.versions = baseline; } } // The versions saved over, while an undo step can bring them back. - s.pg_saved_over.retain(rustgis_map::source::Versions::held_elsewhere); - s.pg_saved_over.push(edit.versions); + s.saved_over.retain(rustgis_map::source::Versions::held_elsewhere); + s.saved_over.push(edit.versions); s.notify( format!("{name}: saved to {table}: {} new, {} changed, {} deleted", saved.inserted.len(), saved.updated, saved.deleted), false, @@ -223,7 +204,7 @@ fn discard_edits(s: &mut Session, p: &Value) -> CmdResult { l.dataset.features = editable.features.into(); l.selection.clear(); l.touch_geometry(); - let baseline = versions_of(&l.dataset.features, &Default::default(), &read); + let baseline = rustgis_map::source::versions_of(&l.dataset.features, &Default::default(), &read); if let Some(pg) = l.postgis.as_mut() { pg.versions = baseline; pg.writable = editable.writable; @@ -393,7 +374,7 @@ fn add_layer(s: &mut Session, p: &Value) -> CmdResult { ds.crs = u32::try_from(src.srid()).ok().filter(|&c| c != 0 && c != 4326).and_then(rustgis_data::raster::Crs::from_epsg); let n = editable.features.len() as u64; let read: std::collections::HashMap = editable.versions.iter().copied().collect(); - let versions = versions_of(&editable.features, &Default::default(), &read); + let versions = rustgis_map::source::versions_of(&editable.features, &Default::default(), &read); ds.features = editable.features.into(); if n < editable.matched { s.notify( diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 05e60d3e8..3c28f39c2 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -294,11 +294,15 @@ const NEVER: &[&str] = &[ "pg.disconnect", // Writes to a database: the user's to do, asked each time. "pg.saveEdits", + "gpkg.saveEdits", ]; /// Commands that write files without naming one in their parameters. const WRITES_FILES: &[&str] = &["project.save", "project.saveAs"]; +/// Commands that read a file a layer names, not their parameters. +const READS_FILES: &[&str] = &["gpkg.discardEdits"]; + /// Parameter keys that name a file, folder or address. (`data` and `text` carry content, which /// plugins may pass: `layer.addDataBytes`, `map.import` from text.) const PLACE_KEYS: &[&str] = &["path", "folder", "dir", "file", "url", "uri", "source", "output", "connection", "database"]; @@ -378,6 +382,7 @@ pub fn reaches(spec: &C, params: &Value) -> Option { let files = super::items::names_place(params, PLACE_KEYS, 0) || PLACE_KEYS.iter().any(|k| spec.params.contains(&format!("\"{k}\""))) || WRITES_FILES.contains(&spec.id) + || READS_FILES.contains(&spec.id) || ["share.", "offline.", "cache.", "notebook."].iter().any(|p| spec.id.starts_with(p)); files.then_some(Reach::Files) } diff --git a/crates/engine/src/cmd/workspace.rs b/crates/engine/src/cmd/workspace.rs index ede7c2fbe..d50b0cda3 100644 --- a/crates/engine/src/cmd/workspace.rs +++ b/crates/engine/src/cmd/workspace.rs @@ -223,6 +223,7 @@ pub fn commands() -> Vec { // Reading a project checks only maps' layers, not offline snapshots', so their // selections are put right here. for l in &mut m.layers { + l.renew_instance_id(); l.normalize_selection(); } s.project.maps.push(m); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dbcb8bcc4..24e9b6d66 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -554,11 +554,11 @@ pub struct Session { /// The PostGIS database that layers loaded for editing work with (`pg.connect`): its /// connection, never with its password. Kept for the session, not in the project. pub postgis: Option, - /// The PostGIS layers' row versions that `pg.saveEdits` saved over: a layer back on one + /// Source layers' row versions that `pg.saveEdits` or `gpkg.saveEdits` saved over: a layer back on one /// (undone, or restored by a failed command, past a save) would write its edits again. /// Outside the project, so undo keeps it. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - pub(crate) pg_saved_over: Vec, + pub(crate) saved_over: Vec, /// The feature cache fills itself after projection changes (Data › Feature Cache). pub auto_cache: bool, /// Mensuration results (Analyze › Mensuration › Results). @@ -716,22 +716,25 @@ fn edited_streamed(before: &Project, after: &Project) -> Option { None } -/// New features of a layer loaded from PostGIS never keep the key of a row it loaded (one +/// New features of a layer loaded for source editing never keep the key of a row it loaded (one /// deleted since, whose id an editing tool can hand out again): saved, they would be that row, /// changed. They take ids past every key instead. Layers whose rows were loaded or saved again by /// the command (their versions replaced) are left alone. -fn fresh_postgis_ids(before: &Project, after: &mut Project) { +fn fresh_source_ids(before: &Project, after: &mut Project) { for (mb, ma) in before.maps.iter().zip(after.maps.iter_mut()) { for la in ma.layers.iter_mut() { - let Some(pg) = &la.postgis else { continue }; + let Some(versions) = la.edit_versions() else { continue }; let Some(lb) = mb.layers.iter().find(|l| l.id == la.id) else { continue }; - let same_rows = lb.postgis.as_ref().is_some_and(|b| b.versions.shares(&pg.versions)); + let same_rows = lb.edit_versions().is_some_and(|b| b.shares(versions)); if !same_rows || la.dataset.features.shares(&lb.dataset.features) { continue; } + // A layer bound to both sources (a hand-made project) reserves both baselines' keys. + let also = la.postgis.as_ref().and(la.gpkg.as_ref()).map(|g| &g.versions); + let loaded = |id: u64| versions.get(id).is_some() || also.is_some_and(|v| v.get(id).is_some()); let had: std::collections::HashSet = lb.dataset.features.iter().map(|f| f.id).collect(); let reused: Vec = (0..la.dataset.features.len()) - .filter(|&i| la.dataset.features.get(i).is_some_and(|f| !had.contains(&f.id) && pg.versions.get(f.id).is_some())) + .filter(|&i| la.dataset.features.get(i).is_some_and(|f| !had.contains(&f.id) && loaded(f.id))) .collect(); if reused.is_empty() { continue; @@ -742,7 +745,8 @@ fn fresh_postgis_ids(before: &Project, after: &mut Project) { .features .iter() .map(|f| f.id) - .chain(pg.versions.iter().map(|(k, _)| k)) + .chain(versions.iter().map(|(k, _)| k)) + .chain(also.into_iter().flat_map(|v| v.iter().map(|(k, _)| k))) .filter(|&k| k.cast_signed() >= 0) .max() .unwrap_or(0); @@ -824,7 +828,7 @@ impl Session { measurements: Vec::new(), auto_cache: false, postgis: None, - pg_saved_over: Vec::new(), + saved_over: Vec::new(), undo: Vec::new(), redo: Vec::new(), merge: None, @@ -1104,7 +1108,7 @@ impl Session { // session's own flag says so, not the answer, which a plugin's command could forge. (Ok(_), Some(_)) if std::mem::take(&mut self.request_only) => {} (Ok(_), Some(mut snap)) => { - fresh_postgis_ids(&snap, &mut self.project); + fresh_source_ids(&snap, &mut self.project); // An edit may change the dates the time slider's window filters on: bound layers // whose features or fields the command changed (features are shared with the // snapshot until written) take the window again. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1b01a1133..086f0e48f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6813,6 +6813,24 @@ fn plugin_file_parameters_reach_files_and_stay_out_of_the_history() { rustgis_plugins::unload("eng-file-edit"); } +#[test] +fn geopackage_plugin_writes_are_user_only_and_reload_needs_files() { + use crate::cmd::plugins::{Reach, reaches}; + let mut s = world(); + plugin("eng-gpkg-edit", &["edit"]); + for id in ["gpkg.addLayer", "gpkg.discardEdits"] { + let spec = s.registry.get(id).copied().unwrap(); + assert_eq!(reaches(&spec, &json!({})), Some(Reach::Files)); + let e = forward(&mut s, "eng-gpkg-edit", id, json!({})).unwrap_err(); + assert!(e.contains("'files' permission"), "{id}: {e}"); + } + rustgis_plugins::unload("eng-gpkg-edit"); + plugin("eng-gpkg-all", &["edit", "files", "network"]); + let e = forward(&mut s, "eng-gpkg-all", "gpkg.saveEdits", json!({})).unwrap_err(); + assert!(e.contains("plugins can't run"), "{e}"); + rustgis_plugins::unload("eng-gpkg-all"); +} + /// This test binary, started as the process test plugin, answers one request and exits. #[test] fn plugin_process_child() { @@ -18360,14 +18378,14 @@ fn postgis_edits_are_counted_and_checked() { // Back on rows a save wrote over (undone past it): its edits, new rows too, aren't written // again, and no database is asked. let read = s.map().layer(id).unwrap().postgis.as_ref().unwrap().versions.clone(); - s.pg_saved_over.push(read.clone()); + s.saved_over.push(read.clone()); let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.contains("back before an edit saved to roads"), "{e}"); // Rows read again are a baseline of their own. s.map_mut().layer_mut(id).unwrap().postgis.as_mut().unwrap().versions = read.iter().collect(); let e = s.run("pg.saveEdits", &p).unwrap_err(); assert!(e.starts_with("gis: "), "{e}"); - s.pg_saved_over.clear(); + s.saved_over.clear(); // A layer not from PostGIS has no edits to save there. crate::cmd::layer::add_dataset(&mut s, Dataset::new("plain", GeometryType::Point, Vec::new())); let e = s.run("pg.saveEdits", &json!({"layer": "plain"})).unwrap_err(); @@ -18500,6 +18518,969 @@ fn postgis_edits_live() { assert_eq!(n, 1); } +/// Edits go back to one local GeoPackage table atomically, without replacing the file or +/// losing columns RustGIS doesn't edit. No external database or program is needed. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_edits_save_back() { + use rusqlite::functions::FunctionFlags; + use rusqlite::types::ValueRef; + use rustgis_data::raster::Crs; + use rustgis_data::source::{Request, gpkg, open_path}; + use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType}; + + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + // Also clean up if an assertion fails during the read-only check. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(self.0.join("roads.gpkg"), std::fs::Permissions::from_mode(0o600)); + } + let _ = std::fs::remove_dir_all(&self.0); + } + } + let serial = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("rustgis-gpkg-edits-{}-{serial}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let _cleanup = Cleanup(dir.clone()); + let path = dir.join("roads.gpkg"); + let mut roads = + Dataset::new("roads", GeometryType::Polyline, vec![Field::new("name", FieldType::Text), Field::new("lanes", FieldType::Integer)]); + for (i, name) in ["a", "b", "c"].iter().enumerate() { + let x = 1_000_000.0 + i as f64 * 10_000.0; + roads.push( + Some(Geometry::Line(vec![vec![Coord::new(x, 2_000_000.0), Coord::new(x + 1_000.0, 2_002_000.0)]])), + vec![rustgis_data::Value::Text((*name).into()), rustgis_data::Value::Int(i as i64 + 1)], + ); + } + gpkg::write_in(&roads, &path, Crs::from_epsg(3857).unwrap()).unwrap(); + let db = rusqlite::Connection::open(&path).unwrap(); + // Even attribute-only external UPDATEs compile the file's R-tree triggers. Supply their + // functions on this independent connection too; these fixture blobs all have XY envelopes. + let flags = FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS; + db.create_scalar_function("ST_IsEmpty", 1, flags, |ctx| { + Ok(match ctx.get_raw(0) { + ValueRef::Blob(b) => b.get(3).map(|f| i64::from(f & 0x10 != 0)), + _ => None, + }) + }) + .unwrap(); + for (name, offset) in [("ST_MinX", 8), ("ST_MaxX", 16), ("ST_MinY", 24), ("ST_MaxY", 32)] { + db.create_scalar_function(name, 1, flags, move |ctx| { + Ok(match ctx.get_raw(0) { + ValueRef::Blob(b) => b.get(offset..offset + 8).and_then(|v| v.try_into().ok()).map(f64::from_le_bytes), + _ => None, + }) + }) + .unwrap(); + } + db.execute_batch( + "ALTER TABLE roads ADD COLUMN photo BLOB; + UPDATE roads SET photo = x'00ff' WHERE fid = 1; + CREATE TABLE other (id INTEGER PRIMARY KEY, v TEXT); + INSERT INTO other VALUES (1, 'keep'); + CREATE TRIGGER no_bad BEFORE INSERT ON roads WHEN NEW.name = 'bad' + BEGIN SELECT RAISE(ABORT, 'bad name'); END; + UPDATE gpkg_contents SET last_change = '2000-01-01T00:00:00.000Z' WHERE table_name = 'roads';", + ) + .unwrap(); + let name_of = |key: i64| -> String { db.query_row("SELECT name FROM roads WHERE fid = ?1", [key], |r| r.get(0)).unwrap() }; + let count = || -> i64 { db.query_row("SELECT count(*) FROM roads", [], |r| r.get(0)).unwrap() }; + let changed_at = + || -> String { db.query_row("SELECT last_change FROM gpkg_contents WHERE table_name = 'roads'", [], |r| r.get(0)).unwrap() }; + let counts = |s: &mut Session| { + let r = s.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap(); + (r["inserts"].as_u64().unwrap(), r["updates"].as_u64().unwrap(), r["deletes"].as_u64().unwrap()) + }; + let p = json!({"layer": "R"}); + let mut s = Session::new(Project::default()); + let loaded = s.run("gpkg.addLayer", &json!({"path": path, "table": "roads", "name": "R"})).unwrap(); + assert_eq!((loaded["features"].as_u64(), loaded["matched"].as_u64(), loaded["skipped"].as_u64()), (Some(3), Some(3), Some(0))); + assert_eq!(loaded["writable"], true); + let id = loaded["layer"].as_u64().unwrap() as u32; + let row_of = |s: &Session, key: u64| s.map().layer(id).unwrap().dataset.features.iter().position(|f| f.id == key).unwrap(); + let ids: Vec<_> = s.map().layer(id).unwrap().dataset.features.iter().map(|f| f.id).collect(); + assert_eq!(ids, vec![1, 2, 3]); + assert_eq!(s.map().layer(id).unwrap().dataset.crs, Some(Crs::from_epsg(3857).unwrap())); + assert_eq!(counts(&mut s), (0, 0, 0)); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); + s.map_mut().editing.settings.enabled = true; + + let r1 = row_of(&s, 1); + s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "renamed"})).unwrap(); + let moved = vec![Coord::new(15.0, 25.0), Coord::new(15.2, 25.3)]; + { + let r2 = row_of(&s, 2); + let l = s.map_mut().layer_mut(id).unwrap(); + l.dataset.features[r2].geometry = Some(Geometry::Line(vec![moved.clone()])); + l.touch_geometry(); + } + let r3 = row_of(&s, 3); + s.map_mut().layer_mut(id).unwrap().selection = vec![r3]; + s.active_layer = Some(id); + s.run("edit.delete", &json!({})).unwrap(); + let new_line = |name: &str| { + json!({"layer": "R", "geometry": {"type": "LineString", "coordinates": [[13, 22], [13.1, 22.1]]}, + "attributes": {"name": name, "lanes": 4}}) + }; + s.run("edit.addFeature", &new_line("new")).unwrap(); + // Deleting the highest loaded key must not make the new feature reuse its baseline id. + let l = s.map().layer(id).unwrap(); + assert_eq!(l.dataset.features.len(), 3); + assert!(l.dataset.features.iter().any(|f| f.id > 3)); + assert!(!l.dataset.features.iter().any(|f| f.id == 3)); + assert_eq!(counts(&mut s), (1, 2, 1)); + + // Saving the project keeps pending edits, not database writes, and stores the file beside + // it relatively. Reopening restores both the absolute source path and the baseline. + let project = dir.join("edit.rgis"); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + let saved: Project = serde_json::from_str(&std::fs::read_to_string(&project).unwrap()).unwrap(); + assert!(std::path::Path::new(&saved.maps[0].layers[0].gpkg.as_ref().unwrap().path).is_relative()); + assert_eq!(name_of(1), "a"); + assert_eq!(count(), 3); + { + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + let edit = reopened.map().layer_by_name("R").unwrap().gpkg.as_ref().unwrap(); + assert_eq!(std::fs::canonicalize(&edit.path).unwrap(), std::fs::canonicalize(&path).unwrap()); + assert_eq!(counts(&mut reopened), (1, 2, 1)); + } + + let before = changed_at(); + let r = s.run("gpkg.saveEdits", &p).unwrap(); + assert_eq!((r["inserted"].as_u64(), r["updated"].as_u64(), r["deleted"].as_u64()), (Some(1), Some(2), Some(1)), "{r}"); + assert_eq!(r["keys"], json!([4])); + assert!(s.map().layer(id).unwrap().dataset.features.iter().any(|f| f.id == 4)); + assert_eq!(counts(&mut s), (0, 0, 0)); + let row: (String, i64, Vec) = + db.query_row("SELECT name, lanes, photo FROM roads WHERE fid = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap(); + assert_eq!(row, ("renamed".into(), 1, vec![0, 255])); + assert_eq!(db.query_row("SELECT count(*) FROM roads WHERE fid = 3", [], |r| r.get::<_, i64>(0)).unwrap(), 0); + assert_eq!(name_of(4), "new"); + assert_eq!(db.query_row("SELECT v FROM other WHERE id = 1", [], |r| r.get::<_, String>(0)).unwrap(), "keep"); + assert_ne!(changed_at(), before); + let native: Vec<_> = moved.iter().copied().map(rustgis_geom::lonlat_to_merc).collect(); + let expected = rustgis_geom::Extent::from_points(native[0], native[1]); + let indexed: (f64, f64, f64, f64) = db + .query_row("SELECT minx, maxx, miny, maxy FROM rtree_roads_geom WHERE id = 2", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + }) + .unwrap(); + for (actual, wanted) in + [(indexed.0, expected.min_x), (indexed.1, expected.max_x), (indexed.2, expected.min_y), (indexed.3, expected.max_y)] + { + // SQLite's R-tree stores outward-rounded float32 bounds. + assert!((actual - wanted).abs() < wanted.abs() * 1e-6, "{actual} != {wanted}"); + } + assert_eq!(db.query_row("SELECT count(*) FROM rtree_roads_geom", [], |r| r.get::<_, i64>(0)).unwrap(), 3); + let bounds: (f64, f64) = + db.query_row("SELECT max_x, max_y FROM gpkg_contents WHERE table_name = 'roads'", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert!(bounds.0 >= expected.max_x && bounds.1 >= expected.max_y, "{bounds:?}"); + { + let source = open_path(&path, Some("roads")).unwrap(); + assert_eq!(source.info().crs, "EPSG:3857"); + let batch = source.read(&Request::all()).unwrap(); + assert_eq!(batch.features.len(), 3); + let f = batch.features.iter().find(|f| f.id == 2).unwrap(); + let Some(Geometry::Line(parts)) = &f.geometry else { panic!("reopened line: {:?}", f.geometry) }; + assert_eq!(parts.len(), 1); + assert_eq!(parts[0].len(), moved.len()); + for (a, b) in parts[0].iter().zip(&moved) { + assert!((a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6, "{a:?} != {b:?}"); + } + } + assert!(s.undo()); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("back before an edit saved"), "{e}"); + assert!(s.redo()); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); + + // Fingerprints include unchanged attributes, not just the name edited in the layer. + db.execute("UPDATE roads SET lanes = 9 WHERE fid = 1", []).unwrap(); + let r1 = row_of(&s, 1); + s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "mine"})).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("row 1 was changed") && e.contains("roads.gpkg"), "{e}"); + assert_eq!(name_of(1), "renamed"); + assert_eq!(counts(&mut s), (0, 1, 0)); + s.run("gpkg.discardEdits", &p).unwrap(); + db.execute("DELETE FROM roads WHERE fid = 2", []).unwrap(); + let r2 = row_of(&s, 2); + s.run("edit.setValue", &json!({"layer": "R", "row": r2, "field": "name", "value": "gone"})).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("row 2 was deleted"), "{e}"); + assert_eq!(counts(&mut s), (0, 1, 0)); + + // An insert rejected by a file-owned trigger rolls back an earlier UPDATE as well. + s.run("gpkg.discardEdits", &p).unwrap(); + let r1 = row_of(&s, 1); + s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": "partial"})).unwrap(); + s.run("edit.addFeature", &new_line("bad")).unwrap(); + let before = changed_at(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("bad name"), "{e}"); + assert_eq!(name_of(1), "renamed"); + assert_eq!(count(), 2); + assert_eq!(changed_at(), before); + assert_eq!(counts(&mut s), (1, 1, 0)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let original = std::fs::metadata(&path).unwrap().permissions(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap(); + let result = s.run("gpkg.saveEdits", &p); + std::fs::set_permissions(&path, original).unwrap(); + let e = result.unwrap_err(); + assert!(e.contains("read-only"), "{e}"); + assert_eq!(counts(&mut s), (1, 1, 0)); + } + s.run("gpkg.discardEdits", &p).unwrap(); + + // A limit scopes the baseline: rows not loaded are never treated as deletions. + let one = s.run("gpkg.addLayer", &json!({"path": path, "table": "roads", "limit": 1, "name": "One"})).unwrap(); + assert_eq!((one["features"].as_u64(), one["matched"].as_u64()), (Some(1), Some(2))); + s.run("edit.setValue", &json!({"layer": "One", "row": 0, "field": "name", "value": "limited"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "One"})).unwrap()["updated"], 1); + assert_eq!(count(), 2); + assert_eq!(name_of(4), "new"); + assert_eq!(s.run("gpkg.discardEdits", &json!({"layer": "One"})).unwrap()["features"], 1); + + // Required Z and M dimensions forbid changed/new geometry, not attribute-only saves. + for (sql, name) in [ + ("UPDATE gpkg_geometry_columns SET z = 1, m = 0", "attributes with Z"), + ("UPDATE gpkg_geometry_columns SET z = 0, m = 1", "attributes with M"), + ] { + db.execute(sql, []).unwrap(); + s.run("gpkg.discardEdits", &p).unwrap(); + let r1 = row_of(&s, 1); + { + let l = s.map_mut().layer_mut(id).unwrap(); + l.dataset.features[r1].geometry = Some(Geometry::Line(vec![moved.clone()])); + l.touch_geometry(); + } + let blob: Vec = db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("Z or M"), "{e}"); + assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), blob); + s.run("gpkg.discardEdits", &p).unwrap(); + s.run("edit.addFeature", &new_line("needs dimensions")).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("Z or M"), "{e}"); + assert_eq!(count(), 2); + s.run("gpkg.discardEdits", &p).unwrap(); + let r1 = row_of(&s, 1); + s.run("edit.setValue", &json!({"layer": "R", "row": r1, "field": "name", "value": name})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + assert_eq!(name_of(1), name); + assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), blob); + } + for _ in 0..2 { + let discarded = s.run("gpkg.discardEdits", &p).unwrap(); + assert_eq!((discarded["features"].as_u64(), discarded["matched"].as_u64()), (Some(2), Some(2))); + assert_eq!(counts(&mut s), (0, 0, 0)); + } + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); +} + +#[cfg(not(target_arch = "wasm32"))] +struct GpkgRegressionFixture { + dir: std::path::PathBuf, +} + +#[cfg(not(target_arch = "wasm32"))] +impl GpkgRegressionFixture { + fn new(name: &str) -> Self { + let serial = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("rustgis-gpkg-{name}-{}-{serial}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Self { dir }; + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + // No AUTOINCREMENT or R-tree triggers: external deletion and column renames exercise + // ordinary INTEGER PRIMARY KEY tables, not the export writer's schema. + db.execute_batch( + "CREATE TABLE gpkg_spatial_ref_sys (srs_name TEXT, srs_id INTEGER PRIMARY KEY, organization TEXT, + organization_coordsys_id INTEGER, definition TEXT, description TEXT); + INSERT INTO gpkg_spatial_ref_sys VALUES ('WGS 84', 4326, 'EPSG', 4326, '', ''); + CREATE TABLE gpkg_contents (table_name TEXT PRIMARY KEY, data_type TEXT, identifier TEXT, + description TEXT, last_change TEXT, min_x DOUBLE, min_y DOUBLE, max_x DOUBLE, max_y DOUBLE, srs_id INTEGER); + INSERT INTO gpkg_contents (table_name, data_type, srs_id, last_change) VALUES ('roads', 'features', 4326, '2000-01-01T00:00:00.000Z'); + CREATE TABLE gpkg_geometry_columns (table_name TEXT, column_name TEXT, geometry_type_name TEXT, + srs_id INTEGER, z TINYINT, m TINYINT); + INSERT INTO gpkg_geometry_columns VALUES ('roads', 'geom', 'POINT', 4326, 0, 0); + CREATE TABLE roads (fid INTEGER PRIMARY KEY, geom BLOB, name TEXT, lanes INTEGER DEFAULT 2);", + ) + .unwrap(); + for (key, name) in [(1, "a"), (2, "b"), (3, "c")] { + db.execute( + "INSERT INTO roads (fid, geom, name) VALUES (?1, ?2, ?3)", + rusqlite::params![key, Self::point_blob(f64::from(key), 20.0), name], + ) + .unwrap(); + } + fixture + } + + fn path(&self) -> std::path::PathBuf { + self.dir.join("roads.gpkg") + } + + fn point_blob(x: f64, y: f64) -> Vec { + let mut blob = b"GP\x00\x03".to_vec(); + blob.extend_from_slice(&4326i32.to_le_bytes()); + for ordinate in [x, x, y, y] { + blob.extend_from_slice(&ordinate.to_le_bytes()); + } + blob.push(1); + blob.extend_from_slice(&1u32.to_le_bytes()); + blob.extend_from_slice(&x.to_le_bytes()); + blob.extend_from_slice(&y.to_le_bytes()); + blob + } + + fn session(&self) -> Session { + let mut s = Session::new(Project::default()); + let loaded = s.run("gpkg.addLayer", &json!({"path": self.path(), "table": "roads", "name": "R"})).unwrap(); + assert_eq!(loaded["features"], 3); + s.map_mut().editing.settings.enabled = true; + s + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for GpkgRegressionFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn assert_gpkg_regression_clean(s: &mut Session) { + let pending = s.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap(); + assert_eq!((pending["inserts"].as_u64(), pending["updates"].as_u64(), pending["deletes"].as_u64()), (Some(0), Some(0), Some(0))); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_delete_trigger_restored_rows_rejoin_the_saved_baseline() { + use rustgis_data::{Coord, Geometry, Value}; + let fixture = GpkgRegressionFixture::new("restored-delete"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + db.execute_batch( + "CREATE TRIGGER restore_deleted AFTER DELETE ON roads WHEN OLD.fid=1 BEGIN + INSERT INTO roads(fid,geom,name,lanes) VALUES(OLD.fid,OLD.geom,'restored',9); END;", + ) + .unwrap(); + let mut s = fixture.session(); + s.run("select.rows", &json!({"layer":"R","rows":[0,1]})).unwrap(); + s.run("edit.delete", &json!({})).unwrap(); + let result = s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap(); + assert_eq!(result["deleted"], 1, "only key 2 remained deleted after triggers"); + let layer = s.map().layer_by_name("R").unwrap(); + let mut keys: Vec<_> = layer.dataset.features.iter().map(|f| f.id).collect(); + keys.sort_unstable(); + assert_eq!(keys, vec![1, 3]); + let row = layer.dataset.features.iter().position(|f| f.id == 1).unwrap(); + assert_eq!(layer.dataset.value(row, layer.dataset.field_index("name").unwrap()), &Value::Text("restored".into())); + assert_eq!(layer.dataset.value(row, layer.dataset.field_index("lanes").unwrap()), &Value::Int(9)); + assert_eq!(layer.dataset.features[row].geometry, Some(Geometry::Point(Coord::new(1.0, 20.0)))); + assert_gpkg_regression_clean(&mut s); + s.run("edit.setValue", &json!({"layer":"R","row":row,"field":"name","value":"next"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap()["updated"], 1); + let stored: (String, i64) = db.query_row("SELECT name,lanes FROM roads WHERE fid=1", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(stored, ("next".into(), 9)); + assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + assert_gpkg_regression_clean(&mut s); + let project = fixture.dir.join("restored.rgis"); + s.run("project.saveAs", &json!({"path":project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path":project})).unwrap(); + assert_gpkg_regression_clean(&mut reopened); + assert_eq!(reopened.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap()["saved"], false); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_cross_row_triggers_refresh_residents_without_expanding_scope() { + use rustgis_data::{Coord, Geometry, Value}; + for deleted in [false, true] { + let fixture = GpkgRegressionFixture::new("resident-triggers"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut s = fixture.session(); + let p = json!({"layer":"R"}); + s.run("edit.addField", &json!({"layer":"R","name":"local","type":"text"})).unwrap(); + for row in 0..3 { + s.run("edit.setValue", &json!({"layer":"R","row":row,"field":"local","value":format!("local {row}")})).unwrap(); + } + s.run("gpkg.saveEdits", &p).unwrap(); + assert_gpkg_regression_clean(&mut s); + s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"Other"})).unwrap(); + let action = if deleted { + "DELETE FROM roads WHERE fid=2;" + } else { + "UPDATE roads SET name='intermediate',lanes=8 WHERE fid=2; + UPDATE roads SET name='trigger result',lanes=9,geom=(SELECT geom FROM roads WHERE fid=3) WHERE fid=2;" + }; + db.execute_batch(&format!( + "CREATE TRIGGER affect_resident AFTER UPDATE OF name ON roads WHEN NEW.fid=1 AND NEW.name='save row1' BEGIN + {action} + INSERT INTO roads(fid,geom,name) VALUES(4,NEW.geom,'trigger only'); + END;" + )) + .unwrap(); + s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"save row1"})).unwrap(); + s.run( + "edit.addFeature", + &json!({"layer":"R","geometry":{"type":"Point","coordinates":[10,20]}, + "attributes":{"name":"synthetic","local":"new local"}}), + ) + .unwrap(); + // A surviving selected row follows its identity when an earlier resident disappears. + s.run("select.rows", &json!({"layer":"R","rows":[2]})).unwrap(); + let saved = s.run("gpkg.saveEdits", &p).unwrap(); + assert_eq!(saved["deleted"], 0, "trigger-only deletions are not submitted deletions"); + let key = saved["keys"][0].as_u64().unwrap(); + assert!(key > 4); + let layer = s.map().layer_by_name("R").unwrap(); + let ds = &layer.dataset; + let row_of = |key| ds.features.iter().position(|f| f.id == key).unwrap(); + assert!(!ds.features.iter().any(|f| f.id == 4), "trigger-only inserts must not replace a pending synthetic feature"); + assert_eq!(ds.value(row_of(key), ds.field_index("name").unwrap()), &Value::Text("synthetic".into())); + assert_eq!(ds.value(row_of(key), ds.field_index("local").unwrap()), &Value::Text("new local".into())); + assert_eq!(layer.selection, vec![row_of(3)]); + if deleted { + assert!(!ds.features.iter().any(|f| f.id == 2)); + assert_eq!(ds.features.len(), 3); + } else { + assert_eq!(ds.features.len(), 4); + assert_eq!(ds.value(row_of(2), ds.field_index("name").unwrap()), &Value::Text("trigger result".into())); + assert_eq!(ds.value(row_of(2), ds.field_index("lanes").unwrap()), &Value::Int(9)); + assert_eq!(ds.value(row_of(2), ds.field_index("local").unwrap()), &Value::Text("local 1".into())); + assert_eq!(ds.features[row_of(2)].geometry, Some(Geometry::Point(Coord::new(3.0, 20.0)))); + } + let other = &s.map().layer_by_name("Other").unwrap().dataset; + assert_eq!(other.value(1, other.field_index("name").unwrap()), &Value::Text("b".into())); + assert_eq!(other.features.len(), 3, "saving one copy must not refresh another"); + let next_key = if deleted { 3 } else { 2 }; + let next_row = row_of(next_key); + assert_gpkg_regression_clean(&mut s); + s.run("edit.setValue", &json!({"layer":"R","row":next_row,"field":"name","value":"next edit"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + let stored: (String, i64) = + db.query_row("SELECT name,lanes FROM roads WHERE fid=?1", [next_key as i64], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(stored, ("next edit".into(), if deleted { 2 } else { 9 })); + assert_gpkg_regression_clean(&mut s); + let project = fixture.dir.join("residents.rgis"); + s.run("project.saveAs", &json!({"path":project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path":project})).unwrap(); + assert_gpkg_regression_clean(&mut reopened); + assert_eq!(reopened.run("gpkg.saveEdits", &p).unwrap()["saved"], false); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_trigger_snapshots_respect_partial_loaded_scope() { + let fixture = GpkgRegressionFixture::new("partial-residents"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + db.execute_batch( + "CREATE TRIGGER affect_unloaded AFTER UPDATE OF name ON roads WHEN NEW.fid=1 BEGIN + UPDATE roads SET name='outside scope' WHERE fid=2; + DELETE FROM roads WHERE fid=3; + INSERT INTO roads(fid,name) VALUES(9,'trigger insert'); + END;", + ) + .unwrap(); + let mut s = Session::new(Project::default()); + s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"R","limit":1})).unwrap(); + s.map_mut().editing.settings.enabled = true; + s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"limited"})).unwrap(); + let saved = s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap(); + assert_eq!(saved["deleted"], 0); + let keys: Vec<_> = s.map().layer_by_name("R").unwrap().dataset.features.iter().map(|f| f.id).collect(); + assert_eq!(keys, vec![1]); + let stored: Vec<(i64, String)> = db + .prepare("SELECT fid,name FROM roads ORDER BY fid") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(stored, vec![(1, "limited".into()), (2, "outside scope".into()), (9, "trigger insert".into())]); + assert_gpkg_regression_clean(&mut s); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_trigger_rebuild_rejects_a_valid_envelope_with_unreadable_wkb() { + use rustgis_data::{Coord, Geometry}; + let fixture = GpkgRegressionFixture::new("unreadable-enveloped-body"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut bad = GpkgRegressionFixture::point_blob(3.0, 20.0); + bad.truncate(40); + bad.push(0); + db.execute("UPDATE roads SET geom=?1 WHERE fid=3", [&bad]).unwrap(); + db.execute_batch( + "CREATE VIRTUAL TABLE rtree_roads_geom USING rtree(id,minx,maxx,miny,maxy); + INSERT INTO rtree_roads_geom VALUES(1,1,1,20,20),(2,2,2,20,20),(3,3,3,20,20);", + ) + .unwrap(); + let index = || { + db.prepare("SELECT id,minx,maxx,miny,maxy FROM rtree_roads_geom ORDER BY id") + .unwrap() + .query_map([], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, f64>(1)?, r.get::<_, f64>(2)?, r.get::<_, f64>(3)?, r.get::<_, f64>(4)?)) + }) + .unwrap() + .collect::>>() + .unwrap() + }; + let mut s = Session::new(Project::default()); + let loaded = s.run("gpkg.addLayer", &json!({"path":fixture.path(),"table":"roads","name":"R"})).unwrap(); + assert_eq!((loaded["features"].as_u64(), loaded["skipped"].as_u64()), (Some(2), Some(1))); + let id = loaded["layer"].as_u64().unwrap() as u32; + // Ordinary saves leave the skipped row and its existing index entry untouched. + s.map_mut().layer_mut(id).unwrap().dataset.features[0].geometry = Some(Geometry::Point(Coord::new(10.0, 20.0))); + s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap(); + assert_eq!(index().last(), Some(&(3, 3.0, 3.0, 20.0, 20.0))); + db.execute_batch( + "CREATE TRIGGER force_rebuild AFTER UPDATE OF name ON roads WHEN NEW.fid=1 + BEGIN UPDATE roads SET lanes=9 WHERE fid=2; END;", + ) + .unwrap(); + let before_index = index(); + let before_revision: String = db.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get(0)).unwrap(); + s.map_mut().editing.settings.enabled = true; + s.run("edit.setValue", &json!({"layer":"R","row":0,"field":"name","value":"must roll back"})).unwrap(); + let error = s.run("gpkg.saveEdits", &json!({"layer":"R"})).unwrap_err(); + assert!(error.contains("stored geometry can't be read"), "{error}"); + assert_eq!(index(), before_index); + assert_eq!(db.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), before_revision); + assert_eq!(db.query_row("SELECT name FROM roads WHERE fid=1", [], |r| r.get::<_, String>(0)).unwrap(), "a"); + assert_eq!(db.query_row("SELECT lanes FROM roads WHERE fid=2", [], |r| r.get::<_, i64>(0)).unwrap(), 2); + assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid=3", [], |r| r.get::<_, Vec>(0)).unwrap(), bad); + assert_eq!(s.run("gpkg.pendingEdits", &json!({"layer":"R"})).unwrap()["updates"], 1); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_template_drops_edit_binding() { + let fixture = GpkgRegressionFixture::new("template"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let rows = || { + db.prepare("SELECT fid, name, lanes, geom FROM roads ORDER BY fid") + .unwrap() + .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec>(3)?))) + .unwrap() + .collect::, _>>() + .unwrap() + }; + let before = rows(); + let mut s = fixture.session(); + let template = fixture.dir.join("roads.rgistemplate"); + s.run("project.saveTemplate", &json!({"path": template})).unwrap(); + let mut restored = Session::new(Project::default()); + restored.run("project.fromTemplate", &json!({"path": template, "name": "Road setup"})).unwrap(); + let layer = restored.map().layer_by_name("R").unwrap(); + assert!(layer.dataset.features.is_empty()); + assert!(layer.gpkg.is_none()); + let error = restored.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap_err(); + assert!(error.contains("not a GeoPackage layer loaded for editing"), "{error}"); + assert_eq!(rows(), before, "saving a feature-less template must not delete or modify its original source"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_stale_project_insert_replay_is_refused() { + for undo_before_project_save in [false, true] { + let fixture = GpkgRegressionFixture::new("stale-insert"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut s = fixture.session(); + let project = fixture.dir.join("pending.rgis"); + s.run( + "edit.addFeature", + &json!({ + "layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, + "attributes": {"name": "one new row"} + }), + ) + .unwrap(); + if undo_before_project_save { + s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap(); + assert!(s.undo()); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + } else { + s.run("project.saveAs", &json!({"path": project})).unwrap(); + // Pending inserts from a project are safe before their source revision changes. + let mut fresh = Session::new(Project::default()); + fresh.run("project.open", &json!({"path": project})).unwrap(); + assert_eq!(fresh.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); + } + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + assert!(reopened.run("gpkg.saveEdits", &json!({"layer": "R"})).is_err()); + assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 4); + assert_eq!(reopened.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap()["inserts"], 1); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_project_requires_a_baseline_but_allows_an_empty_loaded_table() { + let fixture = GpkgRegressionFixture::new("required-baseline"); + let mut s = fixture.session(); + let project = fixture.dir.join("missing.rgis"); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + let mut saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&project).unwrap()).unwrap(); + saved["maps"][0]["layers"][0]["gpkg"].as_object_mut().unwrap().remove("versions"); + std::fs::write(&project, serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(Session::new(Project::default()).run("project.open", &json!({"path": project})).is_err()); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + assert_eq!(db.query_row("SELECT count(*) FROM roads", [], |r| r.get::<_, i64>(0)).unwrap(), 3); + + db.execute("DELETE FROM roads", []).unwrap(); + let mut empty = Session::new(Project::default()); + empty.run("gpkg.addLayer", &json!({"path": fixture.path(), "table": "roads", "name": "R"})).unwrap(); + empty.map_mut().editing.settings.enabled = true; + empty + .run( + "edit.addFeature", + &json!({ + "layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": "first"} + }), + ) + .unwrap(); + empty.run("project.saveAs", &json!({"path": project})).unwrap(); + let mut reopened = Session::new(Project::default()); + reopened.run("project.open", &json!({"path": project})).unwrap(); + assert_eq!(reopened.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); + assert_eq!(db.query_row("SELECT name FROM roads", [], |r| r.get::<_, String>(0)).unwrap(), "first"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_srs_change_refuses_attribute_save_until_discard() { + let fixture = GpkgRegressionFixture::new("changed-srs"); + let mut s = fixture.session(); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let before: Vec = db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap(); + db.execute("UPDATE gpkg_geometry_columns SET srs_id = 3857", []).unwrap(); + s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "pending"})).unwrap(); + assert!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).is_err()); + assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), before); + assert_eq!(db.query_row("SELECT name FROM roads WHERE fid = 1", [], |r| r.get::<_, String>(0)).unwrap(), "a"); + assert_eq!(s.run("gpkg.pendingEdits", &json!({"layer": "R"})).unwrap()["updates"], 1); + s.run("gpkg.discardEdits", &json!({"layer": "R"})).unwrap(); + s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "after reload"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["updated"], 1); + assert_eq!(db.query_row("SELECT geom FROM roads WHERE fid = 1", [], |r| r.get::<_, Vec>(0)).unwrap(), before); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_discard_refreshes_geometry_family_and_visible_point_symbols() { + let fixture = GpkgRegressionFixture::new("changed-geometry-family"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + db.execute_batch("UPDATE gpkg_geometry_columns SET geometry_type_name = 'LINESTRING'; UPDATE roads SET geom = NULL;").unwrap(); + let mut s = fixture.session(); + db.execute("UPDATE gpkg_geometry_columns SET geometry_type_name = 'POINT'", []).unwrap(); + db.execute("UPDATE roads SET geom = ?1 WHERE fid = 1", [GpkgRegressionFixture::point_blob(10.0, 20.0)]).unwrap(); + s.run("gpkg.discardEdits", &json!({"layer": "R"})).unwrap(); + let layer = s.map().layer_by_name("R").unwrap(); + assert_eq!(layer.geometry_type(), rustgis_data::GeometryType::Point); + assert!(layer.renderer.base_symbol().size > 0.0, "reloaded point features need visible markers"); + s.run( + "edit.addFeature", + &json!({ + "layer": "R", "geometry": {"type": "Point", "coordinates": [11, 21]}, "attributes": {"name": "new point"} + }), + ) + .unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &json!({"layer": "R"})).unwrap()["inserted"], 1); + let stored: Vec = db.query_row("SELECT geom FROM roads WHERE name = 'new point'", [], |r| r.get(0)).unwrap(); + assert_eq!(rustgis_data::wkb::decode(&stored[40..]), Some(rustgis_data::Geometry::Point(rustgis_data::Coord::new(11.0, 21.0)))); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_saved_defaults_and_trigger_values_become_baseline() { + use rustgis_data::source::{Request, open_path}; + use rustgis_data::{Coord, Geometry, Value}; + + let fixture = GpkgRegressionFixture::new("stored-values"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut s = fixture.session(); + let p = json!({"layer": "R"}); + let add = |name: &str| json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": name}}); + let row_of = |s: &Session, key: u64| s.map().layer_by_name("R").unwrap().dataset.features.iter().position(|f| f.id == key).unwrap(); + let lanes_of = |s: &Session, key: u64| { + let dataset = &s.map().layer_by_name("R").unwrap().dataset; + dataset.value(row_of(s, key), dataset.field_index("lanes").unwrap()).clone() + }; + let name_of = |s: &Session, key: u64| { + let dataset = &s.map().layer_by_name("R").unwrap().dataset; + dataset.value(row_of(s, key), dataset.field_index("name").unwrap()).clone() + }; + let geometry_of = |s: &Session, key: u64| s.map().layer_by_name("R").unwrap().dataset.features[row_of(s, key)].geometry.clone(); + let stored_lanes = |key: u64| -> i64 { db.query_row("SELECT lanes FROM roads WHERE fid = ?1", [key as i64], |r| r.get(0)).unwrap() }; + + s.run("edit.addFeature", &add("default")).unwrap(); + let saved = s.run("gpkg.saveEdits", &p).unwrap(); + let default_key = saved["keys"][0].as_u64().unwrap(); + assert_eq!(lanes_of(&s, default_key), Value::Int(2), "omitted attributes must reflect SQLite defaults after saving"); + assert_eq!(stored_lanes(default_key), 2); + assert_gpkg_regression_clean(&mut s); + let row = row_of(&s, default_key); + s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "default renamed"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + assert_eq!(stored_lanes(default_key), 2, "a later name edit must not replace the stored default with NULL"); + assert_gpkg_regression_clean(&mut s); + + db.execute_batch("CREATE TABLE normalized_shapes (kind TEXT PRIMARY KEY, geom BLOB);").unwrap(); + for (kind, x, y) in [("insert", 30.0, 40.0), ("update", 31.0, 41.0)] { + db.execute("INSERT INTO normalized_shapes VALUES (?1, ?2)", rusqlite::params![kind, GpkgRegressionFixture::point_blob(x, y)]) + .unwrap(); + } + db.execute_batch( + "CREATE TRIGGER normalize_insert AFTER INSERT ON roads BEGIN + UPDATE roads SET name = upper(trim(NEW.name)), lanes = 7, + geom = (SELECT geom FROM normalized_shapes WHERE kind = 'insert') WHERE fid = NEW.fid; + END; + CREATE TRIGGER normalize_update AFTER UPDATE OF name ON roads + WHEN NEW.name != upper(trim(NEW.name)) BEGIN + UPDATE roads SET name = upper(trim(NEW.name)), lanes = NEW.lanes + 1, + geom = (SELECT geom FROM normalized_shapes WHERE kind = 'update') WHERE fid = NEW.fid; + END;", + ) + .unwrap(); + s.run("edit.addFeature", &add(" inserted ")).unwrap(); + let key = s.run("gpkg.saveEdits", &p).unwrap()["keys"][0].as_u64().unwrap(); + assert_eq!(name_of(&s, key), Value::Text("INSERTED".into())); + assert_eq!(lanes_of(&s, key), Value::Int(7)); + assert_eq!(geometry_of(&s, key), Some(Geometry::Point(Coord::new(30.0, 40.0)))); + assert_gpkg_regression_clean(&mut s); + + let row = row_of(&s, key); + s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": " updated "})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + assert_eq!(name_of(&s, key), Value::Text("UPDATED".into())); + assert_eq!(lanes_of(&s, key), Value::Int(8)); + assert_eq!(geometry_of(&s, key), Some(Geometry::Point(Coord::new(31.0, 41.0)))); + assert_gpkg_regression_clean(&mut s); + + let row = row_of(&s, key); + s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "NEXT"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + let stored: (String, i64) = + db.query_row("SELECT name, lanes FROM roads WHERE fid = ?1", [key as i64], |r| Ok((r.get(0)?, r.get(1)?))).unwrap(); + assert_eq!(stored, ("NEXT".into(), 8), "an ordinary later edit must retain trigger-normalized attributes"); + let batch = open_path(&fixture.path(), Some("roads")).unwrap().read(&Request::all()).unwrap(); + assert_eq!( + batch.features.iter().find(|f| f.id == key).unwrap().geometry, + Some(Geometry::Point(Coord::new(31.0, 41.0))), + "an ordinary later edit must retain trigger-normalized geometry", + ); + assert_gpkg_regression_clean(&mut s); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_insert_reserves_externally_deleted_loaded_keys() { + use rustgis_data::Value; + + let fixture = GpkgRegressionFixture::new("reserved-keys"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut s = fixture.session(); + let p = json!({"layer": "R"}); + db.execute("DELETE FROM roads WHERE fid = 3", []).unwrap(); + s.run("edit.addFeature", &json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}, "attributes": {"name": "new"}})) + .unwrap(); + let saved = s.run("gpkg.saveEdits", &p).unwrap(); + let key = saved["keys"][0].as_u64().unwrap(); + assert!(key > 3, "a newly committed row must not alias the externally deleted loaded key: {saved}"); + { + let dataset = &s.map().layer_by_name("R").unwrap().dataset; + assert_eq!(dataset.features.len(), 4); + let ids: std::collections::HashSet<_> = dataset.features.iter().map(|f| f.id).collect(); + assert_eq!(ids.len(), 4, "the unchanged stale feature and inserted feature must remain distinguishable"); + let name = dataset.field_index("name").unwrap(); + assert_eq!(dataset.features.iter().find(|f| f.id == 3).unwrap().attrs[name], Value::Text("c".into())); + assert_eq!(dataset.features.iter().find(|f| f.id == key).unwrap().attrs[name], Value::Text("new".into())); + } + assert_gpkg_regression_clean(&mut s); + for _ in 0..2 { + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false, "saving again must not insert a phantom duplicate"); + } + let rows: Vec<(i64, String)> = db + .prepare("SELECT fid, name FROM roads ORDER BY fid") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows, vec![(1, "a".into()), (2, "b".into()), (key as i64, "new".into())]); + assert_eq!(s.run("gpkg.discardEdits", &p).unwrap()["features"], 3); + let features = &s.map().layer_by_name("R").unwrap().dataset.features; + assert!(!features.iter().any(|f| f.id == 3)); + assert!(features.iter().any(|f| f.id == key)); + assert_gpkg_regression_clean(&mut s); +} + +/// The declaration is compared with the one loaded, not with a table reopened just before saving. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_command_save_refuses_a_declaration_changed_since_loading() { + for (mutation, label) in [ + ("UPDATE gpkg_geometry_columns SET geometry_type_name = 'MULTIPOINT'", "type"), + ("UPDATE gpkg_geometry_columns SET z = 1", "z"), + ("UPDATE gpkg_geometry_columns SET m = 1", "m"), + ] { + let fixture = GpkgRegressionFixture::new(&format!("declaration-{label}")); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let name_of = || -> String { db.query_row("SELECT name FROM roads WHERE fid = 1", [], |r| r.get(0)).unwrap() }; + let mut s = fixture.session(); + let p = json!({"layer": "R"}); + db.execute(mutation, []).unwrap(); + s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "must not save"})).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("changed shape"), "{label}: {e}"); + assert_eq!(name_of(), "a", "{label}"); + // Discard reloads the current declaration, after which attribute saves work again. + s.run("gpkg.discardEdits", &p).unwrap(); + s.run("edit.setValue", &json!({"layer": "R", "row": 0, "field": "name", "value": "after reload"})).unwrap(); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1, "{label}"); + assert_eq!(name_of(), "after reload", "{label}"); + } +} + +/// Reopened projects keep exact coordinates (no edits appear, nothing is rewritten), and the +/// globe locator's simplified sketches can't be saved over the source. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_reopened_polygons_stay_clean_and_locator_sketches_are_unbound() { + use rustgis_data::raster::Crs; + use rustgis_data::source::gpkg; + use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; + + let fixture = GpkgRegressionFixture::new("locator-sketch"); + let path = fixture.dir.join("parcels.gpkg"); + let mut data = Dataset::new("parcels", GeometryType::Polygon, vec![Field::new("name", FieldType::Text)]); + for k in 1..=3 { + let centre = 10.0 * f64::from(k); + // Many irregular vertices: coordinates whose shortest decimal form needs exact parsing. + let ring = (0..=64) + .map(|i| { + let j = i % 64; + let a = std::f64::consts::TAU * f64::from(j) / 64.0; + Coord::new(centre + 5.0 * a.cos() + 0.01 * f64::from(j % 3), centre + 5.0 * a.sin()) + }) + .collect(); + data.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![Value::Text(format!("p{k}"))]); + } + gpkg::write_in(&data, &path, Crs::LonLat).unwrap(); + let db = rusqlite::Connection::open(&path).unwrap(); + let blobs = || -> Vec> { + db.prepare("SELECT geom FROM parcels ORDER BY fid").unwrap().query_map([], |r| r.get(0)).unwrap().collect::>().unwrap() + }; + let before = blobs(); + let mut s = Session::new(Project::default()); + s.run("gpkg.addLayer", &json!({"path": path, "table": "parcels", "name": "P"})).unwrap(); + let project = fixture.dir.join("parcels.rgis"); + s.run("project.saveAs", &json!({"path": project})).unwrap(); + + let mut s = Session::new(Project::default()); + s.run("project.open", &json!({"path": project})).unwrap(); + s.map_mut().editing.settings.enabled = true; + let p = json!({"layer": "P"}); + let pending = s.run("gpkg.pendingEdits", &p).unwrap(); + assert_eq!((pending["inserts"].as_u64(), pending["updates"].as_u64(), pending["deletes"].as_u64()), (Some(0), Some(0), Some(0))); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); + assert_eq!(blobs(), before, "reopening a project must not rewrite untouched rows"); + + let locator = s.run("layout.publication", &json!({"locator": "globe"})).unwrap()["locator"].as_str().unwrap().to_string(); + let sketch = s.project.maps.iter().find(|m| m.name == locator).unwrap(); + let layer = sketch.layer_by_name("P").unwrap(); + assert!(layer.gpkg.is_none() && layer.postgis.is_none(), "a simplified sketch must not keep the source binding"); + s.run("map.activate", &json!({"name": locator})).unwrap(); + let e = s.run("gpkg.saveEdits", &p).unwrap_err(); + assert!(e.contains("not a GeoPackage layer loaded for editing"), "{e}"); + assert_eq!(blobs(), before); +} + +/// A layer bound to both sources (a hand-made project) reserves both baselines' keys. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_new_features_avoid_keys_of_either_bound_baseline() { + let fixture = GpkgRegressionFixture::new("dual-binding"); + let mut s = fixture.session(); + let id = s.map().layer_by_name("R").unwrap().id; + s.map_mut().layer_mut(id).unwrap().postgis = Some(rustgis_map::source::PostgisEdit { + origin: rustgis_map::source::PostgisOrigin { + database: "postgres:host=127.0.0.1 port=1 dbname=gis".into(), + table: "roads".into(), + family: None, + crs: None, + }, + key: "fid".into(), + srid: 4326, + writable: false, + condition: None, + limit: 0, + versions: Default::default(), + }); + let r3 = s.map().layer(id).unwrap().dataset.features.iter().position(|f| f.id == 3).unwrap(); + s.map_mut().layer_mut(id).unwrap().selection = vec![r3]; + s.active_layer = Some(id); + s.run("edit.delete", &json!({})).unwrap(); + s.run("edit.addFeature", &json!({"layer": "R", "geometry": {"type": "Point", "coordinates": [10, 20]}})).unwrap(); + let ids: Vec = s.map().layer(id).unwrap().dataset.features.iter().map(|f| f.id).collect(); + assert!(!ids.contains(&3), "a new feature must not take the GeoPackage key it deleted: {ids:?}"); + assert!(ids.iter().any(|&k| k > 3), "{ids:?}"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_discard_refreshes_renamed_key_and_geometry() { + use rustgis_data::source::{Request, open_path}; + use rustgis_data::{Coord, Geometry}; + + let fixture = GpkgRegressionFixture::new("renamed-columns"); + let db = rusqlite::Connection::open(fixture.path()).unwrap(); + let mut s = fixture.session(); + let p = json!({"layer": "R"}); + db.execute_batch( + "ALTER TABLE roads RENAME COLUMN fid TO id; + ALTER TABLE roads RENAME COLUMN geom TO shape; + UPDATE gpkg_geometry_columns SET column_name = 'shape' WHERE table_name = 'roads';", + ) + .unwrap(); + assert_eq!(s.run("gpkg.discardEdits", &p).unwrap()["features"], 3); + let layer = s.map().layer_by_name("R").unwrap(); + let binding = layer.gpkg.as_ref().unwrap(); + assert_eq!(binding.key, "id"); + assert_eq!(binding.geometry, "shape"); + let id = layer.id; + let row = layer.dataset.features.iter().position(|f| f.id == 1).unwrap(); + s.run("edit.setValue", &json!({"layer": "R", "row": row, "field": "name", "value": "renamed columns work"})).unwrap(); + let moved = Geometry::Point(Coord::new(15.0, 25.0)); + { + let layer = s.map_mut().layer_mut(id).unwrap(); + layer.dataset.features[row].geometry = Some(moved.clone()); + layer.touch_geometry(); + } + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["updated"], 1); + assert_eq!(db.query_row("SELECT name FROM roads WHERE id = 1", [], |r| r.get::<_, String>(0)).unwrap(), "renamed columns work"); + let batch = open_path(&fixture.path(), Some("roads")).unwrap().read(&Request::all()).unwrap(); + assert_eq!(batch.features.iter().find(|f| f.id == 1).unwrap().geometry, Some(moved)); + assert_eq!(batch.features.len(), 3); + assert_gpkg_regression_clean(&mut s); + assert_eq!(s.run("gpkg.saveEdits", &p).unwrap()["saved"], false); +} + #[test] fn raster_tools_read_projected_zone_files_without_reprojecting_twice() { use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index f0290339f..de5089dfe 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -187,6 +187,12 @@ pub struct Layer { /// saved to, and the rows' versions as loaded (desktop only). #[serde(default, skip_serializing_if = "Option::is_none")] pub postgis: Option, + /// The GeoPackage table this layer's edits save to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpkg: Option, + /// Process-local identity, preserved by snapshots but never stored in a project. + #[serde(skip, default = "next_layer_instance")] + instance_id: u64, /// Shared, like the features: an undo snapshot or other copy of the layer doesn't copy it. #[serde(skip)] projected: std::sync::Arc>>, @@ -217,10 +223,32 @@ fn yes() -> bool { true } +fn next_layer_instance() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + impl Layer { + /// Identifies this live layer, independently of its reusable map-local ID. + pub fn instance_id(&self) -> u64 { + self.instance_id + } + + /// Starts a distinct live instance after copying a layer or its owning map. + /// Undo and render snapshots must retain the original identity instead. + pub fn renew_instance_id(&mut self) { + self.instance_id = next_layer_instance(); + } + + /// Baseline rows for a layer loaded for source editing. + pub fn edit_versions(&self) -> Option<&source::Versions> { + self.postgis.as_ref().map(|e| &e.versions).or_else(|| self.gpkg.as_ref().map(|e| &e.versions)) + } + /// Local linked paths, followed by the dataset's record-only provenance. pub fn file_paths_mut(&mut self) -> Vec<(&mut String, bool)> { - let Layer { dataset, source, raster, .. } = self; + let Layer { dataset, source, raster, gpkg, .. } = self; let mut paths = Vec::new(); if let Some(src) = source { if let Some(g) = &mut src.gdal { @@ -248,6 +276,9 @@ impl Layer { paths.push((&mut z.store, false)); } } + if let Some(g) = gpkg { + paths.push((&mut g.path, false)); + } if let Some(p) = &mut dataset.source { paths.push((p, true)); } @@ -304,6 +335,7 @@ impl Layer { let renderer = Renderer::Single(Symbol::default_for(dataset.geometry_type, id)); Layer { id, + instance_id: next_layer_instance(), name: dataset.name.clone(), visible: true, labels: Labeling::default_for(&dataset), @@ -333,6 +365,7 @@ impl Layer { raster: None, source: None, postgis: None, + gpkg: None, projected: Default::default(), projected_extents: Default::default(), projected_vertices: 0, @@ -1696,6 +1729,7 @@ impl Map { pub fn add_layer(&mut self, mut layer: Layer) -> u32 { let id = self.next_id(); layer.id = id; + layer.renew_instance_id(); layer.selection.clear(); layer.touch_geometry(); self.insert_on_top(layer); diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index fabc1ce83..64fb85af8 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -152,6 +152,34 @@ pub struct PostgisEdit { pub versions: Versions, } +/// A GeoPackage feature table loaded whole for desktop editing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GpkgEdit { + /// Absolute when loaded; relative to a project saved beside it. + pub path: String, + pub table: String, + /// Its INTEGER PRIMARY KEY column. + pub key: String, + pub geometry: String, + /// The source SRS when these rows were loaded. + pub srs_id: i64, + /// The declared geometry type (uppercase) and Z/M flags when these rows were loaded. + /// Missing in a project means unknown: saving refuses until Discard Edits reloads them. + #[serde(default)] + pub geometry_type: String, + #[serde(default)] + pub z: i64, + #[serde(default)] + pub m: i64, + /// The table's durable revision, checked before saving pending inserts. + pub revision: String, + pub writable: bool, + #[serde(default)] + pub limit: usize, + /// Every column's stored fingerprint and the loaded feature's hash, by key. + pub versions: Versions, +} + /// How a loaded row is told apart from the same row edited: a hash of its geometry and /// attributes (FNV-1a over their JSON, the same in every build). pub fn row_hash(f: &rustgis_data::Feature) -> u64 { @@ -174,7 +202,7 @@ pub fn row_hash(f: &rustgis_data::Feature) -> u64 { h.0 } -/// A loaded row: its version (PostgreSQL's `xmin`) and [`row_hash`] as read. +/// A loaded row: its source version or fingerprint and [`row_hash`] as read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RowVersion { pub version: i64, @@ -241,10 +269,10 @@ impl<'de> Deserialize<'de> for Versions { } } -/// What changed in a layer loaded from PostGIS since it was loaded (or last saved), by +/// What changed in a source layer since it was loaded (or last saved), by /// comparing its features with the rows as read. #[derive(Debug, Default)] -pub struct PostgisChanges { +pub struct RowChanges { /// Features of loaded rows that differ from them, with the rows' versions. pub updates: Vec<(rustgis_data::Feature, i64)>, /// Loaded rows no feature holds any more: their keys and versions. @@ -255,20 +283,20 @@ pub struct PostgisChanges { pub insert_rows: Vec, } -impl PostgisChanges { +impl RowChanges { pub fn is_empty(&self) -> bool { self.updates.is_empty() && self.deletes.is_empty() && self.inserts.is_empty() } } -impl PostgisEdit { +impl Versions { /// What changed in `features` since the rows were read. A feature whose id is a loaded row's /// is that row (once: a copy with the same id is new). - pub fn changes(&self, features: &[rustgis_data::Feature]) -> PostgisChanges { - let mut out = PostgisChanges::default(); + pub fn changes(&self, features: &[rustgis_data::Feature]) -> RowChanges { + let mut out = RowChanges::default(); let mut seen = std::collections::HashSet::new(); for (i, f) in features.iter().enumerate() { - match self.versions.get(f.id) { + match self.get(f.id) { Some(row) if seen.insert(f.id) => { if row_hash(f) != row.hash { out.updates.push((f.clone(), row.version)); @@ -280,11 +308,23 @@ impl PostgisEdit { } } } - out.deletes = self.versions.iter().filter(|(k, _)| !seen.contains(k)).map(|(k, r)| (k, r.version)).collect(); + out.deletes = self.iter().filter(|(k, _)| !seen.contains(k)).map(|(k, r)| (k, r.version)).collect(); out } } +/// The loaded rows' new baseline after a save or load. +pub fn versions_of(features: &[rustgis_data::Feature], old: &Versions, saved: &std::collections::HashMap) -> Versions { + let mut seen = std::collections::HashSet::new(); + features + .iter() + .filter_map(|f| { + let version = saved.get(&f.id).copied().or_else(|| old.get(f.id).map(|r| r.version))?; + seen.insert(f.id).then(|| (f.id, RowVersion { version, hash: row_hash(f) })) + }) + .collect() +} + /// A MySQL or MariaDB table a layer reads live. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MysqlOrigin { @@ -1105,10 +1145,10 @@ mod tests { limit: 0, versions: loaded.iter().map(|f| (f.id, super::RowVersion { version: f.id as i64 * 10, hash: super::row_hash(f) })).collect(), }; - assert!(edit.changes(&loaded).is_empty()); + assert!(edit.versions.changes(&loaded).is_empty()); // Row 1 as it was, row 2 renamed, row 3 deleted, a new feature and a copy of row 1. let now = [row(1, "a"), row(2, "bee"), row(9, "new"), row(1, "copy")]; - let c = edit.changes(&now); + let c = edit.versions.changes(&now); assert_eq!(c.updates.iter().map(|(f, v)| (f.id, *v)).collect::>(), vec![(2, 20)]); assert_eq!(c.deletes, vec![(3, 30)]); assert_eq!(c.inserts.iter().map(|f| f.id).collect::>(), vec![9, 1]); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 662c1251c..9181e9510 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -712,6 +712,9 @@ fn search_action(id: &str) -> Option { "pg.connect" | "pg.addLayer" => Dialog("pgAddLayer"), "pg.saveEdits" => Dialog("pgSave"), "pg.discardEdits" => Dialog("pgDiscard"), + "gpkg.addLayer" => Dialog("gpkgAddLayer"), + "gpkg.saveEdits" => Dialog("gpkgSave"), + "gpkg.discardEdits" => Dialog("gpkgDiscard"), "layer.addZarr" => Command("ui.addZarr"), "layer.addLayerFile" => OpenItem("layer"), "layer.save" => SaveItem("layer"), @@ -818,6 +821,7 @@ fn search_platform_disabled(id: &str, web: bool) -> Option<&'static str> { "project.addFolder" => Some("folder connections need the desktop app"), "layer.addDatabase" => Some("databases are read through DuckDB, a program the browser can't run"), "pg.connect" | "pg.addLayer" | "pg.saveEdits" | "pg.discardEdits" => Some("PostGIS tables are edited in the desktop app"), + "gpkg.addLayer" | "gpkg.saveEdits" | "gpkg.discardEdits" => Some("GeoPackage tables are edited in the desktop app"), _ => None, } } @@ -1201,7 +1205,15 @@ mod tests { #[test] fn desktop_only_search_actions_are_disabled_only_on_the_web() { - for id in ["project.addFolder", "layer.addDatabase", "pg.addLayer", "pg.saveEdits"] { + for id in [ + "project.addFolder", + "layer.addDatabase", + "pg.addLayer", + "pg.saveEdits", + "gpkg.addLayer", + "gpkg.saveEdits", + "gpkg.discardEdits", + ] { assert!(search_platform_disabled(id, true).is_some(), "{id} must explain its web restriction"); assert_eq!(search_platform_disabled(id, false), None, "{id} stays available on desktop"); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0b786211c..83adc3be6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,8 +18,17 @@ pub struct Dialogs { pub repair: Option, pub open: Option<&'static str>, pub layer: String, - /// The PostGIS layer whose edits were last counted (Save or Discard PostGIS Edits). + /// GeoPackage save/discard targets use IDs because display names can repeat. + pub gpkg_layer: Option, + /// The live instance captured with the map-local ID; Undo can make that ID reusable. + pub gpkg_instance: Option, + /// Layer IDs are map-local; source writes must stay in the captured project and map. + pub gpkg_map: Option<(u64, usize, String)>, + /// The source-backed layer last counted: its name for PostGIS, numeric ID for GeoPackage. pub counted: String, + /// Revision after the last count command, which itself advances the session revision. + pub counted_revision: Option, + pub counted_instance: Option, pub text: String, pub a: String, pub b: String, @@ -34,9 +43,9 @@ pub struct Dialogs { pub info: Option, /// The saved connection this dialog was opened on, by id (see `connectionui::picked`). pub connection: Option, - /// Choices found for the dialog (Add From Database's tables; Add Delimited Text's columns). + /// Choices found for the dialog (database or GeoPackage tables; delimited text columns). pub list: Vec, - /// Add From Database: the database whose tables were last listed (what Save as Connection keeps). + /// The database or GeoPackage file whose tables were last listed. pub listed: Option, /// Add Delimited Text's file, picked before the dialog opens. pub csv: Option, @@ -93,6 +102,9 @@ impl Dialogs { "pgAddLayer" => "addDatabase", "pgSave" => "pgSave", "pgDiscard" => "pgDiscard", + "gpkgAddLayer" => "gpkgAddLayer", + "gpkgSave" => "gpkgSave", + "gpkgDiscard" => "gpkgDiscard", "chooseConnection" => "chooseConnection", "s3" => "s3", "zarr" => "zarr", @@ -111,6 +123,9 @@ impl Dialogs { if matches!(name, "pgSave" | "pgDiscard") && !s.map().layers.iter().any(|l| l.postgis.is_some()) { return Err("the map has no PostGIS layer loaded for editing (Home › Add Data › Database…, Load for editing)".into()); } + if matches!(name, "gpkgSave" | "gpkgDiscard") && !s.map().layers.iter().any(|l| l.gpkg.is_some()) { + return Err(tl!("the map has no GeoPackage layer loaded for editing (Home › Add Data › Edit GeoPackage Table…)").into()); + } if name == "selectByAttributes" && s.map().layers.is_empty() { return Err("the map has no layers".into()); } @@ -155,6 +170,13 @@ impl Dialogs { let pg = |l: &&rustgis_engine::map::Layer| l.postgis.is_some(); self.layer = active.filter(pg).or_else(|| s.map().layers.iter().find(pg)).map(|l| l.name.clone()).unwrap_or_default(); } + "gpkgSave" | "gpkgDiscard" => { + let gpkg = |l: &&rustgis_engine::map::Layer| l.gpkg.is_some(); + let layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)); + self.gpkg_layer = layer.map(|l| l.id); + self.gpkg_instance = layer.map(|l| l.instance_id()); + self.gpkg_map = Some((s.opened, s.project.active_map, s.map().name.clone())); + } // A table of addresses is what's usually geocoded: picked by its id (`flag` says the // choice is a table), since names can repeat. "geocode" => { @@ -166,6 +188,37 @@ impl Dialogs { } Ok(()) } + + fn gpkg_owner(&self, s: &Session) -> Result<(), String> { + if !self + .gpkg_map + .as_ref() + .is_some_and(|(project, index, name)| *project == s.opened && *index == s.project.active_map && *name == s.map().name) + { + return Err(tl!("The active project or map changed; reopen the GeoPackage dialog for the intended layer.").into()); + } + Ok(()) + } + + fn gpkg_target(&self, s: &Session) -> Result { + self.gpkg_owner(s)?; + let id = self.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; + if !s.map().layer(id).is_some_and(|l| l.gpkg.is_some() && Some(l.instance_id()) == self.gpkg_instance) { + return Err(tl!("The selected GeoPackage layer is no longer available; choose a layer").to_string()); + } + Ok(id) + } +} + +/// Whether `error` is a target refusal from [`Dialogs::gpkg_target`], which a later valid +/// target (Redo restoring the layer) makes stale. Save failures and other errors aren't. +fn is_gpkg_target_error(error: &str) -> bool { + [ + tl!("The active project or map changed; reopen the GeoPackage dialog for the intended layer."), + tl!("Choose a GeoPackage layer"), + tl!("The selected GeoPackage layer is no longer available; choose a layer"), + ] + .contains(&error) } /// Draws the active dialog and leaves it open on validation failure or a newly opened dialog. @@ -204,6 +257,9 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addDatabase" => "Add From Database", "pgSave" => "Save to PostGIS", "pgDiscard" => "Discard PostGIS Edits", + "gpkgAddLayer" => "Edit GeoPackage Table", + "gpkgSave" => "Save to GeoPackage", + "gpkgDiscard" => "Discard GeoPackage Edits", "newConnection" => "New Connection", "editConnection" => "Edit Connection", "chooseConnection" => "Choose Connection", @@ -347,6 +403,9 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addDatabase" => close = add_database(app, ui), "pgSave" => close = pg_save(app, ui), "pgDiscard" => close = pg_discard(app, ui), + "gpkgAddLayer" => close = gpkg_add_layer(app, ui), + "gpkgSave" => close = gpkg_save(app, ui), + "gpkgDiscard" => close = gpkg_discard(app, ui), "newConnection" | "editConnection" => close = crate::connectionui::dialog(app, ui), "chooseConnection" => close = crate::connectionui::chooser(app, ui), "s3" => crate::s3ui::dialog(app, ui), @@ -1224,34 +1283,179 @@ fn add_for_editing(app: &mut RustGisApp, address: String) -> Result<(), String> app.run("pg.addLayer", p).map(|_| ()) } -/// A PostGIS layer to save or discard the edits of, and what changed in it: its table and how -/// many rows are new, changed and deleted (counted again when another layer is chosen). -fn pg_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui) { - let layers: Vec = app.session.map().layers.iter().filter(|l| l.postgis.is_some()).map(|l| l.name.clone()).collect(); - if app.dialogs.counted != app.dialogs.layer { - app.dialogs.counted = app.dialogs.layer.clone(); - app.dialogs.list = match app.run("pg.pendingEdits", json!({"layer": app.dialogs.layer})) { - Ok(v) => vec![ - v["table"].as_str().unwrap_or_default().to_string(), - v["inserts"].to_string(), - v["updates"].to_string(), - v["deletes"].to_string(), - ], +/// Load one GeoPackage feature table whole, to be edited and saved back to its file. +fn gpkg_add_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { + ui.label(egui::RichText::new(tl!("Loads one feature table of a GeoPackage on this computer (up to 100,000 rows) so its edits can be saved back to that table with Edit › Save to GeoPackage. It needs an INTEGER PRIMARY KEY.")).weak()); + let mut list = false; + egui::Grid::new("gpkg_add_layer").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let input_label = ui.label(tl!("File")); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(260.0)).labelled_by(input_label.id); + #[cfg(not(target_arch = "wasm32"))] + if ui.button(tl!("Browse…")).clicked() + && let Some(path) = pick_open(&["gpkg"], tl!("GeoPackage")) + { + app.dialogs.a = path; + } + }); + ui.end_row(); + let input_label = ui.label(tl!("Table")); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("gpkg_table") + .selected_text(app.dialogs.c.clone()) + .width(190.0) + .show_ui(ui, |ui| { + for table in app.dialogs.list.clone() { + ui.selectable_value(&mut app.dialogs.c, table.clone(), table); + } + }) + .response + .labelled_by(input_label.id); + list = ui.button(tl!("List Tables")).clicked(); + }); + ui.end_row(); + let input_label = ui.label(tl!("Most rows")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.e).hint_text("100000").desired_width(280.0)).labelled_by(input_label.id); + ui.end_row(); + }); + if app.dialogs.listed.as_deref().is_some_and(|path| path != app.dialogs.a) { + app.dialogs.listed = None; + app.dialogs.list.clear(); + app.dialogs.c.clear(); + app.dialogs.info = None; + } + if list { + match app.run("info.fileLayers", json!({"path": app.dialogs.a})) { + Ok(v) => { + app.dialogs.list = v["layers"].as_array().into_iter().flatten().filter_map(|t| t.as_str().map(str::to_string)).collect(); + app.dialogs.listed = Some(app.dialogs.a.clone()); + app.dialogs.error = None; + app.dialogs.info = Some(crate::i18n::fmt("{n} tables with geometry", &[("n", &app.dialogs.list.len().to_string())])); + } Err(e) => { + app.dialogs.listed = None; + app.dialogs.list.clear(); + app.dialogs.c.clear(); + app.dialogs.info = None; app.dialogs.error = Some(e); - Vec::new() } - }; + } + } + buttons(app, ui, "Load", |app| { + let mut p = json!({"path": app.dialogs.a.trim()}); + if !app.dialogs.c.trim().is_empty() { + p["table"] = json!(app.dialogs.c.trim()); + } + if !app.dialogs.e.trim().is_empty() { + let input = app.dialogs.e.trim(); + let mut groups = input.split(','); + let first = groups.next().unwrap_or_default(); + let grouped = first.len() != input.len(); + if grouped + && (first.is_empty() + || first.len() > 3 + || !first.bytes().all(|b| b.is_ascii_digit()) + || !groups.all(|group| group.len() == 3 && group.bytes().all(|b| b.is_ascii_digit()))) + { + return Err(tl!("Most rows: a whole number").to_string()); + } + let n: u64 = if grouped { input.replace(',', "").parse() } else { input.parse() } + .map_err(|_| tl!("Most rows: a whole number").to_string())?; + p["limit"] = json!(n); + } + app.run("gpkg.addLayer", p).map(|_| ()) + }) +} + +/// A source-backed layer to save or discard the edits of, and what changed in it: its table +/// and how many rows are new, changed and deleted (cached until the selection or session changes). +fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static str) { + if prefix == "gpkg" + && let Err(error) = app.dialogs.gpkg_owner(&app.session) + { + app.dialogs.list.clear(); + app.dialogs.counted.clear(); + app.dialogs.error = Some(error); + return; } - egui::Grid::new("pg_edits").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let layers: Vec<(u32, u64, String)> = app + .session + .map() + .layers + .iter() + .filter(|l| if prefix == "pg" { l.postgis.is_some() } else { l.gpkg.is_some() }) + .map(|l| (l.id, l.instance_id(), l.name.clone())) + .collect(); + egui::Grid::new(("source_edits", prefix)).num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { ui.label(tl!("Layer")); - egui::ComboBox::from_id_salt("pg_edit_layer").selected_text(app.dialogs.layer.clone()).width(260.0).show_ui(ui, |ui| { - for l in layers { - ui.selectable_value(&mut app.dialogs.layer, l.clone(), l); + let selected = if prefix == "gpkg" { + layers + .iter() + .find(|(id, instance, _)| Some(*id) == app.dialogs.gpkg_layer && Some(*instance) == app.dialogs.gpkg_instance) + .map(|(id, _, name)| format!("{name} (#{id})")) + .unwrap_or_else(|| tl!("Choose a layer").to_string()) + } else { + app.dialogs.layer.clone() + }; + let previous = app.dialogs.gpkg_layer.zip(app.dialogs.gpkg_instance); + let mut selected_instance = previous; + egui::ComboBox::from_id_salt(("source_edit_layer", prefix)).selected_text(selected).width(260.0).show_ui(ui, |ui| { + for (id, instance, name) in &layers { + if prefix == "gpkg" { + ui.selectable_value(&mut selected_instance, Some((*id, *instance)), format!("{name} (#{id})")); + } else { + ui.selectable_value(&mut app.dialogs.layer, name.clone(), name); + } } }); + if prefix == "gpkg" && selected_instance != previous { + app.dialogs.gpkg_layer = selected_instance.map(|(id, _)| id); + app.dialogs.gpkg_instance = selected_instance.map(|(_, instance)| instance); + app.dialogs.error = None; + } ui.end_row(); + let counted = + if prefix == "gpkg" { app.dialogs.gpkg_layer.map(|id| id.to_string()).unwrap_or_default() } else { app.dialogs.layer.clone() }; + let invalid = if prefix == "gpkg" { app.dialogs.gpkg_target(&app.session).err() } else { None }; + if invalid.is_some() + || app.dialogs.counted != counted + || (prefix == "gpkg" && app.dialogs.counted_revision != Some(app.session.revision)) + || (prefix == "gpkg" && app.dialogs.counted_instance != app.dialogs.gpkg_instance) + { + app.dialogs.counted = counted; + app.dialogs.counted_instance = app.dialogs.gpkg_instance; + let result = if let Some(error) = invalid { + Err(error) + } else { + let target = if prefix == "gpkg" { json!(app.dialogs.gpkg_layer) } else { json!(app.dialogs.layer) }; + app.run(&format!("{prefix}.pendingEdits"), json!({"layer": target})) + }; + app.dialogs.list = match result { + Ok(v) => { + if prefix == "gpkg" && app.dialogs.error.as_deref().is_some_and(is_gpkg_target_error) { + app.dialogs.error = None; + } + vec![ + v["table"].as_str().unwrap_or_default().to_string(), + v["inserts"].to_string(), + v["updates"].to_string(), + v["deletes"].to_string(), + v["file"].as_str().unwrap_or_default().to_string(), + ] + } + Err(e) => { + app.dialogs.error = Some(e); + Vec::new() + } + }; + app.dialogs.counted_revision = Some(app.session.revision); + } let shown = app.dialogs.list.clone(); + if let Some(file) = shown.get(4).filter(|file| !file.is_empty()) { + ui.label(tl!("File")); + ui.label(egui::RichText::new(file).strong()); + ui.end_row(); + } for (label, value) in [ (tl!("Table"), shown.first()), (tl!("New rows"), shown.get(1)), @@ -1269,7 +1473,7 @@ fn pg_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui) { /// of them when a row changed there since. fn pg_save(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.label(egui::RichText::new(tl!("Saves the layer's edits to its table in one transaction. When a row you changed or deleted was changed or deleted in the database since it was loaded, or a new row's key is taken, nothing is saved and the rows are listed.")).weak()); - pg_layer_edits(app, ui); + source_layer_edits(app, ui, "pg"); buttons(app, ui, "Save", |app| { let r = app.run("pg.saveEdits", json!({"layer": app.dialogs.layer})).map(|_| ()); // Counted again, whatever happened. @@ -1281,10 +1485,37 @@ fn pg_save(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { /// Discard PostGIS Edits: a PostGIS layer's rows loaded again as they are in the database. fn pg_discard(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.label(egui::RichText::new(tl!("Loads the layer's rows again as they are in the database now. Its edits that weren't saved are dropped (Undo brings them back).")).weak()); - pg_layer_edits(app, ui); + source_layer_edits(app, ui, "pg"); buttons(app, ui, "Discard", |app| app.run("pg.discardEdits", json!({"layer": app.dialogs.layer})).map(|_| ())) } +/// Save a GeoPackage layer's edits to its table in one transaction. +fn gpkg_save(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { + ui.label(egui::RichText::new(tl!("Saves the layer's edits to its table in the GeoPackage file in one transaction. When a row you changed or deleted was changed or deleted in the file since it was loaded, nothing is saved and the rows are listed. Saving the project keeps edits in the project only; exporting writes a new file.")).weak()); + source_layer_edits(app, ui, "gpkg"); + buttons(app, ui, "Save", |app| { + let id = app.dialogs.gpkg_target(&app.session)?; + let r = app.run("gpkg.saveEdits", json!({"layer": id})).map(|_| ()); + app.dialogs.counted.clear(); + r + }) +} + +/// Reload a GeoPackage layer's rows as they are in the file. +fn gpkg_discard(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { + ui.label( + egui::RichText::new(tl!( + "Loads the layer's rows again as they are in the file now. Its edits that weren't saved are dropped (Undo brings them back)." + )) + .weak(), + ); + source_layer_edits(app, ui, "gpkg"); + buttons(app, ui, "Discard", |app| { + let id = app.dialogs.gpkg_target(&app.session)?; + app.run("gpkg.discardEdits", json!({"layer": id})).map(|_| ()) + }) +} + /// Add Tile Layer: an XYZ/TMS/WMS template, vector tiles or a MapLibre style by address. fn add_tiles(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { use rustgis_engine::map::TileKind; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63e01810a..c2fa0d5b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3999,6 +3999,8 @@ const DEFAULTS_TO_ACTIVE_LAYER: &[&str] = &[ "share.locator", "pg.saveEdits", "pg.discardEdits", + "gpkg.saveEdits", + "gpkg.discardEdits", "zarr.reduce", "cube.trend", "cube.reduce", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e9e3d6ff8..2a46d8d14 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -472,6 +472,8 @@ fn home_tab(app: &mut RustGisApp, ui: &mut Ui) { // Databases are read through DuckDB, a program the browser can't run. #[cfg(not(target_arch = "wasm32"))] menu_item(ui, app, "", "Database…", "ui.dialog", json!({"name": "addDatabase"})); + #[cfg(not(target_arch = "wasm32"))] + menu_item(ui, app, "", "Edit GeoPackage Table…", "ui.dialog", json!({"name": "gpkgAddLayer"})); menu_item(ui, app, "", "S3…", "ui.dialog", json!({"name": "s3"})); submenu_row(ui, "", tl!("New Connection"), |ui| { for (kind, label) in crate::connectionui::KINDS { @@ -2396,6 +2398,31 @@ fn edit_tab(app: &mut RustGisApp, ui: &mut Ui) { ); }); } + #[cfg(not(target_arch = "wasm32"))] + if app.session.map().layers.iter().any(|l| l.gpkg.is_some()) { + col(ui, |ui| { + small( + ui, + app, + "save", + "Save to GeoPackage…", + "Save a GeoPackage layer's edits to its table in the file, unless rows changed there since.", + "ui.dialog", + json!({"name": "gpkgSave"}), + false, + ); + small( + ui, + app, + "update", + "Discard GeoPackage Edits…", + "Load a GeoPackage layer's rows again as they are in the file, dropping its edits.", + "ui.dialog", + json!({"name": "gpkgDiscard"}), + false, + ); + }); + } col(ui, |ui| { small(ui, app, "settings", "Settings", "Editing settings.", "ui.pane.editing", Value::Null, pane); small( diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index b604272ed..3caa30e95 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -33,6 +33,379 @@ fn search_layer_command(h: &mut egui_kittest::Harness<'_, RustGisApp>, command: assert!(h.state().ui.command_search.is_none()); } +/// Duplicate names must not redirect a source write or discard to the first matching layer. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_edit_dialogs_target_numeric_ids_with_duplicate_names() { + use rustgis_engine::data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value, raster::Crs, source::gpkg}; + + let dir = tempfile::tempdir().unwrap(); + let mut h = harness(); + for table in ["alpha", "beta"] { + let path = dir.path().join(format!("{table}.gpkg")); + let mut data = Dataset::new(table, GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + for x in [0.0, 1.0] { + data.push(Some(Geometry::Point(Coord::new(x, 0.0))), vec![Value::Text("original".into())]); + } + gpkg::write_in(&data, &path, Crs::LonLat).unwrap(); + let loaded = h.state_mut().run("gpkg.addLayer", json!({"path": path, "table": table})).unwrap(); + h.state_mut().run("layer.rename", json!({"layer": loaded["layer"], "name": "Duplicate"})).unwrap(); + } + let ids: Vec<_> = h.state().session.map().layers.iter().filter(|l| l.gpkg.is_some()).map(|l| l.id).collect(); + let (upper, lower) = (ids[0], ids[1]); + let source = |h: &egui_kittest::Harness<'_, RustGisApp>, id| { + let edit = h.state().session.map().layer(id).unwrap().gpkg.as_ref().unwrap(); + (std::path::PathBuf::from(&edit.path), edit.table.clone()) + }; + let (upper_path, upper_table) = source(&h, upper); + let (lower_path, lower_table) = source(&h, lower); + let stored = |path: &std::path::Path, table: &str| { + gpkg::edit::EditTable::open(path, table).unwrap().read_editable(100).unwrap().features[0].attrs[0].clone() + }; + let pending = |h: &mut egui_kittest::Harness<'_, RustGisApp>, id| { + h.state_mut().run("gpkg.pendingEdits", json!({"layer": id})).unwrap()["updates"].as_u64().unwrap() + }; + let choose = |h: &mut egui_kittest::Harness<'_, RustGisApp>, from, to| { + let from_label = format!("Duplicate (#{from})"); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some(from_label.as_str())).unwrap().click(); + h.run_steps(2); + h.get_by_label(&format!("Duplicate (#{to})")).click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.gpkg_layer, Some(to)); + }; + let shown_count = |h: &egui_kittest::Harness<'_, RustGisApp>, count: &str| { + let label = h.get_by_label("Changed rows").rect(); + assert!( + h.query_all_by_label(count).any(|n| n.rect().left() > label.right() && (n.rect().center().y - label.center().y).abs() < 3.0), + "the Changed rows value must display {count}" + ); + }; + h.state_mut().run("edit.enable", json!({"on": true})).unwrap(); + h.state_mut().run("edit.setValue", json!({"layer": upper, "row": 0, "field": "name", "value": "upper pending"})).unwrap(); + for row in [0, 1] { + h.state_mut().run("edit.setValue", json!({"layer": lower, "row": row, "field": "name", "value": "lower saved"})).unwrap(); + } + h.state_mut().run("layer.select", json!({"layer": lower})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "gpkgSave"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.gpkg_layer, Some(lower), "the lower duplicate is active"); + h.get_by_label(&lower_table); + assert_eq!(h.state().dialogs.list[2], "2"); + shown_count(&h, "2"); + choose(&mut h, lower, upper); + h.get_by_label(&upper_table); + assert_eq!(h.state().dialogs.list[2], "1", "same display name must still invalidate counts"); + shown_count(&h, "1"); + choose(&mut h, upper, lower); + h.get_by_label(&lower_table); + assert_eq!(h.state().dialogs.list[2], "2"); + shown_count(&h, "2"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Save").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + assert_eq!(pending(&mut h, lower), 0); + assert_eq!(pending(&mut h, upper), 1); + assert_eq!(stored(&lower_path, &lower_table), Value::Text("lower saved".into())); + assert_eq!(stored(&upper_path, &upper_table), Value::Text("original".into())); + + h.state_mut().run("edit.setValue", json!({"layer": lower, "row": 0, "field": "name", "value": "lower discarded"})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "gpkgDiscard"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.gpkg_layer, Some(lower)); + h.get_by_label(&lower_table); + choose(&mut h, lower, upper); + h.get_by_label(&upper_table); + choose(&mut h, upper, lower); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Discard").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + assert_eq!(pending(&mut h, lower), 0); + assert_eq!(pending(&mut h, upper), 1); + assert_eq!(h.state().session.map().layer(lower).unwrap().dataset.features[0].attrs[0], Value::Text("lower saved".into())); + assert_eq!(h.state().session.map().layer(upper).unwrap().dataset.features[0].attrs[0], Value::Text("upper pending".into())); + assert_eq!(stored(&lower_path, &lower_table), Value::Text("lower saved".into())); + assert_eq!(stored(&upper_path, &upper_table), Value::Text("original".into())); + + // Losing the target must never fall back to the active layer or a same-name layer. + for (dialog, button) in [("gpkgSave", "Save"), ("gpkgDiscard", "Discard")] { + for target in [None, Some(u32::MAX)] { + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.state_mut().dialogs.gpkg_layer = target; + h.run_steps(3); + assert!(h.state().dialogs.list.is_empty()); + h.get_by_role_and_label(egui::accesskit::Role::Button, button).click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some(dialog)); + assert!(h.state().dialogs.error.is_some()); + assert_eq!(pending(&mut h, upper), 1); + assert_eq!(pending(&mut h, lower), 0); + assert_eq!(stored(&upper_path, &upper_table), Value::Text("original".into())); + } + } + h.state_mut().run("layer.select", json!({"layer": "Countries"})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "gpkgSave"})).unwrap(); + assert_eq!(h.state().dialogs.gpkg_layer, Some(upper), "a non-editable active layer falls back to the first editable ID"); +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_dialog_gpkg(path: &std::path::Path) { + use rustgis_engine::data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value, raster::Crs, source::gpkg}; + let mut data = Dataset::new("roads", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + for x in [0.0, 1.0, 2.0] { + data.push(Some(Geometry::Point(Coord::new(x, 20.0))), vec![Value::Text("original".into())]); + } + gpkg::write_in(&data, path, Crs::LonLat).unwrap(); +} + +/// Undo rewinds map-local IDs, not a dialog's ownership of a particular live layer. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_edit_dialogs_refuse_reused_layer_ids() { + use rustgis_engine::data::{Value, source::gpkg::edit::EditTable}; + + for same_source in [false, true] { + for intervening_frame in [false, true] { + for (dialog, button) in [("gpkgSave", "Save"), ("gpkgDiscard", "Discard")] { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("alias-a.gpkg"); + let b = dir.path().join("alias-b.gpkg"); + write_dialog_gpkg(&a); + write_dialog_gpkg(&b); + let mut h = harness(); + h.state_mut().run("project.new", json!({})).unwrap(); + h.state_mut().run("edit.enable", json!({"on": true})).unwrap(); + let id = h.state_mut().run("gpkg.addLayer", json!({"path": a, "table": "roads", "name": "R"})).unwrap()["layer"] + .as_u64() + .unwrap() as u32; + assert_eq!(id, 1); + let instance = h.state().session.map().layer(id).unwrap().instance_id(); + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[4], a.to_string_lossy()); + assert_eq!(h.state().dialogs.gpkg_instance, Some(instance)); + + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(h.state().session.map().layer(id).is_none()); + let replacement = if same_source { &a } else { &b }; + let other_id = + h.state_mut().run("gpkg.addLayer", json!({"path": replacement, "table": "roads", "name": "R"})).unwrap()["layer"] + .as_u64() + .unwrap() as u32; + assert_eq!(other_id, id); + let other_instance = h.state().session.map().layer(id).unwrap().instance_id(); + assert_ne!(other_instance, instance); + h.state_mut().run("edit.setValue", json!({"layer": id, "row": 0, "field": "name", "value": "wrong source saved"})).unwrap(); + if intervening_frame { + h.run_steps(3); + assert!(h.state().dialogs.list.is_empty(), "a replacement must not inherit the old source preview"); + } + // With no intervening frame this clicks the original rendered confirmation. + h.get_by_role_and_label(egui::accesskit::Role::Button, button).click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some(dialog)); + assert!(h.state().dialogs.error.is_some()); + assert_eq!(h.state().dialogs.gpkg_instance, Some(instance), "rendering must never adopt the replacement"); + assert!(h.state().dialogs.list.is_empty()); + assert_eq!( + h.state().session.map().layer(id).unwrap().dataset.features[0].attrs[0], + Value::Text("wrong source saved".into()), + "Discard must leave the replacement's pending edit intact" + ); + assert_eq!(h.state_mut().run("gpkg.pendingEdits", json!({"layer": id})).unwrap()["updates"], 1); + for path in [&a, &b] { + let stored = EditTable::open(path, "roads").unwrap().read_editable(10).unwrap(); + assert_eq!( + stored.features[0].attrs[0], + Value::Text("original".into()), + "{button}: same_source={same_source}, intervening_frame={intervening_frame}, {path:?}" + ); + } + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.gpkg_instance, Some(other_instance)); + assert_eq!(h.state().dialogs.list[4], replacement.to_string_lossy()); + assert_eq!(h.state().dialogs.list[2], "1"); + assert!(h.state().dialogs.error.is_none()); + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_edit_dialog_counts_follow_edits_and_surviving_undo_snapshots() { + use rustgis_engine::data::{Value, source::gpkg::edit::EditTable}; + + for (dialog, button) in [("gpkgSave", "Save"), ("gpkgDiscard", "Discard")] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("live-counts.gpkg"); + write_dialog_gpkg(&path); + let mut h = harness(); + h.state_mut().run("project.new", json!({})).unwrap(); + h.state_mut().run("edit.enable", json!({"on": true})).unwrap(); + let id = h.state_mut().run("gpkg.addLayer", json!({"path": path, "table": "roads"})).unwrap()["layer"].as_u64().unwrap() as u32; + let instance = h.state().session.map().layer(id).unwrap().instance_id(); + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[2], "0"); + for row in [0, 1] { + h.state_mut().run("edit.setValue", json!({"layer": id, "row": row, "field": "name", "value": "pending"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[2], (row + 1).to_string(), "open counts follow subsequent edits"); + } + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[2], "1"); + assert_eq!(h.state().session.map().layer(id).unwrap().instance_id(), instance); + assert!(h.state().dialogs.error.is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[2], "2"); + assert_eq!(h.state().session.map().layer(id).unwrap().instance_id(), instance); + assert!(h.state().dialogs.error.is_none()); + let revision = h.state().session.revision; + h.run_steps(3); + assert_eq!(h.state().session.revision, revision, "pendingEdits must not invalidate its own count cache"); + // Undoing the layer's addition makes the target unavailable; Redo restores the same + // instance, and the stale refusal must go with it rather than sit beside valid counts. + for _ in 0..3 { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + h.run_steps(3); + assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("no longer available")), "{:?}", h.state().dialogs.error); + for _ in 0..3 { + h.state_mut().run("edit.redo", json!({})).unwrap(); + } + h.run_steps(3); + assert_eq!(h.state().session.map().layer(id).unwrap().instance_id(), instance); + assert_eq!(h.state().dialogs.error, None, "the restored target's stale refusal must clear"); + assert_eq!(h.state().dialogs.list[2], "2"); + + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(3); + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[2], "2", "reopening counts the current state"); + h.get_by_role_and_label(egui::accesskit::Role::Button, button).click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + let expected = Value::Text(if button == "Save" { "pending" } else { "original" }.into()); + let stored = EditTable::open(&path, "roads").unwrap().read_editable(10).unwrap(); + for row in [0, 1] { + assert_eq!(stored.features[row].attrs[0], expected); + assert_eq!(h.state().session.map().layer(id).unwrap().dataset.features[row].attrs[0], expected); + } + assert_eq!(h.state_mut().run("gpkg.pendingEdits", json!({"layer": id})).unwrap()["updates"], 0); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_edit_dialogs_refuse_map_and_project_identity_changes() { + use rustgis_engine::data::{Value, source::gpkg::edit::EditTable}; + for project_switch in [false, true] { + for (dialog, button) in [("gpkgSave", "Save"), ("gpkgDiscard", "Discard")] { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.gpkg"); + let b = dir.path().join("b.gpkg"); + write_dialog_gpkg(&a); + write_dialog_gpkg(&b); + let mut h = harness(); + h.state_mut().run("map.new", json!({"name": "Editable A"})).unwrap(); + let id = h.state_mut().run("gpkg.addLayer", json!({"path": a, "table": "roads", "name": "R"})).unwrap()["layer"] + .as_u64() + .unwrap() as u32; + h.state_mut().run("edit.enable", json!({"on": true})).unwrap(); + h.state_mut().run("edit.setValue", json!({"layer": id, "row": 0, "field": "name", "value": "A pending"})).unwrap(); + let project = dir.path().join("other.rgis"); + if project_switch { + // Same map index, map name and layer ID: only the opened-project generation differs. + let mut other = h.state().session.project.clone(); + let index = other.active_map; + let layer = other.maps[index].layer_mut(id).unwrap(); + layer.gpkg.as_mut().unwrap().path = b.to_string_lossy().into_owned(); + layer.dataset.features[0].attrs[0] = Value::Text("B pending".into()); + let mut session = rustgis_engine::Session::new(other); + session.run("project.saveAs", &json!({"path": project})).unwrap(); + } else { + h.state_mut().run("map.new", json!({"name": "Editable B"})).unwrap(); + let other_id = h.state_mut().run("gpkg.addLayer", json!({"path": b, "table": "roads", "name": "R"})).unwrap()["layer"] + .as_u64() + .unwrap() as u32; + assert_eq!(id, other_id, "layer IDs collide across maps"); + h.state_mut().run("edit.enable", json!({"on": true})).unwrap(); + h.state_mut().run("edit.setValue", json!({"layer": id, "row": 0, "field": "name", "value": "B pending"})).unwrap(); + h.state_mut().run("map.activate", json!({"name": "Editable A"})).unwrap(); + } + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[4], a.to_string_lossy()); + if project_switch { + h.state_mut().run("project.open", json!({"path": project})).unwrap(); + } else { + h.state_mut().run("map.activate", json!({"name": "Editable B"})).unwrap(); + } + h.run_steps(3); + assert!(h.state().dialogs.list.is_empty(), "the old source preview must disappear"); + h.get_by_role_and_label(egui::accesskit::Role::Button, button).click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some(dialog)); + assert!(h.state().dialogs.error.is_some()); + assert_eq!(h.state().session.map().layer(id).unwrap().dataset.features[0].attrs[0], Value::Text("B pending".into())); + assert_eq!(h.state_mut().run("gpkg.pendingEdits", json!({"layer": id})).unwrap()["updates"], 1); + for path in [&a, &b] { + let stored = EditTable::open(path, "roads").unwrap().read_editable(10).unwrap(); + assert_eq!(stored.features[0].attrs[0], Value::Text("original".into()), "{project_switch}: {button}: {path:?}"); + } + h.state_mut().run("ui.dialog", json!({"name": dialog})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialogs.list[4], b.to_string_lossy(), "reopening targets the current owner"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn geopackage_load_dialog_rejects_fractional_row_limits() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rows.gpkg"); + write_dialog_gpkg(&path); + let mut h = harness(); + h.state_mut().run("ui.dialog", json!({"name": "gpkgAddLayer"})).unwrap(); + h.state_mut().dialogs.a = path.to_string_lossy().into_owned(); + h.state_mut().dialogs.c = "roads".into(); + for input in ["1.5", "0.5", "-1", "1,5", "1,,000", "12,34"] { + h.state_mut().dialogs.e = input.into(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Load").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some("gpkgAddLayer")); + assert!(h.state().dialogs.error.is_some()); + assert!(h.state().session.map().layers.iter().all(|l| l.gpkg.is_none())); + } + h.state_mut().dialogs.e = "1".into(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Load").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + let layer = h.state().session.map().layers.iter().find(|l| l.gpkg.is_some()).unwrap(); + assert_eq!(layer.dataset.features.len(), 1); + assert_eq!( + layer.dataset.features[0].geometry, + Some(rustgis_engine::data::Geometry::Point(rustgis_engine::data::Coord::new(0.0, 20.0))) + ); + h.state_mut().run("ui.dialog", json!({"name": "gpkgAddLayer"})).unwrap(); + h.state_mut().dialogs.a = path.to_string_lossy().into_owned(); + h.state_mut().dialogs.c = "roads".into(); + h.state_mut().dialogs.e = "1,000".into(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Load").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, None, "{:?}", h.state().dialogs.error); + let layer = h.state().session.map().layers.iter().find(|l| l.gpkg.is_some()).unwrap(); + assert_eq!(layer.dataset.features.len(), 3, "valid thousands grouping must still load all available rows"); +} + /// Both No Fill controls preserve unique-value styling and undo, and stay hidden for points. #[test] fn polygon_no_fill_is_reachable_from_the_pane_and_the_ribbon() { diff --git a/docs/comparison/index.md b/docs/comparison/index.md index 8a6107703..00ef86e6f 100644 --- a/docs/comparison/index.md +++ b/docs/comparison/index.md @@ -135,14 +135,17 @@ opens, and writes MapLibre styles and OGC API Features collections for the web. Stated plainly, so the tables above are worth trusting: -- **Format breadth.** PostgreSQL/PostGIS, MySQL/MariaDB and SQL Server/Azure SQL are read only, - and these live connections and DuckDB files are desktop only. The web app doesn't write - GeoPackages. The native File Geodatabase reader is 2D and does not read compressed tables, +- **Format breadth.** MySQL/MariaDB and SQL Server/Azure SQL are read only; + PostGIS and GeoPackage support table write-back on desktop. Live database connections and + DuckDB files are desktop only. The web app doesn't write GeoPackages. + The native File Geodatabase reader is 2D and does not read compressed tables, raster fields or sparse row layouts; GDAL can fill these gaps on desktop. DWG and rasters such as JPEG 2000, NetCDF and HDF also need GDAL. SQL queries need DuckDB; both are external programs. - **Coordinate systems.** Vector data is stored in longitude/latitude; there is no full PROJ. -- **Editing depth.** Vertex move, insert and delete are supported, but not versioned or - multi-user database editing. Edits are kept in the project and exported, not written back to databases. +- **Editing depth.** Vertex move, insert and delete are supported, but not versioned multi-user + database editing. Desktop GeoPackage and PostGIS layers loaded for editing + can save back to their source tables with row-conflict checks; other edits stay in the project + until exported. - **Specialized analysis.** Built-in spatial joins, overlays (intersect, identity, union) and shortest paths on line networks are available, but full network datasets and advanced spatial statistics depend on the external suite or another GIS. diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index adbaf7822..1e5fcda6e 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -268,6 +268,75 @@ Connecting and adding a layer read the database, so the [AI Assistant](assistant before it connects or adds a table, and asks every time before it saves edits. Plugins can't connect or save at all. +### Editing GeoPackage tables + +On the desktop, **Home › Add › Add Data › Edit GeoPackage Table…** (`gpkg.addLayer`) loads one +feature table of an existing local `.gpkg` for editing. Choose its table with **List Tables** +when the file has several. The table needs one `INTEGER PRIMARY KEY`. At most 100,000 rows +are loaded; **Most rows** can lower that limit. Whole numbers may use comma-grouped thousands +(for example, `1,000`); fractional values are rejected. RustGIS does not +directly edit rows outside that load, including rows with unreadable geometry. File-owned +triggers can change other rows. + +**Edit › Manage Edits › Save to GeoPackage…** (`gpkg.saveEdits`) writes pending inserts, updates +and deletes to that table in one SQLite transaction. If any row you changed or deleted has +changed or disappeared in the file since it was loaded, nothing is saved. A constraint or +trigger failure also rolls everything back; pending edits remain in the layer. New features +get source-assigned keys that cannot alias loaded rows, and unrelated columns and tables remain +intact. The saved rows are read back within the transaction, so the layer keeps values assigned +by defaults and triggers before it starts tracking its next edits. If a DELETE trigger recreates +a target row, that stored row reappears in the layer and is not counted as deleted. If triggers +change or remove other rows already loaded in the layer, those rows are refreshed or removed +before the next edits; rows outside the load are not added. + +New-feature saves also check the table's persisted `last_change` revision. If it changed since +the layer was loaded, the save is refused rather than risking duplicate inserts from an older +project or an undone save reopened in another session. Pending edits remain available to export; +Discard Edits reloads the source and drops those pending edits. Identical feature values are not +used to guess whether a new row was saved before. + +Spatial indexes use the final stored geometry after table triggers run. Bounds are refreshed +from the whole table's readable native extents after inserts, geometry edits or deletes; they +can shrink, and become NULL when no readable nonempty geometry remains. Attribute-only saves +keep known bounds unless a trigger writes feature rows. Such trigger writes require a whole-table +spatial rebuild in the transaction; unreadable nonempty geometry, including a body hidden behind +a valid header envelope, makes that rebuild refuse the entire save rather than guess its index +coverage. Metadata or index-repair triggers that would change features after the final snapshot +are also refused, with the transaction rolled back. A geometry type, Z/M declaration or SRS that +changed since the layer was loaded also refuses the save until Discard Edits reloads it. + +**Discard GeoPackage Edits…** (`gpkg.discardEdits`) reloads the scoped rows as they are in the +file now, including renamed columns and its current geometry type and coordinate system; Undo +restores the discarded layer edits. A changed geometry family resets its renderer to a compatible +default. Saving refuses a changed source SRS until the layer is reloaded. +Undoing a save does not undo file writes: Redo or Discard Edits is needed before saving again. +Saving the project keeps pending edits in the project only; exporting writes a new file rather +than changing this table. +Save and Discard dialogs remain bound to the project, map and specific layer instance where they +opened. Switching maps or projects, or undoing and re-adding a layer, requires reopening or +choosing again; a reused layer ID cannot retarget another file. +Feature-less project templates retain the layer's schema and styling, but no source-edit binding: +opening a template cannot turn its absent features into source-row deletions. The same holds for +the simplified layers a publication layout's globe locator map keeps: they can't be saved back. + +Geometry is converted back to the table's coordinate system and must match its declared family. +Single-part columns reject multipart features; collection, curve and surface columns cannot +receive geometry writes. Geometry writes that would lose existing Z/M dimensions, or insert +geometry into a table requiring them, are refused; attribute-only edits can still be saved. +Read-only files cannot be saved. These editing commands are desktop-only; normal GeoPackage +reading remains available in the browser. + +```sh +rustgis-cli run --cmd 'gpkg.addLayer={"path":"/data/roads.gpkg","table":"roads","name":"Roads"}' \ + --cmd 'edit.enable={"on":true}' \ + --cmd 'edit.setValue={"layer":"Roads","row":0,"field":"name","value":"Main St"}' \ + --cmd 'gpkg.saveEdits={"layer":"Roads"}' +``` + +The AI Assistant asks before reading the file and every time before saving edits. Plugins +need file permission to load or discard rows and cannot save edits back to the table. + + ## MySQL and MariaDB **Home › Add › Add Data › Database…** (`layer.addDatabase`) also connects to a MySQL (8 or later) or