Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Spatial / Geo Queries

Spatial queries let you find documents by geographic relationship — proximity (within a radius, inside a bounding box, nearest to a point) or full-geometry topology (intersects, contains, overlaps, touches, …).

Two feature levels share one cfg.MapSpatialProperty registration:

  • Point spatial — map a GeoPoint and use WithinRadius / WithinBoundingBox / NearestNeighbors.
  • Full geometry (v11+) — map any Geometry (lines, polygons with holes, multi-geometries) and use the Geo-prefixed topological predicate family plus GeoWithinDistance.

Supported providers: SQLite (R*Tree), PostgreSQL / MySQL / SQL Server / Oracle / DuckDB (dependency-free envelope sidecar), CosmosDB (native GeoJSON), and MongoDB (2dsphere). The key-partitioned / file stores (LiteDB, IndexedDB, Azure Table, DynamoDB) report SupportsSpatial => false and throw NotSupportedException.

Spatial capability is a runtime property — probe it before issuing a query if your provider is configurable:

if (store.SupportsSpatial)
{
var nearby = await store.WithinRadius<Restaurant>(center, 5000);
}
Provider SupportsSpatial Mechanism
SQLite / SQLCipher true R*Tree virtual table (bbox) + in-process refine
PostgreSQL, MySQL, SQL Server, Oracle, DuckDB true envelope-sidecar table (indexed bbox) + in-process refine
CosmosDB true native GeoJSON spatial index
MongoDB true 2dsphere index
LiteDB, IndexedDB, Azure Table, DynamoDB false

A WGS84 coordinate. Serializes as GeoJSON {"type":"Point","coordinates":[longitude,latitude]}.

public readonly record struct GeoPoint(double Latitude, double Longitude);

A GeoPoint implicitly converts to a Geometry, so you can pass a bare point to any geometry query method.

A rectangular area, used by WithinBoundingBox and returned by Geometry.GetEnvelope().

public readonly record struct GeoBoundingBox(
double MinLatitude, double MinLongitude,
double MaxLatitude, double MaxLongitude);

Wraps a matched document with a computed distance. For WithinRadius / NearestNeighbors the distance is from the query point; for the geometry predicates it is the distance from orderByDistanceFrom (or 0 when no distance reference was supplied).

public class SpatialResult<T> where T : class
{
public required T Document { get; init; }
public double DistanceMeters { get; init; }
}

Register which property drives spatial indexing per document type. The same method accepts a GeoPoint(?) or a Geometry(?) property:

public class Restaurant
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public GeoPoint Location { get; set; }
public string Cuisine { get; set; } = "";
}
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
);
options.ConfigureDocument<Restaurant>(cfg => cfg.MapSpatialProperty(r => r.Location)); // GeoPoint
options.ConfigureDocument<Zone>(cfg => cfg.MapSpatialProperty(z => z.Area)); // Geometry?

One spatial property is mapped per type. The index sidecar (SQLite/relational) or native spatial index (Cosmos/Mongo) is created automatically the first time the table/collection is initialized, and CRUD keeps it in sync — no manual maintenance.

Every spatial query — store.Geo*, DocumentFunctions in a Where/OrderBy, and the string grammar — must name the mapped property. A second geometry property on the same type is stored and round-trips fine, but it is not spatially queryable, and asking for it throws rather than returning a wrong answer:

options.ConfigureDocument<Delivery>(cfg => cfg.MapSpatialProperty(d => d.Route));
store.Query<Delivery>().Where(d => DocumentFunctions.Intersects(d.Destination!, zone));
// NotSupportedException: 'destination' is not a mapped spatial property … Mapped: 'route'.

This is not merely a bookkeeping rule. Most relational providers answer these predicates from the sidecar’s geometry column, which holds only the mapped property — so an unmapped path could not be answered honestly.

If a document genuinely has several shapes, decide which case you are in:

  • One semantic slot, several pieces (a service area made of three disjoint polygons) → put them in one property as a GeoMultiPolygon or GeoGeometryCollection. The index envelope is their union, which is exactly right here.
  • Distinct slots (a delivery’s origin and destination) → the union envelope would span both and destroy index selectivity, so pick the one you actually search by and map that. Model the other as plain data.

