How can I filter on a nullable navigation property in a DTO when using OData with .NET Core?

Viewed 126

I'm using OData v8 with .NET 5 over Entity Framework. I have defined an entity Hour on my model and configured it in a pretty cookie-cutter way as follows, on my DbContext:

public class Hour
{
    // Removed non-related properties
    public Guid? CustomerId { get; set; }
    public string CustomerName { get; set; }
    public Guid Id { get; set; }
}

public class HourConfiguration : IEntityTypeConfiguration<Hour>
{
    public void Configure(EntityTypeBuilder<Hour> builder)
    {
        builder.ToTable("Hours");

        builder.HasPrimaryKey();
    }
}

The client however requires a slight change in the model (corporate restrictions), so I'm projecting it using .Select() into a DTO which is very similar. The main difference is, since CustomerId (and thus CustomerName) are optional, I'd like to put these properties together in a DTO, here, simply, IdNameDto:

public class IdNameDto
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

public class HourDto
{
    public Guid Id { get; set; } // <- PK of the Hour entity
    public IdNameDto Customer { get; set; } // <- can be null
}

The aforementioned projection happens in an endpoint as follows, you'll see I've enabled the necessary OData querying:

    [HttpGet("odata/hours")]
    [EnableQuery(AllowedQueryOptions = AllowedQueryOptions.All)]
    public IActionResult Get()
    {
        IQueryable<Hour> query = readDbContext.Hours
                                              .Select(source =>
                                                          new HourDto
                                                          {
                                                              Id = source.Id,
                                                              Customer = source.CustomerId != null
                                                                             ? new IdNameDto
                                                                               {
                                                                                   Id = source.CustomerId.Value,
                                                                                   Name = source.CustomerName
                                                                               }
                                                                             : null
                                                          });

        return new OkObjectResult(query);
    }

Note, that since CustomerId is optional, I'm forced to do a null-check to avoid null-ref exceptions - this is probably the culprit.

And finally, the OData mappings, added in ConfigureServices():

internal static class MvcBuilderExtensions
{
    internal static IMvcBuilder AddODataModel(this IMvcBuilder builder, int maxTop = 200)
    {
        return builder.AddOData(options =>
                                {
                                    options.EnableQueryFeatures(maxTop);
                                    options.AddRouteComponents("odata", GetEdmModel());
                                });
    }

    private static IEdmModel GetEdmModel()
    {
        var builder = new ODataConventionModelBuilder();
        builder.EnableLowerCamelCase();

        builder.EntitySet<HourDto>("hours");

        return builder.GetEdmModel();
    }
}

I'd like to think this is a pretty standard setup. As long as I don't use any filters, this works fine and both Customers with Id=null and Id=<GUID> are returned correctly.

Now the main problem, when I try to use the $filter clause to select a specific Customer by Id as such:

/odata/hours?$filter(Customer/Id eq <GUID>) (GUID is of course an actual GUID), the following error occurs:

enter image description here

I've added the full stacktrace here: https://pastebin.com/r3YRX3FB

The error is quite cryptic, but I suspect it has something to with OData thinking the underlying key is a Guid instead of a Guid?. But even that is right, I'm not sure how to solve it.

Any help is greatly appreciated as I've been breaking my head over this for some time.

0 Answers
Related