Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -77,7 +79,7 @@ arrow-schema = { version = "60", default-features = false }
bytes = "1"
flatgeobuf = { version = "6", default-features = false }
geozero = { version = "0.15", default-features = false, features = ["with-wkb"] }
rusqlite = { version = "0.40", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled", "functions"] }
# PostgreSQL/PostGIS, in pure Rust: the client, and TLS through rustls (ring, as ureq has it).
postgres = { version = "0.19", default-features = false }
tokio-postgres-rustls = { version = "0.14", default-features = false, features = ["ring"] }
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@ And alongside it:
- A histogram for raster stretches; raster attribute tables.

### Data at the source
- Write edits back to GeoPackage and PostGIS (edits stay in the project today). _Coordinate
first._
- GeoLibre opening RustGIS `.rgis` projects, once RustGIS's project format and supported
data types settle. RustGIS imports GeoLibre projects; combined colour and size rendering
is tracked in [#1017](https://github.com/opengeos/RustGIS/issues/1017).
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions crates/data/src/source/edits.rs
Original file line number Diff line number Diff line change
@@ -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<Feature>,
}

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<String>,
}
44 changes: 27 additions & 17 deletions crates/data/src/source/gpkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -378,8 +380,11 @@ fn blob(g: &crate::Geometry, multi_points: bool, srs_id: i32) -> Vec<u8> {
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<u8> {
let mut out = Vec::with_capacity(40 + wkb.len());
out.extend_from_slice(b"GP");
out.push(0);
if e.is_empty() {
Expand All @@ -393,7 +398,7 @@ fn blob(g: &crate::Geometry, multi_points: bool, srs_id: i32) -> Vec<u8> {
out.extend_from_slice(&v.to_le_bytes());
}
}
out.extend_from_slice(&wkb::encode(g));
out.extend_from_slice(wkb);
out
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading