#![expect(
clippy::unwrap_used,
reason = "clippy's allow-*-in-tests covers #[test] fns but not the free helper fns in an integration-test crate; the unwraps in these helpers are the intended failure mechanism"
)]
use geo_types::Point;
use geopackage::core::gpb::{Envelope, encode_header};
use geopackage::core::triggers::{self, TriggerGeneration};
use geopackage::core::types::{ColumnType, GeometryType};
use geopackage::{
BoundingBox, BulkIndexOptions, BulkVerification, ColumnSpec, Error, GeoPackage, GeometrySpec,
NewFeature, SpatialIndexStatus, TableSchemaBuilder, Value, ValueRef,
};
use hegel::generators;
use rusqlite::{Connection, OptionalExtension};
use std::path::Path;
fn gpb_empty_point(srs_id: i32) -> Vec<u8> {
let mut blob = encode_header(srs_id, &Envelope::None, true, false);
blob.push(1);
blob.extend_from_slice(&1u32.to_le_bytes());
blob.extend_from_slice(&f64::NAN.to_le_bytes());
blob.extend_from_slice(&f64::NAN.to_le_bytes());
blob
}
fn gpb_point(srs_id: i32, x: f64, y: f64) -> Vec<u8> {
let mut blob = encode_header(srs_id, &Envelope::Xy([x, x, y, y]), false, false);
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 add_points_layer(gpkg: &GeoPackage, points: &[(i64, f64, f64)]) {
let builder = TableSchemaBuilder::new("pts")
.column(ColumnSpec::new("name", ColumnType::Text(None)))
.geometry(GeometrySpec::new(GeometryType::Point, 4326))
.spatial_index(false);
let layer = gpkg.create_layer(&builder).unwrap();
let mut writer = layer.writer().unwrap();
for &(fid, x, y) in points {
writer
.insert(Some(fid), &Point::new(x, y), &[ValueRef::Null])
.unwrap();
}
writer.commit().unwrap();
}
fn gpkg_with_points(points: &[(i64, f64, f64)]) -> (tempfile::TempDir, GeoPackage) {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("t.gpkg")).unwrap();
add_points_layer(&gpkg, points);
(dir, gpkg)
}
fn in_memory_with_points(points: &[(i64, f64, f64)]) -> GeoPackage {
let gpkg = GeoPackage::create(Path::new(":memory:")).unwrap();
add_points_layer(&gpkg, points);
gpkg
}
fn rtree_rows(conn: &Connection) -> Vec<(i64, f64, f64, f64, f64)> {
let mut stmt = conn
.prepare("SELECT id, minx, maxx, miny, maxy FROM rtree_pts_geom ORDER BY id")
.unwrap();
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
}
fn rtree_nodes(conn: &Connection) -> Vec<(i64, Vec<u8>)> {
let mut stmt = conn
.prepare("SELECT nodeno, data FROM rtree_pts_geom_node ORDER BY nodeno")
.unwrap();
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
}
fn envelope_scan(conn: &Connection) -> Vec<(i64, f64, f64, f64, f64)> {
let mut stmt = conn
.prepare(
"SELECT fid, ST_MinX(geom), ST_MaxX(geom), ST_MinY(geom), ST_MaxY(geom) \
FROM pts WHERE geom NOT NULL AND NOT ST_IsEmpty(geom) ORDER BY fid",
)
.unwrap();
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
}
fn trigger_names(conn: &Connection) -> Vec<String> {
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'pts'")
.unwrap();
let mut names: Vec<String> = stmt
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
names.sort();
names
}
fn generation(conn: &Connection) -> TriggerGeneration {
triggers::classify_triggers(
trigger_names(conn).iter().map(String::as_str),
"pts",
"geom",
)
}
fn table_exists(conn: &Connection, name: &str) -> bool {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ?1",
[name],
|_| Ok(()),
)
.optional()
.unwrap()
.is_some()
}
#[test]
fn create_populates_index_matching_envelope_scan() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
assert!(!layer.has_spatial_index().unwrap());
layer.create_spatial_index().unwrap();
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn), envelope_scan(conn));
assert!(layer.has_spatial_index().unwrap());
assert_eq!(generation(conn), TriggerGeneration::V1_4);
}
#[test]
fn extension_row_matches_spec_strings() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let (table_name, column_name, extension_name, definition, scope): (
String,
String,
String,
String,
String,
) = gpkg
.connection()
.query_row(
"SELECT table_name, column_name, extension_name, definition, scope \
FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
assert_eq!(table_name, "pts");
assert_eq!(column_name, "geom");
assert_eq!(extension_name, "gpkg_rtree_index");
assert_eq!(scope, "write-only");
assert_eq!(
definition,
"http://www.geopackage.org/spec140/#extension_rtree"
);
assert_eq!(extension_name, triggers::EXTENSION_NAME);
assert_eq!(definition, triggers::EXTENSION_DEFINITION);
assert_eq!(scope, triggers::EXTENSION_SCOPE);
}
#[test]
fn create_skips_null_and_empty_geometries() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 10.0, 20.0)]);
let conn = gpkg.connection();
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (2, 'null', NULL)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (3, 'empty', ?1)",
[gpb_empty_point(4326)],
)
.unwrap();
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let ids: Vec<i64> = rtree_rows(conn).into_iter().map(|r| r.0).collect();
assert_eq!(ids, vec![1]);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
#[test]
fn features_in_uses_vtab_with_identical_results() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 0.0, 0.0), (2, 50.0, 50.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
let bbox = BoundingBox::new(40.0, 40.0, 60.0, 60.0);
layer.create_spatial_index().unwrap();
assert!(layer.has_spatial_index().unwrap());
let plan = query_plan(gpkg.connection(), &layer.features_in_sql().unwrap());
assert!(
plan.contains("VIRTUAL TABLE INDEX"),
"expected rtree vtab in plan, got: {plan}"
);
let indexed: Vec<i64> = layer
.features_in(bbox)
.unwrap()
.map(|f| f.unwrap().fid())
.collect();
assert_eq!(indexed, vec![2]);
layer.drop_spatial_index().unwrap();
assert!(!layer.has_spatial_index().unwrap());
let scan_plan = query_plan(gpkg.connection(), &layer.features_in_sql().unwrap());
assert!(
!scan_plan.contains("VIRTUAL TABLE INDEX"),
"expected a full scan after drop, got: {scan_plan}"
);
let scanned: Vec<i64> = layer
.features_in(bbox)
.unwrap()
.map(|f| f.unwrap().fid())
.collect();
assert_eq!(indexed, scanned);
}
fn query_plan(conn: &Connection, sql: &str) -> String {
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
let params = vec![0.0f64; stmt.parameter_count()];
let details: Vec<String> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
r.get::<_, String>(3)
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
details.join("\n")
}
#[test]
fn upsert_through_new_index_maintains_rtree() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let conn = gpkg.connection();
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (1, 'b', ?1) \
ON CONFLICT (fid) DO UPDATE SET geom = excluded.geom, name = excluded.name",
[gpb_point(4326, 9.0, 9.0)],
)
.unwrap();
let rows = rtree_rows(conn);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, 1);
assert!((rows[0].1 - 9.0).abs() < 1e-6);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
#[test]
fn drop_removes_triggers_vtab_and_extension_row() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0), (2, 2.0, 2.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let conn = gpkg.connection();
assert!(table_exists(conn, "rtree_pts_geom"));
assert!(!trigger_names(conn).is_empty());
layer.drop_spatial_index().unwrap();
assert!(trigger_names(conn).is_empty());
assert!(!table_exists(conn, "rtree_pts_geom"));
let ext_rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(ext_rows, 0);
assert!(table_exists(conn, "gpkg_extensions"));
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM pts", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2);
layer.drop_spatial_index().unwrap();
assert!(!table_exists(conn, "rtree_pts_geom"));
}
fn install_legacy_triggers(conn: &Connection) {
conn.execute_batch(
"CREATE TRIGGER \"rtree_pts_geom_insert\" AFTER INSERT ON \"pts\"
WHEN (new.geom NOT NULL AND NOT ST_IsEmpty(NEW.geom))
BEGIN
INSERT OR REPLACE INTO rtree_pts_geom VALUES (
NEW.fid,
ST_MinX(NEW.geom), ST_MaxX(NEW.geom),
ST_MinY(NEW.geom), ST_MaxY(NEW.geom)
);
END;
CREATE TRIGGER \"rtree_pts_geom_update1\" AFTER UPDATE OF geom ON \"pts\"
WHEN OLD.fid = NEW.fid AND
(NEW.geom NOTNULL AND NOT ST_IsEmpty(NEW.geom))
BEGIN
INSERT OR REPLACE INTO rtree_pts_geom VALUES (
NEW.fid,
ST_MinX(NEW.geom), ST_MaxX(NEW.geom),
ST_MinY(NEW.geom), ST_MaxY(NEW.geom)
);
END;
CREATE TRIGGER \"rtree_pts_geom_update2\" AFTER UPDATE OF geom ON \"pts\"
WHEN OLD.fid = NEW.fid AND
(NEW.geom ISNULL OR ST_IsEmpty(NEW.geom))
BEGIN
DELETE FROM rtree_pts_geom WHERE id = OLD.fid;
END;
CREATE TRIGGER \"rtree_pts_geom_update3\" AFTER UPDATE ON \"pts\"
WHEN OLD.fid != NEW.fid AND
(NEW.geom NOTNULL AND NOT ST_IsEmpty(NEW.geom))
BEGIN
DELETE FROM rtree_pts_geom WHERE id = OLD.fid;
INSERT OR REPLACE INTO rtree_pts_geom VALUES (
NEW.fid,
ST_MinX(NEW.geom), ST_MaxX(NEW.geom),
ST_MinY(NEW.geom), ST_MaxY(NEW.geom)
);
END;
CREATE TRIGGER \"rtree_pts_geom_update4\" AFTER UPDATE ON \"pts\"
WHEN OLD.fid != NEW.fid AND
(NEW.geom ISNULL OR ST_IsEmpty(NEW.geom))
BEGIN
DELETE FROM rtree_pts_geom WHERE id IN (OLD.fid, NEW.fid);
END;
CREATE TRIGGER \"rtree_pts_geom_delete\" AFTER DELETE ON \"pts\"
WHEN old.geom NOT NULL
BEGIN
DELETE FROM rtree_pts_geom WHERE id = OLD.fid;
END;",
)
.unwrap();
}
#[test]
fn repair_upgrades_legacy_triggers_to_v1_4() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let conn = gpkg.connection();
conn.execute_batch(&triggers::create_rtree_table_sql("pts", "geom").unwrap())
.unwrap();
install_legacy_triggers(conn);
conn.execute(
"INSERT INTO rtree_pts_geom VALUES (999, 1000.0, 1000.0, 1000.0, 1000.0)",
[],
)
.unwrap();
assert_eq!(generation(conn), TriggerGeneration::PreV1_4);
let layer = gpkg.layer("pts").unwrap();
layer.repair_spatial_index().unwrap();
assert_eq!(generation(conn), TriggerGeneration::V1_4);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let ids: Vec<i64> = rtree_rows(conn).into_iter().map(|r| r.0).collect();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn repair_on_v1_4_is_a_noop() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 10.0, 20.0), (2, -5.0, 7.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let conn = gpkg.connection();
let triggers_before = trigger_names(conn);
let rtree_before = rtree_rows(conn);
layer.repair_spatial_index().unwrap();
assert_eq!(trigger_names(conn), triggers_before);
assert_eq!(rtree_rows(conn), rtree_before);
assert_eq!(generation(conn), TriggerGeneration::V1_4);
}
#[test]
fn repair_without_index_errors() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
let err = layer.repair_spatial_index().unwrap_err();
assert!(
matches!(err, geopackage::Error::NoSpatialIndex { .. }),
"expected NoSpatialIndex, got {err:?}"
);
}
#[test]
fn create_on_already_indexed_errors() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let err = layer.create_spatial_index().unwrap_err();
assert!(
matches!(err, geopackage::Error::SpatialIndexExists { .. }),
"expected SpatialIndexExists, got {err:?}"
);
}
#[test]
fn create_on_attribute_layer_errors() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("t.gpkg")).unwrap();
let builder =
TableSchemaBuilder::new("notes").column(ColumnSpec::new("body", ColumnType::Text(None)));
let layer = gpkg.create_attributes_table(&builder).unwrap();
let err = layer.create_spatial_index().unwrap_err();
assert!(
matches!(err, geopackage::Error::NoGeometryColumn { .. }),
"expected NoGeometryColumn, got {err:?}"
);
}
fn draw_coord(tc: &hegel::TestCase) -> f64 {
f64::from(
tc.draw(
generators::floats::<f32>()
.min_value(-1000.0)
.max_value(1000.0),
),
)
}
fn upsert_point(conn: &Connection, fid: i64, x: f64, y: f64) {
conn.execute(
"INSERT INTO pts (fid, geom) VALUES (?1, ?2) \
ON CONFLICT(fid) DO UPDATE SET geom = excluded.geom",
rusqlite::params![fid, gpb_point(4326, x, y)],
)
.unwrap();
}
fn upsert_null(conn: &Connection, fid: i64) {
conn.execute(
"INSERT INTO pts (fid, geom) VALUES (?1, NULL) \
ON CONFLICT(fid) DO UPDATE SET geom = NULL",
[fid],
)
.unwrap();
}
#[hegel::test]
fn rtree_tracks_full_scan_through_write_ops(tc: hegel::TestCase) {
let gpkg = in_memory_with_points(&[]);
let layer = gpkg.layer("pts").unwrap();
let conn = gpkg.connection();
let seed = tc.draw(generators::integers::<usize>().min_value(0).max_value(10));
for fid in 1..=(seed as i64) {
upsert_point(conn, fid, draw_coord(&tc), draw_coord(&tc));
}
let options = if tc.draw(generators::booleans()) {
BulkIndexOptions::always_bulk()
} else {
BulkIndexOptions::never_bulk()
};
layer.create_spatial_index_with(options).unwrap();
assert_eq!(
rtree_rows(conn),
envelope_scan(conn),
"index diverged from the scan right after the initial build"
);
let n_ops = tc.draw(generators::integers::<usize>().min_value(0).max_value(40));
for step in 0..n_ops {
let fid = tc.draw(generators::integers::<i64>().min_value(1).max_value(8));
match tc.draw(generators::integers::<u8>().min_value(0).max_value(3)) {
0 => upsert_point(conn, fid, draw_coord(&tc), draw_coord(&tc)),
1 => upsert_null(conn, fid),
2 => {
let (x, y) = (draw_coord(&tc), draw_coord(&tc));
let mut writer = layer.writer().unwrap();
writer
.update(fid, &Point::new(x, y), &[ValueRef::Null])
.unwrap();
writer.commit().unwrap();
}
_ => {
let mut writer = layer.writer().unwrap();
writer.delete(fid).unwrap();
writer.commit().unwrap();
}
}
assert_eq!(
rtree_rows(conn),
envelope_scan(conn),
"index diverged from the scan after op {step}"
);
}
}
#[hegel::test]
fn bulk_and_triggered_builds_agree(tc: hegel::TestCase) {
let n = tc.draw(generators::integers::<usize>().min_value(0).max_value(140));
let points: Vec<(i64, f64, f64)> = (1..=(n as i64))
.map(|fid| (fid, draw_coord(&tc), draw_coord(&tc)))
.collect();
let triggered = in_memory_with_points(&points);
triggered
.layer("pts")
.unwrap()
.create_spatial_index_with(BulkIndexOptions::never_bulk())
.unwrap();
let bulk = in_memory_with_points(&points);
bulk.layer("pts")
.unwrap()
.create_spatial_index_with(BulkIndexOptions::always_bulk())
.unwrap();
assert_eq!(
rtree_rows(bulk.connection()),
rtree_rows(triggered.connection()),
"bulk and triggered index contents differ"
);
assert_eq!(
rtree_rows(bulk.connection()),
envelope_scan(bulk.connection()),
"bulk index does not match the scan"
);
}
#[hegel::test]
fn write_all_bulk_envelopes_match_triggered_build(tc: hegel::TestCase) {
let n = tc.draw(generators::integers::<usize>().min_value(0).max_value(140));
let points: Vec<(i64, f64, f64)> = (1..=(n as i64))
.map(|fid| (fid, draw_coord(&tc), draw_coord(&tc)))
.collect();
let triggered = in_memory_with_points(&points);
triggered
.layer("pts")
.unwrap()
.create_spatial_index_with(BulkIndexOptions::never_bulk())
.unwrap();
let gpkg = in_memory_with_points(&[]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let features: Vec<_> = points
.iter()
.map(|&(fid, x, y)| NewFeature::new(Point::new(x, y), vec![Value::Null]).with_fid(fid))
.collect();
layer
.write_all_with(features, 0, BulkIndexOptions::always_bulk())
.unwrap();
assert_eq!(
rtree_rows(gpkg.connection()),
rtree_rows(triggered.connection()),
"write_all bulk index differs from the triggered build"
);
assert_eq!(
rtree_rows(gpkg.connection()),
envelope_scan(gpkg.connection()),
"write_all bulk index does not match the scan"
);
}
#[test]
fn write_all_bulk_with_preexisting_rows_indexes_every_row() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("m.gpkg")).unwrap();
let builder = TableSchemaBuilder::new("pts")
.column(ColumnSpec::new("name", ColumnType::Text(None)))
.geometry(GeometrySpec::new(GeometryType::Point, 4326))
.spatial_index(false);
let layer = gpkg.create_layer(&builder).unwrap();
{
let mut writer = layer.writer().unwrap();
writer.insert_row(Some(1), &[ValueRef::Null]).unwrap();
writer.insert_row(Some(2), &[ValueRef::Null]).unwrap();
writer.commit().unwrap();
}
layer.create_spatial_index().unwrap();
assert_eq!(rtree_rows(gpkg.connection()).len(), 0);
let features = vec![
NewFeature::new(Point::new(5.0, 6.0), vec![Value::Null]).with_fid(3),
NewFeature::new(Point::new(7.0, 8.0), vec![Value::Null]).with_fid(4),
];
layer
.write_all_with(features, 0, BulkIndexOptions::always_bulk())
.unwrap();
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let ids: Vec<i64> = rtree_rows(conn).into_iter().map(|r| r.0).collect();
assert_eq!(ids, vec![3, 4]);
}
#[test]
fn bulk_build_with_full_database_check_is_correct() {
let points: Vec<(i64, f64, f64)> = (1..=20).map(|i| (i, i as f64, -(i as f64))).collect();
let (_dir, gpkg) = gpkg_with_points(&points);
gpkg.layer("pts")
.unwrap()
.create_spatial_index_with(
BulkIndexOptions::always_bulk().with_verification(BulkVerification::Database),
)
.unwrap();
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn), envelope_scan(conn));
assert_eq!(rtree_rows(conn).len(), 20);
}
#[test]
fn feature_writer_maintains_new_index() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0), (2, 2.0, 2.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
{
let mut writer = layer.writer().unwrap();
writer
.insert(Some(3), &Point::new(30.0, 40.0), &[ValueRef::Null])
.unwrap();
writer
.update(1, &Point::new(-7.0, -8.0), &[ValueRef::Null])
.unwrap();
writer.delete(2).unwrap();
writer.commit().unwrap();
}
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let ids: Vec<i64> = rtree_rows(conn).into_iter().map(|r| r.0).collect();
assert_eq!(ids, vec![1, 3]);
let (min_x, min_y, max_x, max_y): (f64, f64, f64, f64) = conn
.query_row(
"SELECT min_x, min_y, max_x, max_y FROM gpkg_contents WHERE table_name = 'pts'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert!(min_x <= -7.0 && min_y <= -8.0 && max_x >= 30.0 && max_y >= 40.0);
let hits: Vec<i64> = layer
.features_in(BoundingBox::new(25.0, 35.0, 35.0, 45.0))
.unwrap()
.map(|f| f.unwrap().fid())
.collect();
assert_eq!(hits, vec![3]);
}
#[test]
fn deep_packed_tree_matches_triggered_build() {
let points: Vec<(i64, f64, f64)> = (1..=3000)
.map(|i| {
let x = f64::from(u32::try_from((i * 7) % 1021).unwrap()) / 4.0;
let y = f64::from(u32::try_from((i * 13) % 509).unwrap()) / 8.0;
(i, x, y)
})
.collect();
let triggered = in_memory_with_points(&points);
triggered
.layer("pts")
.unwrap()
.create_spatial_index_with(BulkIndexOptions::never_bulk())
.unwrap();
let packed = in_memory_with_points(&points);
packed
.layer("pts")
.unwrap()
.create_spatial_index_with(BulkIndexOptions::always_bulk())
.unwrap();
let root: Vec<u8> = packed
.connection()
.query_row(
"SELECT data FROM rtree_pts_geom_node WHERE nodeno = 1",
[],
|r| r.get(0),
)
.unwrap();
let depth = u16::from_be_bytes([root[0], root[1]]);
assert_eq!(depth, 2, "expected a tree with two internal levels");
let report: String = packed
.connection()
.query_row("SELECT rtreecheck('rtree_pts_geom')", [], |r| r.get(0))
.unwrap();
assert_eq!(report, "ok", "rtreecheck rejected the packed tree");
assert_eq!(
rtree_rows(packed.connection()),
rtree_rows(triggered.connection()),
"deep packed index differs from the triggered build"
);
assert_eq!(
rtree_rows(packed.connection()),
envelope_scan(packed.connection()),
"deep packed index does not match the scan"
);
let bbox = BoundingBox::new(10.0, 5.0, 120.0, 40.0);
let layer = packed.layer("pts").unwrap();
let reference = triggered.layer("pts").unwrap();
let mut got: Vec<i64> = layer
.features_in(bbox)
.unwrap()
.map(|f| f.unwrap().fid())
.collect();
let mut want: Vec<i64> = reference
.features_in(bbox)
.unwrap()
.map(|f| f.unwrap().fid())
.collect();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "deep packed index answers queries differently");
assert!(!got.is_empty(), "the query box should match something");
}
#[test]
fn large_append_into_a_populated_index_rebuilds_it() {
let existing: Vec<(i64, f64, f64)> = (1..=200).map(|i| (i, i as f64, -(i as f64))).collect();
let gpkg = in_memory_with_points(&existing);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
assert_eq!(rtree_rows(gpkg.connection()).len(), 200);
let new: Vec<NewFeature<Point<f64>>> = (201..=300)
.map(|i| NewFeature::new(Point::new(i as f64, -(i as f64)), vec![Value::Null]).with_fid(i))
.collect();
layer
.write_all_with(new, 0, BulkIndexOptions::with_threshold(1))
.unwrap();
let conn = gpkg.connection();
assert_eq!(
rtree_rows(conn).len(),
300,
"every row indexed after the merge"
);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let nodes: i64 = conn
.query_row("SELECT count(*) FROM rtree_pts_geom_node", [], |r| r.get(0))
.unwrap();
assert_eq!(
nodes, 7,
"expected a packed rebuild, not a triggered append"
);
assert_eq!(
layer.spatial_index_status().unwrap(),
geopackage::SpatialIndexStatus::Current,
"the triggers must be back after a rebuild"
);
{
let mut writer = layer.writer().unwrap();
writer
.insert(Some(301), &Point::new(301.0, -301.0), &[ValueRef::Null])
.unwrap();
writer.commit().unwrap();
}
assert_eq!(rtree_rows(conn).len(), 301);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
#[test]
fn small_append_into_a_populated_index_appends_to_it() {
let existing: Vec<(i64, f64, f64)> = (1..=200).map(|i| (i, i as f64, -(i as f64))).collect();
let new = || -> Vec<NewFeature<Point<f64>>> {
(201..=205)
.map(|i| {
NewFeature::new(Point::new(i as f64, -(i as f64)), vec![Value::Null]).with_fid(i)
})
.collect()
};
let gpkg = in_memory_with_points(&existing);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
layer
.write_all_with(new(), 0, BulkIndexOptions::with_threshold(1))
.unwrap();
let reference = in_memory_with_points(&existing);
let ref_layer = reference.layer("pts").unwrap();
ref_layer.create_spatial_index().unwrap();
ref_layer
.write_all_with(new(), 0, BulkIndexOptions::never_bulk())
.unwrap();
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn).len(), 205);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
assert_eq!(
rtree_nodes(conn),
rtree_nodes(reference.connection()),
"the append built a different tree from the one the triggers build"
);
{
let mut writer = layer.writer().unwrap();
writer
.insert(Some(206), &Point::new(206.0, -206.0), &[ValueRef::Null])
.unwrap();
writer.commit().unwrap();
}
assert_eq!(rtree_rows(conn).len(), 206);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
fn unsized_points(count: i64) -> impl Iterator<Item = NewFeature<Point<f64>>> {
let mut next = 0i64;
std::iter::from_fn(move || {
next += 1;
(next <= count).then(|| {
NewFeature::new(Point::new(next as f64, -(next as f64)), vec![Value::Null])
.with_fid(next)
})
})
}
#[test]
fn unsized_iterator_reaches_the_bulk_path() {
let gpkg = in_memory_with_points(&[]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let features = unsized_points(300);
assert_eq!(
features.size_hint(),
(0, None),
"the point of the test is an iterator with no usable hint"
);
layer
.write_all_with(features, 0, BulkIndexOptions::with_threshold(100))
.unwrap();
let conn = gpkg.connection();
assert_eq!(rtree_rows(conn).len(), 300, "every row indexed");
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let nodes: i64 = conn
.query_row("SELECT count(*) FROM rtree_pts_geom_node", [], |r| r.get(0))
.unwrap();
assert_eq!(nodes, 7, "expected the bulk path, not a triggered write");
}
#[test]
fn unsized_iterator_below_the_threshold_writes_every_row() {
let gpkg = in_memory_with_points(&[]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let fids = layer
.write_all_with(unsized_points(50), 0, BulkIndexOptions::with_threshold(100))
.unwrap();
assert_eq!(fids, (1..=50).collect::<Vec<i64>>(), "ids in input order");
let conn = gpkg.connection();
let rows: i64 = conn
.query_row("SELECT count(*) FROM pts", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 50, "every buffered row written exactly once");
assert_eq!(rtree_rows(conn).len(), 50);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
#[test]
fn bulk_build_skips_null_and_empty_geometries() {
let gpkg = in_memory_with_points(&[(1, 10.0, 20.0), (4, -5.0, 7.0)]);
let conn = gpkg.connection();
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (2, 'null', NULL)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (3, 'empty', ?1)",
[gpb_empty_point(4326)],
)
.unwrap();
let layer = gpkg.layer("pts").unwrap();
layer
.create_spatial_index_with(BulkIndexOptions::always_bulk())
.unwrap();
let ids: Vec<i64> = rtree_rows(conn).into_iter().map(|r| r.0).collect();
assert_eq!(
ids,
vec![1, 4],
"NULL and empty geometries must not be indexed"
);
assert_eq!(rtree_rows(conn), envelope_scan(conn));
}
#[test]
fn bulk_build_indexes_envelope_less_geometries() {
let gpkg = in_memory_with_points(&[(1, 1.0, 1.0)]);
let conn = gpkg.connection();
let mut blob = encode_header(4326, &Envelope::None, false, false);
blob.push(1);
blob.extend_from_slice(&1u32.to_le_bytes());
blob.extend_from_slice(&12.5f64.to_le_bytes());
blob.extend_from_slice(&(-3.25f64).to_le_bytes());
conn.execute(
"INSERT INTO pts (fid, name, geom) VALUES (2, 'bare', ?1)",
[blob],
)
.unwrap();
let layer = gpkg.layer("pts").unwrap();
layer
.create_spatial_index_with(BulkIndexOptions::always_bulk())
.unwrap();
assert_eq!(rtree_rows(conn), envelope_scan(conn));
let row = rtree_rows(conn)
.into_iter()
.find(|r| r.0 == 2)
.expect("envelope-less geometry should be indexed");
assert!((row.1 - 12.5).abs() < 1e-6 && (row.3 - (-3.25)).abs() < 1e-6);
}
#[test]
fn create_layer_builds_a_spatial_index_by_default() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("d.gpkg")).unwrap();
let layer = gpkg
.create_layer(
&TableSchemaBuilder::new("pts").geometry(GeometrySpec::new(GeometryType::Point, 4326)),
)
.unwrap();
assert!(layer.has_spatial_index().unwrap());
assert_eq!(
layer.spatial_index_status().unwrap(),
geopackage::SpatialIndexStatus::Current,
"the 1.4 trigger set is installed, not just the virtual table"
);
assert!(rtree_rows(gpkg.connection()).is_empty());
{
let mut writer = layer.writer().unwrap();
writer.insert(Some(1), &Point::new(1.0, 2.0), &[]).unwrap();
writer.commit().unwrap();
}
assert_eq!(rtree_rows(gpkg.connection()).len(), 1);
}
#[test]
fn the_spatial_index_can_be_declined() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("o.gpkg")).unwrap();
let layer = gpkg
.create_layer(
&TableSchemaBuilder::new("pts")
.geometry(GeometrySpec::new(GeometryType::Point, 4326))
.spatial_index(false),
)
.unwrap();
assert!(!layer.has_spatial_index().unwrap());
assert_eq!(
layer.spatial_index_status().unwrap(),
geopackage::SpatialIndexStatus::Absent
);
layer.create_spatial_index().unwrap();
assert!(layer.has_spatial_index().unwrap());
}
#[test]
fn an_attributes_table_is_unaffected_by_the_default() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("a.gpkg")).unwrap();
let layer = gpkg
.create_attributes_table(
&TableSchemaBuilder::new("notes")
.column(ColumnSpec::new("body", ColumnType::Text(None))),
)
.unwrap();
assert!(!layer.has_spatial_index().unwrap());
}
#[test]
fn a_layer_whose_index_fails_leaves_no_table() {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("f.gpkg")).unwrap();
gpkg.connection()
.execute_batch("CREATE TABLE rtree_pts_geom (squatter INTEGER)")
.unwrap();
let result = gpkg.create_layer(
&TableSchemaBuilder::new("pts").geometry(GeometrySpec::new(GeometryType::Point, 4326)),
);
assert!(result.is_err(), "the index build should have failed");
let conn = gpkg.connection();
let tables: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'pts'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(tables, 0, "the user table survived a failed create_layer");
let contents: i64 = conn
.query_row(
"SELECT count(*) FROM gpkg_contents WHERE table_name = 'pts'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(contents, 0, "a gpkg_contents row survived");
let geometry_columns: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master \
WHERE type = 'table' AND name = 'gpkg_geometry_columns'",
[],
|r| r.get(0),
)
.unwrap();
if geometry_columns > 0 {
let rows: i64 = conn
.query_row(
"SELECT count(*) FROM gpkg_geometry_columns WHERE table_name = 'pts'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(rows, 0, "a gpkg_geometry_columns row survived");
}
}
#[test]
fn audit_finds_contents_that_structure_cannot() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0), (2, 2.0, 2.0), (3, 3.0, 3.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
let audit = layer.audit_spatial_index().unwrap();
assert!(audit.is_consistent(), "{audit:?}");
assert_eq!(audit.indexable, 3);
assert_eq!(audit.entries, 3);
let conn = gpkg.connection();
conn.execute("DELETE FROM rtree_pts_geom WHERE id = 1", [])
.unwrap();
conn.execute(
"UPDATE rtree_pts_geom SET minx = 900, maxx = 901, miny = 900, maxy = 901 WHERE id = 2",
[],
)
.unwrap();
conn.execute("INSERT INTO rtree_pts_geom VALUES (99, 0, 1, 0, 1)", [])
.unwrap();
assert_eq!(
layer.spatial_index_status().unwrap(),
SpatialIndexStatus::Current,
"the structure should still look perfect"
);
let audit = layer.audit_spatial_index().unwrap();
assert!(!audit.is_consistent());
assert_eq!(audit.missing, 1);
assert_eq!(audit.not_covering, 1);
assert_eq!(audit.extra, 1);
layer.repair_spatial_index().unwrap();
assert!(
!layer.audit_spatial_index().unwrap().is_consistent(),
"repair should not have touched the contents"
);
layer.rebuild_spatial_index().unwrap();
let audit = layer.audit_spatial_index().unwrap();
assert!(audit.is_consistent(), "{audit:?}");
assert_eq!(audit.entries, 3);
assert_eq!(
rtree_rows(gpkg.connection()),
envelope_scan(gpkg.connection())
);
}
#[test]
fn audit_ignores_rows_the_triggers_would_ignore() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
let mut w = layer.writer().unwrap();
w.insert_row(Some(2), &[ValueRef::Null]).unwrap();
w.commit().unwrap();
layer.create_spatial_index().unwrap();
let audit = layer.audit_spatial_index().unwrap();
assert!(audit.is_consistent(), "{audit:?}");
assert_eq!(audit.indexable, 1, "the NULL geometry is not indexable");
assert_eq!(audit.entries, 1);
gpkg.connection()
.execute("INSERT INTO rtree_pts_geom VALUES (2, 0, 1, 0, 1)", [])
.unwrap();
let audit = layer.audit_spatial_index().unwrap();
assert_eq!(audit.extra, 1);
assert!(!audit.is_consistent());
}
#[test]
fn audit_and_rebuild_need_an_index() {
let (_dir, gpkg) = gpkg_with_points(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
assert!(matches!(
layer.audit_spatial_index(),
Err(Error::NoSpatialIndex { .. })
));
assert!(matches!(
layer.rebuild_spatial_index(),
Err(Error::NoSpatialIndex { .. })
));
}