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
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ And alongside it:
([#853](https://github.com/opengeos/RustGIS/issues/853)). _Coordinate first_ (it changes
`FieldType`).
- Editable standalone tables (they are read-only today).
- Export into an existing GeoPackage as an added or replaced table, leaving its other tables
([#852](https://github.com/opengeos/RustGIS/issues/852); export refuses an existing file today).
- Polygon editing: shared edges, where moving one boundary moves its neighbour's too.
Coincident polygon vertices can already move together with Shared Vertices
(`edit.moveSharedVertex`); different vertex densities and linked insertion/deletion remain
Expand Down Expand Up @@ -149,6 +147,9 @@ Open questions rather than plans; discuss before building.

## Done

- Export into an existing GeoPackage on desktop: a layer or table added as one more table, or
replacing one of its tables, leaving the others as they were (`mode` on `share.exportLayer` and
`share.exportTable`).
- Polygon editing: Cut Polygon, Reshape (polygons and lines) and Trace along an edge while
drawing, on the map and through `edit.cutPolygon`, `edit.reshape` and `edit.trace`.
- Geometry conversions: Polygon To Line, Split Line At Vertices, Feature Envelope To Polygon
Expand Down
18 changes: 17 additions & 1 deletion apps/rustgis-python/python/rustgis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,18 +268,34 @@ def to_geopandas(self, selected: bool = False) -> Any:
collection = self.to_geojson(selected)
return gpd.GeoDataFrame.from_features(collection["features"], crs="EPSG:4326")

def to_file(self, path: PathLike, crs: Optional[str] = None) -> Any:
def to_file(
self,
path: PathLike,
crs: Optional[str] = None,
*,
mode: Optional[str] = None,
table_name: Optional[str] = None,
) -> Any:
"""Writes the layer's features to a file (``share.exportLayer``).

Args:
path: The file; its extension picks the format: ``.geojson``, ``.csv``, ``.fgb``,
``.gpkg``, ``.parquet``, ``.shp``, ``.zip``, ``.kml``, ``.kmz`` or ``.gpx``.
crs: The coordinate system of a GeoPackage, Shapefile or FlatGeobuf, e.g.
``"EPSG:3857"`` (the one the layer was read in when left out).
mode: For a GeoPackage: ``"newFile"`` (the default; an existing file is refused),
``"addTable"`` (one more table in an existing GeoPackage, or a new file when
there is none) or ``"replaceTable"`` (replacing its table of that name, which
must be there).
table_name: The GeoPackage table (the layer's name when left out).
"""
params: Dict[str, Any] = {"layer": self.id, "path": os.fspath(path)}
if crs is not None:
params["crs"] = crs
if mode is not None:
params["mode"] = mode
if table_name is not None:
params["tableName"] = table_name
return self.session.run("share.exportLayer", params)

def __eq__(self, other: object) -> bool:
Expand Down
36 changes: 32 additions & 4 deletions crates/data/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use std::path::Path;

use crate::io::{CsvWriter, GeoJsonWriter, PROJECTED_FORMATS, check_projected, project_each, write_atomic_with};
use crate::io::{CsvWriter, GeoJsonWriter, GpkgTable, GpkgWritten, PROJECTED_FORMATS, check_projected, project_each, write_atomic_with};
use crate::raster::Crs;
use crate::source::FeatureSource;
use crate::{DataError, Dataset, Feature, GeometryType, Result};
Expand Down Expand Up @@ -43,8 +43,32 @@ pub fn write_source_in(src: &dyn FeatureSource, layer: &Dataset, path: &Path, cr
write_chunks_in(src, layer, path, crs, CHUNK_BYTES)
}

/// As [`write_source_in`], a GeoPackage's table placed as `gpkg` says (see
/// [`crate::io::write_path_to`]): also returns the table written.
pub fn write_source_to(
src: &dyn FeatureSource,
layer: &Dataset,
path: &Path,
crs: Crs,
gpkg: &GpkgTable,
) -> Result<(Exported, Option<GpkgWritten>)> {
write_chunks_to(src, layer, path, crs, gpkg, CHUNK_BYTES)
}

/// [`write_source_in`], `bytes` of features a chunk.
fn write_chunks_in(src: &dyn FeatureSource, layer: &Dataset, path: &Path, crs: Crs, bytes: usize) -> Result<Exported> {
write_chunks_to(src, layer, path, crs, &GpkgTable::default(), bytes).map(|(e, _)| e)
}

/// [`write_source_to`], `bytes` of features a chunk.
fn write_chunks_to(
src: &dyn FeatureSource,
layer: &Dataset,
path: &Path,
crs: Crs,
gpkg: &GpkgTable,
bytes: usize,
) -> Result<(Exported, Option<GpkgWritten>)> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if !streams(&ext) {
return Err(DataError::Unsupported(format!(".{ext} written a chunk at a time")));
Expand Down Expand Up @@ -83,14 +107,18 @@ fn write_chunks_in(src: &dyn FeatureSource, layer: &Dataset, path: &Path, crs: C
skipped = batch.skipped;
Ok(())
};
let mut table = None;
let left_out = match ext.as_str() {
#[cfg(not(target_arch = "wasm32"))]
"gpkg" => {
crate::source::gpkg::write_features_in(&layer.name, geometry_type, &layer.fields, path, crs, &mut feed)?;
table = Some(crate::source::gpkg::write_features_in(&layer.name, geometry_type, &layer.fields, path, crs, gpkg, &mut feed)?);
0
}
#[cfg(target_arch = "wasm32")]
"gpkg" => return Err(DataError::Unsupported("GeoPackage in the browser".into())),
"gpkg" => {
let _ = (gpkg, &mut table);
return Err(DataError::Unsupported("GeoPackage in the browser".into()));
}
"geojson" | "json" => write_atomic_with(path, |out| {
let mut w = GeoJsonWriter::new(out, &layer.name, &layer.fields)?;
feed(&mut |features| w.write(features))?;
Expand All @@ -111,7 +139,7 @@ fn write_chunks_in(src: &dyn FeatureSource, layer: &Dataset, path: &Path, crs: C
Ok::<_, DataError>(0)
})?,
};
Ok(Exported { written: total.saturating_sub(left_out), left_out, skipped })
Ok((Exported { written: total.saturating_sub(left_out), left_out, skipped }, table))
}

/// The kind of a source's first geometry, read up to it; `None` for a source without any (read
Expand Down
48 changes: 48 additions & 0 deletions crates/data/src/export/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,51 @@ fn a_panicking_writer_leaves_nothing_behind() {
assert!(r.is_err());
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
}

/// Streamed into an existing GeoPackage, an export that starts again (a multipoint, then a
/// repeated id) rolls back what it wrote and writes the table once, as a whole export would; one
/// whose source fails leaves the file's bytes as they were.
#[test]
fn a_streamed_export_into_an_existing_geopackage_starts_again_inside_its_transaction() {
let d = folder("redo-existing");
let county = d.join("county.gpkg");
let mut parcels = Dataset::new("parcels", GeometryType::Point, vec![]);
parcels.push_next(Some(Geometry::Point(Coord::new(5.0, 5.0))), vec![]);
crate::io::write_path(&parcels, &county).unwrap();
let fields = vec![Field::new("n", FieldType::Integer)];
let mut both = Dataset::new("both", GeometryType::Point, fields.clone());
for i in 0..20 {
let c = Coord::new(i as f64, 1.0);
let g = if i == 15 { Geometry::MultiPoint(vec![c, Coord::new(i as f64, 2.0)]) } else { Geometry::Point(c) };
both.push_next(Some(g), vec![Value::Int(i)]);
}
let first = both.features.first().map(|f| f.id).unwrap();
if let Some(f) = both.features.get_mut(18) {
f.id = first;
}
let layer = Dataset::new("both", GeometryType::Point, fields);
let add = GpkgTable { mode: crate::io::GpkgMode::AddTable, name: None };
let src = Runs::new(&both, 4);
let (e, table) = write_chunks_to(&src, &layer, &county, Crs::LonLat, &add, 1).unwrap();
assert_eq!(src.passes.load(std::sync::atomic::Ordering::Relaxed), 3, "written three times");
assert_eq!((e.written, table), (20, Some(GpkgWritten { table: "both".into(), replaced: false, dependents: vec![] })));
let whole = d.join("whole.gpkg");
crate::io::write_path(&both, &whole).unwrap();
let read =
|p: &Path, t: &str| crate::source::open_path(p, Some(t)).unwrap().read(&Request { extent: None, limit: 100 }).unwrap().features;
assert_eq!(read(&county, "both"), read(&whole, "both"));
assert_eq!(read(&county, "parcels").len(), 1);
let c = rusqlite::Connection::open(&county).unwrap();
let kind: String =
c.query_row("SELECT geometry_type_name FROM gpkg_geometry_columns WHERE table_name = 'both'", [], |r| r.get(0)).unwrap();
assert_eq!(kind, "MULTIPOINT");
drop(c);
// A source failing on the second pass: nothing of the table is left.
let before = std::fs::read(&county).unwrap();
let mut layer2 = layer.clone();
layer2.name = "again".into();
let mut failing = Runs::new(&both, 4);
(failing.fail_after, failing.fail_pass) = (Some(2), Some(1));
assert!(write_chunks_to(&failing, &layer2, &county, Crs::LonLat, &add, 1).is_err());
assert_eq!(std::fs::read(&county).unwrap(), before);
}
48 changes: 43 additions & 5 deletions crates/data/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,9 +494,44 @@ pub fn write_path(ds: &Dataset, path: &Path) -> Result<usize> {
write_path_in(ds, path, Crs::LonLat)
}

/// How a GeoPackage export treats a GeoPackage already at its path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GpkgMode {
/// Writes a new file; an existing one is refused, as it may hold other tables.
#[default]
NewFile,
/// Adds the table to the GeoPackage (a new file when there is none); refuses a table of its
/// name.
AddTable,
/// Replaces the GeoPackage's feature or attribute table of its name, which must be there.
ReplaceTable,
}

/// Where a GeoPackage export puts its table: how, and under what name (default: the layer's).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GpkgTable {
pub mode: GpkgMode,
pub name: Option<String>,
}

/// The table a GeoPackage export wrote: its name in the file (a reserved name is prefixed),
/// whether it replaced one, and the views and triggers on other tables that name a replaced one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GpkgWritten {
pub table: String,
pub replaced: bool,
pub dependents: Vec<String>,
}

/// As [`write_path`], in the coordinate system `crs` (a GeoPackage, Shapefile or FlatGeobuf; see
/// [`write_bytes_in`]).
pub fn write_path_in(ds: &Dataset, path: &Path, crs: Crs) -> Result<usize> {
write_path_to(ds, path, crs, &GpkgTable::default()).map(|(left_out, _)| left_out)
}

/// As [`write_path_in`], a GeoPackage's table placed as `gpkg` says (other formats ignore it):
/// also returns the table written.
pub fn write_path_to(ds: &Dataset, path: &Path, crs: Crs, gpkg: &GpkgTable) -> Result<(usize, Option<GpkgWritten>)> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let projected;
let ds = if crs == Crs::LonLat || !PROJECTED_FORMATS.contains(&ext.as_str()) {
Expand All @@ -511,11 +546,14 @@ pub fn write_path_in(ds: &Dataset, path: &Path, crs: Crs) -> Result<usize> {
#[cfg(not(target_arch = "wasm32"))]
{
crate::progress::begin(ds.features.len());
crate::source::gpkg::write_in(ds, path, crs)?;
Ok(0)
let table = crate::source::gpkg::write_table_in(ds, path, crs, gpkg)?;
Ok((0, Some(table)))
}
#[cfg(target_arch = "wasm32")]
Err(DataError::Unsupported("GeoPackage in the browser".into()))
{
let _ = gpkg;
Err(DataError::Unsupported("GeoPackage in the browser".into()))
}
}
"shp" => {
crate::progress::begin(ds.features.len());
Expand Down Expand Up @@ -592,15 +630,15 @@ pub fn write_path_in(ds: &Dataset, path: &Path, crs: Crs) -> Result<usize> {
for stale in ["qix", "sbn", "sbx"] {
let _ = std::fs::remove_file(path.with_extension(stale));
}
Ok(left_out)
Ok((left_out, None))
}
_ if crs != Crs::LonLat && !PROJECTED_FORMATS.contains(&ext.as_str()) => {
Err(DataError::Unsupported(format!(".{ext} in {} (it holds longitude/latitude only)", crs.label())))
}
_ => {
let (bytes, left_out) = projected_bytes(ds, &ext, crs)?;
write_atomic(path, &bytes)?;
Ok(left_out)
Ok((left_out, None))
}
}
}
Expand Down
Loading
Loading