February 22nd, 2022

Customizing $filter for spatial data in ASP.NET Core OData 8

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.

odata and nettopologysuite spatial types

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:

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 is geo.distance. If true call BindGeoDistance. If not call the base method to handle other function calls.
  • BindGeoDistance – Creates an expression from the SingleValueFunctionCallNode.
  • GetPointExpressions – Transforms SingleValueFunctionCallNode arguments’ expressions and outputs a MemberExpression and a ConstantExpression. This will be used to create a MethodCallExpression in BindGeoDistance. The Expressions from the BindArguments method in BindGeoDistance have OData spatial types. However EFCore only understand NetTopologySuite types. So we recreate the expressions with NetTopologySuite types.
  • GetGeographyPointFromConstantExpression – Extracts the GeographyPoint property from the ConstantExpression.
  • CreatePoint – Create Point value, which is a NetTopologySuite 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

Discussion are closed.