Background
The OData URI parser parses the $filter query to a FilterClause.
The FilterBinder
translates an OData $filter
parse tree represented by a FilterClause to an Expression. The Expression
can be applied to an IQueryable
and passed to an ORM (e.g Entity Framework Core) for processing.
The FilterBinder
contains the default implementation on how the Expressions should be created. This creates a few issues:
- Not everyone uses EF Core so in some cases, the expressions generated by the
FilterBinder
may not be compatible with the target entity-relational mapper (O/RM) framework. - Users may want to enhance performance of some of the created Expressions e.g flexibility on how they handle constant parameterizations.
In this blog post, we will demonstrate how to implement support for the OData spatial geo.distance
built-in function. The current ASP.NET Core OData 8 doesn’t support it.
EF Core enables mapping to spatial data types in the database by using NetTopologySuite
types in the model. These types are unknown to the IEdmModel
. We will be customizing our logic to work with both OData spatial
and NetTopologySuite
types.
Overview of spatial data types
Spatial types are supported in OData using the Microsoft.Spatial
library. OData defines 8 spatial data types for geography
& geometry
respectively.
EF Core supports mapping to spatial data types using the NetTopologySuite
spatial library.
Previously in EF classic, System.Data.Spatial
library was used.
Building the service
In ASP.NET Core OData v8.0.5 we added ability to inject a custom implementation of the FilterBinder
.
This tutorial assumes that you already have the knowledge to build an ASP.NET Core OData 8.x service. If not, start by reading Tutorial: Creating a Service with ASP.NET Core OData 8.0 for .NET 5. Let’s get started.
In this sample service, we will add capability to filter spatial data in AspNetCore OData 8, which is currently not supported.
We need to install 2 nuget packages:
- NetTopologySuite – used for manipulating 2-dimensional linear geometry.
- Microsoft.EntityFrameworkCore.InMemory – In-memory database provider for Entity Framework Core.
Model
Creating the Customer
entity model.
public class Customer { public int Id { get; set; } public string Name { get; set; } public Point Loc { get; set; } }
Database Context
Creating the database context.
public class CustomerContext : DbContext { public CustomerContext(DbContextOptions<CustomerContext> options) : base(options) { } public DbSet<Customer> Customers { get; set; } }
Data Source
We can then add a DataSource.cs
file to prepopulate data whenever we run the service.
public class DataSource { private static DataSource instance = new DataSource(); public static DataSource Instance => instance; public List<Customer> Customers { get; set; } private DataSource() { this.Reset(); this.Initialize(); } public void Reset() { this.Customers = new List<Customer>(); } public void Initialize() { Point seattle = new Point(-122.333056, 47.609722) { SRID = 4326 }; Point redmond = new Point(-122.123889, 47.669444) { SRID = 4326 }; this.Customers.AddRange(new List<Customer>() { new Customer() { Id = 1, Name = "Customer 1", Loc = seattle }, new Customer() { Id = 2, Name = "Customer 2", Loc = redmond } }); } }
Other Configurations
In Startup.cs
, we replace the ConfigureServices
method with the following code:
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<CustomerContext>(opt => opt.UseInMemoryDatabase("CustomerGeoContext")); services.AddControllers().AddOData(opt => opt.Count().Filter().Expand().Select().OrderBy().SetMaxTop(5) .AddRouteComponents("odata", GetEdmModel())); } private static IEdmModel GetEdmModel() { ODataConventionModelBuilder builder = new ODataConventionModelBuilder(); builder.EntitySet<Customer>("Customers"); return builder.GetEdmModel(); }
If we run our service and try to get the Customers
data, we get a truncated response.
GET ~/odata/Customers
{ "@odata.context": "http://localhost:5000/odata/$metadata#Customers", "value": [ { "Id": 1, "Name": "Customer 1"
Let’s modify the code in GetEdmModel
method as follows:
public static IEdmModel GetEdmModel() { var builder = new ODataModelBuilder(); builder.EntitySet<Customer>("Customers"); var customer = builder.EntityType<Customer>(); customer.Property(c => c.Id); customer.Property(c => c.Name); customer.ComplexProperty(c => c.Loc); customer.HasKey(c => c.Id); var point = builder.ComplexType<Point>(); point.Property(p => p.X); point.Property(p => p.Y); point.Property(p => p.Z); point.Property(p => p.M); point.ComplexProperty(p => p.CoordinateSequence); point.Property(p => p.NumPoints); var coordinateSequence = builder.ComplexType<CoordinateSequence>(); coordinateSequence.Property(c => c.Dimension); coordinateSequence.Property(c => c.Measures); coordinateSequence.Property(c => c.Spatial); coordinateSequence.Property(c => c.HasZ); coordinateSequence.Property(c => c.HasM); coordinateSequence.Property(c => c.ZOrdinateIndex); coordinateSequence.Property(c => c.MOrdinateIndex); coordinateSequence.Property(c => c.Count); return builder.GetEdmModel(); }
In the above changes, we are including the spatial properties in the model so that we can get the correct response.
If we re-run GET ~/odata/Customers
, we will get the correct response.
{ "@odata.context": "http://localhost:5000/odata/$metadata#Customers", "value": [ { "Id": 1, "Name": "Customer 1", "Loc": { "X": -122.333056, "Y": 47.609722, "Z": "NaN", "M": "NaN", "NumPoints": 1, "CoordinateSequence": { "Dimension": 2, "Measures": 0, "Spatial": 2, "HasZ": false, "HasM": false, "ZOrdinateIndex": -1, "MOrdinateIndex": -1, "Count": 1 } } }, { "Id": 2, "Name": "Customer 2", "Loc": { "X": -122.123889, "Y": 47.669444, "Z": "NaN", "M": "NaN", "NumPoints": 1, "CoordinateSequence": { "Dimension": 2, "Measures": 0, "Spatial": 2, "HasZ": false, "HasM": false, "ZOrdinateIndex": -1, "MOrdinateIndex": -1, "Count": 1 } } } ] }
Let’s inspect the metadata: GET ~/odata/$metadata
<?xml version="1.0" encoding="utf-8"?> <edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"> <edmx:DataServices> <Schema Namespace="GeometryWebAPI.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityType Name="Customer"> <Key> <PropertyRef Name="Id" /> </Key> <Property Name="Id" Type="Edm.Int32" Nullable="false" /> <Property Name="Name" Type="Edm.String" /> <Property Name="Loc" Type="NetTopologySuite.Geometries.Point" Nullable="false" /> </EntityType> </Schema> <Schema Namespace="NetTopologySuite.Geometries" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <ComplexType Name="Point"> <Property Name="X" Type="Edm.Double" Nullable="false" /> <Property Name="Y" Type="Edm.Double" Nullable="false" /> <Property Name="Z" Type="Edm.Double" Nullable="false" /> <Property Name="M" Type="Edm.Double" Nullable="false" /> <Property Name="CoordinateSequence" Type="NetTopologySuite.Geometries.CoordinateSequence" Nullable="false" /> <Property Name="NumPoints" Type="Edm.Int32" Nullable="false" /> </ComplexType> <ComplexType Name="CoordinateSequence"> <Property Name="Dimension" Type="Edm.Int32" Nullable="false" /> <Property Name="Measures" Type="Edm.Int32" Nullable="false" /> <Property Name="Spatial" Type="Edm.Int32" Nullable="false" /> <Property Name="HasZ" Type="Edm.Boolean" Nullable="false" /> <Property Name="HasM" Type="Edm.Boolean" Nullable="false" /> <Property Name="ZOrdinateIndex" Type="Edm.Int32" Nullable="false" /> <Property Name="MOrdinateIndex" Type="Edm.Int32" Nullable="false" /> <Property Name="Count" Type="Edm.Int32" Nullable="false" /> </ComplexType> </Schema> <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityContainer Name="Container"> <EntitySet Name="Customers" EntityType="GeometryWebAPI.Models.Customer" /> </EntityContainer> </Schema> </edmx:DataServices> </edmx:Edmx>
The Loc
property has a type NetTopologySuite.Geometries.Point
. This is not an OData type. If we filter against this property, an ODataException
will be thrown since the Odata Uri Parser doesn’t understand this type.
{ "error": { "code": "", "message": "The query specified in the URI is not valid. No function signature for the function with name 'geo.distance' matches the specified arguments. The function signatures considered are: geo.distance(Edm.GeographyPoint Nullable=true, Edm.GeographyPoint Nullable=true); geo.distance(Edm.GeometryPoint Nullable=true, Edm.GeometryPoint Nullable=true).", "details": [], "innererror": {} } }
The error above is occurring because geo.distance
spatial function expects arguments of the type Edm.GeographyPoint
or Edm.GeometryPoint
. Instead we are passing arguments of the type NetTopologySuite.Geometries.Point
.
Wrapping the Spatial Data types
To circumvent the issue described above, we will write custom wrappers to convert NetTopologySuite
types to OData spatial types.
public class GeographyPointWrapper { private readonly Point _dbPoint; protected GeographyPointWrapper(Point point) { _dbPoint = point; } public static implicit operator GeographyPoint(GeographyPointWrapper wrapper) { return GeographyPointConverter.ConvertPointTo(wrapper._dbPoint); } public static implicit operator GeographyPointWrapper(GeographyPoint pt) { return new GeographyPointWrapper(GeographyPointConverter.ConvertFrom(pt)); } public static implicit operator Point(GeographyPointWrapper wrapper) { return wrapper._dbPoint; } public static implicit operator GeographyPointWrapper(Point point) { return new GeographyPointWrapper(point); } }
public class GeographyPointConverter { public static GeographyPoint ConvertPointTo(Point dbPoint) { Debug.Assert(dbPoint.GeometryType == "Point"); double lat = dbPoint.X; double lon = dbPoint.Y; double? alt = dbPoint.Z; double? m = dbPoint.M; return GeographyPoint.Create(lat, lon, alt, m); } public static Point ConvertFrom(GeographyPoint geographyPoint) { return new Point(geographyPoint.Latitude, geographyPoint.Longitude, (double)geographyPoint.Z); } }
Modify the Edm Model
We make the following modifications to the Customer
model so that we can apply the wrapper on the model properties.
public class Customer { private GeographyPointWrapper _ptWrapper; public int Id { get; set; } public string Name { get; set; } public Point Loc { get { return _ptWrapper; } set { _ptWrapper = value; } } [NotMapped] public GeographyPoint EdmLoc { get { return _ptWrapper; } set { _ptWrapper = value; } } }
The [NotMapped]
attribute ensures the EdmLoc
is not mapped to EFcore
since EFCore
doesn’t understand GeographyPoint
which is an OData type.
Build the modified Edm Model
We are using the model builder to build the modified EdmModel
.
We are ignoring Loc
property which is of type Point
(a NetTopologySuite
type).
We are adding the EdmLoc
property (which is of type Edm.GeographyPoint
) to the model and renaming it to Loc
.
public static IEdmModel GetEdmModel() { var builder = new ODataModelBuilder(); builder.EntitySet<Customer>("Customers"); var customerType = builder.EntityType<Customer>(); customerType.HasKey(c => c.Id).Ignore(c => c.Loc); customerType.Property(c => c.Name); var customer = builder.StructuralTypes.First(t => t.ClrType == typeof(Customer)); customer.AddProperty(typeof(Customer).GetProperty("EdmLoc")).Name = "Loc"; return builder.GetEdmModel(); }
Now if we inspect the service metadata, we will find the property OData types.
GET ~/odata/$metadata
<?xml version="1.0" encoding="utf-8"?> <edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"> <edmx:DataServices> <Schema Namespace="GeometryWebAPI.Models" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityType Name="Customer"> <Key> <PropertyRef Name="Id" /> </Key> <Property Name="Id" Type="Edm.Int32" Nullable="false" /> <Property Name="Name" Type="Edm.String" /> <Property Name="Loc" Type="Edm.GeographyPoint" /> </EntityType> </Schema> <Schema Namespace="Default" xmlns="http://docs.oasis-open.org/odata/ns/edm"> <EntityContainer Name="Container"> <EntitySet Name="Customers" EntityType="GeometryWebAPI.Models.Customer" /> </EntityContainer> </Schema> </edmx:DataServices> </edmx:Edmx>
We can see the Loc
property now has an Edm.GeographyPoint
type.
Let’s now filter against the Loc
property
GET odata/Customers?$filter=geo.distance(Loc, geography'POINT(10 30)') gt 20
We get an error
{ "error": { "code": "", "message": "The query specified in the URI is not valid. Unknown function 'geo.distance'.", "details": [], "innererror": { "message": "Unknown function 'geo.distance'.", "type": "System.NotImplementedException", "stacktrace": "..." } } }
This means that geo.distance
(a spatial function) is not supported in AspNetCore OData 8.
Customizing FilterBinder
We can customize the FilterBinder
in 2 ways
- Implement
IFilterBinder
interface. - Inherit from the
FilterBinder
class.
If we implement IFilterBinder
interface, we will have to write all implementations of Bind
methods from scratch. This can be time consuming but offers the most flexibility.
If we inherit from the FilterBinder
class, we can override the specific Bind
methods that we want to modify while re-using the existing implementations of Bind
methods.
Some of the methods we can override include:
public override Expression BindCollectionNode(Microsoft.OData.UriParser.CollectionNode node, QueryBinderContext context) public override Expression BindConstantNode(Microsoft.OData.UriParser.ConstantNode constantNode, QueryBinderContext context) public override Expression BindCountNode(Microsoft.OData.UriParser.CountNode node, QueryBinderContext context) public override Expression BindNavigationPropertyNode(Microsoft.OData.UriParser.QueryNode sourceNode, IEdmNavigationProperty navigationProperty, string propertyPath, QueryBinderContext context) public override Expression Expression BindSingleValueNode(Microsoft.OData.UriParser.SingleValueNode node, QueryBinderContext context)
In this example, we will inherit from the FilterBinder
class and override the BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context)
method. This is the method that binds function calls. We will modify this method to support geo.distance
spatial function.
Note: In OData, a SingleValueFunctionCallNode
is a node that represents a function call that returns a single value.
CustomFilterBinder
public class CustomFilterBinder : FilterBinder { internal const string GeoDistanceFunctionName = "geo.distance"; private static readonly MethodInfo distanceMethodDb = typeof(Geometry).GetMethod("Distance"); public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) { switch (node.Name) { case GeoDistanceFunctionName: return BindGeoDistance(node, context); default: return base.BindSingleValueFunctionCallNode(node, context); } } public Expression BindGeoDistance(SingleValueFunctionCallNode node, QueryBinderContext context) { Expression[] arguments = BindArguments(node.Parameters, context); string propertyName = null; foreach(var queryNode in node.Parameters) { if(queryNode.GetType() == typeof(SingleValuePropertyAccessNode)) { SingleValuePropertyAccessNode svpan = queryNode as SingleValuePropertyAccessNode; propertyName = svpan.Property.Name; } } GetPointExpressions(arguments, propertyName, out MemberExpression memberExpression, out ConstantExpression constantExpression); var ex = Expression.Call(memberExpression, distanceMethodDb, constantExpression); return ex; } private static void GetPointExpressions(Expression[] expressions, string propertyName, out MemberExpression memberExpression, out ConstantExpression constantExpression) { memberExpression = null; constantExpression = null; foreach (Expression expression in expressions) { var memberExpr = expression as MemberExpression; var constantExpr = memberExpr.Expression as ConstantExpression; if (constantExpr != null) { GeographyPoint point = GetGeographyPointFromConstantExpression(constantExpr); constantExpression = Expression.Constant(CreatePoint(point.Latitude, point.Longitude)); } else { memberExpression = Expression.Property(memberExpr.Expression, propertyName); } } } private static GeographyPoint GetGeographyPointFromConstantExpression(ConstantExpression expression) { GeographyPoint point = null; if (expression != null) { PropertyInfo constantExpressionValuePropertyInfo = expression.Type.GetProperty("Property"); point = constantExpressionValuePropertyInfo.GetValue(expression.Value) as GeographyPoint; } return point; } private static Point CreatePoint(double latitude, double longitude) { // 4326 is most common coordinate system used by GPS/Maps var geometryFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); // see https://docs.microsoft.com/en-us/ef/core/modeling/spatial // Longitude and Latitude var newLocation = geometryFactory.CreatePoint(new Coordinate(longitude, latitude)); return newLocation; } }
Let’s look at what each method in the above code does:
BindSingleValueFunctionCallNode
– checks if a function name isgeo.distance
. If true callBindGeoDistance
. If not call the base method to handle other function calls.BindGeoDistance
– Creates an expression from theSingleValueFunctionCallNode
.GetPointExpressions
– TransformsSingleValueFunctionCallNode
arguments’ expressions and outputs aMemberExpression
and aConstantExpression
. This will be used to create aMethodCallExpression
inBindGeoDistance
. The Expressions from theBindArguments
method inBindGeoDistance
have OData spatial types. However EFCore only understandNetTopologySuite
types. So we recreate the expressions with NetTopologySuite types.GetGeographyPointFromConstantExpression
– Extracts theGeographyPoint
property from theConstantExpression
.CreatePoint
– CreatePoint
value, which is aNetTopologySuite
type.
Inject CustomFilterBinder into DI container
In Startup.cs
, we inject the CustomFilterBinder
as a Singleton.
public void ConfigureServices(IServiceCollection services) { services.AddControllers().AddOData(opt => opt.Count().Filter().Expand().Select().OrderBy().SetMaxTop(5) .AddRouteComponents("odata", GetEdmModel(), svcs => { svcs.AddSingleton<IFilterBinder, CustomFilterBinder>(); })); }
If we re-run the filter query, we will get the correct results.
{ "@odata.context": "http://localhost:5000/odata/$metadata#Customers", "value": [ { "Id": 1, "Name": "Customer 1", "Loc": { "type": "Point", "coordinates": [ 47.609722, -122.333056, "NaN", "NaN" ], "crs": { "type": "name", "properties": { "name": "EPSG:4326" } } } }, { "Id": 2, "Name": "Customer 2", "Loc": { "type": "Point", "coordinates": [ 47.669444, -122.123889, "NaN", "NaN" ], "crs": { "type": "name", "properties": { "name": "EPSG:4326" } } } } ] }
Final thoughts
We have created an OData service that handles spatial data. We have customized the FilterBinder
to allows us filter against geo.distance
which is an OData spatial function currently not supported by AspNetCore OData 8.
- There are so many benefits of having the capabilities to customize the expression binders.
- We will continue evolving and adding more capabilities based on customer feedback.
Useful links
AspNetCore OData repo: https://github.com/OData/AspNetCoreOData
AspNetCore OData issues page: https://github.com/OData/AspNetCoreOData/issues
0 comments