Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix type casting exceptions in 'isof' and 'cast' calls. #3117

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/Microsoft.OData.Core/UriParser/Binders/FunctionCallBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,17 @@ internal QueryNode BindFunctionCall(FunctionCallToken functionCallToken)

// If there isn't, bind as Uri function
// Bind all arguments
List<QueryNode> argumentNodes = new List<QueryNode>(functionCallToken.Arguments.Select(ar => this.bindMethod(ar)));
List<QueryNode> argumentNodes = functionCallToken.Arguments.Select(argument =>
{
// If the function is IsOf or Cast and the argument is a dotted identifier, we need to bind it differently
if (UnboundFunctionNames.Contains(functionCallToken.Name) && argument.ValueToken is DottedIdentifierToken dottedIdentifier)
{
return this.TryBindDottedIdentifierForIsOfOrCastFunctionCall(dottedIdentifier);
}

return this.bindMethod(argument);
}).ToList();

return BindAsUriFunction(functionCallToken, argumentNodes);
}

Expand Down Expand Up @@ -426,6 +436,52 @@ private bool TryBindIdentifier(string identifier, IEnumerable<FunctionParameterT
return true;
}

/// <summary>
/// Binds a <see cref="DottedIdentifierToken"/> for the 'isof' and 'cast' function calls.
/// </summary>
/// <param name="dottedIdentifierToken">The dotted identifier token to bind.</param>
/// <returns>A <see cref="QueryNode"/> representing the bound single resource node.</returns>
/// <exception cref="ODataException">Thrown when the token cannot be bound as a single resource node.</exception>
private QueryNode TryBindDottedIdentifierForIsOfOrCastFunctionCall(DottedIdentifierToken dottedIdentifierToken)
{
QueryNode parent = null;
IEdmType parentType = null;

if (state.ImplicitRangeVariable != null)
{
if (dottedIdentifierToken.NextToken == null)
{
parent = NodeFactory.CreateRangeVariableReferenceNode(state.ImplicitRangeVariable);
parentType = state.ImplicitRangeVariable.TypeReference.Definition;
}
else
{
parent = this.bindMethod(dottedIdentifierToken.NextToken);
parentType = parent.GetEdmType();
}
}

IEdmSchemaType childType = UriEdmHelpers.FindTypeFromModel(state.Model, dottedIdentifierToken.Identifier, this.Resolver);
IEdmStructuredType childStructuredType = childType as IEdmStructuredType;

if (childStructuredType == null)
{
return this.bindMethod(dottedIdentifierToken);
}
Comment on lines +465 to +470
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
IEdmStructuredType childStructuredType = childType as IEdmStructuredType;
if (childStructuredType == null)
{
return this.bindMethod(dottedIdentifierToken);
}
if ( childType is IEdmStructuredType childStructuredType)
{
return this.bindMethod(dottedIdentifierToken);
}

nit: Use is operator instead


if (parentType != null)
{
this.state.ParsedSegments.Add(new TypeSegment(childType, parentType, null));
}

if (parent is CollectionResourceNode parentAsCollection)
{
return new CollectionResourceCastNode(parentAsCollection, childStructuredType);
}

return new SingleResourceCastNode(parent as SingleResourceNode, childStructuredType);
}

/// <summary>
/// Bind path segment's operation or operationImport's parameters.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -844,27 +844,31 @@ public void IsOfFunctionWithTwoParameters_WithoutSingleQuotesOnTypeParameter_Sho
}

[Theory]
[InlineData("isof(Fully.Qualified.Namespace.Pet1)", "Fully.Qualified.Namespace.Pet1")]
[InlineData("cast(Fully.Qualified.Namespace.HomeAddress)/City eq 'City1'", "Fully.Qualified.Namespace.HomeAddress")]
public void IsOfAndCastFunctionsWithSingleParameterWithoutSingleQuotes_WithIncorrectType_ThrowException(string filterQuery, string fullyQualifiedTypeName)
[InlineData("isof(Fully.Qualified.Namespace.Pet1)")]
[InlineData("isof(MyAddress,Fully.Qualified.Namespace.Pet1)")]
[InlineData("isof(null,Fully.Qualified.Namespace.Person)")]
[InlineData("isof('',Fully.Qualified.Namespace.Person)")]
public void IsOfFunctionsWithUnquotedTypeParameter_WithIncorrectType_DoesNotThrowException(string filterQuery)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this behaviour consistent with how isof is handled when the type param is quoted?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same applied to isof as discussed in #3117 (comment), however, the method in AspNetCore that is responsible to create LINQ is different. Here is the method Expression BindIsOf(SingleValueFunctionCallNode node, QueryBinderContext context). I will also update this method to allow cast to SingleResourceCastNode as it currently only support cast to ConstantNode

