Expanding collections with EntitySetController in MVC Web Api

914 views Asked by At

I'm using the OData framework (version 5.0.0) together with MVC Web Api (MVC version 4.0.305060), and cannot get the $expand query to work for my collections.

According to this wiki page, and more specifically case #2 under "Supported scenarios", I should be able to expand on Collections to include their data in a single query, but my $expand query seems to be completely ignored. No error, the response is exactly the same as if I left out the $expand altogether.

This is (part of) my data structure:

DebtCalculation
    - string Name
    - string Description
    - Collection<Expense> Expenses
    - Collection<Participant> Participants

Participant
    - bool HasPaid
    - User User
    - DebtCalculation DebtCalculation

Expense
    - decimal Amount
    - User Payer
    - Collection<Debtor> Debtors
    - DebtCalculation DebtCalculation

To summarize; querying with $expand for a single entity works as expected, both through myservice/api/Expenses?$expand=Payer and myservice/api/Expenses(10)?$expand=Payer, but querying for myservice/api/DebtCalculations?$expand=Participants does not.

This is (part of) my controller and ODataConventionModelBuilder setup:

[Authorize]
public class DebtCalculationsController : EntitySetController<DebtCalculation, long>
{
    private DebtCalculationManager _debtCalculationManager { get; set; }

    public DebtCalculationsController(DebtCalculationManager debtCalculationManager)
    {
        _debtCalculationManager = debtCalculationManager;
    }

    public override IQueryable<DebtCalculation> Get()
    {
        return _debtCalculationManager.AllDebtCalculationsForApi();
    }

    protected override DebtCalculation GetEntityByKey(long key)
    {
        return _debtCalculationManager.GetDebtCalculation(key);
    }

    public IQueryable<Participant> GetParticipants(long key)
    {
        return _debtCalculationManager.GetDebtCalculation(key).Participants.AsQueryable();
    }

    public IQueryable<Expense> GetExpenses(long key)
    {
        return _debtCalculationManager.GetDebtCalculation(key).Expenses.AsQueryable();
    }
}

[Authorize]
public class ExpensesController : EntitySetController<Expense, long>
{
    private DebtCalculationManager _debtCalculationManager { get; set; }

    public ExpensesController(DebtCalculationManager debtCalculationManager)
    {
        _debtCalculationManager = debtCalculationManager;
    }

    public override IQueryable<Expense> Get()
    {
        return _debtCalculationManager.AllDebtCalculationsForApi()
            .SelectMany(dc => dc.Expenses);
    }

    protected override Expense GetEntityByKey(long key)
    {
        return _debtCalculationManager.ExpenseForApi(key);
    }

    public User GetPayer(long key)
    {
        return _debtCalculationManager.ExpenseForApi(key).Payer;
    }

    public IQueryable<Debtor> GetDebtors(long key)
    {
        return _debtCalculationManager.ExpenseForApi(key).Debtors.AsQueryable();
    }

    public DebtCalculation GetDebtCalculation(long key)
    {
        return _debtCalculationManager.ExpenseForApi(key).DebtCalculation;
    }
}

[Authorize]
public class ParticipantsController : EntitySetController<Participant, long>
{
    private DebtCalculationManager _debtCalculationManager { get; set; }

    public ParticipantsController(DebtCalculationManager debtCalculationManager)
    {
        _debtCalculationManager = debtCalculationManager;
    }

    public override IQueryable<Participant> Get()
    {
        return _debtCalculationManager.AllDebtCalculationsForApi()
            .SelectMany(dc => dc.Participants);
    }

    protected override Participant GetEntityByKey(long key)
    {
        return _debtCalculationManager.ParticipantForApi(key);
    }

    public DebtCalculation GetDebtCalculation(long key)
    {
        return _debtCalculationManager.ParticipantForApi(key).DebtCalculation;
    }
}

public static class WebApiConfig
{
    public static HttpConfiguration Config { get; set; }

    public static void Register(HttpConfiguration config)
    {
        var modelBuilder = new ODataConventionModelBuilder();

        modelBuilder.EntitySet<DebtCalculation>("DebtCalculations")
            .EntityType.HasKey(dc => dc.Id);

        modelBuilder.EntitySet<Participant>("Participants")
            .EntityType.HasKey(p => p.Id)
            .Ignore(p => p.UniqueKey);

        modelBuilder.EntitySet<Expense>("Expenses")
            .EntityType.HasKey(e => e.Id);

        ...

        var model = modelBuilder.GetEdmModel();

        var routingConventions = ODataRoutingConventions.CreateDefault();
        routingConventions.Insert(0, new CreateNavigationPropertyRoutingConvention());

        config.Routes.MapODataRoute("ODataRoute", "api", model, new DefaultODataPathHandler(), routingConventions);

        config.EnableQuerySupport();
    }

