use geopackage_core::ddl;
use geopackage_core::ident::quote;
use geopackage_core::triggers::{self, TriggerGeneration};
use rusqlite::Connection;
use crate::bulk::{self, BuildPath, BulkIndexOptions, TestFault};
use crate::{Error, GeometryColumn, Layer, Result, table_exists};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SpatialIndexStatus {
Absent,
Current,
Legacy,
Stale,
}
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> {
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 = conn.unchecked_transaction()?;
create_index_in_transaction(&tx, 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<()> {
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 = conn.unchecked_transaction()?;
drop_all_rtree_triggers(&tx, self.table_name(), &geom.column_name)?;
tx.execute_batch(&format!("DROP TABLE IF EXISTS {}", quote(&rtree)?))?;
if table_exists(&tx, "gpkg_extensions")? {
tx.execute(
"DELETE FROM gpkg_extensions \
WHERE table_name = ?1 AND column_name = ?2 AND extension_name = ?3",
rusqlite::params![
self.table_name(),
geom.column_name,
triggers::EXTENSION_NAME
],
)?;
}
tx.commit()?;
Ok(())
}
pub fn repair_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);
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(),
});
}
let tx = conn.unchecked_transaction()?;
drop_all_rtree_triggers(&tx, self.table_name(), &geom.column_name)?;
for sql in triggers::create_triggers_sql(self.table_name(), &geom.column_name, pk)? {
tx.execute_batch(&sql)?;
}
if table_exists(&tx, &rtree)? {
tx.execute_batch(&format!("DELETE FROM {}", quote(&rtree)?))?;
} else {
tx.execute_batch(&triggers::create_rtree_table_sql(
self.table_name(),
&geom.column_name,
)?)?;
}
tx.execute_batch(&triggers::populate_rtree_sql(
self.table_name(),
&geom.column_name,
pk,
)?)?;
tx.commit()?;
Ok(())
}
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<()> {
if !table_exists(conn, "gpkg_extensions")? {
conn.execute_batch(ddl::CREATE_GPKG_EXTENSIONS)?;
}
conn.execute(
"INSERT INTO gpkg_extensions \
(table_name, column_name, extension_name, definition, scope) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
table,
column,
triggers::EXTENSION_NAME,
triggers::EXTENSION_DEFINITION,
triggers::EXTENSION_SCOPE,
],
)?;
Ok(())
}
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(())
}
#[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(), corrupt_written_index)
.unwrap();
assert_eq!(path, BuildPath::TriggeredFallback);
assert!(layer.has_spatial_index().unwrap());
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 { .. })
));
}
}