Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Controlling what is returned with an $expand request

So, using the ODataController, you get to control what gets returned if somebody does /odata/Foos(42)/Bars, because you'll be called on the FoosController like so:

public IQueryable<Bar> GetBars([FromODataUri] int key) { }

But what if you want to control what gets returned when somebody does /odata/Foos?$expand=Bars? How do you deal with that? It triggers this method:

public IQueryable<Foo> GetFoos() { }

And I assume it just does an .Include("Bars") on the IQueryable<Foo> that you return, so... how do I get more control? In particular, how do I do it in such a way that OData doesn't break (i.e. things like $select, $orderby, $top, etc. continue working.)

like image 373
Alex Avatar asked Sep 29 '15 13:09

Alex


1 Answers

While not the solution I wanted (make this a built-in feature, guys!), I have found a way to do what I wanted, albeit in a somewhat limited manner (so far I only support direct Where() filtering).

First, I made a custom ActionFilterAttribute class. Its purpose is to take action after the EnableQueryAttribute has done its thing, as it modifies the query that EnableQueryAttribute has produced.

In your GlobalConfiguration.Configure(config => { ... }) call, add the following before the call to config.MapODataServiceRoute():

config.Filters.Add(new NavigationFilterAttribute(typeof(NavigationFilter)));

It has to be before, because the OnActionExecuted() methods are called in reverse order. You can also decorate specific controllers with this filter, although I've found it harder to ensure that it's run in the correct order that way. The NavigationFilter is a class you create yourself, I'll post an example of one farther down.

NavigationFilterAttribute, and its inner class, an ExpressionVisitor is relatively well documented with comments, so I'll just paste them without further comments below:

public class NavigationFilterAttribute : ActionFilterAttribute
{
    private readonly Type _navigationFilterType;

    class NavigationPropertyFilterExpressionVisitor : ExpressionVisitor
    {
        private Type _navigationFilterType;

        public bool ModifiedExpression { get; private set; }

        public NavigationPropertyFilterExpressionVisitor(Type navigationFilterType)
        {
            _navigationFilterType = navigationFilterType;
        }

        protected override Expression VisitMember(MemberExpression node)
        {
            // Check properties that are of type ICollection<T>.
            if (node.Member.MemberType == System.Reflection.MemberTypes.Property
                && node.Type.IsGenericType
                && node.Type.GetGenericTypeDefinition() == typeof(ICollection<>))
            {
                var collectionType = node.Type.GenericTypeArguments[0];

                // See if there is a static, public method on the _navigationFilterType
                // which has a return type of Expression<Func<T, bool>>, as that can be
                // handed to a .Where(...) call on the ICollection<T>.
                var filterMethod = (from m in _navigationFilterType.GetMethods()
                                    where m.IsStatic
                                    let rt = m.ReturnType
                                    where rt.IsGenericType && rt.GetGenericTypeDefinition() == typeof(Expression<>)
                                    let et = rt.GenericTypeArguments[0]
                                    where et.IsGenericType && et.GetGenericTypeDefinition() == typeof(Func<,>)
                                        && et.GenericTypeArguments[0] == collectionType
                                        && et.GenericTypeArguments[1] == typeof(bool)

                                    // Make sure method either has a matching PropertyDeclaringTypeAttribute or no such attribute
                                    let pda = m.GetCustomAttributes<PropertyDeclaringTypeAttribute>()
                                    where pda.Count() == 0 || pda.Any(p => p.DeclaringType == node.Member.DeclaringType)

                                    // Make sure method either has a matching PropertyNameAttribute or no such attribute
                                    let pna = m.GetCustomAttributes<PropertyNameAttribute>()
                                    where pna.Count() == 0 || pna.Any(p => p.Name == node.Member.Name)
                                    select m).SingleOrDefault();

                if (filterMethod != null)
                {
                    // <node>.Where(<expression>)
                    var expression = filterMethod.Invoke(null, new object[0]) as Expression;
                    var whereCall = Expression.Call(typeof(Enumerable), "Where", new Type[] { collectionType }, node, expression);
                    ModifiedExpression = true;
                    return whereCall;
                }
            }
            return base.VisitMember(node);
        }
    }

    public NavigationFilterAttribute(Type navigationFilterType)
    {
        _navigationFilterType = navigationFilterType;
    }

    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        HttpResponseMessage response = actionExecutedContext.Response;

        if (response != null && response.IsSuccessStatusCode && response.Content != null)
        {
            ObjectContent responseContent = response.Content as ObjectContent;
            if (responseContent == null)
            {
                throw new ArgumentException("HttpRequestMessage's Content must be of type ObjectContent", "actionExecutedContext");
            }

            // Take the query returned to us by the EnableQueryAttribute and run it through out
            // NavigationPropertyFilterExpressionVisitor.
            IQueryable query = responseContent.Value as IQueryable;
            if (query != null)
            {
                var visitor = new NavigationPropertyFilterExpressionVisitor(_navigationFilterType);
                var expressionWithFilter = visitor.Visit(query.Expression);
                if (visitor.ModifiedExpression)
                    responseContent.Value = query.Provider.CreateQuery(expressionWithFilter);
            }
        }
    }
}

Next, there are a few simple attribute classes, for the purpose of narrowing down the filtering.

If you put PropertyDeclaringTypeAttribute on one of the methods on your NavigationFilter, it will only call that method if the property is on that type. For instance, given a class Foo with a property of type ICollection<Bar>, if you have a filter method with [PropertyDeclaringType(typeof(Foo))], then it will only be called for ICollection<Bar> properties on Foo, but not for any other class.

PropertyNameAttribute does something similar, but for the property's name rather than type. It can be useful if you have an entity type with multiple properties of the same ICollection<T> where you want to filter differently depending on the property name.

Here they are:

[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class PropertyDeclaringTypeAttribute : Attribute
{
    public PropertyDeclaringTypeAttribute(Type declaringType)
    {
        DeclaringType = declaringType;
    }

    public Type DeclaringType { get; private set; }
}

[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class PropertyNameAttribute : Attribute
{
    public PropertyNameAttribute(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Finally, here's an example of a NavigationFilter class:

class NavigationFilter
{
    [PropertyDeclaringType(typeof(Foo))]
    [PropertyName("Bars")]
    public static Expression<Func<Bar,bool>> OnlyReturnBarsWithSpecificSomeValue()
    {
        var someValue = SomeClass.GetAValue();
        return b => b.SomeValue == someValue;
    }
}
like image 119
Alex Avatar answered Oct 29 '22 20:10

Alex