    // routing convention to handle POST requests to navigation properties.
    public class CreateNavigationPropertyRoutingConvention : EntitySetRoutingConvention
    {
        public override string SelectAction(ODataPath odataPath, HttpControllerContext controllerContext, ILookup<string, HttpActionDescriptor> actionMap)
        {
            if (odataPath.PathTemplate == "~/entityset/key/navigation" && controllerContext.Request.Method == HttpMethod.Post)
            {
                IEdmNavigationProperty navigationProperty = (odataPath.Segments[2] as NavigationPathSegment).NavigationProperty;
                controllerContext.RouteData.Values["key"] = (odataPath.Segments[1] as KeyValuePathSegment).Value; // set the key for model binding.
                return "PostTo" + navigationProperty.Name;
            }

            return null;
        }
    }

The metadata for DebtCalculation looks like this:

    <EntityType Name="DebtCalculation">
            <Key>
                    <PropertyRef Name="Id"/>
            </Key>
            <Property Name="Id" Type="Edm.Int64" Nullable="false"/>
            <Property Name="Name" Type="Edm.String"/>
            <Property Name="Description" Type="Edm.String"/>
            <NavigationProperty Name="Participants" Relationship="Sujut.Core.Sujut_Core_DebtCalculation_Participants_Sujut_Core_Participant_ParticipantsPartner" ToRole="Participants" FromRole="ParticipantsPartner"/>
            <NavigationProperty Name="Expenses" Relationship="Sujut.Core.Sujut_Core_DebtCalculation_Expenses_Sujut_Core_Expense_ExpensesPartner" ToRole="Expenses" FromRole="ExpensesPartner"/>
    </EntityType>

Recognize any problems?

Edit:

The problem seems to be combining ODataController querying with Linq-to-NHibernate. Here is the stack trace. An upgrade to the NH 4.0.0.1000 alpha release did not solve the problem. This bug report seems to be about the same issue.

System.ArgumentException
at System.Linq.Expressions.Expression.Condition(Expression test, Expression ifTrue, Expression ifFalse)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitConditionalExpression(ConditionalExpression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.Visitors.NhExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.SelectClauseRewriter.VisitExpression(Expression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitBinaryExpression(BinaryExpression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.Visitors.NhExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.SelectClauseRewriter.VisitExpression(Expression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitConditionalExpression(ConditionalExpression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.Visitors.NhExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.SelectClauseRewriter.VisitExpression(Expression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberAssignment(MemberAssignment memberAssigment)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberBinding(MemberBinding memberBinding)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitList[T](ReadOnlyCollection`1 list, Func`2 visitMethod)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberBindingList(ReadOnlyCollection`1 expressions)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberInitExpression(MemberInitExpression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.Visitors.NhExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.SelectClauseRewriter.VisitExpression(Expression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberAssignment(MemberAssignment memberAssigment)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberBinding(MemberBinding memberBinding)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitList[T](ReadOnlyCollection`1 list, Func`2 visitMethod)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberBindingList(ReadOnlyCollection`1 expressions)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitMemberInitExpression(MemberInitExpression expression)
 at Remotion.Linq.Parsing.ExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.Visitors.NhExpressionTreeVisitor.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.SelectClauseRewriter.VisitExpression(Expression expression)
 at NHibernate.Linq.NestedSelects.NestedSelectRewriter.ReWrite(QueryModel queryModel, ISessionFactory sessionFactory)
 at NHibernate.Linq.Visitors.QueryModelVisitor.GenerateHqlQuery(QueryModel queryModel, VisitorParameters parameters, Boolean root)
 at NHibernate.Linq.NhLinqExpression.Translate(ISessionFactoryImplementor sessionFactory)
 at NHibernate.Hql.Ast.ANTLR.ASTQueryTranslatorFactory.CreateQueryTranslators(String queryIdentifier, IQueryExpression queryExpression, String collectionRole, Boolean shallow, IDictionary`2 filters, ISessionFactoryImplementor factory)
 at NHibernate.Engine.Query.HQLExpressionQueryPlan.CreateTranslators(String expressionStr, IQueryExpression queryExpression, String collectionRole, Boolean shallow, IDictionary`2 enabledFilters, ISessionFactoryImplementor factory)
 at NHibernate.Engine.Query.HQLExpressionQueryPlan..ctor(String expressionStr, IQueryExpression queryExpression, String collectionRole, Boolean shallow, IDictionary`2 enabledFilters, ISessionFactoryImplementor factory)
 at NHibernate.Engine.Query.HQLExpressionQueryPlan..ctor(String expressionStr, IQueryExpression queryExpression, Boolean shallow, IDictionary`2 enabledFilters, ISessionFactoryImplementor factory)
 at NHibernate.Engine.Query.QueryPlanCache.GetHQLQueryPlan(IQueryExpression queryExpression, Boolean shallow, IDictionary`2 enabledFilters)
 at NHibernate.Impl.AbstractSessionImpl.GetHQLQueryPlan(IQueryExpression queryExpression, Boolean shallow)
 at NHibernate.Impl.AbstractSessionImpl.CreateQuery(IQueryExpression queryExpression)
 at NHibernate.Linq.DefaultQueryProvider.PrepareQuery(Expression expression, IQuery& query, NhLinqExpression& nhQuery)
 at NHibernate.Linq.DefaultQueryProvider.Execute(Expression expression)
 at NHibernate.Linq.DefaultQueryProvider.Execute[TResult](Expression expression)
 at Remotion.Linq.QueryableBase`1.System.Collections.IEnumerable.GetEnumerator()
 at System.Web.Http.OData.Formatter.Serialization.ODataFeedSerializer.WriteFeed(IEnumerable enumerable, IEdmTypeReference feedType, ODataWriter writer, ODataSerializerContext writeContext)
 at System.Web.Http.OData.Formatter.Serialization.ODataFeedSerializer.WriteObjectInline(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext)
 at System.Web.Http.OData.Formatter.Serialization.ODataFeedSerializer.WriteObject(Object graph, Type type, ODataMessageWriter messageWriter, ODataSerializerContext writeContext)
 at System.Web.Http.OData.Formatter.ODataMediaTypeFormatter.WriteToStream(Type type, Object value, Stream writeStream, HttpContent content, HttpContentHeaders contentHeaders)
 at System.Web.Http.OData.Formatter.ODataMediaTypeFormatter.WriteToStreamAsync(Type type, Object value, Stream writeStream, HttpContent content, TransportContext transportContext)
 --- End of stack trace from previous location where exception was thrown ---
 at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
 at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
 at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
 at System.Web.Http.WebHost.HttpControllerHandler.<WriteBufferedResponseContentAsync>d__14.MoveNext()
2

There are 2 answers

0
nforss On BEST ANSWER

Problem is with Linq-to-NHibernate, bug reported here.

Worked around the problem by calling .ToList().AsQueryable() on the returned IQueryable<DebtCalculation> set. This is obviously not very efficient, but will allow me to continue development and testing of apps relying on the API until bug is fixed.

0
Grassycup On

I ran into the same problem and I tried the workaround, but for big datasets, it seemed to trigger a lot of select statements due to lazy loading.

So I took a different approach and manually joined on the related tables.

  // Using Chinook Database
  Album albumAlias = null;
  Track trackAlias = null;
  Artist artistAlias = null;
  return session.QueryOver<Album>(() => albumAlias)
    .Left.JoinAlias(() => albumAlias.Tracks, () => trackAlias)
    .Left.JoinAlias(() => albumAlias.Artist, () => artistAlias)
    .TransformUsing(Transformers.DistinctRootEntity)
    .List().AsQueryable();

Album has many Tracks and a single Artist associated with it. This way it will only generate a single select statement, and will still be able to expand on either properties.

For your example it will probably look something like

DebtCalculation debtCalculationAlias = null;
Expense expenseAlias = null;
Participant participantAlias = null;
session.QueryOver<DebtCalculation>(() => debtCalculationAlias)
.Left.JoinAlias(() => debtCalculationAlias.Expenses, () => expenseAlias)
.Left.JoinAlias(() => debtCalculationAlias.Participants, () => participantAlias)
.TransformUsing(Transformers.DistinctRootEntity)
.List().AsQueryable();

I'm just getting into OData, so if you think this is not the right way to go, feel free to correct me.