The mapped property may be nullable (GeoPoint? or Geometry?). A document whose spatial value is null is simply left out of the spatial index — it still stores and non-spatial-queries normally:

public class CalendarEvent
{
public string Id { get; set; } = "";
public string Title { get; set; } = "";
public GeoPoint? Location { get; set; } // optional
}
options.ConfigureDocument<CalendarEvent>(cfg => cfg.MapSpatialProperty(e => e.Location));

Inserting/updating with a null location does not throw and does not index the document; it never appears in spatial results. Setting a previously-populated location back to null on update purges the stale index entry.

Documents within a distance (meters) of a center point, ordered by distance ascending.

var nearby = await store.WithinRadius<Restaurant>(
new GeoPoint(45.5231, -122.6765), // Portland, OR
5000); // 5 km
foreach (var r in nearby)
Console.WriteLine($"{r.Document.Name}{r.DistanceMeters:N0}m away");

An optional filter composes with the spatial predicate and is pushed to the database:

var italian = await store.WithinRadius<Restaurant>(
new GeoPoint(45.5231, -122.6765), 5000,
filter: r => r.Cuisine == "Italian");

Documents inside a rectangular area (no ordering; returns T, not SpatialResult<T>).

var inArea = await store.WithinBoundingBox<Restaurant>(
new GeoBoundingBox(45.0, -123.0, 46.0, -122.0),
filter: r => r.Cuisine == "Italian"); // filter optional

The K closest documents to a point, ordered by distance. Uses an expanding-envelope search so the true nearest are found even when they sit outside a fixed radius. Works over point and geometry mappings (point-to-geometry distance).

var closest = await store.NearestNeighbors<Restaurant>(
new GeoPoint(45.5231, -122.6765), count: 10,
filter: r => r.Cuisine == "Italian"); // filter optional

Geometry is the base type; all types serialize as GeoJSON in the document body.

Type Shape / rules
GeoPoint a single coordinate (implicitly a point geometry)
GeoLineString 2+ coordinates
GeoPolygon one exterior ring + optional interior rings (holes); each ring closed (first == last), 4+ points
GeoMultiPoint set of points
GeoMultiLineString set of line strings
GeoMultiPolygon set of polygons
GeoGeometryCollection heterogeneous set of geometries

Construction (remember GeoPoint is (Latitude, Longitude)):

// A route
var route = new GeoLineString(new[]
{
new GeoPoint(45.50, -122.68),
new GeoPoint(45.52, -122.66),
new GeoPoint(45.55, -122.64),
});
// A delivery zone — the ring must be closed and have >= 4 points
var zone = new GeoPolygon(new[]
{
new GeoPoint(45.50, -122.70),
new GeoPoint(45.50, -122.60),
new GeoPoint(45.60, -122.60),
new GeoPoint(45.60, -122.70),
new GeoPoint(45.50, -122.70), // closes the ring
});
// A polygon with a hole (exterior ring + one or more interior rings)
var withHole = new GeoPolygon(exteriorRing, new[] { holeRing });
// A region made of several polygons
var region = new GeoMultiPolygon(new[] { zoneA, zoneB });

Geometry also exposes GetEnvelope() (its GeoBoundingBox) and the measurement/validity members described below.

Every predicate reads stored-geometry <predicate> query-geometry — the argument is the query shape, the result is the stored documents that stand in that relationship to it. All share one signature:

