GeoSPARQL
What it replaces, and where it stops. This is the surface that lets an RDF
project drop the PostGIS it kept beside its triple store for spatial
predicates: ?a geo:sfWithin ?b and its Simple Features, Egenhofer and RCC8
siblings, plus the geof: functions, answered in-process over the dataset the
query already holds, exactly, with no GEOS or PROJ, and byte-identical natively
and on wasm32. It is GeoSPARQL 1.1’s topological predicates, accessors, and
exactly computable measures and constructors over vector geometry, not a
PostGIS: geof:transform hard-errors by name (there is no CRS database), a
metric* measure answers only in a CRS the caller declared in metres (there is
no ellipsoidal geodesic), and the buffers, the concave hull (geof:convexHull
is implemented), the overlay set operations and the GML/KML/DGGS encodings are
registered and hard-error by name. No raster.
purrdf-geo (purrdf::geo from the umbrella crate) implements GeoSPARQL 1.1
(OGC 22-047r1) for PurRDF: exact, float-free geometry reached from SPARQL
through the evaluator’s two existing extension seams. It parses
geo:wktLiteral and geo:geoJSONLiteral lexical forms into an exact geometry
model, decides the OGC topological relations over that model, computes the
non-topological accessors, measures and constructors, and hands the geof:
family to a host as scalar-function registrations and the spatial relations as
property-function registrations. There is no GEOS and no PROJ behind it — the
DE-9IM engine, WKT and GeoJSON are implemented in-crate, in pure Rust, which is
what lets it build for wasm32-unknown-unknown.
It mints no vocabulary
GeoSPARQL’s IRIs are OGC’s, not PurRDF’s. Every IRI the crate reads or writes —
the two literal datatypes, the geof: function names, the geo: spatial
relations, the Simple Features geometry classes, and the coordinate reference
system a WKT literal omits — is supplied by the caller through GeoVocab,
which has no Default and never will. A term that is absent makes the feature
that needs it a hard error, never a fabricated fallback.
use purrdf::geo::{Crs, GeoVocabBuilder};
let crs = Crs::new("http://www.opengis.net/def/crs/OGC/1.3/CRS84")?;
let vocab = GeoVocabBuilder::new(
"http://www.opengis.net/ont/geosparql#", // geo:
"http://www.opengis.net/def/function/geosparql/", // geof:
crs.clone(), // the CRS a bare WKT literal means
crs.clone(), // the CRS GeoJSON is in
)?
.declare_crs_unit(&crs, "http://www.opengis.net/def/uom/OGC/1.0/metre")?
.declare_metre("http://www.opengis.net/def/uom/OGC/1.0/metre")?
.declare_simple_features_namespace("http://www.opengis.net/ont/sf#")?
.build();
The geof: family on the scalar seam
functions::register installs every geof: function into a
UserFunctionRegistry under the vocabulary’s function namespace, and the
registry is handed to the engine through QueryOptions::functions:
use purrdf::geo::functions;
use purrdf::sparql::{NativeSparqlEngine, QueryOptions, UserFunctionRegistry};
use purrdf::SparqlRequest;
let mut functions_registry = UserFunctionRegistry::new();
functions::register(&mut functions_registry, &vocab);
let result = NativeSparqlEngine::new().query_with_options_view(
&dataset,
SparqlRequest {
query: r#"PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
PREFIX geo: <http://www.opengis.net/ont/geosparql#>
SELECT ?a ?b WHERE {
?fa geo:hasGeometry/geo:asWKT ?a .
?fb geo:hasGeometry/geo:asWKT ?b .
FILTER(geof:sfWithin(?a, ?b))
}"#,
base_iri: None,
substitutions: &[],
},
QueryOptions { functions: &functions_registry, ..QueryOptions::EMPTY },
)?;
A function’s refusals travel exactly as far as SPARQL says they should. A
malformed literal or a domain refusal — mixed CRSs, the measure of an empty
geometry, an out-of-range index — is a per-solution expression error: the
row is eliminated under FILTER, and the variable is left unbound under
BIND/SELECT, while the query continues. An unimplemented function, an
undeclared vocabulary term or a wrong argument count holds for every solution
alike and stays query-fatal, because answering “no value” there would empty a
result set and present that as the answer. A caller that needs the refusal
itself, with its message and kind intact, calls functions::compute.
Spatial relations on the property-function seam
GeoSPARQL’s Query Rewrite rules let ?a geo:sfWithin ?b hold between
features whose geometries satisfy the relation, not only where a triple
asserts it. GeoIndex::from_dataset projects a dataset’s geometry literals
once, and relation::register installs one property function per relation of
the families the caller names — Simple Features, Egenhofer, RCC8 — against a
PropertyFunctionRegistry. An empty family list is refused: registering
nothing and returning success would surface much later as a query whose
geo:sfWithin was parsed as an ordinary triple pattern and matched nothing.
use std::sync::Arc;
use purrdf::geo::relation::{self, GeoIndex, GeoIndexConfig, GraphSelector};
use purrdf::geo::{GeoTerm, RelationFamily};
use purrdf::sparql::{ParserOptions, PropertyFunctionRegistry};
use purrdf::TermValue;
let config = GeoIndexConfig::new(
vec![TermValue::iri(vocab.term(GeoTerm::AsWkt))],
GraphSelector::Any,
)?;
let index = Arc::new(GeoIndex::from_dataset(&dataset, &vocab, &config)?);
let mut relations = PropertyFunctionRegistry::new();
relation::register(&mut relations, &vocab, &index, &[RelationFamily::SimpleFeatures])?;
// The parser must claim `geo:sfWithin` in predicate position; the registry's
// own descriptors are exactly the IRIs it should claim.
let parser_options = ParserOptions {
extension_fn_namespaces: Vec::new(),
property_fn_namespaces: Vec::new(),
property_fn_iris: relations.describe()?.into_iter().map(|d| d.iri).collect(),
};
An asserted geo:sfWithin triple matches whether or not the geometries
satisfy it — the rewrite rules are entailments, not definitions — and a
relation the index refutes contributes no row beyond what the data asserts.
Every answer is exact, and identical on every target
Geometry is where floating point normally destroys reproducibility: f64
addition is not associative, so a different traversal order gives a different
answer, and a native build and a wasm32 build can disagree about a predicate
that sits near a boundary. This crate closes that channel rather than
mitigating it.
- Coordinates are read as exact rationals. A lexical decimal is parsed digit by digit into an exact numerator and denominator; nothing is rounded on the way in.
- Every geometric decision is integer arithmetic. Orientation, segment intersection, point-in-ring, ring winding and the DE-9IM matrix are comparisons of exact rationals over arbitrary-precision integers, which Rust specifies completely and identically on every target.
- Irrational measures are integer square roots at a fixed internal scale, summed as integers — one rounding, at the end, of a value that was exact until then.
- The single float boundary is the result literal. An
xsd:doubleresult is the correctly rounded nearest double, computed with integer arithmetic and assembled withf64::from_bits. The crate root deniesclippy::float_arithmetic, so there is no second float path to find.
The cross-target claim is executed, not argued: make geo-determinism runs
the same corpus natively and on wasm32 and compares bytes.
What is here, and what is not
Implemented: the WKT and GeoJSON codecs (with CRS and coordinate-dimension support); every topological relation of the Simple Features, Egenhofer and RCC8 families over an exact DE-9IM; the accessors; and the measures and constructors that are exactly computable.
Registered but hard-erroring by name: the operations that would require
facilities the crate deliberately does not have — a coordinate-reference-system
database for geof:transform, and an ellipsoidal geodesic for the metric*
family. They are never silently absent and never answer a default. A
topological predicate that returned false because it was unimplemented would
be indistinguishable from one that returned false because the geometries
genuinely do not relate, and that is the failure this crate exists to keep out.
Like the full-text index, this is a Rust-host seam: the registrations are host
closures and do not cross the Python, WebAssembly or C boundary. The full
exactness accounting is
docs/design/purrdf-geo-exactness.md.