OData, $orderby and properties of complex type

Topics: ASP.NET Web API
Dec 31, 2012 at 8:41 AM
Edited Dec 31, 2012 at 8:42 AM

Hi,

I'm seeing some strange behaviour with the OData $orderby and properties of complex type. Suppose I have a class of the form:

Name
Title
B
+ Name
+ SubTitle
+ C
  + Name
  + SubSubTitle

i.e. I have a containment hierarchy with some properties (Name) shared by several levels of the hierarchy, and some (Title, SubTitle, SubSubTitle) specific to a particular level.

I would expect to be able to run OData queries to sort by any of these properties:

  1. $orderby=Name
  2. $orderby=B/Name
  3. $orderby=B/C/Name
  4. $orderby=B/SubTitle
  5. $orderby=B/C/SubSubTitle

and am relatively sure I was able to do this using the old WCF Web API. However, I'm now finding two unexpected consequences:

Requests 1, 2 and 3 all give the same recordset - it's as if the Name property on the parent object is being used as the key to sort by even if the specified $orderby field is on a subobject.

Request 5 throws an exception of the form:

Instance property 'SubSubTitle' is not defined for type 'A'.

Is this a known limitation of the current OData support? If so, is there a workaround that doesn't involve polluting the object model by creating dummy properties on the parent object that defer to the children?

Let me know if further clarification is required...

Cheers,

Jack.

Jan 3, 2013 at 7:41 PM

We don't support $orderby on nested properties. It is weird that queries 2, 3 and 4 are not throwing a 400. I will investigate.

Jan 4, 2013 at 8:48 AM

Thanks for looking into this. The inevitable follow-up question: are there plans to introduce support for nested properties in a later release, and do you have any idea of a time-frame? We're currently involved in porting several services from the older WCF WebAPI, where this functionality was available.

For now, the only possibility I can see (without breaking existing clients) is to add the properties to the parent object (BSubTitle, BCName, BCSubSubTitle), and build a new ODataQueryOptions object in which we replace, e.g., $orderby=B/SubTitle with $orderby=BSubTitle. Any pointers you could offer for how to accomplish this is a neat fashion would be greatly appreciated!

4. is returning a 500 status code: here's the stack trace in case it's useful.