Task<IReadOnlyList<SpatialResult<T>>> GeoXxx<T>(
Geometry geometry,
Geometry? orderByDistanceFrom = null, // point OR geometry; orders + fills DistanceMeters
Expression<Func<T, bool>>? filter = null,
CancellationToken ct = default) where T : class;
Method Matches stored documents whose geometry…
GeoIntersects(g) shares any point with g (the general “do they touch at all”)
GeoDisjoint(g) shares no point with g
GeoContains(g) contains g (g lies inside the stored geometry)
GeoContainedBy(g) is within g (the stored geometry lies inside g)
GeoCovers(g) covers g (boundary-inclusive contains)
GeoCoveredBy(g) is covered by g (boundary-inclusive within)
GeoEquals(g) is spatially equal to g
GeoTouches(g) meets g only at a boundary (interiors do not intersect)
GeoCrosses(g) crosses g (interiors intersect in a lower dimension — e.g. a line cutting through a polygon)
GeoOverlaps(g) partially overlaps g (same dimension, neither contains the other)
GeoWithinDistance(g, meters) lies within meters of g
// "Which delivery zones contain this GPS point?" — a bare point converts implicitly.
var zones = await store.GeoIntersects<Zone>(new GeoPoint(45.52, -122.68));
// Zones fully inside a search polygon, nearest-first from a reference point.
var inside = await store.GeoContainedBy<Zone>(
searchArea, orderByDistanceFrom: new GeoPoint(45.5, -122.6));
// Zones a road passes through (line crosses polygon).
var crossed = await store.GeoCrosses<Zone>(roadLine);
// Everything within 500 m of a route, filtered.
var near = await store.GeoWithinDistance<Zone>(
route, meters: 500, filter: z => z.Active);

The dedicated store.Geo* methods above run standalone. To compose a spatial predicate with the rest of a LINQ query — other Where clauses, OrderBy, Count, paging — use DocumentFunctions, which lowers each predicate to the database’s native spatial function (or a registered UDF on SQLite) so the whole query runs server-side:

var page = await store.Query<Zone>()
.Where(z => DocumentFunctions.Intersects(z.Area!, searchArea) && z.Active)
.OrderBy(z => DocumentFunctions.Distance(z.Area!, origin))
.Skip(20).Take(20)
.ToList();

Available: Intersects, Disjoint, Contains, Within, Covers, CoveredBy, Touches, Crosses, Overlaps, GeoEquals, WithinDistance(field, query, meters) (all in Where), and Distance(field, query) (for OrderBy). Read them as field <predicate> query; argument order is handled for the asymmetric ones (Contains(big, z.Area) is the same as z.Area within big). Distance-in-OrderBy translates to native SQL on SQLite, PostgreSQL, MySQL, DuckDB and SQL Server (SQL Server sorts by planar STDistance over the indexed geometry column); on MongoDB use store.NearestNeighbors / orderByDistanceFrom instead.

Provider support for DocumentFunctions-in-Where:

Provider Mechanism & index Predicate coverage
SQLite R*Tree bbox prune + registered docdb_st_* UDF refine (dependency-free) all
PostgreSQL native geometry sidecar column + GiST spatial index (needs PostGIS) all
MySQL native GEOMETRY column + SPATIAL index all (CoversContains)
DuckDB native GEOMETRY column + R-Tree index (auto-loads spatial) all except WithinDistance
SQL Server native planar geometry column + spatial index + .ST* methods all except Covers/CoveredBy and WithinDistance
Oracle native SDO_GEOMETRY column + MDSYS spatial index + SDO_RELATE operators (requires Oracle Spatial) all except Crosses
CosmosDB native ST_INTERSECTS/ST_WITHIN/ST_DISTANCE (native spatial index) Intersects, Within, Disjoint, WithinDistance
MongoDB native $geoIntersects/$geoWithin/$centerSphere (2dsphere index) Intersects, Within, WithinDistance (point)

On the relational providers, the DocumentFunctions predicate runs over a spatially-indexed geometry column in the sidecar, so the engine’s true 2-D spatial index does the pruning (not a scan, and not just the bounding-box B-tree). The geometry column is populated from the document body on write. The dedicated store.Geo* methods use the bounding-box envelope tier (which also works under PortableSpatial).

Where a predicate isn’t natively available on a provider, the DocumentFunctions call in a Where throws a clear message — use the dedicated store.Geo* method, which supports every predicate on every spatial-capable provider. A handful of predicates are provider approximations (MySQL/SQL Server CoversContains). WithinDistance in a Where needs a geodesic (metric) distance function: SQL Server (planar geometry sidecar) and DuckDB (no polygon geodesic distance) don’t have one, so rather than silently approximating with planar degrees — which is wrong away from the equator — they throw a clear message. Use store.GeoWithinDistance(...), which refines with an exact Haversine distance in managed code and is correct on every spatial-capable provider.

