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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Oproto.FluentDynamoDb.Geospatial" Version="0.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Oproto.FluentDynamoDb.Geospatial" Version="0.8.0" />
                    
Directory.Packages.props
<PackageReference Include="Oproto.FluentDynamoDb.Geospatial" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Oproto.FluentDynamoDb.Geospatial --version 0.8.0
                    
#r "nuget: Oproto.FluentDynamoDb.Geospatial, 0.8.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Oproto.FluentDynamoDb.Geospatial@0.8.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Oproto.FluentDynamoDb.Geospatial&version=0.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Oproto.FluentDynamoDb.Geospatial&version=0.8.0
                    
Install as a Cake Tool

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:

  1. When a GeoLocation is deserialized from DynamoDB, it stores the original spatial index value (GeoHash/S2 token/H3 index) in the SpatialIndex property
  2. GeoLocation has an implicit cast to string? that returns the SpatialIndex value
  3. 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

  1. 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
  2. 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
  3. 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(), or FromH3Index() 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:

  1. Recalculate the spatial index for every comparison (slow)
  2. Use string-based expressions instead of lambda expressions (no type safety)

With the SpatialIndex property:

  1. ✅ No recalculation needed - value is preserved from DynamoDB
  2. ✅ Type-safe lambda expressions work naturally
  3. ✅ Compile-time checking of property names
  4. ✅ 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:

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.

License

MIT License - see LICENSE for details.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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