From 871b82416e344069964fb0bbb847002b5e928d52 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 00:13:52 +0100 Subject: [PATCH 1/9] feat(data): save GeoPackage edits back to their source table --- Cargo.toml | 2 +- ROADMAP.md | 4 +- crates/data/src/source/edits.rs | 56 ++ crates/data/src/source/gpkg.rs | 44 +- crates/data/src/source/gpkg/edit.rs | 1098 +++++++++++++++++++++++++++ crates/data/src/source/mod.rs | 13 + crates/data/src/source/postgis.rs | 56 +- crates/data/src/wkb.rs | 77 ++ crates/engine/src/cmd/gpkg.rs | 267 +++++++ crates/engine/src/cmd/items.rs | 3 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/pg.rs | 37 +- crates/engine/src/cmd/plugins.rs | 5 + crates/engine/src/lib.rs | 20 +- crates/engine/src/tests.rs | 558 +++++++++++++- crates/map/src/lib.rs | 14 +- crates/map/src/source.rs | 51 +- crates/ui-egui/src/chrome.rs | 14 +- crates/ui-egui/src/dialogs.rs | 158 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/ribbon.rs | 27 + docs/comparison/index.md | 13 +- docs/user-guide/data.md | 42 + 23 files changed, 2414 insertions(+), 149 deletions(-) create mode 100644 crates/data/src/source/edits.rs create mode 100644 crates/data/src/source/gpkg/edit.rs create mode 100644 crates/engine/src/cmd/gpkg.rs diff --git a/Cargo.toml b/Cargo.toml index 3eaebbeed..295a10604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,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 a4ec8f3c8..b76f92af9 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._ - Open GeoLibre `.geolibre` projects as maps, the way supported QGIS projects open ([#962](https://github.com/opengeos/RustGIS/issues/962)). GeoLibre opening `.rgis` projects comes later, once RustGIS's project format and supported data types settle. @@ -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..f278867e0 --- /dev/null +++ b/crates/data/src/source/gpkg/edit.rs @@ -0,0 +1,1098 @@ +//! 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::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)>, + 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, + /// Every requested update (even unchanged) followed by inserts, with committed keys. + /// Attributes follow the supplied fields; fields absent from the table retain submitted values. + pub features: Vec, +} + +/// 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 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(), + 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. + /// 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, + ) -> 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 columns: Vec = table_columns(&tx, self.table())?.into_iter().map(|c| c.name).collect(); + let (key, geom) = self.positions(&columns)?; + let mut saved = Saved::default(); + let mut targets = Vec::new(); + let mut snapshot_columns = 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()); + } + } + 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)); + } + + // A file with no R-tree triggers still needs its existing index kept in step. + let manual_rtree = if let Some(rt) = &self.src.rtree { + let triggered: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND lower(tbl_name) = lower(?1) AND instr(lower(sql), lower(?2)) > 0)", + params![self.table(), rt], |r| r.get(0), + ).map_err(err)?; + (!triggered).then_some(rt.as_str()) + } else { + None + }; + let table = quote(self.table()); + let pk = quote(self.key()); + let mut written_extent = Extent::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; + let mut extent = Extent::EMPTY; + if geometry_changed { + let (blob, e) = self.geometry_blob(f.geometry.as_ref(), Some((f.id, old)))?; + extent = e; + 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())); + tx.execute(&format!("UPDATE {table} SET {} WHERE {pk} = ?", sets.join(", ")), params_from_iter(args)).map_err(err)?; + saved.updated += 1; + if geometry_changed { + written_extent = written_extent.union(&extent); + maintain_rtree(&tx, manual_rtree, f.id.cast_signed(), &extent)?; + } + } + // 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.filter_map(|k| i64::try_from(k).ok()).max().unwrap_or(0).max(0); + 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)]; + let mut extent = Extent::EMPTY; + if let Some(g) = &f.geometry { + let (blob, e) = self.geometry_blob(Some(g), None)?; + extent = e; + 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(", ")); + tx.execute(&sql, params_from_iter(args)).map_err(err)?; + let key = new_key; + saved.inserted.push((f.id, key.cast_unsigned())); + maintain_rtree(&tx, manual_rtree, key, &extent)?; + written_extent = written_extent.union(&extent); + } + for (k, _) in &edits.deletes { + saved.deleted += tx.execute(&format!("DELETE FROM {table} WHERE {pk} = ?1"), [k.cast_signed()]).map_err(err)?; + maintain_rtree(&tx, manual_rtree, k.cast_signed(), &Extent::EMPTY)?; + } + tx.execute( + "UPDATE gpkg_contents SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE lower(table_name) = lower(?1)", + [self.table()], + ) + .map_err(err)?; + if !written_extent.is_empty() { + tx.execute( + "UPDATE gpkg_contents SET min_x = min(min_x, ?2), min_y = min(min_y, ?3), max_x = max(max_x, ?4), max_y = max(max_y, ?5) + WHERE lower(table_name) = lower(?1) AND min_x IS NOT NULL AND min_y IS NOT NULL AND max_x IS NOT NULL AND max_y IS NOT NULL", + params![self.table(), written_extent.min_x, written_extent.min_y, written_extent.max_x, written_extent.max_y], + ).map_err(err)?; + } + let keys: Vec = + edits.updates.iter().map(|(f, _)| f.id.cast_signed()).chain(saved.inserted.iter().map(|(_, k)| k.cast_signed())).collect(); + let rows = self.fetch_rows(&tx, &columns, key, &keys)?; + let mut features = Vec::with_capacity(keys.len()); + let originals = edits + .updates + .iter() + .map(|(f, _)| (f, f.id.cast_signed())) + .chain(edits.inserts.iter().zip(&saved.inserted).map(|(f, (_, k))| (f, k.cast_signed()))); + 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 = original.clone(); + feature.id = k.cast_unsigned(); + feature.geometry = geometry; + feature.attrs.resize(fields.len(), Value::Null); + for &(i, c) in &snapshot_columns { + feature.attrs[i] = value(ValueRef::from(row.get(c).ok_or_else(|| self.shape_error())?), fields[i].ty); + } + saved.versions.push((k.cast_unsigned(), fingerprint(row.iter().map(ValueRef::from)))); + features.push(feature); + } + tx.commit().map_err(err)?; + Ok(Ok(SavedRows { changes: saved, features })) + } + + 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<(Sql, Extent)> { + // 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, Extent::EMPTY)); + }; + 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)), extent)) + } + + 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 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; + + 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', 'before', 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()).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()) + .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()).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()).unwrap().unwrap_err(), vec![Conflict::Changed(1)]); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), "before"); + conn.execute("DELETE FROM roads WHERE fid = 1", []).unwrap(); + assert_eq!(table.save(&edits, &table.info().fields, std::iter::empty()).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()).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()).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()); + assert_eq!(table.save(&edits, &table.info().fields, std::iter::empty()).unwrap().unwrap().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()).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()).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(); + table.save(&update(&table, Some(line(10.0))), &table.info().fields, std::iter::empty()).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(), "before"); + table.save(&update(&table, None), &table.info().fields, std::iter::empty()).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()).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(); + table.save(&update(&table, Some(line(8.0))), &table.info().fields, std::iter::empty()).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()).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()); + table.save(&edits, &[], std::iter::empty()).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()).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()).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()).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()).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.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()).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()).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()).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(), "before"); + } + } + } + + #[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)).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()).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()).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"); + 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()).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()).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()).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..f0dc79654 --- /dev/null +++ b/crates/engine/src/cmd/gpkg.rs @@ -0,0 +1,267 @@ +//! 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) { + l.gpkg = Some(rustgis_map::source::GpkgEdit { + path, + table: src.table().to_string(), + key: src.key().to_string(), + geometry: src.geometry_column().to_string(), + 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)?; + if !src.key().eq_ignore_ascii_case(&edit.key) || !src.geometry_column().eq_ignore_ascii_case(&edit.geometry) { + 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)).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; + for feature in l.dataset.features.iter_mut() { + if let Some(row) = stored.remove(&feature.id) { + geometry_changed |= row.geometry != feature.geometry; + *feature = row; + } + } + 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; + } + } + 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")?; + 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(); + } + 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 ea2fd6636..6a40e6c8c 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -526,6 +526,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/mod.rs b/crates/engine/src/cmd/mod.rs index 9c2cda25d..c89cf9269 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ pub mod effects; pub mod embeddings; pub mod geocode; pub mod gp; +pub mod gpkg; pub mod graphics; pub mod group; pub mod history; @@ -100,6 +101,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/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 96f5b7ec1..0c420f085 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -291,11 +291,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"]; @@ -375,6 +379,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/lib.rs b/crates/engine/src/lib.rs index 5440c2e14..deac464f7 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,22 @@ 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; } 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) && versions.get(f.id).is_some())) .collect(); if reused.is_empty() { continue; @@ -742,7 +742,7 @@ 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)) .filter(|&k| k.cast_signed() >= 0) .max() .unwrap_or(0); @@ -824,7 +824,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, @@ -1086,7 +1086,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 746a14bbe..685c74aba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6789,6 +6789,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() { @@ -18030,14 +18048,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(); @@ -18170,6 +18188,542 @@ 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) VALUES ('roads', 'features', 4326); + 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_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_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); +} + +#[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 4fa5ac226..e256ebe5a 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -186,6 +186,9 @@ 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, /// Shared, like the features: an undo snapshot or other copy of the layer doesn't copy it. #[serde(skip)] projected: std::sync::Arc>>, @@ -217,9 +220,14 @@ fn yes() -> bool { } impl Layer { + /// 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 { @@ -247,6 +255,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)); } @@ -332,6 +343,7 @@ impl Layer { raster: None, source: None, postgis: None, + gpkg: None, projected: Default::default(), projected_extents: Default::default(), projected_vertices: 0, diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index fabc1ce83..2f7a89a32 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -152,6 +152,23 @@ 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, + pub writable: bool, + #[serde(default)] + pub limit: usize, + /// Every column's stored fingerprint and the loaded feature's hash, by key. + #[serde(default)] + 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 +191,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 +258,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 +272,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 +297,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 +1134,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 39e5a8070..047715165 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"), @@ -817,6 +820,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, } } @@ -1200,7 +1204,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..07040598d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,7 +18,7 @@ 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). + /// The source-backed layer whose edits were last counted (Save or Discard Edits). pub counted: String, pub text: String, pub a: String, @@ -34,9 +34,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 +93,9 @@ impl Dialogs { "pgAddLayer" => "addDatabase", "pgSave" => "pgSave", "pgDiscard" => "pgDiscard", + "gpkgAddLayer" => "gpkgAddLayer", + "gpkgSave" => "gpkgSave", + "gpkgDiscard" => "gpkgDiscard", "chooseConnection" => "chooseConnection", "s3" => "s3", "zarr" => "zarr", @@ -111,6 +114,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 +161,10 @@ 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(); + self.layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)).map(|l| l.name.clone()).unwrap_or_default(); + } // 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" => { @@ -204,6 +214,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 +360,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,18 +1240,97 @@ 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(); +/// 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); + } + } + } + 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 n: u64 = app.dialogs.e.trim().replace([',', '.'], "").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 (counted again when another layer is chosen). +fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static str) { + let layers: Vec = app + .session + .map() + .layers + .iter() + .filter(|l| if prefix == "pg" { l.postgis.is_some() } else { l.gpkg.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})) { + app.dialogs.list = match app.run(&format!("{prefix}.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(), + v["file"].as_str().unwrap_or_default().to_string(), ], Err(e) => { app.dialogs.error = Some(e); @@ -1243,15 +1338,23 @@ fn pg_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui) { } }; } - egui::Grid::new("pg_edits").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + 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); - } - }); + egui::ComboBox::from_id_salt(("source_edit_layer", prefix)).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); + } + }, + ); ui.end_row(); 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 +1372,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 +1384,33 @@ 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 r = app.run("gpkg.saveEdits", json!({"layer": app.dialogs.layer})).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| app.run("gpkg.discardEdits", json!({"layer": app.dialogs.layer})).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 514c1f680..31dd39ffc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3928,6 +3928,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/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..1de98b420 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -268,6 +268,48 @@ 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. Rows outside that load, including rows with +unreadable geometry, are left untouched when saving. + +**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. + +**Discard GeoPackage Edits…** (`gpkg.discardEdits`) reloads the scoped rows as they are in the +file now, including renamed key and geometry columns; Undo restores the discarded layer edits. +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, while exporting writes a new file rather than changing this table. +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. + +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 From e7368884138ce078a9359faa6bc0b445ce372072 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 08:26:49 +0100 Subject: [PATCH 2/9] fix(gpkg): target edit dialogs by stable layer IDs --- crates/ui-egui/src/dialogs.rs | 89 +++++++++++++++++--------- crates/ui-egui/tests/smoke.rs | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 07040598d..40d96fa02 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,7 +18,9 @@ pub struct Dialogs { pub repair: Option, pub open: Option<&'static str>, pub layer: String, - /// The source-backed layer whose edits were last counted (Save or Discard Edits). + /// GeoPackage save/discard targets use IDs because display names can repeat. + pub gpkg_layer: Option, + /// The source-backed layer last counted: its name for PostGIS, numeric ID for GeoPackage. pub counted: String, pub text: String, pub a: String, @@ -163,7 +165,7 @@ impl Dialogs { } "gpkgSave" | "gpkgDiscard" => { let gpkg = |l: &&rustgis_engine::map::Layer| l.gpkg.is_some(); - self.layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)).map(|l| l.name.clone()).unwrap_or_default(); + self.gpkg_layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)).map(|l| l.id); } // A table of addresses is what's usually geocoded: picked by its id (`flag` says the // choice is a table), since names can repeat. @@ -1314,41 +1316,66 @@ fn gpkg_add_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { /// 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 (counted again when another layer is chosen). fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static str) { - let layers: Vec = app + let layers: Vec<(u32, String)> = app .session .map() .layers .iter() .filter(|l| if prefix == "pg" { l.postgis.is_some() } else { l.gpkg.is_some() }) - .map(|l| l.name.clone()) + .map(|l| (l.id, l.name.clone())) .collect(); - if app.dialogs.counted != app.dialogs.layer { - app.dialogs.counted = app.dialogs.layer.clone(); - app.dialogs.list = match app.run(&format!("{prefix}.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(), - v["file"].as_str().unwrap_or_default().to_string(), - ], - Err(e) => { - app.dialogs.error = Some(e); - Vec::new() - } - }; - } 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(("source_edit_layer", prefix)).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, _)| Some(*id) == app.dialogs.gpkg_layer) + .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; + egui::ComboBox::from_id_salt(("source_edit_layer", prefix)).selected_text(selected).width(260.0).show_ui(ui, |ui| { + for (id, name) in &layers { + if prefix == "gpkg" { + ui.selectable_value(&mut app.dialogs.gpkg_layer, Some(*id), format!("{name} (#{id})")); + } else { + ui.selectable_value(&mut app.dialogs.layer, name.clone(), name); } - }, - ); + } + }); + if prefix == "gpkg" && app.dialogs.gpkg_layer != previous { + 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 missing = prefix == "gpkg" && !layers.iter().any(|(id, _)| Some(*id) == app.dialogs.gpkg_layer); + if missing || app.dialogs.counted != counted { + app.dialogs.counted = counted; + let result = if missing { + Err(tl!("The selected GeoPackage layer is no longer available; choose a layer").to_string()) + } 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) => { + 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() + } + }; + } let shown = app.dialogs.list.clone(); if let Some(file) = shown.get(4).filter(|file| !file.is_empty()) { ui.label(tl!("File")); @@ -1393,7 +1420,8 @@ 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 r = app.run("gpkg.saveEdits", json!({"layer": app.dialogs.layer})).map(|_| ()); + let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; + let r = app.run("gpkg.saveEdits", json!({"layer": id})).map(|_| ()); app.dialogs.counted.clear(); r }) @@ -1408,7 +1436,10 @@ fn gpkg_discard(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { .weak(), ); source_layer_edits(app, ui, "gpkg"); - buttons(app, ui, "Discard", |app| app.run("gpkg.discardEdits", json!({"layer": app.dialogs.layer})).map(|_| ())) + buttons(app, ui, "Discard", |app| { + let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; + app.run("gpkg.discardEdits", json!({"layer": id})).map(|_| ()) + }) } /// Add Tile Layer: an XYZ/TMS/WMS template, vector tiles or a MapLibre style by address. diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 264eb8fa4..dcc8058d2 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -33,6 +33,120 @@ 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"); +} + /// 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() { From 2f9087834744f1a9f98d802e9e0719fbd038dd99 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 08:26:49 +0100 Subject: [PATCH 3/9] fix(gpkg): guard stale inserts and preserve final spatial state --- crates/data/src/source/gpkg/edit.rs | 605 ++++++++++++++++++++++++---- crates/engine/src/cmd/gpkg.rs | 18 +- crates/engine/src/tests.rs | 114 +++++- crates/map/src/source.rs | 5 +- docs/user-guide/data.md | 21 +- 5 files changed, 675 insertions(+), 88 deletions(-) diff --git a/crates/data/src/source/gpkg/edit.rs b/crates/data/src/source/gpkg/edit.rs index f278867e0..f5369a8a1 100644 --- a/crates/data/src/source/gpkg/edit.rs +++ b/crates/data/src/source/gpkg/edit.rs @@ -12,6 +12,10 @@ use rusqlite::{Connection, OpenFlags, TransactionBehavior, params, params_from_i use rustgis_geom::Extent; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; use std::time::Duration; /// A whole-table editing session is bounded, unlike a streamed map source. @@ -21,6 +25,8 @@ pub const MAX_EDITABLE: usize = 100_000; 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, @@ -33,6 +39,7 @@ pub struct SavedRows { /// Every requested update (even unchanged) followed by inserts, with committed keys. /// Attributes follow the supplied fields; fields absent from the table retain submitted values. pub features: Vec, + pub revision: String, } /// An existing feature table; its read connection is never reused for writes. @@ -90,6 +97,10 @@ impl EditTable { &self.src.geom } + pub fn srs_id(&self) -> i64 { + self.srs_id + } + 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()))?; @@ -107,6 +118,7 @@ impl EditTable { 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()), @@ -149,6 +161,7 @@ impl EditTable { /// 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. @@ -157,6 +170,7 @@ impl EditTable { 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)?; @@ -165,8 +179,26 @@ impl EditTable { 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 srs_id: i64 = tx + .query_row("SELECT srs_id FROM gpkg_geometry_columns WHERE lower(table_name) = lower(?1)", [self.table()], |r| r.get(0)) + .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() + ))); + } let columns: Vec = table_columns(&tx, self.table())?.into_iter().map(|c| c.name).collect(); let (key, geom) = self.positions(&columns)?; + let feature_writes = watch_feature_writes(&tx, self.table())?; + let mut direct_changes = 0_u64; let mut saved = Saved::default(); let mut targets = Vec::new(); let mut snapshot_columns = Vec::new(); @@ -197,19 +229,8 @@ impl EditTable { return Ok(Err(conflicts)); } - // A file with no R-tree triggers still needs its existing index kept in step. - let manual_rtree = if let Some(rt) = &self.src.rtree { - let triggered: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND lower(tbl_name) = lower(?1) AND instr(lower(sql), lower(?2)) > 0)", - params![self.table(), rt], |r| r.get(0), - ).map_err(err)?; - (!triggered).then_some(rt.as_str()) - } else { - None - }; let table = quote(self.table()); let pk = quote(self.key()); - let mut written_extent = Extent::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(); @@ -225,10 +246,8 @@ impl EditTable { } 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; - let mut extent = Extent::EMPTY; if geometry_changed { - let (blob, e) = self.geometry_blob(f.geometry.as_ref(), Some((f.id, old)))?; - extent = e; + let blob = self.geometry_blob(f.geometry.as_ref(), Some((f.id, old)))?; sets.push(format!("{} = ?", quote(self.geometry_column()))); args.push(blob); } @@ -237,12 +256,9 @@ impl EditTable { continue; } args.push(Sql::Integer(f.id.cast_signed())); - tx.execute(&format!("UPDATE {table} SET {} WHERE {pk} = ?", sets.join(", ")), params_from_iter(args)).map_err(err)?; + direct_changes += + tx.execute(&format!("UPDATE {table} SET {} WHERE {pk} = ?", sets.join(", ")), params_from_iter(args)).map_err(err)? as u64; saved.updated += 1; - if geometry_changed { - written_extent = written_extent.union(&extent); - maintain_rtree(&tx, manual_rtree, f.id.cast_signed(), &extent)?; - } } // 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. @@ -268,10 +284,8 @@ impl EditTable { high_water = new_key; let mut names = vec![pk.clone()]; let mut args = vec![Sql::Integer(new_key)]; - let mut extent = Extent::EMPTY; if let Some(g) = &f.geometry { - let (blob, e) = self.geometry_blob(Some(g), None)?; - extent = e; + let blob = self.geometry_blob(Some(g), None)?; names.push(quote(self.geometry_column())); args.push(blob); } @@ -284,32 +298,26 @@ impl EditTable { } } let sql = format!("INSERT INTO {table} ({}) VALUES ({})", names.join(", "), vec!["?"; names.len()].join(", ")); - tx.execute(&sql, params_from_iter(args)).map_err(err)?; + 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())); - maintain_rtree(&tx, manual_rtree, key, &extent)?; - written_extent = written_extent.union(&extent); } for (k, _) in &edits.deletes { - saved.deleted += tx.execute(&format!("DELETE FROM {table} WHERE {pk} = ?1"), [k.cast_signed()]).map_err(err)?; - maintain_rtree(&tx, manual_rtree, k.cast_signed(), &Extent::EMPTY)?; + let deleted = tx.execute(&format!("DELETE FROM {table} WHERE {pk} = ?1"), [k.cast_signed()]).map_err(err)?; + direct_changes += deleted as u64; + saved.deleted += deleted; } - tx.execute( - "UPDATE gpkg_contents SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE lower(table_name) = lower(?1)", - [self.table()], - ) - .map_err(err)?; - if !written_extent.is_empty() { - tx.execute( - "UPDATE gpkg_contents SET min_x = min(min_x, ?2), min_y = min(min_y, ?3), max_x = max(max_x, ?4), max_y = max(max_y, ?5) - WHERE lower(table_name) = lower(?1) AND min_x IS NOT NULL AND min_y IS NOT NULL AND max_x IS NOT NULL AND max_y IS NOT NULL", - params![self.table(), written_extent.min_x, written_extent.min_y, written_extent.max_x, written_extent.max_y], - ).map_err(err)?; - } - let keys: Vec = - edits.updates.iter().map(|(f, _)| f.id.cast_signed()).chain(saved.inserted.iter().map(|(_, k)| k.cast_signed())).collect(); + // Spatial/metadata repair must not itself trigger more feature writes after the snapshot. + feature_writes.allowed.store(false, Ordering::Relaxed); + let 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(); let rows = self.fetch_rows(&tx, &columns, key, &keys)?; - let mut features = Vec::with_capacity(keys.len()); + let mut features = Vec::with_capacity(edits.updates.len() + edits.inserts.len()); let originals = edits .updates .iter() @@ -330,8 +338,78 @@ impl EditTable { 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; + 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)) + .transpose()? + .unwrap_or(Extent::EMPTY); + maintain_rtree(&tx, self.src.rtree.as_deref(), k, &extent)?; + written_extent = written_extent.union(&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 unknown_bounds && rebuild { + let extent = (!written_extent.is_empty()).then_some(written_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)?; + } else if unknown_bounds { + // Unknown bounds must include untouched rows too. One aggregate scan, in native CRS. + let geom = quote(self.geometry_column()); + tx.execute( + &format!( + "UPDATE gpkg_contents SET (min_x, min_y, max_x, max_y) = + (SELECT min(ST_MinX({geom})), min(ST_MinY({geom})), max(ST_MaxX({geom})), max(ST_MaxY({geom})) FROM {table}) + WHERE lower(table_name) = lower(?1)" + ), + [self.table()], + ) + .map_err(err)?; + } else if !written_extent.is_empty() { + tx.execute( + "UPDATE gpkg_contents SET min_x = min(min_x, ?2), min_y = min(min_y, ?3), max_x = max(max_x, ?4), max_y = max(max_y, ?5) + WHERE lower(table_name) = lower(?1)", + params![self.table(), written_extent.min_x, written_extent.min_y, written_extent.max_x, written_extent.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 })) + Ok(Ok(SavedRows { changes: saved, features, 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 { @@ -357,7 +435,7 @@ impl EditTable { } } - fn geometry_blob(&self, g: Option<&Geometry>, row: Option<(u64, ValueRef<'_>)>) -> Result<(Sql, Extent)> { + 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)), @@ -377,7 +455,7 @@ impl EditTable { return Err(DataError::Format(message)); } let Some(g) = g else { - return Ok((Sql::Null, Extent::EMPTY)); + 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()); @@ -408,7 +486,7 @@ impl EditTable { 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)), extent)) + Ok(Sql::Blob(header_blob(&body, &extent, srs))) } fn fetch_rows(&self, conn: &Connection, columns: &[String], key: usize, keys: &[i64]) -> Result>> { @@ -434,6 +512,69 @@ impl EditTable { } } +struct FeatureWrites { + count: AtomicU64, + allowed: AtomicBool, +} + +fn watch_feature_writes(conn: &Connection, table: &str) -> Result> { + let state = Arc::new(FeatureWrites { count: AtomicU64::new(0), allowed: AtomicBool::new(true) }); + let observed = state.clone(); + conn.create_scalar_function("rustgis_feature_write", 0, FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_INNOCUOUS, move |_| { + 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); + Ok(0_i64) + }) + .map_err(err)?; + // Connection-local observers also see writes from file-owned triggers. They never alter + // the file's schema and disappear when the write connection closes. + let table = quote(table); + for event in ["INSERT", "UPDATE", "DELETE"] { + conn.execute_batch(&format!( + "CREATE TEMP TRIGGER rustgis_observe_{event} AFTER {event} ON main.{table} + BEGIN SELECT rustgis_feature_write(); END;" + )) + .map_err(err)?; + } + Ok(state) +} + +fn stored_extent(value: ValueRef<'_>, key: i64) -> Result { + match value { + ValueRef::Null => Ok(Extent::EMPTY), + ValueRef::Blob(bytes) if is_empty_blob(bytes) => Ok(Extent::EMPTY), + value => native_extent(value) + .ok_or_else(|| DataError::Format(format!("row {key}: its stored geometry can't be read, so nothing was saved"))), + } +} + +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)?; + 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, @@ -547,6 +688,288 @@ 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_srs_inside_write_transaction() { + let (_dir, path) = fixture(); + let table = EditTable::open(&path, "roads").unwrap(); + assert_eq!(table.srs_id(), 4326); + let before = table.read_editable(MAX_EDITABLE).unwrap(); + let mut changed = before.features[0].clone(); + changed.attrs[0] = Value::Text("must not save".into()); + let edits = Edits { + updates: vec![(changed, before.versions[0].1)], + inserts: vec![Feature { id: 100, geometry: Some(line(4.0)), attrs: vec![] }], + ..Default::default() + }; + Connection::open(&path).unwrap().execute("UPDATE gpkg_geometry_columns SET srs_id = 3857", []).unwrap(); + let error = table.save(&edits, &table.info().fields, [1].into_iter(), &before.revision).unwrap_err(); + assert!(error.to_string().contains("SRS changed"), "{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, (0.0, 0.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() }; + table.save(&edits, &table.info().fields, [1, 2].into_iter(), &saved.revision).unwrap().unwrap(); + 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); + assert_eq!(conn.query_row("SELECT max_x FROM gpkg_contents", [], |r| r.get::<_, f64>(0)).unwrap(), 91.0); + } + } + + #[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() }; + table.save(&edits, &table.info().fields, [1, 2, 3].into_iter(), &loaded.revision).unwrap().unwrap(); + 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}"); + } + assert_eq!(conn.query_row("SELECT max_x FROM gpkg_contents", [], |r| r.get::<_, f64>(0)).unwrap(), 61.0); + 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_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)]]) } @@ -559,7 +982,7 @@ mod tests { "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', 'before', 0, 0, 2, 3); + 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); @@ -590,7 +1013,7 @@ mod tests { 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()).unwrap().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(); @@ -601,7 +1024,7 @@ mod tests { let multipart = Geometry::Line(vec![part.clone(), part]); assert!( table - .save(&update(&table, Some(multipart)), &table.info().fields, std::iter::empty()) + .save(&update(&table, Some(multipart)), &table.info().fields, std::iter::empty(), &saved.revision) .unwrap_err() .to_string() .contains("holds one part") @@ -619,7 +1042,7 @@ mod tests { 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()).unwrap_err().to_string().contains("bad name")); + 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); @@ -633,10 +1056,16 @@ mod tests { 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()).unwrap().unwrap_err(), vec![Conflict::Changed(1)]); - assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), "before"); + 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()).unwrap().unwrap_err(), vec![Conflict::Deleted(1)]); + 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()) @@ -661,7 +1090,7 @@ mod tests { 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()).unwrap().unwrap().changes.deleted, 3); + 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"); } @@ -682,19 +1111,31 @@ mod tests { 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()).unwrap_err().to_string().contains("Z or M")); + 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()); - assert_eq!(table.save(&edits, &table.info().fields, std::iter::empty()).unwrap().unwrap().changes.updated, 1); + 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()).unwrap_err().to_string().contains("needs Z or M")); + 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()).unwrap().unwrap().changes.inserted, vec![(100, 2)]); + assert_eq!( + table.save(&edits, &table.info().fields, std::iter::empty(), &saved.revision).unwrap().unwrap().changes.inserted, + vec![(100, 2)] + ); } #[test] @@ -707,7 +1148,7 @@ mod tests { ) .unwrap(); let table = EditTable::open(&path, "roads").unwrap(); - table.save(&update(&table, Some(line(10.0))), &table.info().fields, std::iter::empty()).unwrap().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)?)) @@ -715,8 +1156,8 @@ mod tests { .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(), "before"); - table.save(&update(&table, None), &table.info().fields, std::iter::empty()).unwrap().unwrap(); + 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 { @@ -724,7 +1165,7 @@ mod tests { 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()).unwrap().unwrap(); + 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); @@ -747,9 +1188,9 @@ mod tests { ) .unwrap(); let table = EditTable::open(&path, "roads").unwrap(); - table.save(&update(&table, Some(line(8.0))), &table.info().fields, std::iter::empty()).unwrap().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()).unwrap().unwrap(); + 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')"] { @@ -774,7 +1215,8 @@ mod tests { 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()); - table.save(&edits, &[], std::iter::empty()).unwrap().unwrap(); + 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())); @@ -803,20 +1245,20 @@ mod tests { 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()).unwrap().unwrap(); + 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()).unwrap().unwrap().changes.updated, 1); + 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()).unwrap_err().to_string().contains("changed shape")); + assert!(table.save(&Edits::default(), &fields, std::iter::empty(), REVISION).unwrap_err().to_string().contains("changed shape")); } #[test] @@ -862,7 +1304,7 @@ mod tests { 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()).unwrap().unwrap(); + 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); @@ -889,7 +1331,7 @@ mod tests { 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()).unwrap().unwrap(); + 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)); @@ -899,7 +1341,7 @@ mod tests { 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()).unwrap().unwrap(); + 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); } @@ -930,13 +1372,13 @@ mod tests { attrs: vec![Value::Text("broken".into()), Value::Null], }); } - let error = table.save(&edits, &table.info().fields, [1].into_iter()).unwrap_err(); + 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(), "before"); + assert_eq!(conn.query_row("SELECT last_change FROM gpkg_contents", [], |r| r.get::<_, String>(0)).unwrap(), read.revision); } } } @@ -953,7 +1395,7 @@ mod tests { 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)).unwrap().unwrap(); + 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); @@ -975,7 +1417,7 @@ mod tests { .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()).unwrap().unwrap(); + 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); @@ -1011,13 +1453,13 @@ mod tests { ..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()).unwrap_err(); + 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"); + 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(), @@ -1043,14 +1485,17 @@ mod tests { 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()).is_err(), "{ty}"); + 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()).is_err(), "{ty}"); + 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}"); @@ -1073,7 +1518,7 @@ mod tests { 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()).unwrap().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}"); diff --git a/crates/engine/src/cmd/gpkg.rs b/crates/engine/src/cmd/gpkg.rs index f0dc79654..34665c5f2 100644 --- a/crates/engine/src/cmd/gpkg.rs +++ b/crates/engine/src/cmd/gpkg.rs @@ -113,6 +113,8 @@ fn add_layer(s: &mut Session, p: &Value) -> CmdResult { table: src.table().to_string(), key: src.key().to_string(), geometry: src.geometry_column().to_string(), + srs_id: src.srs_id(), + revision: editable.revision, writable: editable.writable, limit, versions, @@ -157,12 +159,15 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { } let err = |e: rustgis_data::DataError| format!("{name}: nothing was saved to {table}: {e}"); let src = EditTable::open(path, table).map_err(err)?; - if !src.key().eq_ignore_ascii_case(&edit.key) || !src.geometry_column().eq_ignore_ascii_case(&edit.geometry) { + if !src.key().eq_ignore_ascii_case(&edit.key) + || !src.geometry_column().eq_ignore_ascii_case(&edit.geometry) + || src.srs_id() != edit.srs_id + { 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)).map_err(err)? { + 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(); @@ -199,6 +204,7 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { 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); @@ -250,6 +256,12 @@ fn discard_edits(s: &mut Session, p: &Value) -> CmdResult { 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(); @@ -260,6 +272,8 @@ fn discard_edits(s: &mut Session, p: &Value) -> CmdResult { g.writable = editable.writable; g.key = src.key().to_string(); g.geometry = src.geometry_column().to_string(); + g.srs_id = src.srs_id(); + 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/tests.rs b/crates/engine/src/tests.rs index 685c74aba..eda6a9490 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18476,7 +18476,7 @@ impl GpkgRegressionFixture { 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) VALUES ('roads', 'features', 4326); + 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); @@ -18559,6 +18559,118 @@ fn geopackage_template_drops_edit_binding() { 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() { diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index 2f7a89a32..ad7d52c02 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -161,11 +161,14 @@ pub struct GpkgEdit { /// Its INTEGER PRIMARY KEY column. pub key: String, pub geometry: String, + /// The source SRS when these rows were loaded. + pub srs_id: 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. - #[serde(default)] pub versions: Versions, } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 1de98b420..b5dc4e49c 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -284,11 +284,24 @@ get source-assigned keys that cannot alias loaded rows, and unrelated columns an 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. +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 and bounds use the final stored geometry after table triggers run. A trigger +that can write other feature rows requires a whole-table spatial rebuild in the transaction. +Metadata or index-repair triggers that would change features after that final snapshot are refused, +with the transaction rolled back. + **Discard GeoPackage Edits…** (`gpkg.discardEdits`) reloads the scoped rows as they are in the -file now, including renamed key and geometry columns; Undo restores the discarded layer edits. -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, while exporting writes a new file rather than changing this table. +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. 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. From 00035ba5f9d4c035f0ab79982d79481789d77bfa Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 10:03:21 +0100 Subject: [PATCH 4/9] fix(gpkg): bind dialogs to their project and map --- crates/ui-egui/src/dialogs.rs | 39 +++++++++++- crates/ui-egui/tests/smoke.rs | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40d96fa02..b35c6452b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -20,6 +20,8 @@ pub struct Dialogs { pub layer: String, /// GeoPackage save/discard targets use IDs because display names can repeat. pub gpkg_layer: 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, pub text: String, @@ -166,6 +168,7 @@ impl Dialogs { "gpkgSave" | "gpkgDiscard" => { let gpkg = |l: &&rustgis_engine::map::Layer| l.gpkg.is_some(); self.gpkg_layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)).map(|l| l.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. @@ -178,6 +181,17 @@ 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(()) + } } /// Draws the active dialog and leaves it open on validation failure or a newly opened dialog. @@ -1306,7 +1320,20 @@ fn gpkg_add_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { p["table"] = json!(app.dialogs.c.trim()); } if !app.dialogs.e.trim().is_empty() { - let n: u64 = app.dialogs.e.trim().replace([',', '.'], "").parse().map_err(|_| tl!("Most rows: a whole number").to_string())?; + 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(|_| ()) @@ -1316,6 +1343,14 @@ fn gpkg_add_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { /// 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 (counted again when another layer is chosen). 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; + } let layers: Vec<(u32, String)> = app .session .map() @@ -1420,6 +1455,7 @@ 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| { + app.dialogs.gpkg_owner(&app.session)?; let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; let r = app.run("gpkg.saveEdits", json!({"layer": id})).map(|_| ()); app.dialogs.counted.clear(); @@ -1437,6 +1473,7 @@ fn gpkg_discard(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ); source_layer_edits(app, ui, "gpkg"); buttons(app, ui, "Discard", |app| { + app.dialogs.gpkg_owner(&app.session)?; let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; app.run("gpkg.discardEdits", json!({"layer": id})).map(|_| ()) }) diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 17b7fc2cc..02e68c1bd 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -147,6 +147,123 @@ fn geopackage_edit_dialogs_target_numeric_ids_with_duplicate_names() { 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(); +} + +#[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() { From 3327169fdde71861c8fa06f5ca7742c1d10848d8 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 10:03:21 +0100 Subject: [PATCH 5/9] fix(gpkg): reconcile trigger rows and refresh exact bounds --- crates/data/src/source/gpkg/edit.rs | 127 +++++++++++++++++++++++----- crates/engine/src/cmd/gpkg.rs | 6 ++ crates/engine/src/tests.rs | 39 +++++++++ docs/user-guide/data.md | 22 +++-- 4 files changed, 165 insertions(+), 29 deletions(-) diff --git a/crates/data/src/source/gpkg/edit.rs b/crates/data/src/source/gpkg/edit.rs index f5369a8a1..9677e7e97 100644 --- a/crates/data/src/source/gpkg/edit.rs +++ b/crates/data/src/source/gpkg/edit.rs @@ -231,6 +231,7 @@ impl EditTable { 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(); @@ -246,6 +247,7 @@ impl EditTable { } 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()))); @@ -260,6 +262,7 @@ impl EditTable { 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; @@ -305,7 +308,6 @@ impl EditTable { 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; - saved.deleted += deleted; } // Spatial/metadata repair must not itself trigger more feature writes after the snapshot. feature_writes.allowed.store(false, Ordering::Relaxed); @@ -317,18 +319,20 @@ impl EditTable { .chain(edits.deletes.iter().map(|(k, _)| k.cast_signed())) .collect(); let rows = self.fetch_rows(&tx, &columns, key, &keys)?; - let mut features = Vec::with_capacity(edits.updates.len() + edits.inserts.len()); + saved.deleted = edits.deletes.iter().filter(|(k, _)| !rows.contains_key(&k.cast_signed())).count(); + let mut features = Vec::with_capacity(edits.updates.len() + edits.inserts.len() + edits.deletes.len() - saved.deleted); let originals = edits .updates .iter() - .map(|(f, _)| (f, f.id.cast_signed())) - .chain(edits.inserts.iter().zip(&saved.inserted).map(|(f, (_, k))| (f, k.cast_signed()))); + .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()))); 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 = original.clone(); + let mut feature = original.cloned().unwrap_or_else(|| Feature { id: k.cast_unsigned(), geometry: None, attrs: Vec::new() }); feature.id = k.cast_unsigned(); feature.geometry = geometry; feature.attrs.resize(fields.len(), Value::Null); @@ -352,7 +356,6 @@ impl EditTable { .transpose()? .unwrap_or(Extent::EMPTY); maintain_rtree(&tx, self.src.rtree.as_deref(), k, &extent)?; - written_extent = written_extent.union(&extent); } } let unknown_bounds: bool = tx @@ -363,32 +366,25 @@ impl EditTable { |r| r.get(0), ) .map_err(err)?; - if unknown_bounds && rebuild { + if rebuild { let extent = (!written_extent.is_empty()).then_some(written_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)?; - } else if unknown_bounds { - // Unknown bounds must include untouched rows too. One aggregate scan, in native CRS. - let geom = quote(self.geometry_column()); + } else if spatial_changed || unknown_bounds { + // Keep untouched unreadable rows and their index entries; bounds cover readable geometry. + let geometry = quote(self.geometry_column()); tx.execute( &format!( "UPDATE gpkg_contents SET (min_x, min_y, max_x, max_y) = - (SELECT min(ST_MinX({geom})), min(ST_MinY({geom})), max(ST_MaxX({geom})), max(ST_MaxY({geom})) FROM {table}) + (SELECT min(ST_MinX({geometry})), min(ST_MinY({geometry})), max(ST_MaxX({geometry})), max(ST_MaxY({geometry})) FROM {table}) WHERE lower(table_name) = lower(?1)" ), [self.table()], ) .map_err(err)?; - } else if !written_extent.is_empty() { - tx.execute( - "UPDATE gpkg_contents SET min_x = min(min_x, ?2), min_y = min(min_y, ?3), max_x = max(max_x, ?4), max_y = max(max_y, ?5) - WHERE lower(table_name) = lower(?1)", - params![self.table(), written_extent.min_x, written_extent.min_y, written_extent.max_x, written_extent.max_y], - ) - .map_err(err)?; } tx.execute( "UPDATE gpkg_contents SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ', @@ -870,7 +866,7 @@ mod tests { 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, 91.0, 3.0), "{coverage}"); + 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'; @@ -878,12 +874,20 @@ mod tests { ) .unwrap(); let edits = Edits { deletes: saved.changes.versions, ..Default::default() }; - table.save(&edits, &table.info().fields, [1, 2].into_iter(), &saved.revision).unwrap().unwrap(); + 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); - assert_eq!(conn.query_row("SELECT max_x FROM gpkg_contents", [], |r| r.get::<_, f64>(0)).unwrap(), 91.0); + 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}"); } } @@ -935,7 +939,10 @@ mod tests { 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}"); } - assert_eq!(conn.query_row("SELECT max_x FROM gpkg_contents", [], |r| r.get::<_, f64>(0)).unwrap(), 61.0); + 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") @@ -949,6 +956,82 @@ mod tests { } } + #[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(); + 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, (vec![0], 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_metadata_triggers_cannot_change_features_after_spatial_repair() { let (_dir, path) = fixture(); diff --git a/crates/engine/src/cmd/gpkg.rs b/crates/engine/src/cmd/gpkg.rs index 34665c5f2..d59703484 100644 --- a/crates/engine/src/cmd/gpkg.rs +++ b/crates/engine/src/cmd/gpkg.rs @@ -198,6 +198,12 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { *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(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4fcbb6ea0..07199ca20 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18862,6 +18862,45 @@ fn assert_gpkg_regression_clean(s: &mut Session) { 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_template_drops_edit_binding() { diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index b5dc4e49c..f1586ff29 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -273,8 +273,10 @@ connect or save at all. 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. Rows outside that load, including rows with -unreadable geometry, are left untouched when saving. +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 @@ -282,7 +284,8 @@ changed or disappeared in the file since it was loaded, nothing is saved. A cons 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. +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. 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 @@ -290,10 +293,13 @@ project or an undone save reopened in another session. Pending edits remain avai 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 and bounds use the final stored geometry after table triggers run. A trigger -that can write other feature rows requires a whole-table spatial rebuild in the transaction. -Metadata or index-repair triggers that would change features after that final snapshot are refused, -with the transaction rolled back. +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 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. **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 @@ -302,6 +308,8 @@ 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 and map where they opened. After switching +maps or projects, reopen the dialog for the intended layer; confirmation 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. From c2becce5b5258189889619a516e64555eec5752b Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 11:27:49 +0100 Subject: [PATCH 6/9] fix(gpkg): bind dialogs to layer instances and refresh counts --- crates/engine/src/cmd/layout.rs | 1 + crates/engine/src/cmd/navigate.rs | 1 + crates/engine/src/cmd/workspace.rs | 1 + crates/map/src/lib.rs | 22 +++++ crates/ui-egui/src/dialogs.rs | 59 +++++++++---- crates/ui-egui/tests/smoke.rs | 128 +++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 808235e6a..669c2d302 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1500,6 +1500,7 @@ 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(); layer.dataset.features = l .dataset .features 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/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/map/src/lib.rs b/crates/map/src/lib.rs index 6269a42bb..de5089dfe 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -190,6 +190,9 @@ pub struct Layer { /// 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>>, @@ -220,7 +223,24 @@ 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)) @@ -315,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), @@ -1708,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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b35c6452b..c95be78a3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -20,10 +20,15 @@ pub struct Dialogs { pub layer: String, /// 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, @@ -167,7 +172,9 @@ impl Dialogs { } "gpkgSave" | "gpkgDiscard" => { let gpkg = |l: &&rustgis_engine::map::Layer| l.gpkg.is_some(); - self.gpkg_layer = active.filter(gpkg).or_else(|| s.map().layers.iter().find(gpkg)).map(|l| l.id); + 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 @@ -192,6 +199,15 @@ impl Dialogs { } 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) + } } /// Draws the active dialog and leaves it open on validation failure or a newly opened dialog. @@ -1341,7 +1357,7 @@ fn gpkg_add_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { } /// 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 (counted again when another layer is chosen). +/// 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) @@ -1351,46 +1367,54 @@ fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static app.dialogs.error = Some(error); return; } - let layers: Vec<(u32, String)> = app + 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.name.clone())) + .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")); let selected = if prefix == "gpkg" { layers .iter() - .find(|(id, _)| Some(*id) == app.dialogs.gpkg_layer) - .map(|(id, name)| format!("{name} (#{id})")) + .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; + 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, name) in &layers { + for (id, instance, name) in &layers { if prefix == "gpkg" { - ui.selectable_value(&mut app.dialogs.gpkg_layer, Some(*id), format!("{name} (#{id})")); + 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" && app.dialogs.gpkg_layer != previous { + 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 missing = prefix == "gpkg" && !layers.iter().any(|(id, _)| Some(*id) == app.dialogs.gpkg_layer); - if missing || app.dialogs.counted != counted { + 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; - let result = if missing { - Err(tl!("The selected GeoPackage layer is no longer available; choose a layer").to_string()) + 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})) @@ -1410,6 +1434,7 @@ fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static 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()) { @@ -1455,8 +1480,7 @@ 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| { - app.dialogs.gpkg_owner(&app.session)?; - let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; + let id = app.dialogs.gpkg_target(&app.session)?; let r = app.run("gpkg.saveEdits", json!({"layer": id})).map(|_| ()); app.dialogs.counted.clear(); r @@ -1473,8 +1497,7 @@ fn gpkg_discard(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ); source_layer_edits(app, ui, "gpkg"); buttons(app, ui, "Discard", |app| { - app.dialogs.gpkg_owner(&app.session)?; - let id = app.dialogs.gpkg_layer.ok_or_else(|| tl!("Choose a GeoPackage layer").to_string())?; + let id = app.dialogs.gpkg_target(&app.session)?; app.run("gpkg.discardEdits", json!({"layer": id})).map(|_| ()) }) } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 02e68c1bd..55162d8dc 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -157,6 +157,134 @@ fn write_dialog_gpkg(path: &std::path::Path) { 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"); + + 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() { From f7e163c44719da2caa4db3102193edf169591b4e Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 11:27:49 +0100 Subject: [PATCH 7/9] fix(gpkg): reconcile trigger-affected rows and strict spatial checks --- crates/data/src/source/gpkg/edit.rs | 251 +++++++++++++++++++++------- crates/engine/src/cmd/gpkg.rs | 18 +- crates/engine/src/tests.rs | 164 ++++++++++++++++++ docs/user-guide/data.md | 17 +- 4 files changed, 380 insertions(+), 70 deletions(-) diff --git a/crates/data/src/source/gpkg/edit.rs b/crates/data/src/source/gpkg/edit.rs index 9677e7e97..642346eb4 100644 --- a/crates/data/src/source/gpkg/edit.rs +++ b/crates/data/src/source/gpkg/edit.rs @@ -13,7 +13,7 @@ use rustgis_geom::Extent; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, }; use std::time::Duration; @@ -36,9 +36,13 @@ pub struct Editable { #[derive(Debug)] pub struct SavedRows { pub changes: Saved, - /// Every requested update (even unchanged) followed by inserts, with committed keys. - /// Attributes follow the supplied fields; fields absent from the table retain submitted values. + /// 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, } @@ -186,8 +190,12 @@ impl EditTable { self.table() ))); } - let srs_id: i64 = tx - .query_row("SELECT srs_id FROM gpkg_geometry_columns WHERE lower(table_name) = lower(?1)", [self.table()], |r| r.get(0)) + 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!( @@ -195,13 +203,24 @@ impl EditTable { 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 feature_writes = watch_feature_writes(&tx, self.table())?; + 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) }; + 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)); @@ -212,6 +231,7 @@ impl EditTable { } } else { saved.skipped_fields.push(f.name.clone()); + unstored_fields.push(i); } } let keys: Vec = @@ -267,7 +287,7 @@ impl EditTable { // 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.filter_map(|k| i64::try_from(k).ok()).max().unwrap_or(0).max(0); + 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 @@ -311,33 +331,47 @@ impl EditTable { } // Spatial/metadata repair must not itself trigger more feature writes after the snapshot. feature_writes.allowed.store(false, Ordering::Relaxed); - let keys: Vec = edits + let affected = feature_writes.affected()?; + 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)).map(|k| k.cast_unsigned()).collect(); + removed.sort_unstable(); saved.deleted = edits.deletes.iter().filter(|(k, _)| !rows.contains_key(&k.cast_signed())).count(); - let mut features = Vec::with_capacity(edits.updates.len() + edits.inserts.len() + edits.deletes.len() - saved.deleted); + 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(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 = original.cloned().unwrap_or_else(|| Feature { id: k.cast_unsigned(), geometry: None, attrs: Vec::new() }); - feature.id = k.cast_unsigned(); - feature.geometry = geometry; - feature.attrs.resize(fields.len(), Value::Null); + 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 { - feature.attrs[i] = value(ValueRef::from(row.get(c).ok_or_else(|| self.shape_error())?), fields[i].ty); + 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); @@ -352,7 +386,7 @@ impl EditTable { 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)) + .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)?; @@ -366,25 +400,16 @@ impl EditTable { |r| r.get(0), ) .map_err(err)?; - if rebuild { - let extent = (!written_extent.is_empty()).then_some(written_extent); + 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)?; - } else if spatial_changed || unknown_bounds { - // Keep untouched unreadable rows and their index entries; bounds cover readable geometry. - let geometry = quote(self.geometry_column()); - tx.execute( - &format!( - "UPDATE gpkg_contents SET (min_x, min_y, max_x, max_y) = - (SELECT min(ST_MinX({geometry})), min(ST_MinY({geometry})), max(ST_MaxX({geometry})), max(ST_MaxY({geometry})) FROM {table}) - WHERE lower(table_name) = lower(?1)" - ), - [self.table()], - ) - .map_err(err)?; } tx.execute( "UPDATE gpkg_contents SET last_change = strftime('%Y-%m-%dT%H:%M:%fZ', @@ -395,7 +420,7 @@ impl EditTable { .map_err(err)?; let revision = self.revision(&tx)?; tx.commit().map_err(err)?; - Ok(Ok(SavedRows { changes: saved, features, revision })) + Ok(Ok(SavedRows { changes: saved, features, removed, unstored_fields, revision })) } fn revision(&self, conn: &Connection) -> Result { @@ -511,41 +536,88 @@ impl EditTable { struct FeatureWrites { count: AtomicU64, allowed: AtomicBool, + residents: Mutex>, } -fn watch_feature_writes(conn: &Connection, table: &str) -> Result> { - let state = Arc::new(FeatureWrites { count: AtomicU64::new(0), allowed: AtomicBool::new(true) }); +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) + } +} + +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", 0, FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_INNOCUOUS, move |_| { + 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 also see writes from file-owned triggers. They never alter - // the file's schema and disappear when the write connection closes. + // Connection-local observers see file-owned triggers without growing beyond resident keys. let table = quote(table); - for event in ["INSERT", "UPDATE", "DELETE"] { + 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(); END;" + BEGIN SELECT rustgis_feature_write({old}, {new}); END;" )) .map_err(err)?; } Ok(state) } -fn stored_extent(value: ValueRef<'_>, key: i64) -> Result { +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 => native_extent(value) - .ok_or_else(|| DataError::Format(format!("row {key}: its stored geometry can't be read, so nothing was saved"))), + 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 { @@ -560,7 +632,7 @@ fn rebuild_spatial(conn: &Connection, table: &str, key: &str, geometry: &str, rt 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)?; + 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)?; @@ -745,25 +817,32 @@ mod tests { } #[test] - fn gpkg_rechecks_srs_inside_write_transaction() { - let (_dir, path) = fixture(); - let table = EditTable::open(&path, "roads").unwrap(); - assert_eq!(table.srs_id(), 4326); - let before = table.read_editable(MAX_EDITABLE).unwrap(); - let mut changed = before.features[0].clone(); - changed.attrs[0] = Value::Text("must not save".into()); - let edits = Edits { - updates: vec![(changed, before.versions[0].1)], - inserts: vec![Feature { id: 100, geometry: Some(line(4.0)), attrs: vec![] }], - ..Default::default() - }; - Connection::open(&path).unwrap().execute("UPDATE gpkg_geometry_columns SET srs_id = 3857", []).unwrap(); - let error = table.save(&edits, &table.info().fields, [1].into_iter(), &before.revision).unwrap_err(); - assert!(error.to_string().contains("SRS changed"), "{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); + 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] @@ -933,7 +1012,12 @@ mod tests { 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() }; - table.save(&edits, &table.info().fields, [1, 2, 3].into_iter(), &loaded.revision).unwrap().unwrap(); + 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(); @@ -956,6 +1040,43 @@ mod tests { } } + #[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(); @@ -1000,6 +1121,9 @@ mod tests { 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() @@ -1025,7 +1149,7 @@ mod tests { 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, (vec![0], 2)); + 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(); @@ -1391,6 +1515,7 @@ mod tests { 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!( diff --git a/crates/engine/src/cmd/gpkg.rs b/crates/engine/src/cmd/gpkg.rs index d59703484..0da328bec 100644 --- a/crates/engine/src/cmd/gpkg.rs +++ b/crates/engine/src/cmd/gpkg.rs @@ -192,8 +192,24 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { } } 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(row) = stored.remove(&feature.id) { + 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; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07199ca20..358ee1eed 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18901,6 +18901,170 @@ fn geopackage_delete_trigger_restored_rows_rejoin_the_saved_baseline() { 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() { diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index f1586ff29..f38644e5b 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -285,7 +285,9 @@ trigger failure also rolls everything back; pending edits remain in the layer. N 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. +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 @@ -297,9 +299,11 @@ Spatial indexes use the final stored geometry after table triggers run. Bounds a 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 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. +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 changed geometry type, Z/M declaration or SRS +between loading and the write transaction also refuses the save. **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 @@ -308,8 +312,9 @@ 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 and map where they opened. After switching -maps or projects, reopen the dialog for the intended layer; confirmation cannot retarget another file. +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. From 9f14e1fc331233ab2c4ab8e312b8cff00a9906f5 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 12:28:45 +0100 Subject: [PATCH 8/9] fix(gpkg): compare loaded declaration, unbind locator sketches, catch evictions --- Cargo.toml | 4 +- crates/data/src/source/gpkg/edit.rs | 66 +++++++++++++++- crates/engine/src/cmd/gpkg.rs | 14 ++++ crates/engine/src/cmd/layout.rs | 3 + crates/engine/src/lib.rs | 6 +- crates/engine/src/tests.rs | 112 ++++++++++++++++++++++++++++ crates/map/src/source.rs | 8 ++ docs/user-guide/data.md | 7 +- 8 files changed, 213 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 295a10604..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). diff --git a/crates/data/src/source/gpkg/edit.rs b/crates/data/src/source/gpkg/edit.rs index 642346eb4..a321c50df 100644 --- a/crates/data/src/source/gpkg/edit.rs +++ b/crates/data/src/source/gpkg/edit.rs @@ -105,6 +105,11 @@ impl EditTable { 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()))?; @@ -215,6 +220,8 @@ impl EditTable { .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(); @@ -332,6 +339,10 @@ impl EditTable { // 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() @@ -344,8 +355,9 @@ impl EditTable { 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)).map(|k| k.cast_unsigned()).collect(); + 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 @@ -378,7 +390,7 @@ impl EditTable { } // 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; + 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())?; @@ -510,6 +522,24 @@ impl EditTable { 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) { @@ -546,6 +576,12 @@ impl FeatureWrites { 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> { @@ -1156,6 +1192,32 @@ mod tests { 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(); diff --git a/crates/engine/src/cmd/gpkg.rs b/crates/engine/src/cmd/gpkg.rs index 0da328bec..b4a89114f 100644 --- a/crates/engine/src/cmd/gpkg.rs +++ b/crates/engine/src/cmd/gpkg.rs @@ -108,12 +108,16 @@ fn add_layer(s: &mut Session, p: &Value) -> CmdResult { 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, @@ -159,9 +163,15 @@ fn save_edits(s: &mut Session, p: &Value) -> CmdResult { } 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")); } @@ -295,6 +305,10 @@ fn discard_edits(s: &mut Session, p: &Value) -> CmdResult { 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); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 669c2d302..f7a44063f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1501,6 +1501,9 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // (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/lib.rs b/crates/engine/src/lib.rs index 79d136ec4..24e9b6d66 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -729,9 +729,12 @@ fn fresh_source_ids(before: &Project, after: &mut Project) { 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) && 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; @@ -743,6 +746,7 @@ fn fresh_source_ids(before: &Project, after: &mut Project) { .iter() .map(|f| f.id) .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); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 358ee1eed..086f0e48f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -19330,6 +19330,118 @@ fn geopackage_insert_reserves_externally_deleted_loaded_keys() { 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() { diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index ad7d52c02..64fb85af8 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -163,6 +163,14 @@ pub struct GpkgEdit { 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, diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index f38644e5b..1e5fcda6e 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -302,8 +302,8 @@ keep known bounds unless a trigger writes feature rows. Such trigger writes requ 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 changed geometry type, Z/M declaration or SRS -between loading and the write transaction also refuses the save. +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 @@ -316,7 +316,8 @@ Save and Discard dialogs remain bound to the project, map and specific layer ins 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. +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 From fc5bfe30a7a3d3f5e95594f37b06a5d3f6d12202 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer Date: Wed, 28 Oct 2026 12:28:45 +0100 Subject: [PATCH 9/9] fix(gpkg): clear stale target refusal after Redo --- crates/ui-egui/src/dialogs.rs | 14 ++++++++++++++ crates/ui-egui/tests/smoke.rs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c95be78a3..83adc3be6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -210,6 +210,17 @@ impl Dialogs { } } +/// 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. pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let Some(which) = app.dialogs.open else { return }; @@ -1421,6 +1432,9 @@ fn source_layer_edits(app: &mut RustGisApp, ui: &mut egui::Ui, prefix: &'static }; 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(), diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 55162d8dc..3caa30e95 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -266,6 +266,20 @@ fn geopackage_edit_dialog_counts_follow_edits_and_surviving_undo_snapshots() { 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);