Set PortableSpatial = true on a relational provider to force the dependency-free envelope tier (dedicated store.Geo* methods only; DocumentFunctions-in-Where then throws — no native extension required). Boundary-case results (e.g. a query polygon sharing an exact edge with a stored one) can differ slightly between a provider’s native predicate and the C# engine.

// Stock postgres, no PostGIS available:
var provider = new PostgreSqlDatabaseProvider(connectionString) { PortableSpatial = true };

Wiring through Aspire? The same switch is a client setting, so you don’t have to construct the provider yourself:

builder.AddDocumentStore("orders", settings => settings.PortableSpatial = true);

The native tier is the default, and it needs the backend’s spatial extension: PostGIS on PostgreSQL (the stock postgres image doesn’t ship it — use postgis/postgis, or grant the connecting role permission to CREATE EXTENSION), and the spatial extension on DuckDB. When that provisioning fails, the store throws a DocumentConfigurationException naming the remedy, with the driver error kept as the inner exception — so a missing extension reads as a configuration problem rather than an opaque extension "postgis" is not available from deep inside the first spatial write.

The geo functions also work in the string-expression surface — Where("…"), the interpolated Where($"…"), OrderBy("…"), and Project("…") — with the same names as the LINQ DocumentFunctions family. The query geometry is supplied either as an interpolated value (a Geometry or GeoPoint, bound as a parameter — only available where the string carries arguments, i.e. Where($"…")) or as an inline GeoJSON string literal (works everywhere, including OrderBy/Project):

// Interpolated geometry (bound, never formatted into the string):
await store.Query<Zone>().Where($"intersects(area, {searchArea}) and active == true").ToList();
await store.Query<Zone>().Where($"withindistance(area, {origin}, 5000)").Count();
// GeoJSON string literal (for fully-string callers — OData-style gateways, config, OrderBy):
await store.Query<Zone>().Where("within(area, '{\"type\":\"Polygon\",\"coordinates\":[...]}')").ToList();
await store.Query<Zone>().OrderBy("distance(area, '{\"type\":\"Point\",\"coordinates\":[-122.6,45.5]}')").ToList();

contains(field, …) is the geo predicate when field is a Geometry-mapped property, and the ordinary string Contains when it’s a string field. The field argument must be a Geometry property (use the dedicated store.Geo* methods for GeoPoint fields).

Pass orderByDistanceFrom to any predicate to sort the matches by ascending distance and populate DistanceMeters. The reference can be a point (fast, native on every provider) or a geometry (geometry-to-geometry distance; native on SQLite/relational/Cosmos, in-process on Mongo):

var origin = new GeoPoint(45.5, -122.6);
var ranked = await store.GeoIntersects<Zone>(searchArea, orderByDistanceFrom: origin);
// ranked[0] is the closest matching zone; each .DistanceMeters is set.

For “the N nearest geometries to a point”, NearestNeighbors<T>(point, count) works directly over a geometry-mapped type using point-to-geometry distance.

Geometry exposes in-memory helpers computed directly on the object (geodesic approximations):

Member Meaning
Area area in m² (0 for points/lines)
Length / Perimeter total length / ring perimeter in m
Centroid the centroid GeoPoint
NumPoints / NumGeometries coordinate count / sub-geometry count
IsValid coordinate finiteness/range, min-vertex counts, ring closure, and (polygons) exterior ring non-self-intersection
IsSimple no self-intersections
MakeValid() best-effort repair: closes rings, fixes winding, drops degenerate/duplicate vertices
var region = (GeoPolygon)zone.Area!;
Console.WriteLine($"{region.Area / 1e6:N1} km², perimeter {region.Perimeter / 1000:N1} km");
if (!candidate.IsValid)
candidate = candidate.MakeValid(); // e.g. before storing user-drawn shapes

