use geopackage_core::ident::quote;
use geopackage_core::triggers::{self, TriggerGeneration};
use rusqlite::Connection;
use crate::bulk::{self, BuildPath, BulkIndexOptions, TestFault};
use crate::transaction::WriteTransaction;
use crate::{Error, GeometryColumn, Layer, Result, table_exists};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SpatialIndexStatus {
Absent,
Current,
Legacy,
Stale,
}
impl SpatialIndexStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Absent => "absent",
Self::Current => "current",
Self::Legacy => "legacy trigger set",
Self::Stale => "stale",
}
}
}
impl std::fmt::Display for SpatialIndexStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct SpatialIndexAudit {
pub indexable: usize,
pub entries: usize,
pub missing: usize,
pub extra: usize,
pub not_covering: usize,
}
impl SpatialIndexAudit {
#[must_use]
pub fn is_consistent(&self) -> bool {
self.missing == 0 && self.extra == 0 && self.not_covering == 0
}
}
impl Layer<'_> {
pub fn spatial_index_status(&self) -> Result<SpatialIndexStatus> {
let Some(geom) = self.geometry_column() else {
return Ok(SpatialIndexStatus::Absent);
};
let conn = self.gpkg().connection();
let rtree = triggers::rtree_table_name(self.table_name(), &geom.column_name);
let rtree_exists = table_exists(conn, &rtree)?;
let generation = self.classify_rtree_triggers(&geom.column_name)?;
Ok(match (rtree_exists, generation) {
(false, TriggerGeneration::None) => SpatialIndexStatus::Absent,
(true, TriggerGeneration::V1_4) => SpatialIndexStatus::Current,
(true, TriggerGeneration::PreV1_4 | TriggerGeneration::Mixed) => {
SpatialIndexStatus::Legacy
}
(true, TriggerGeneration::None) | (false, _) => SpatialIndexStatus::Stale,
})
}
}
impl Layer<'_> {
pub fn create_spatial_index(&self) -> Result<()> {
self.create_spatial_index_with(BulkIndexOptions::default())
}
pub fn create_spatial_index_with(&self, options: BulkIndexOptions) -> Result<()> {
self.create_spatial_index_impl(options, bulk::no_fault)
.map(|_| ())
}
fn create_spatial_index_impl(
&self,
options: BulkIndexOptions,
fault: TestFault,
) -> Result<BuildPath> {
self.gpkg().check_writable(self.table_name())?;
let geom = self.require_geometry_column()?;
let pk = self.require_primary_key()?;
let conn = self.gpkg().connection();
let table = self.table_name();
let column = &geom.column_name;
let rtree = triggers::rtree_table_name(table, column);
if table_exists(conn, &rtree)? {
return Err(Error::SpatialIndexExists {
table_name: table.to_owned(),
column_name: column.clone(),
});
}
if bulk::table_row_count(conn, table)? < options.bulk_threshold {
let tx = WriteTransaction::begin(conn)?;
create_index_in_transaction(conn, table, column, pk)?;
tx.commit()?;
return Ok(BuildPath::Triggered);
}
bulk::fill_index(
conn,
table,
column,
pk,
&rtree,
options,
None,
fault,
|conn| {
for sql in triggers::create_triggers_sql(table, column, pk)? {
conn.execute_batch(&sql)?;
}
register_extension_row(conn, table, column)?;
Ok(())
},
)
}
pub fn drop_spatial_index(&self) -> Result<()> {
self.gpkg().check_writable(self.table_name())?;
let geom = self.require_geometry_column()?;
let conn = self.gpkg().connection();
let rtree = triggers::rtree_table_name(self.table_name(), &geom.column_name);
let tx = WriteTransaction::begin(conn)?;
drop_all_rtree_triggers(conn, self.table_name(), &geom.column_name)?;
conn.execute_batch(&format!("DROP TABLE IF EXISTS {}", quote(&rtree)?))?;
crate::extensions::unregister(
conn,
self.table_name(),
&geom.column_name,
triggers::EXTENSION_NAME,
)?;
tx.commit()?;
Ok(())
}
pub fn repair_spatial_index(&self) -> Result<()> {
self.gpkg().check_writable(self.table_name())?;
let geom = self.require_geometry_column()?;
let pk = self.require_primary_key()?;
let conn = self.gpkg().connection();
let rtree = triggers::rtree_table_name(self.table_name(), &geom.column_name);
let generation = self.classify_rtree_triggers(&geom.column_name)?;
let rtree_exists = table_exists(conn, &rtree)?;
if generation == TriggerGeneration::V1_4 && rtree_exists {
return Ok(());
}
if generation == TriggerGeneration::None && !rtree_exists {
return Err(Error::NoSpatialIndex {
table_name: self.table_name().to_owned(),
column_name: geom.column_name.clone(),
});
}
self.rebuild_index_impl(geom, pk)
}
pub fn rebuild_spatial_index(&self) -> Result<()> {
let geom = self.require_geometry_column()?;
let pk = self.require_primary_key()?;
let conn = self.gpkg().connection();
let rtree = triggers::rtree_table_name(self.table_name(), &geom.column_name);
if !table_exists(conn, &rtree)?
&& self.classify_rtree_triggers(&geom.column_name)? == TriggerGeneration::None
{
return Err(Error::NoSpatialIndex {
table_name: self.table_name().to_owned(),
column_name: geom.column_name.clone(),
});
}
self.rebuild_index_impl(geom, pk)
}
fn rebuild_index_impl(&self, geom: &GeometryColumn, pk: &str) -> Result<()> {
let conn = self.gpkg().connection();
let rtree = triggers::rtree_table_name(self.table_name(), &geom.column_name);
let tx = WriteTransaction::begin(conn)?;
drop_all_rtree_triggers(conn, self.table_name(), &geom.column_name)?;
for sql in triggers::create_triggers_sql(self.table_name(), &geom.column_name, pk)? {
conn.execute_batch(&sql)?;
}
if table_exists(conn, &rtree)? {
conn.execute_batch(&format!("DELETE FROM {}", quote(&rtree)?))?;
} else {
conn.execute_batch(&triggers::create_rtree_table_sql(
self.table_name(),
&geom.column_name,
)?)?;
}
conn.execute_batch(&triggers::populate_rtree_sql(
self.table_name(),
&geom.column_name,
pk,
)?)?;
tx.commit()?;
Ok(())
}
pub fn audit_spatial_index(&self) -> Result<SpatialIndexAudit> {
let geom = self.require_geometry_column()?;
let pk = self.require_primary_key()?;
let conn = self.gpkg().connection();
let rtree = quote(&triggers::rtree_table_name(
self.table_name(),
&geom.column_name,
))?;
if !table_exists(
conn,
&triggers::rtree_table_name(self.table_name(), &geom.column_name),
)? {
return Err(Error::NoSpatialIndex {
table_name: self.table_name().to_owned(),
column_name: geom.column_name.clone(),
});
}
let table = quote(self.table_name())?;
let column = quote(&geom.column_name)?;
let key = quote(pk)?;
let (indexable, missing, not_covering): (i64, i64, i64) = conn.query_row(
&format!(
"SELECT count(*), sum(CASE WHEN r.id IS NULL THEN 1 ELSE 0 END), sum(CASE WHEN r.id IS NOT NULL AND ( r.minx > ST_MinX(f.{column}) OR r.maxx < ST_MaxX(f.{column}) OR r.miny > ST_MinY(f.{column}) OR r.maxy < ST_MaxY(f.{column}) ) THEN 1 ELSE 0 END) FROM {table} f LEFT JOIN {rtree} r ON r.id = f.{key} WHERE f.{column} IS NOT NULL AND NOT ST_IsEmpty(f.{column})"
),
[],
|row| {
Ok((
row.get(0)?,
row.get::<_, Option<i64>>(1)?.unwrap_or(0),
row.get::<_, Option<i64>>(2)?.unwrap_or(0),
))
},
)?;
let (entries, extra): (i64, i64) = conn.query_row(
&format!(
"SELECT count(*), sum(CASE WHEN NOT EXISTS ( SELECT 1 FROM {table} f WHERE f.{key} = r.id AND f.{column} IS NOT NULL AND NOT ST_IsEmpty(f.{column}) ) THEN 1 ELSE 0 END) FROM {rtree} r"
),
[],
|row| Ok((row.get(0)?, row.get::<_, Option<i64>>(1)?.unwrap_or(0))),
)?;
let count = |n: i64| usize::try_from(n).unwrap_or(0);
Ok(SpatialIndexAudit {
indexable: count(indexable),
entries: count(entries),
missing: count(missing),
extra: count(extra),
not_covering: count(not_covering),
})
}
fn require_geometry_column(&self) -> Result<&GeometryColumn> {
self.geometry_column()
.ok_or_else(|| Error::NoGeometryColumn {
table_name: self.table_name().to_owned(),
})
}
fn require_primary_key(&self) -> Result<&str> {
self.primary_key_column()
.ok_or_else(|| Error::NoPrimaryKey {
table_name: self.table_name().to_owned(),
})
}
}
pub(crate) fn create_index_in_transaction(
conn: &Connection,
table: &str,
column: &str,
pk: &str,
) -> Result<()> {
conn.execute_batch(&triggers::create_rtree_table_sql(table, column)?)?;
for sql in triggers::create_triggers_sql(table, column, pk)? {
conn.execute_batch(&sql)?;
}
conn.execute_batch(&triggers::populate_rtree_sql(table, column, pk)?)?;
register_extension_row(conn, table, column)
}
fn register_extension_row(conn: &Connection, table: &str, column: &str) -> Result<()> {
crate::extensions::register(
conn,
Some(table),
Some(column),
triggers::EXTENSION_NAME,
triggers::EXTENSION_DEFINITION,
triggers::EXTENSION_SCOPE,
)
}
pub(crate) fn drop_all_rtree_triggers(conn: &Connection, table: &str, column: &str) -> Result<()> {
let prefix = format!("{}_", triggers::rtree_table_name(table, column));
let names: Vec<String> = {
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?1")?;
stmt.query_map([table], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?
};
for name in names.iter().filter(|n| n.starts_with(&prefix)) {
conn.execute_batch(&format!("DROP TRIGGER IF EXISTS {}", quote(name)?))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{GeoPackage, GeometrySpec, TableSchemaBuilder};
use geo_types::Point;
use geopackage_core::types::GeometryType;
fn populated(points: &[(i64, f64, f64)]) -> (tempfile::TempDir, GeoPackage) {
let dir = tempfile::tempdir().unwrap();
let gpkg = GeoPackage::create(dir.path().join("t.gpkg")).unwrap();
let builder = TableSchemaBuilder::new("pts")
.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), &[]).unwrap();
}
writer.commit().unwrap();
(dir, gpkg)
}
fn rtree_matches_scan(gpkg: &GeoPackage) -> bool {
let conn = gpkg.connection();
let read = |sql: &str| -> Vec<(i64, f64, f64, f64, f64)> {
let mut stmt = conn.prepare(sql).unwrap();
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
};
read("SELECT id, minx, maxx, miny, maxy FROM rtree_pts_geom ORDER BY id")
== read(
"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",
)
}
fn corrupt_written_index(conn: &Connection, rtree: &str) -> Result<()> {
conn.execute_batch(&format!(
"DELETE FROM {} WHERE id = (SELECT min(id) FROM {})",
quote(rtree)?,
quote(rtree)?
))?;
Ok(())
}
fn shrink_one_stored_bound(conn: &Connection, rtree: &str) -> Result<()> {
conn.execute_batch(&format!(
"UPDATE {} SET minx = 1e6, maxx = 1e6 WHERE id = (SELECT max(id) FROM {})",
quote(rtree)?,
quote(rtree)?
))?;
Ok(())
}
#[test]
fn bulk_path_builds_a_correct_index() {
let (_dir, gpkg) = populated(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
let path = layer
.create_spatial_index_impl(BulkIndexOptions::always_bulk(), bulk::no_fault)
.unwrap();
assert_eq!(path, BuildPath::Bulk);
assert!(layer.has_spatial_index().unwrap());
assert!(rtree_matches_scan(&gpkg));
}
#[test]
fn below_threshold_uses_the_triggered_path() {
let (_dir, gpkg) = populated(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
let path = layer
.create_spatial_index_impl(BulkIndexOptions::never_bulk(), bulk::no_fault)
.unwrap();
assert_eq!(path, BuildPath::Triggered);
assert!(rtree_matches_scan(&gpkg));
}
#[test]
fn corrupt_scratch_falls_back_to_the_triggered_path() {
let (_dir, gpkg) = populated(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
let path = layer
.create_spatial_index_impl(
BulkIndexOptions::always_bulk()
.with_verification(crate::BulkVerification::Structure),
corrupt_written_index,
)
.unwrap();
assert_eq!(path, BuildPath::TriggeredFallback);
assert!(layer.has_spatial_index().unwrap());
assert!(rtree_matches_scan(&gpkg));
}
#[test]
fn a_corrupt_index_is_kept_when_verification_is_off() {
let (_dir, gpkg) = populated(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
let path = layer
.create_spatial_index_impl(BulkIndexOptions::always_bulk(), corrupt_written_index)
.unwrap();
assert_eq!(path, BuildPath::Bulk, "no gate, so no fallback");
assert!(!layer.audit_spatial_index().unwrap().is_consistent());
}
#[test]
fn stored_bounds_that_exclude_their_geometry_fall_back() {
let (_dir, gpkg) = populated(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
let path = layer
.create_spatial_index_impl(
BulkIndexOptions::always_bulk()
.with_verification(crate::BulkVerification::Contents),
shrink_one_stored_bound,
)
.unwrap();
assert_eq!(path, BuildPath::TriggeredFallback);
assert!(rtree_matches_scan(&gpkg));
}
#[test]
fn status_classifies_absent_and_current() {
let (_dir, gpkg) = populated(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
assert_eq!(
layer.spatial_index_status().unwrap(),
SpatialIndexStatus::Absent
);
layer.create_spatial_index().unwrap();
assert_eq!(
layer.spatial_index_status().unwrap(),
SpatialIndexStatus::Current
);
}
#[test]
fn stale_index_is_detected_and_repaired() {
let (_dir, gpkg) = populated(&[(1, 10.0, 20.0), (2, -5.0, 7.0), (3, 100.0, 100.0)]);
let layer = gpkg.layer("pts").unwrap();
layer.create_spatial_index().unwrap();
assert!(rtree_matches_scan(&gpkg));
drop_all_rtree_triggers(gpkg.connection(), "pts", "geom").unwrap();
assert_eq!(
layer.spatial_index_status().unwrap(),
SpatialIndexStatus::Stale
);
assert!(!layer.has_spatial_index().unwrap());
layer.repair_spatial_index().unwrap();
assert_eq!(
layer.spatial_index_status().unwrap(),
SpatialIndexStatus::Current
);
assert!(layer.has_spatial_index().unwrap());
assert!(rtree_matches_scan(&gpkg));
}
#[test]
fn repair_absent_index_errors() {
let (_dir, gpkg) = populated(&[(1, 1.0, 1.0)]);
let layer = gpkg.layer("pts").unwrap();
assert!(matches!(
layer.repair_spatial_index(),
Err(Error::NoSpatialIndex { .. })
));
}
}