Type 'ClassLibrary1.ParentTest' does not have a property 'SubTitle'.
   at Microsoft.Data.OData.Query.PropertyAccessBinder.GeneratePropertyAccessQueryForOpenType(PropertyAccessQueryToken propertyAccessToken, SingleValueNode parentNode)
   at Microsoft.Data.OData.Query.PropertyAccessBinder.BindPropertyAccess(PropertyAccessQueryToken propertyAccessToken, BindingState state)
   at Microsoft.Data.OData.Query.MetadataBinder.BindPropertyAccess(PropertyAccessQueryToken propertyAccessToken)
   at Microsoft.Data.OData.Query.MetadataBinder.Bind(QueryToken token)
   at Microsoft.Data.OData.Query.OrderByBinder.ProcessSingleOrderBy(BindingState state, OrderByNode thenBy, OrderByQueryToken orderByToken)
   at Microsoft.Data.OData.Query.OrderByBinder.BindOrderBy(BindingState state, CollectionNode collectionFromPath, IEnumerable`1 orderByTokens)
   at Microsoft.Data.OData.Query.ODataUriParser.ParseOrderBy(String orderBy, IEdmModel model, IEdmType elementType, IEdmEntitySet entitySet)
   at Microsoft.Data.OData.Query.ODataUriParser.ParseOrderBy(String orderBy, IEdmModel model, IEdmType elementType)
   at System.Web.Http.OData.Query.OrderByQueryOption.get_OrderByClause()
   at System.Web.Http.OData.Query.OrderByQueryOption.get_OrderByNodes()
   at System.Web.Http.OData.Query.OrderByQueryOption.ApplyToCore(IQueryable query)
   at System.Web.Http.OData.Query.OrderByQueryOption.ApplyTo(IQueryable query)
   at System.Web.Http.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query, ODataQuerySettings querySettings)
   at System.Web.Http.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query, ODataQuerySettings querySettings)
   at System.Web.Http.OData.Query.ODataQueryOptions.ApplyTo(IQueryable query)
   at System.Web.Http.OData.Query.ODataQueryOptions`1.ApplyTo(IQueryable query)
   at MvcApplication2.Controllers.ValuesController.Get(ODataQueryOptions`1 options) in c:\Projects\Local\Polymorphic POST\MvcApplication2\Controllers\ValuesController.cs:line 86
   at lambda_method(Closure , Object , Object[] )
   at System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ActionExecutor.<>c__DisplayClass13.<GetExecutor>b__c(Object instance, Object[] methodParameters)
   at System.Web.Http.Controllers.ReflectedHttpActionDescriptor.ActionExecutor.Execute(Object instance, Object[] arguments)
   at System.Web.Http.Controllers.ReflectedHttpActionDescriptor.<>c__DisplayClass5.<ExecuteAsync>b__4()
   at System.Threading.Tasks.TaskHelpers.RunSynchronously[TResult](Func`1 func, CancellationToken cancellationToken)

Jan 6, 2013 at 1:13 AM
Edited Jan 20, 2013 at 6:11 PM

Jack,

I ran into the same problem, below a work around. It borrows quite a bit from ExpressionHelperMethods.cs in System.Web.Http.OData

EDIT: Updated the OrderByLamdanizer, removed the use of ODataQueryOptions reference.

 

/// <summary>
/// Class which generates a lambda expression from the given odata orderby expression
/// </summary>
public sealed class OrderByLambdanizer
{
	/// <summary>
	/// OrderBy string split(',') nodes
	/// </summary>
	private IList<string> m_nodes;

	/// <summary>
	/// Constructor initialized with the raw orderby value
	/// </summary>
	/// <param name="orderByRawValue">Raw orderby value</param>
	public OrderByLambdanizer(string orderByRawValue)
	{
		Condition.Requires(orderByRawValue).IsNotNullOrWhiteSpace();

		m_nodes = orderByRawValue.Split(',');
	}

	/// <summary>
	/// Determines if the orderby raw value contains a nested property expression
	/// </summary>
	/// <param name="rawValue">Raw orderby to check</param>
	/// <returns>True if the orderby contains a nested property expression false otherwise</returns>
	public static bool ContainsNestedOrderBy(string rawValue)
	{
		return rawValue != null && rawValue.Contains('/');
	}
	/// <summary>
	/// Apply the order by query options to the given query
	/// </summary>
	/// <typeparam name="T">Queryable type</typeparam>
	/// <param name="query">Queryable instance</param>
	/// <returns>Query with orderby applied</returns>
	public IOrderedQueryable<T> ApplyTo<T>(IQueryable<T> query)
	{
		return m_nodes.Aggregate((IOrderedQueryable<T>)null, (a, n) =>
			{
				ParameterExpression oParamT = Expression.Parameter(typeof(T), "x");
				Tuple<string[], OrderByDirection> parsedNode = GetNodeProperties(n);

				LambdaExpression oExpression = Expression.Lambda(parsedNode.Item1
					.Aggregate((MemberExpression)null, (me, s) =>
					{
						if (me == null)
							return Expression.Property(oParamT, s);

						return Expression.Property(me, s);
					}),
					oParamT);

				MethodInfo oOrderByMethod = GetOrderingMethod<T>(parsedNode.Item2, a != null, oExpression);
				return oOrderByMethod.Invoke((object)null, new object[2]
				{
					(object) a ?? query,
					(object) oExpression
				}) as IOrderedQueryable<T>;
			});
	}
	/// <summary>
	/// Get the paresd order by expression for the property
	/// </summary>
	/// <param name="node">Node to split</param>
	/// <returns>Order by expression split by '/'</returns>
	private static Tuple<string[], OrderByDirection> GetNodeProperties(string node)
	{
		Condition.Requires(node).IsNotNullOrWhiteSpace();

		if (!node.Contains(' '))
			return new Tuple<string[], OrderByDirection>(node.Split('/'), OrderByDirection.Ascending);

		if(!node.Substring(node.IndexOf(' ')).Contains("desc"))
			return new Tuple<string[], OrderByDirection>(
				node
					.Substring(0, node.IndexOf(' '))
					.Split('/'),
				OrderByDirection.Descending);

		return new Tuple<string[], OrderByDirection>(
			node
				.Substring(0, node.IndexOf(' '))
				.Split('/'),
			OrderByDirection.Ascending);
	}
	/// <summary>
	/// Gets the ordering method for based on the current direction and ordering state
	/// </summary>
	/// <typeparam name="T">Type being queried</typeparam>
	/// <param name="orderByDirection">Direction to the order</param>
	/// <param name="isOrdered">Flag determining if the query is already ordered</param>
	/// <param name="expression">Lambda expression for orderby method</param>
	/// <returns>Ordering method</returns>
	private static MethodInfo GetOrderingMethod<T>(OrderByDirection orderByDirection, 
		bool isOrdered, LambdaExpression expression)
	{
		MethodInfo oResult = null;
		if (orderByDirection == OrderByDirection.Ascending)
		{
			if (isOrdered)
				oResult = _thenByMethod.MakeGenericMethod(typeof(T), expression.Body.Type);
			else
				oResult = _orderByMethod.MakeGenericMethod(typeof(T), expression.Body.Type);

			return oResult;
		}

		if (isOrdered)
			oResult = _thenByDescendingMethod.MakeGenericMethod(typeof(T), expression.Body.Type);
		else
			oResult = _orderByDescendingMethod.MakeGenericMethod(typeof(T), expression.Body.Type);

		return oResult;
	}

	/// <summary>
	/// OrderBy method info
	/// </summary>
	/// <remarks>
	/// Copied from
	/// http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/3283da822ade#src/System.Web.Http.OData/OData/ExpressionHelperMethods.cs
	/// </remarks>
	private static MethodInfo _orderByMethod =
		GenericMethodOf(_ => Queryable.OrderBy<int, int>(default(IQueryable<int>), 
			default(Expression<Func<int, int>>)));
	/// <summary>
	/// OrderByDecending method info
	/// </summary>
	/// <remarks>
	/// Copied from
	/// http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/3283da822ade#src/System.Web.Http.OData/OData/ExpressionHelperMethods.cs
	/// </remarks> 
	private static MethodInfo _orderByDescendingMethod =
		GenericMethodOf(_ => Queryable.OrderByDescending<int, int>(default(IQueryable<int>), 
			default(Expression<Func<int, int>>)));
	/// <summary>
	/// ThenBy method info
	/// </summary>
	/// <remarks>
	/// Copied from
	/// http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/3283da822ade#src/System.Web.Http.OData/OData/ExpressionHelperMethods.cs
	/// </remarks>
	private static MethodInfo _thenByMethod =
		GenericMethodOf(_ => Queryable.ThenBy<int, int>(default(IOrderedQueryable<int>), 
			default(Expression<Func<int, int>>)));
	/// <summary>
	/// ThenByDescending method info
	/// </summary>
	/// <remarks>
	/// Copied from
	/// http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/3283da822ade#src/System.Web.Http.OData/OData/ExpressionHelperMethods.cs
	/// </remarks>
	private static MethodInfo _thenByDescendingMethod =
		GenericMethodOf(_ => Queryable.ThenByDescending<int, int>(default(IOrderedQueryable<int>), 
			default(Expression<Func<int, int>>)));
	/// <summary>
	/// Generate a generic method from the given expression
	/// </summary>
	/// <typeparam name="TReturn">Method return type</typeparam>
	/// <param name="expression">Expression to make generic</param>
	/// <returns>Generic verions of the given expression</returns>
	/// <remarks>
	/// Copied from
	/// http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/3283da822ade#src/System.Web.Http.OData/OData/ExpressionHelperMethods.cs
	/// </remarks>
	private static MethodInfo GenericMethodOf<TReturn>(Expression<Func<object, TReturn>> expression)
	{
		LambdaExpression lambdaExpression = expression as LambdaExpression;

		Condition.Requires(expression.NodeType).IsEqualTo(ExpressionType.Lambda);
		Condition.Requires(lambdaExpression).IsNotNull();
		Condition.Requires(lambdaExpression.Body.NodeType).IsEqualTo(ExpressionType.Call);

		return (lambdaExpression.Body as MethodCallExpression).Method.GetGenericMethodDefinition();
	}
}

 

 

To use the class

 

[HttpGet]
public PageResult<T> Get(ODataQueryOptions options)
{
	Condition.Requires(options).IsNotNull();
	Condition.Requires(options.Skip).IsNotNull();
	Condition.Requires(options.Top).IsNotNull();

	IQueryable<T> query = MainRepository.GetList();
	if(options.Filter != null)
		query = options.Filter.ApplyTo(query, new ODataQuerySettings()) as IQueryable<T>;

	long count = query.LongCount();
	if (options.OrderBy != null)
	{
		if (!OrderByLambdanizer.ContainsNestedOrderBy(options.OrderBy.RawValue))
			query = options.OrderBy.ApplyTo(query);
		else
		{
			OrderByLambdanizer lambdanizer = new OrderByLambdanizer(options);
			query = lambdanizer.ApplyTo(query);
		}
	}

	query = options.Skip.ApplyTo(query);
	query = options.Top.ApplyTo(query);

	return new PageResult<T>(query, null, count);
}

 

This is currently working with regular properties and/or nested properties. Your mileage may very.

Jeremy

Jul 4, 2013 at 7:09 PM
Is there a time frame for when $orderby will be supported on nested properties?