{
// Arrange & Act
Action test = () => ParseFilter(filterQuery, HardCodedTestModel.TestModel, HardCodedTestModel.GetPersonType(), HardCodedTestModel.GetPeopleSet());
var exception = Record.Exception(() => ParseFilter(filterQuery, HardCodedTestModel.TestModel, HardCodedTestModel.GetPersonType(), HardCodedTestModel.GetPeopleSet()));

// Assert
test.Throws<ODataException>(Strings.MetadataBinder_HierarchyNotFollowed(fullyQualifiedTypeName, "Fully.Qualified.Namespace.Person"));
Assert.Null(exception);
}

[Theory]
[InlineData("isof(MyAddress,Fully.Qualified.Namespace.Pet1)", "Fully.Qualified.Namespace.Pet1")]
[InlineData("cast(MyAddress,Fully.Qualified.Namespace.Employee)/WorkID eq 345", "Fully.Qualified.Namespace.Employee")]
public void IsOfAndCastFunctionsWithTwoParameterWhereTypeParameterIsWithoutSingleQuotes_WithIncorrectType_ThrowException(string filterQuery, string fullyQualifiedTypeName)
[InlineData("cast(Fully.Qualified.Namespace.HomeAddress)/City eq 'City1'")]
[InlineData("cast(MyAddress,Fully.Qualified.Namespace.Employee)/WorkID eq 345")]
[InlineData("cast(null,Fully.Qualified.Namespace.Employee)/WorkID eq 345")]
[InlineData("cast('',Fully.Qualified.Namespace.Employee)/WorkID eq 345")]
public void CastFunctionWithUnquotedTypeParameter_WithIncorrectType_DoesNotThrowException(string filterQuery)
Copy link
Contributor

@habbes habbes Nov 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the expected behaviour of cast() when the types are incorrect? If it doesn't throw an exception, what will happen? How is this scenario expected to be handled? How will a library like AspNetCore know that the cast is not possible? And would this be a breaking change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what is happening currently with cast with quoted type parameter

For cast with unquoted type parameter

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this added context. Can you also shed light on the behaviour with respect to handling of types where a cast is not possible? Ideally, from the user's endpoint the behaviour should be the same regardless of whether they user quoted or non-quoted syntax, and regardless of our internal implementation details which you have highlighted.

So could you clarify how we handle both supported and invalid cast scenarios in both quoted and unquoted variants? In which cases are we throwing an exception? Is the exception thrown consistent? Is that the expected behaviour?

My expectation is that isof should not throw an exception when a cast is invalid, but rather evaluate to a boolean that will perform the correct filter. I think cast throwing an exception when a cast is invalid is reasonable. What is not clear to me is whether such an exception should be thrown in ODL or AspNetCore. For the case of cast I would be cautious about changing the existing behaviour to avoid introducing a breaking change.

Copy link
Contributor Author

@WanjohiSammy WanjohiSammy Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current behavior, ODL does not throw exception or to be more specific, it doesn't check if target type is related to source type. It only bind the quoted type parameters to constant node and return the entire token as FunctionCall token. The AspNetCoreOData, on the other end, will get the token and try to create LINQ expression. If expression is not possible, AspNetCoreOData returns null BindSingleResourceCastFunctionCall will return null expression.

Let's look at this query:

/products?$filter=cast('Microsoft.AspNetCore.OData.E2E.Tests.Cast.Category')/Name eq 'Cat

AspNetCore will try to relate Entity Product to Entity Category in BindSingleResourceCastFunctionCall and returns null expression if one is not assignable from the other.

And then CreatePropertyAccessExpression will throw an expression when trying to get the Property Name from a null expression.

image

An exception like below will be thrown:

{
    "error": {
        "code": "",
        "message": "Instance property 'Name' is not defined for type 'System.Object'"
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this behaviour consistent with how cast is handled when the type param is quoted?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
// Arrange & Act
Action test = () => ParseFilter(filterQuery, HardCodedTestModel.TestModel, HardCodedTestModel.GetPersonType(), HardCodedTestModel.GetPeopleSet());
var exception = Record.Exception(() => ParseFilter(filterQuery, HardCodedTestModel.TestModel, HardCodedTestModel.GetPersonType(), HardCodedTestModel.GetPeopleSet()));

// Assert
test.Throws<ODataException>(Strings.MetadataBinder_HierarchyNotFollowed(fullyQualifiedTypeName, "Fully.Qualified.Namespace.Address"));
Assert.Null(exception);
}

[Fact]
Expand Down