Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 11 additions & 9 deletions crates/data/src/source/gpkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,14 +307,18 @@ impl GpkgSource {
fn feature(&self, r: &rusqlite::Row<'_>, native: Option<&Extent>, req: &Request) -> rusqlite::Result<Row> {
// Integer keys, negative ones too, map to distinct ids.
let Some(id) = r.get::<_, Option<i64>>(0)?.map(|i| i as u64) else { return Ok(Row::Bad) };
let attrs =
|| self.info.fields.iter().enumerate().map(|(i, f)| r.get_ref(i + 2).map(|v| value(v, f.ty)).unwrap_or(Value::Null)).collect();
let blob = match r.get_ref(1)? {
ValueRef::Blob(b) => b,
ValueRef::Null => return Ok(Row::Skip),
ValueRef::Blob(b) if !is_empty_blob(b) => b,
// A row without geometry (NULL, or an empty one) is kept in a whole read; it has no
// place to match an extent.
ValueRef::Null | ValueRef::Blob(_) if req.extent.is_none() => {
return Ok(Row::Feature(Feature { id, geometry: None, attrs: attrs() }));
}
ValueRef::Null | ValueRef::Blob(_) => return Ok(Row::Skip),
_ => return Ok(Row::Bad),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if is_empty_blob(blob) {
return Ok(Row::Skip);
}
let Some((envelope, body)) = split_blob(blob) else { return Ok(Row::Bad) };
if let (Some(q), Some(e)) = (native, envelope)
&& !e.intersects(q)
Expand All @@ -325,15 +329,13 @@ impl GpkgSource {
if !in_request(&geometry, req) {
return Ok(Row::Skip);
}
let attrs =
self.info.fields.iter().enumerate().map(|(i, f)| r.get_ref(i + 2).map(|v| value(v, f.ty)).unwrap_or(Value::Null)).collect();
Ok(Row::Feature(Feature { id, geometry: Some(geometry), attrs }))
Ok(Row::Feature(Feature { id, geometry: Some(geometry), attrs: attrs() }))
}
}

enum Row {
Feature(Feature),
/// Outside the request, or without geometry.
/// Outside the request (a row without geometry is, when an extent is asked for).
Skip,
/// Unreadable.
Bad,
Expand Down
62 changes: 34 additions & 28 deletions crates/data/src/source/gpkg_mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ impl FeatureSource for GpkgMemSource {
&self.info
}

/// Keeps rows without geometry in whole reads; they have no location to match an extent.
fn read(&self, req: &Request) -> Result<Batch> {
let limit = clamp_limit(req.limit);
let native = req.extent.map(|e| self.reproject.extent_from_lonlat(&e));
Expand All @@ -278,16 +279,20 @@ impl FeatureSource for GpkgMemSource {
self.db
.rows(&self.table, |rowid, r| {
let blob = match r.get(self.geom_col) {
Some(Val::Blob(b)) => b,
Some(Val::Null) | None => return ControlFlow::Continue(()),
Some(Val::Blob(b)) if !super::gpkg_blob::is_empty_blob(b) => b,
// A row without geometry (NULL, or an empty one) is kept in a whole read; it
// has no place to match an extent.
Some(Val::Null | Val::Blob(_)) | None => {
if native.is_none() && sample.wants() {
sample.keep(Feature { id: rowid as u64, geometry: None, attrs: self.attrs(r) });
}
return ControlFlow::Continue(());
}
Some(_) => {
skipped += 1;
return ControlFlow::Continue(());
}
};
if super::gpkg_blob::is_empty_blob(blob) {
return ControlFlow::Continue(());
}
let Some((envelope, body)) = super::gpkg_blob::split_blob(blob) else {
skipped += 1;
return ControlFlow::Continue(());
Expand Down Expand Up @@ -333,35 +338,36 @@ impl FeatureSource for GpkgMemSource {
let mut failed = None;
self.db
.rows(&self.table, |rowid, r| {
let blob = match r.get(self.geom_col) {
Some(Val::Blob(b)) => b,
Some(Val::Null) | None => return ControlFlow::Continue(()),
let geometry = match r.get(self.geom_col) {
Some(Val::Blob(b)) if !super::gpkg_blob::is_empty_blob(b) => {
let Some((envelope, body)) = super::gpkg_blob::split_blob(b) else {
out.skipped += 1;
return ControlFlow::Continue(());
};
if let (Some(q), Some(e)) = (&native, envelope)
&& !e.intersects(q)
{
return ControlFlow::Continue(());
}
let Some(geometry) = wkb::decode(body).and_then(|g| self.reproject.geometry(g)) else {
out.skipped += 1;
return ControlFlow::Continue(());
};
if !in_request(&geometry, &req) {
return ControlFlow::Continue(());
}
Some(geometry)
}
// Without geometry (NULL, or an empty one): kept unless an extent is asked for.
Some(Val::Null | Val::Blob(_)) | None if native.is_none() => None,
Some(Val::Null | Val::Blob(_)) | None => return ControlFlow::Continue(()),
Some(_) => {
out.skipped += 1;
return ControlFlow::Continue(());
}
};
if super::gpkg_blob::is_empty_blob(blob) {
return ControlFlow::Continue(());
}
let Some((envelope, body)) = super::gpkg_blob::split_blob(blob) else {
out.skipped += 1;
return ControlFlow::Continue(());
};
if let (Some(q), Some(e)) = (&native, envelope)
&& !e.intersects(q)
{
return ControlFlow::Continue(());
}
let Some(geometry) = wkb::decode(body).and_then(|g| self.reproject.geometry(g)) else {
out.skipped += 1;
return ControlFlow::Continue(());
};
if !in_request(&geometry, &req) {
return ControlFlow::Continue(());
}
out.matched += 1;
match chunks.push(Feature { id: rowid as u64, geometry: Some(geometry), attrs: self.attrs(r) }) {
match chunks.push(Feature { id: rowid as u64, geometry, attrs: self.attrs(r) }) {
Ok(()) => ControlFlow::Continue(()),
Err(e) => {
failed = Some(e);
Expand Down
50 changes: 44 additions & 6 deletions crates/data/src/source/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,8 @@ fn writes_geopackage_geoparquet_and_shapefile() {
let holds_all = matches!(ext, "gpkg" | "parquet");
assert_eq!(left_out, if holds_all { 0 } else { 1 }, "{ext}");
let (back, _) = crate::io::read_path(&p).unwrap();
// GeoParquet retains nullable geometry; the other readers omit rows without a shape.
assert_eq!(back.features.len(), if ext == "parquet" { 3 } else { 2 }, "{ext}");
// GeoParquet and GeoPackage keep a row without a shape; the other formats can't hold it.
assert_eq!(back.features.len(), if holds_all { 3 } else { 2 }, "{ext}");
assert_eq!(back.geometry_type, GeometryType::Polygon, "{ext}");
let e = back.extent();
assert_eq!((e.min_x, e.min_y, e.max_x, e.max_y), (-30.0, -40.0, 21.0, 21.0), "{ext}");
Expand Down Expand Up @@ -2383,6 +2383,44 @@ fn shapefile_complete_chunks_keep_all_rows_order_attributes_and_budget() {
}

/// Every feature of a complete traversal, in order, checking each chunk against the budget.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (quality, can wait for the release quality pass): this line was the doc comment of visit_all. The new test is inserted between it and visit_all, so the new test's rustdoc starts with "Every feature of a complete traversal…", which describes visit_all, not this test. Moving the new test above this line, or dropping this stray first line, would keep each doc on the item it describes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2334e48: the line is back on visit_all, and the duplicate doc I'd added there is gone.

/// GeoPackage rows without geometry (NULL, or an empty geometry) keep their row and attributes in
/// whole reads and visits, with and without the R-tree and in the browser's reader, and are left
/// out of a read of an extent.
#[test]
fn geopackage_keeps_rows_without_geometry() {
let d = dir().join("geometryless");
std::fs::create_dir_all(&d).unwrap();
let (indexed, scan) = (d.join("indexed.gpkg"), d.join("scan.gpkg"));
for (p, rtree) in [(&indexed, true), (&scan, false)] {
write_gpkg(p, rtree);
let c = rusqlite::Connection::open(p).unwrap();
let empty = b"GP\x00\x11\x11\x0f\x00\x00".to_vec();
c.execute("INSERT INTO \"grid \"\"q\"\"\" VALUES (601, NULL, 'no place', 7, 1)", []).unwrap();
c.execute("INSERT INTO \"grid \"\"q\"\"\" VALUES (602, ?1, 'empty', 8, 0)", [empty]).unwrap();
}
let sources: Vec<Box<dyn FeatureSource>> = vec![
open_path(&indexed, None).unwrap(),
open_path(&scan, None).unwrap(),
Box::new(gpkg_mem::GpkgMemSource::open_shared(Shared(Arc::new(std::fs::read(&scan).unwrap())), None).unwrap()),
];
for src in &sources {
let format = src.info().format;
assert_eq!(src.info().count, Some(602), "{format}");
let whole = src.read(&Request::all()).unwrap();
assert_eq!((whole.features.len(), whole.skipped), (602, 0), "{format}");
let bare: Vec<_> = whole.features.iter().filter(|f| f.geometry.is_none()).map(|f| (f.id, f.attrs[0].clone())).collect();
assert_eq!(bare, vec![(601, Value::Text("no place".into())), (602, Value::Text("empty".into()))], "{format}");
let (rows, summary, _) = visit_all(src.as_ref(), None, 1024);
assert_eq!((rows, summary.matched), (whole.features, 602), "{format}");
let world = Extent::new(-180.0, -85.0, 180.0, 85.0);
let part = src.read(&Request { extent: Some(world), limit: 10_000 }).unwrap();
assert_eq!(part.features.len(), 600, "{format}");
assert_eq!(visit_all(src.as_ref(), Some(world), 1024).0.len(), 600, "{format}");
}
}

/// Every chunk a visit of `extent` gives, joined, checking each fits `budget` (or is one row too
/// big for it): the rows, the visit's summary and how many chunks came.
fn visit_all(src: &dyn FeatureSource, extent: Option<Extent>, budget: usize) -> (Vec<Feature>, Batch, usize) {
let (mut rows, mut calls) = (Vec::new(), 0);
let summary = src
Expand Down Expand Up @@ -2491,20 +2529,20 @@ fn geopackage_flatgeobuf_and_geoparquet_visit_what_a_whole_read_gives() {
let error = src.visit_chunks(None, 4096, &mut |_| Ok(())).unwrap_err();
assert!(matches!(&error, crate::DataError::Io(e) if e.kind() == std::io::ErrorKind::NotFound), "{error}");
}
// An unreadable geometry is skipped and counted, and a row without one left out, as a read does.
// An unreadable geometry is skipped and counted, and a row without one kept, as a read does.
drop(sources);
let c = rusqlite::Connection::open(&gpkg_scan).unwrap();
c.execute_batch("INSERT INTO \"grid \"\"q\"\"\" (geom, name) VALUES (X'4750000300000000DEAD', 'bad'), (NULL, 'none');").unwrap();
drop(c);
let src = open_path(&gpkg_scan, None).unwrap();
let whole = src.read(&Request::all()).unwrap();
assert_eq!((whole.features.len(), whole.skipped), (600, 1));
assert_eq!((whole.features.len(), whole.skipped), (601, 1));
let (rows, summary, _) = visit_all(src.as_ref(), None, 4096);
assert_eq!((rows, summary.skipped), (whole.features, 1));
// The browser's reader of the same file skips and leaves out the same rows.
// The browser's reader of the same file skips and keeps the same rows.
let mem = gpkg_mem::GpkgMemSource::open_shared(Shared(Arc::new(std::fs::read(&gpkg_scan).unwrap())), None).unwrap();
let whole = mem.read(&Request::all()).unwrap();
assert_eq!((whole.features.len(), whole.skipped), (600, 1));
assert_eq!((whole.features.len(), whole.skipped), (601, 1));
let (rows, summary, _) = visit_all(&mem, None, 4096);
assert_eq!((rows, summary.skipped), (whole.features, 1));
}
Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/data-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ not every EPSG code; azimuthal equidistant systems such as Equi7 need the deskto
| AutoCAD DXF | `.dxf` (ASCII) | Add Data, drop the file, or upload | Points, lines and polygons layers from the drawing's entities: POINT, LINE, LWPOLYLINE and POLYLINE (bulges drawn as arcs; closed ones stay lines; polyface meshes become polygons), CIRCLE, ARC, ELLIPSE, SPLINE, HELIX, LEADER, MLINE (its centre line), TEXT, MTEXT, ATTRIB and constant ATTDEF (points with their text, at the alignment point of justified text), HATCH boundaries (holes found by nesting), SOLID, TRACE and 3DFACE (polygons). Blocks (named in any case) are drawn where INSERTs place them (scaled, rotated, in arrays, nested up to 8 deep), and so are DIMENSIONs' blocks (a linear dimension without one draws its lines and measured text). Entities in an object's own coordinate system are turned into the drawing's. Fields: `layer`, `entity`, `color` (ACI, BYLAYER and BYBLOCK resolved), `linetype`, `text`, `height`, `rotation`, `block`, `elevation` and `paperspace`. The drawing's layouts (paper space: title blocks, viewport frames) are left out, with a notice, unless `"paperSpace": true`. A drawing never says its coordinate system: give `"crs"` (such as `EPSG:2263`), or its coordinates are kept as they are, with a notice. `"layer"` picks one of the drawing's own layers, and `info.fileLayers` lists them. Up to 2 million features (500,000 in the web app). Not read: binary DXF, MULTILEADER, WIPEOUT, IMAGE and ACIS solids (3DSOLID, REGION), named in a notice. |
| Geotagged photos | `.jpg`, `.jpeg` | Add Data (several at once in the web app), drop them, upload, or `layer.addPhotos` with `"paths"` or a `"folder"` | JPEGs with a GPS position in their EXIF become points in the **Photos** layer (photos added one at a time join it): each with its `name`, `file`, `time` (when it was taken), `altitude`, `direction` (where the camera faced), `camera` and a `thumbnail` of at most 160 pixels, kept with the feature so the project needs no other file. Identify shows the thumbnail. Photos without a position are named in a notice and left out; a layer holds up to 2,000 photos. A JPEG is always read as a photo unless `layer.addData` or `layer.addDataBytes` is given `"as": "scan"` (a phone photo of a paper map, say: see [Scans with no coordinates](#rasters)). |
| LiDAR point clouds | `.las`, `.laz` (COPC too) | Add Data, drop the file, upload, or From URL… | LAS 1.0–1.4 (point formats 0–10) and LAZ, on desktop and web: points with `z`, `intensity`, `classification`, `return` and colour (`red`, `green`, `blue`), coloured by ASPRS class or elevation. An evenly spread sample is read once, at most 2 million points (500,000 in the web app); views use that sample, not the COPC spatial index. LAZ decompression reads at most the first 400 million points (40 million on the web), with a notice when capped. Converted from the file's WKT or GeoTIFF-key record; without one, coordinates are kept with a notice. A local project keeps the path; uploads keep the file bytes. Remote files download whole. |
| GeoPackage | `.gpkg` | Add Data, or upload in the web app | Local files and browser uploads add every feature table, up to 32 (with the rest counted in `skippedCount` and up to 100 named in `skipped`); `"layer"` picks one, and `info.fileLayers` lists them. Desktop byte uploads and downloads add the first table unless `"layer"` names another. Large local tables use their R-tree. A table is read in its coordinate system: by its EPSG code, or, for a system without one (or with a code RustGIS doesn't know), by the WKT definition the file gives it, on the desktop and in the web app alike (an azimuthal equidistant one opens on the desktop only, and the web app says so). Browser tables are scanned, testing each geometry header's envelope first; UTF-16 databases aren't read there. Raster tile tables aren't imported. |
| GeoPackage | `.gpkg` | Add Data, or upload in the web app | Local files and browser uploads add every feature table, up to 32 (with the rest counted in `skippedCount` and up to 100 named in `skipped`); `"layer"` picks one, and `info.fileLayers` lists them. Desktop byte uploads and downloads add the first table unless `"layer"` names another. Large local tables use their R-tree. Rows with no geometry (NULL or empty) are kept, with their attributes, in whole reads and exports; a read of an extent leaves them out, as they have no location. A table is read in its coordinate system: by its EPSG code, or, for a system without one (or with a code RustGIS doesn't know), by the WKT definition the file gives it, on the desktop and in the web app alike (an azimuthal equidistant one opens on the desktop only, and the web app says so). Browser tables are scanned, testing each geometry header's envelope first; UTF-16 databases aren't read there. Raster tile tables aren't imported. |
| GeoParquet | `.parquet`, `.geoparquet` | Add Data, or upload in the web app | WKB geometry, with the CRS from the file's `geo` metadata. A binary geometry column is required; plain non-spatial Parquet tables and GeoArrow-native geometry encodings aren't read by this importer. Row groups are pruned by the bbox covering. Whole reads and exports retain rows with null geometry and their attributes; extent-filtered reads exclude them because they have no location. Snappy, gzip, LZ4 and zstd compression are read, in the browser too; Brotli isn't. Text, whole-number, floating-point and true/false columns become fields, as do decimals stored as 32- or 64-bit integers (up to 18 digits), read as floating-point numbers. Dates and times stored as 32- or 64-bit integers are read as those whole numbers (such as milliseconds since 1970). Other columns are left out: decimals stored as bytes (how many writers store every decimal), binary, lists, maps, structs, INT96 timestamps and further geometry columns. A notice gives how many were left out and names the first 12, each with its kind; another names the date and time columns with their unit. |
| FlatGeobuf | `.fgb` | Add Data, or upload | Read in place by its packed R-tree. Binary columns are left out, with a notice counting them and naming the first 12. |
| Esri File Geodatabase | `.gdb` folder (desktop) | In Add Data pick a `.gdbtable` file inside it; or double-click the `.gdb` in the Browser, or drop the folder | The native reader loads up to 512 feature classes and tables, up to 2 GiB per table. Feature classes become layers and non-spatial tables standalone tables; dates are text. Coordinates use the EPSG/Esri code, else WKT. `"layer"` picks one; `info.fileLayers` lists them. Multipatches become footprints with `z_min`, `z_max` and `height`; large or wall-only ones may use a convex hull. Curves become straight segments between their ends. Unreadable tables (compressed storage, raster fields or unsupported row indexes) are tried through GDAL when installed; otherwise they are named in a notice. |
Expand Down
Loading