Oproto.FluentDynamoDb.Geospatial
0.8.0
dotnet add package Oproto.FluentDynamoDb.Geospatial --version 0.8.0
NuGet\Install-Package Oproto.FluentDynamoDb.Geospatial -Version 0.8.0
<PackageReference Include="Oproto.FluentDynamoDb.Geospatial" Version="0.8.0" />
<PackageVersion Include="Oproto.FluentDynamoDb.Geospatial" Version="0.8.0" />
<PackageReference Include="Oproto.FluentDynamoDb.Geospatial" />
paket add Oproto.FluentDynamoDb.Geospatial --version 0.8.0
#r "nuget: Oproto.FluentDynamoDb.Geospatial, 0.8.0"
#:package Oproto.FluentDynamoDb.Geospatial@0.8.0
#addin nuget:?package=Oproto.FluentDynamoDb.Geospatial&version=0.8.0
#tool nuget:?package=Oproto.FluentDynamoDb.Geospatial&version=0.8.0
Oproto.FluentDynamoDb.Geospatial
Geospatial query support for Oproto.FluentDynamoDb with multiple spatial indexing systems. This package enables efficient location-based queries in DynamoDB with type-safe APIs and seamless integration with the FluentDynamoDb library.
Features
- Multiple Spatial Index Types: Choose between GeoHash, S2, and H3 based on your needs
- GeoLocation Type: Type-safe geographic coordinates with validation
- Distance Calculations: Built-in Haversine formula for accurate distance calculations in meters, kilometers, and miles
- Flexible Encoding: GeoHash, S2 (Google), and H3 (Uber) spatial indexing systems
- Lambda Expression Queries: Type-safe proximity and bounding box queries
- Bounding Box Support: Create rectangular geographic areas for queries
- Coordinate Storage: Optional full-resolution coordinate storage alongside spatial indices
- Pagination Support: Efficient paginated queries with spiral ordering
- AOT Compatible: Works with Native AOT compilation
- Zero External Dependencies: Custom implementations with no external geospatial libraries
Installation
dotnet add package Oproto.FluentDynamoDb.Geospatial
Or add to your .csproj file:
<PackageReference Include="Oproto.FluentDynamoDb.Geospatial" Version="1.0.0" />
Configuration
Geospatial features require explicit configuration using FluentDynamoDbOptions. Use the AddGeospatial() extension method to enable geospatial support:
using Oproto.FluentDynamoDb;
var options = new FluentDynamoDbOptions()
.AddGeospatial();
var table = new StoresTable(client, "stores", options);
Error Without Configuration
If you attempt to use geospatial features without calling AddGeospatial(), you'll receive an error:
"Geospatial features require configuration. Add the Oproto.FluentDynamoDb.Geospatial package and call options.AddGeospatial() when creating your table."
Combining with Other Features
Chain AddGeospatial() with other configuration methods:
using Oproto.FluentDynamoDb;
using Oproto.FluentDynamoDb.Logging.Extensions;
var options = new FluentDynamoDbOptions()
.WithLogger(loggerFactory.ToDynamoDbLogger<StoresTable>())
.AddGeospatial();
var table = new StoresTable(client, "stores", options);
Quick Start
1. Define Your Entity
Choose your spatial index type based on your needs:
Option A: GeoHash (Simple, Fast)
using Oproto.FluentDynamoDb.Attributes;
using Oproto.FluentDynamoDb.Geospatial;
[DynamoDbTable("stores")]
public partial class Store
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string StoreId { get; set; }
[DynamoDbAttribute("location", GeoHashPrecision = 7)]
public GeoLocation Location { get; set; }
[DynamoDbAttribute("name")]
public string Name { get; set; }
}
Option B: S2 (Global Coverage, Better at Poles)
[DynamoDbTable("stores")]
public partial class Store
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string StoreId { get; set; }
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 16)]
public GeoLocation Location { get; set; }
[DynamoDbAttribute("name")]
public string Name { get; set; }
}
Option C: H3 (Hexagonal, Most Uniform Coverage)
[DynamoDbTable("stores")]
public partial class Store
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string StoreId { get; set; }
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.H3, H3Resolution = 9)]
public GeoLocation Location { get; set; }
[DynamoDbAttribute("name")]
public string Name { get; set; }
}
2. Create and Store Locations
using Oproto.FluentDynamoDb.Geospatial;
var store = new Store
{
StoreId = "STORE#123",
Location = new GeoLocation(37.7749, -122.4194), // San Francisco
Name = "Downtown Store"
};
await storeTable.PutAsync(store);
3. Query by Proximity
GeoHash (Legacy Lambda Expression API):
using Oproto.FluentDynamoDb.Geospatial.GeoHash;
// Find stores within 5 kilometers
var center = new GeoLocation(37.7749, -122.4194);
var nearbyStores = await storeTable.Query
.Where<Store>(x => x.Location.WithinDistanceKilometers(center, 5))
.ExecuteAsync();
// Sort by actual distance
var sortedStores = nearbyStores
.OrderBy(s => s.Location.DistanceToKilometers(center))
.ToList();
S2 and H3 (New SpatialQueryAsync API with Lambda Expressions):
using Oproto.FluentDynamoDb.Geospatial;
// Find ALL stores within 5km (non-paginated - fastest)
var center = new GeoLocation(37.7749, -122.4194);
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 5,
queryBuilder: (query, cell, pagination) => query
// Lambda expression: x.Location == cell works due to implicit cast
// The GeoLocation.SpatialIndex property is compared to the cell token
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: null // No pagination - queries all cells in parallel
);
// Results are automatically sorted by distance
foreach (var store in result.Items)
{
var distance = store.Location.DistanceToKilometers(center);
Console.WriteLine($"{store.Name}: {distance:F2}km away");
}
Understanding the Lambda Expression Syntax:
The x.Location == cell syntax works because:
- When a
GeoLocationis deserialized from DynamoDB, it stores the original spatial index value (GeoHash/S2 token/H3 index) in theSpatialIndexproperty GeoLocationhas an implicit cast tostring?that returns theSpatialIndexvalue- This enables natural comparison syntax in lambda expressions without explicitly accessing
.SpatialIndex
Alternative Query Expression Styles:
// 1. Implicit cast (recommended - most concise)
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
// 2. Explicit property access (also works)
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location.SpatialIndex == cell)
// 3. Format string (works without lambda expressions)
.Where("pk = {0} AND location = {1}", "STORE", cell)
// 4. Plain text with parameters (works without lambda expressions)
.Where("pk = :pk AND location = :loc")
.WithValue(":pk", "STORE")
.WithValue(":loc", cell)
All four approaches produce the same DynamoDB query, but lambda expressions provide compile-time type safety.
Basic Usage Examples
Working with GeoLocation
// Create a location from coordinates
var sanFrancisco = new GeoLocation(37.7749, -122.4194);
var newYork = new GeoLocation(40.7128, -74.0060);
// Calculate distances in different units
double distanceMeters = sanFrancisco.DistanceToMeters(newYork); // ~4,130,000 meters
double distanceKm = sanFrancisco.DistanceToKilometers(newYork); // ~4,130 km
double distanceMiles = sanFrancisco.DistanceToMiles(newYork); // ~2,566 miles
// Encode to GeoHash
string hash = sanFrancisco.ToGeoHash(7); // "9q8yy9r"
// Decode from GeoHash
var location = GeoLocation.FromGeoHash("9q8yy9r");
// Understanding the SpatialIndex Property
// When created from coordinates, SpatialIndex is null
Console.WriteLine(sanFrancisco.SpatialIndex); // null
// When deserialized from DynamoDB, SpatialIndex contains the stored value
// This enables efficient query comparisons without recalculation
var store = await storeTable.GetAsync("STORE#123");
Console.WriteLine(store.Location.SpatialIndex); // "9q8yy9r" (or S2 token/H3 index)
// The implicit cast enables natural comparison syntax
if (store.Location == "9q8yy9r")
{
Console.WriteLine("Location matches the expected cell");
}
Proximity Queries (All Distance Units)
var center = new GeoLocation(37.7749, -122.4194);
// Query with meters
var storesInMeters = await storeTable.Query
.Where<Store>(x => x.Location.WithinDistanceMeters(center, 5000))
.ExecuteAsync();
// Query with kilometers
var storesInKm = await storeTable.Query
.Where<Store>(x => x.Location.WithinDistanceKilometers(center, 5))
.ExecuteAsync();
// Query with miles
var storesInMiles = await storeTable.Query
.Where<Store>(x => x.Location.WithinDistanceMiles(center, 3.1))
.ExecuteAsync();
Bounding Box Queries
// Define a rectangular area
var southwest = new GeoLocation(37.7, -122.5);
var northeast = new GeoLocation(37.8, -122.4);
var storesInArea = await storeTable.Query
.Where<Store>(x => x.Location.WithinBoundingBox(southwest, northeast))
.ExecuteAsync();
// Or create from center and distance
var bbox = GeoBoundingBox.FromCenterAndDistanceKilometers(center, 5);
var storesInBbox = await storeTable.Query
.Where<Store>(x => x.Location.WithinBoundingBox(bbox.Southwest, bbox.Northeast))
.ExecuteAsync();
Manual Query Pattern
// For advanced scenarios
var center = new GeoLocation(37.7749, -122.4194);
var bbox = GeoBoundingBox.FromCenterAndDistanceKilometers(center, 5);
var (minHash, maxHash) = bbox.GetGeoHashRange(7);
var stores = await storeTable.Query
.Where("location BETWEEN :minHash AND :maxHash")
.WithValue(":minHash", minHash)
.WithValue(":maxHash", maxHash)
.ExecuteAsync();
Choosing a Spatial Index Type
Quick Comparison
| Feature | GeoHash | S2 | H3 |
|---|---|---|---|
| Cell Shape | Rectangle | Square | Hexagon |
| Precision Levels | 1-12 | 0-30 | 0-15 |
| Default | 6 (~610m) | 16 (~1.5km) | 9 (~174m) |
| Query Type | Single BETWEEN | Multiple queries | Multiple queries |
| Best For | Simple queries | Global coverage | Uniform coverage |
| Pole Handling | Poor | Good | Excellent |
When to Use Each
Use GeoHash when:
- ✅ Simple proximity queries
- ✅ Low latency critical (single query)
- ✅ Mid-latitude locations
- ✅ Backward compatibility needed
Use S2 when:
- ✅ Global coverage needed
- ✅ Polar regions important
- ✅ Hierarchical queries
- ✅ Area uniformity matters
Use H3 when:
- ✅ Most uniform coverage needed
- ✅ Hexagonal neighbors important
- ✅ Grid analysis required
- ✅ Visual appeal matters
Precision Guide
GeoHash: | Precision | Cell Size | Use Case | |-----------|-----------|----------| | 5 | ~2.4 km | Neighborhood | | 6 | ~610 m | Default - District | | 7 | ~76 m | Street-level | | 8 | ~19 m | Building-level |
S2: | Level | Cell Size | Use Case | |-------|-----------|----------| | 14 | ~6 km | District | | 16 | ~1.5 km | Default - Neighborhood | | 18 | ~400 m | Street-level | | 20 | ~100 m | Building-level |
H3: | Resolution | Cell Edge | Use Case | |------------|-----------|----------| | 7 | ~1.2 km | Neighborhood | | 8 | ~460 m | Local area | | 9 | ~174 m | Default - Street-level | | 10 | ~66 m | Building-level |
⚠️ Warning: Higher precision = more cells = slower paginated queries. See Precision Guide for details.
Important Limitations
DynamoDB Query Patterns
Rectangular Queries Only: DynamoDB BETWEEN queries create rectangular bounding boxes, not circles
- Results may include locations outside the circular distance
- Use post-filtering with
DistanceTo()methods for exact circular queries
No Native Distance Sorting: DynamoDB cannot sort by distance from a point
- Retrieve results and sort in memory using
DistanceTo()methods - Pagination with distance sorting requires custom implementation
- Retrieve results and sort in memory using
Single Range Query: DynamoDB supports one BETWEEN condition per query
- Boundary cases may require querying neighbor cells
- Multiple non-contiguous areas require multiple queries
Edge Cases
- Poles: GeoHash precision decreases near poles due to longitude convergence
- Date Line: Queries crossing the international date line (±180°) require special handling
- Cell Boundaries: Locations near GeoHash cell boundaries may require querying neighbor cells for complete results
Performance Considerations
- Encoding/Decoding: Very fast (<1 microsecond), O(precision) complexity
- Query Efficiency: Lower precision = fewer items scanned but less accurate
- Memory: All types are readonly structs with minimal heap allocations
- Caching: Consider caching frequently used GeoHash values for hot paths
Understanding the SpatialIndex Property
The GeoLocation struct includes a SpatialIndex property that stores the original spatial index value (GeoHash/S2 token/H3 index) when deserialized from DynamoDB. This enables efficient spatial queries using natural lambda expression syntax.
When is SpatialIndex Populated?
SpatialIndex is NULL when:
- Creating a location directly from coordinates:
new GeoLocation(37.7749, -122.4194) - Using extension methods:
location.ToGeoHash(7)returns a string, not a GeoLocation with SpatialIndex
SpatialIndex is POPULATED when:
- Deserializing from DynamoDB (source generator automatically includes it)
- Using
FromGeoHash(),FromS2Token(), orFromH3Index()methods - The value matches what's stored in the DynamoDB attribute
Implicit Cast Behavior
GeoLocation has an implicit cast to string? that returns the SpatialIndex value:
GeoLocation location = await GetLocationFromDynamoDB();
// These are equivalent:
string? index1 = location.SpatialIndex; // Explicit property access
string? index2 = location; // Implicit cast
// Both return the same value (e.g., "9q8yy9r" for GeoHash)
Using in Lambda Expressions
The implicit cast enables natural comparison syntax in spatial queries:
// ✅ Recommended: Implicit cast (most concise)
.Where<Store>(x => x.Location == cell)
// ✅ Also works: Explicit property access
.Where<Store>(x => x.Location.SpatialIndex == cell)
// Both compile to the same DynamoDB expression: location = :cell
Equality Operators
GeoLocation provides equality operators for comparing with spatial index strings:
var location = await GetLocationFromDynamoDB();
string cell = "9q8yy9r";
// All of these work:
if (location == cell) { } // Implicit cast
if (cell == location) { } // Reverse order
if (location.SpatialIndex == cell) { } // Explicit property
if (location != cell) { } // Inequality
Why This Matters
Without the SpatialIndex property, spatial queries would need to:
- Recalculate the spatial index for every comparison (slow)
- Use string-based expressions instead of lambda expressions (no type safety)
With the SpatialIndex property:
- ✅ No recalculation needed - value is preserved from DynamoDB
- ✅ Type-safe lambda expressions work naturally
- ✅ Compile-time checking of property names
- ✅ IntelliSense support in IDEs
Example: Complete Flow
// 1. Create and store a location
var newStore = new Store
{
StoreId = "STORE#123",
Location = new GeoLocation(37.7749, -122.4194), // SpatialIndex is null
Name = "Downtown Store"
};
await storeTable.PutAsync(newStore);
// DynamoDB now contains: { "location": "9q8yy9r", ... }
// 2. Query using spatial index
var center = new GeoLocation(37.7749, -122.4194);
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 5,
queryBuilder: (query, cell, pagination) => query
// cell = "9q8yy9r" (or similar)
// x.Location == cell works because SpatialIndex is populated during deserialization
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination)
);
// 3. Retrieved locations have SpatialIndex populated
foreach (var store in result.Items)
{
Console.WriteLine($"Store: {store.Name}");
Console.WriteLine($"Coordinates: {store.Location.Latitude}, {store.Location.Longitude}");
Console.WriteLine($"Spatial Index: {store.Location.SpatialIndex}"); // "9q8yy9r"
// Can compare directly
if (store.Location == "9q8yy9r")
{
Console.WriteLine("This is the exact cell we queried!");
}
}
Advanced Features
Paginated Queries
For large result sets, use pagination:
var result = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 50 // Paginated - queries cells sequentially in spiral order
);
Console.WriteLine($"Found {result.Items.Count} stores (page 1)");
Console.WriteLine($"Has more: {result.ContinuationToken != null}");
// Get next page
if (result.ContinuationToken != null)
{
var nextPage = await storeTable.SpatialQueryAsync(
spatialAttributeName: "location",
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 50,
continuationToken: result.ContinuationToken
);
}
Storing Exact Coordinates
Preserve full-resolution coordinates alongside spatial indices:
[DynamoDbTable("stores")]
public partial class Store
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string StoreId { get; set; }
// Spatial index for queries
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 16)]
public GeoLocation Location { get; set; }
// Store exact coordinates
[DynamoDbAttribute("location_lat")]
public double LocationLatitude => Location.Latitude;
[DynamoDbAttribute("location_lon")]
public double LocationLongitude => Location.Longitude;
}
// Deserialization automatically uses exact coordinates if available
// Falls back to spatial index (cell center) if coordinates are missing
Working with Cells Directly
using Oproto.FluentDynamoDb.Geospatial.S2;
using Oproto.FluentDynamoDb.Geospatial.H3;
var location = new GeoLocation(37.7749, -122.4194);
// S2 cells
var s2Cell = location.ToS2Cell(level: 16);
var s2Neighbors = s2Cell.GetNeighbors(); // 8 neighbors
var s2Parent = s2Cell.GetParent(); // Level 15
var s2Children = s2Cell.GetChildren(); // 4 children
// H3 cells
var h3Cell = location.ToH3Cell(resolution: 9);
var h3Neighbors = h3Cell.GetNeighbors(); // 6 neighbors (hexagons)
var h3Parent = h3Cell.GetParent(); // Resolution 8
var h3Children = h3Cell.GetChildren(); // 7 children
Documentation
For comprehensive documentation, examples, and advanced usage patterns, see:
- S2 and H3 Usage Guide - Choosing between index types
- Precision Selection Guide - Choosing precision levels and avoiding query explosion
- Performance Guide - Query optimization and performance tuning
- Coordinate Storage Guide - Storing full-resolution coordinates
- Examples - More code examples and patterns
Requirements
- .NET 8.0 or later
- Oproto.FluentDynamoDb 1.0.0 or later
- AOT compatible
Acknowledgments
This package includes implementations based on algorithms from:
- Google S2 Geometry Library - The S2 spatial indexing algorithms are derived from Google's S2 Geometry Library (Apache License 2.0)
- Uber H3 - The H3 hexagonal indexing algorithms are derived from Uber's H3 library (Apache License 2.0)
See THIRD-PARTY-NOTICES.md for full attribution and license details.
Links
- 📚 Documentation: fluentdynamodb.dev
- 🐙 GitHub: github.com/oproto/fluent-dynamodb
- 📦 NuGet: Oproto.FluentDynamoDb.Geospatial
License
MIT License - see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Oproto.FluentDynamoDb (>= 0.8.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.8.0 | 191 | 12/5/2025 |