AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

Expand with cast causes an InvalidCastException

Open andygjp opened this issue 2 years ago • 3 comments

Assemblies affected Microsoft.AspNetCore.OData 8.0.10 Microsoft.OData.Core 7.11.0

Describe the bug Trying to cast in an expand, eg ~/Orders?$expand=Customer/Model.VipCustomer($orderby=VipEmail) (taken from https://github.com/OData/odata.net/pull/2300), results in an InvalidCastException:

An unhandled exception has occurred while executing the request.
     System.InvalidCastException: Unable to cast object of type 'Microsoft.OData.UriParser.TypeSegment' to type 'Microsoft.OData.UriParser.NavigationPropertySegment'.
        at Microsoft.AspNetCore.OData.Query.Validator.SelectExpandQueryValidator.ValidateRestrictions(Nullable`1 remainDepth, Int32 currentDepth, SelectExpandClause selectExpandClause, IEdmNavigationProperty navigationProperty, ODataValidationSettings validationSettings)
        at Microsoft.AspNetCore.OData.Query.Validator.SelectExpandQueryValidator.Validate(SelectExpandQueryOption selectExpandQueryOption, ODataValidationSettings validationSettings)
        at Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption.Validate(ODataValidationSettings validationSettings)
        at Microsoft.AspNetCore.OData.Query.Validator.ODataQueryValidator.Validate(ODataQueryOptions options, ODataValidationSettings validationSettings)
        at Microsoft.AspNetCore.OData.Query.ODataQueryOptions.Validate(ODataValidationSettings validationSettings)
        at Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.ValidateQuery(HttpRequest request, ODataQueryOptions queryOptions)
        at Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.OnActionExecuting(ActionExecutingContext actionExecutingContext)
        at Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute.OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
     --- End of stack trace from previous location ---
        at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
        at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
        at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
        at Microsoft.AspNetCore.OData.Routing.ODataRouteDebugMiddleware.Invoke(HttpContext context)
        at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

Trying to $filter for the correct type results in the same bug.

Reproduce steps I have created a repo: https://github.com/andygjp/CastThenExpand

After starting the app, browse to https://localhost:7138/default/Emails?$expand=Customer/Default.VipCustomer and you'll get an InvalidCastException.

Browse to https://localhost:7138/default/Emails?$filter=isof(Customer,'Default.VipCustomer')&$expand=Customer/Default.VipCustomer and you'll get the same exception.

Data Model See https://github.com/andygjp/CastThenExpand

EDM (CSDL) Model

<?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="Default" 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" Nullable="false"/>
                <Property Name="EmailID" Type="Edm.Int32"/>
                <NavigationProperty Name="Email" Type="Default.Email">
                    <ReferentialConstraint Property="EmailID" ReferencedProperty="ID"/>
                </NavigationProperty>
            </EntityType>
            <EntityType Name="VipCustomer" BaseType="Default.Customer">
                <Property Name="VipNumber" Type="Edm.Int32" Nullable="false"/>
            </EntityType>
            <EntityType Name="Email">
                <Key>
                    <PropertyRef Name="ID"/>
                </Key>
                <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
                <Property Name="EmailAddress" Type="Edm.String" Nullable="false"/>
                <Property Name="CustomerID" Type="Edm.Int32"/>
                <NavigationProperty Name="Customer" Type="Default.Customer">
                    <ReferentialConstraint Property="CustomerID" ReferencedProperty="ID"/>
                </NavigationProperty>
            </EntityType>
            <EntityContainer Name="Container">
                <EntitySet Name="Customers" EntityType="Default.Customer">
                    <NavigationPropertyBinding Path="Email" Target="Emails"/>
                </EntitySet>
                <EntitySet Name="VipCustomers" EntityType="Default.VipCustomer">
                    <NavigationPropertyBinding Path="Email" Target="Emails"/>
                </EntitySet>
                <EntitySet Name="Emails" EntityType="Default.Email">
                    <NavigationPropertyBinding Path="Customer" Target="Customers"/>
                </EntitySet>
            </EntityContainer>
        </Schema>
    </edmx:DataServices>
</edmx:Edmx>

Expected behavior It should filter the correct type and cast without the exception.

andygjp avatar May 17 '22 13:05 andygjp

It seems Web API assumes the last segment in the $expand path must be a navigation property segment. That was true in the previous ODL. Recently, ODL supports type-cast as the last segment recently see. https://github.com/OData/odata.net/pull/2300. So, that's a feature gap.

FYI, from OData ABNF:

expandPath        = [ ( qualifiedEntityTypeName / qualifiedComplexTypeName ) "/" ] 
                    *( ( complexProperty / complexColProperty ) "/" [ qualifiedComplexTypeName "/" ] )
                    ( STAR / streamProperty / navigationProperty [ "/" qualifiedEntityTypeName ] )

In order to fix the gap, we must:

  1. Fix the select expand validator
  2. Fix the select expand binder
  3. Fix the SelectExpandNode

@corranrogue9 Whether you want to take a look at this feature or not, please let me know. If no, you can unassign and re-assign it to me.

@KenitoInc I looped you into here since you finished the ODL part.

xuzhg avatar May 18 '22 23:05 xuzhg

To clarify...

This: https://localhost:7138/default/Emails?$expand=Customer/Default.VipCustomer($select=ID)&$select=ID, fails with the same exception.

But this: https://localhost:7138/default/Emails?$expand=Customer($select=ID)&$select=ID works fine.

andygjp avatar May 19 '22 08:05 andygjp

@andygjp I believe the OData URI /Emails?$expand=Customer/Default.VipCustomer is invalid for two reasons:

  • The Customer navigation property may contain either a Customer or VipCustomer object. Trying to cast a Customer object into a VipCustomer would/should validly throw an invalid cast exception
  • The type cast segment is being used in a manner to suggest that it should filter out cases where the Customer navigation property doesn't contain a VipCustomer object. Such usage of cast segment is only applicable to collection properties, e.g., /Customers?$expand=Orders/Default.VipOrder. If the idea is to return only emails associated with VIP customers while at the same time expanding the Customer navigation property, the relevant query should be:
    /Emails?$filter=isof(Customer,'Default.VipCustomer')&$expand=Customer
    
    The type cast segment would not be necessary in this case because the expanded Customer objects would be VipCustomer objects anyway.

Note: The expression /Customers?$expand=Orders/Default.VipOrder throws an exception currently but should probably be supported given @xuzhg 's comment. One can however use the following as a workaround:

Customers?$expand=Orders($filter=isof('Default.VipOrder'))

gathogojr avatar Nov 01 '23 08:11 gathogojr