All predicates return identical results everywhere; the difference is where the work runs. On SQLite and the relational providers, every predicate is a bounding-box prune (index) + in-process relate refine. On the document stores, the coarse filter is native where an operator exists and refines in-process otherwise:

Predicate SQLite / relational CosmosDB MongoDB
GeoIntersects bbox + refine native ST_INTERSECTS native $geoIntersects
GeoContainedBy bbox + refine native ST_WITHIN native $geoWithin
GeoWithinDistance bbox + refine native ST_DISTANCE native $near/$centerSphere (point)
GeoDisjoint type scan + refine native NOT ST_INTERSECTS type scan + refine
GeoContains / Covers / CoveredBy / Touches / Crosses / Overlaps / Equals bbox + refine ST_INTERSECTS candidates + refine $geoIntersects candidates + refine
distance ordering in-process native ST_DISTANCE order point ref native, geometry ref in-process

Spatial queries are a two-pass pipeline. Pass 1 uses an index to cheaply narrow candidates by bounding box; pass 2 runs the exact geometry test (and distance) in C#. This is the same shape PostGIS uses internally — the difference between providers is only which index backs pass 1.

For each table with a spatial-mapped type, two companion tables are created:

  • {table}_spatial_map — maps document text IDs to integer rowids (R*Tree requires integer keys)
  • {table}_spatial — the R*Tree index storing lat/lng envelopes

Insert/Update/Upsert sync the envelope; Remove/Clear purge it. Pass 1 is an R*Tree bbox join; pass 2 is the Haversine/relate refine.

PostgreSQL / MySQL / SQL Server / Oracle / DuckDB — envelope sidecar

Section titled “PostgreSQL / MySQL / SQL Server / Oracle / DuckDB — envelope sidecar”

These providers use a plain, dependency-free sidecar table — no PostGIS, geography, SDO_GEOMETRY, or DuckDB spatial extension required:

{table}_spatial ( docId, typeName, minLat, maxLat, minLng, maxLng,
PRIMARY KEY (docId, typeName) )
-- + a composite B-tree index on (typeName, minLat, maxLat, minLng, maxLng)

Pass 1 is an indexed range join over the envelope columns; pass 2 is the in-process relate/refine. Each provider emits its own DDL/upsert dialect (ON CONFLICT / ON DUPLICATE KEY / MERGE), but the behavior is identical.

Geometry serializes as GeoJSON in the document body and CosmosDB indexes it via the container’s spatial policy (added automatically at init). GeoIntersects/GeoContainedBy/GeoWithinDistance/GeoDisjoint and distance ordering push down to ST_* functions; the finer predicates fetch the ST_INTERSECTS candidate set and refine in-process.

A 2dsphere index is created over the mapped GeoJSON path at first query. $geoIntersects, $geoWithin, and $near/$centerSphere run natively; the finer predicates and geometry-to-geometry distance refine in-process over the candidate set.

  • GeoDisjoint is O(n) on SQLite/relational — its answer is mostly rows outside any bounding box, so the index can’t prune it and it scans the type. Use it deliberately on large collections.
  • Relational bbox selectivity — the sidecar uses a B-tree over the envelope columns, which is weaker than a true 2-D spatial index (R*Tree/GiST) for queries spanning a wide longitude range. It’s still index-assisted, not a full scan. A native ST_*/SDO pushdown tier could be layered on per engine later.
  • Distance approximation — SQLite/relational and the in-process refine paths use a Haversine/planar approximation; CosmosDB and native MongoDB distance use geodesic ST_DISTANCE. Ordering can differ on near-ties across providers.
  • Antimeridian / poles — bounding boxes crossing ±180° longitude or the poles are not special-cased.
  • One spatial property per type — map a single GeoPoint/Geometry property per document type.

On iOS, Android, and Mac Catalyst, Shiny.DocumentDb.Geofencing runs these queries against background GPS so a spatially-mapped document type becomes an unlimited set of geofence regions — polygons by containment, points by radius, with the region document delivered on every enter/exit. See Geofencing.