31

I've got a simple WebApi method like this decorated with the OData queryable attribute.

    [Queryable]
    public virtual IQueryable<PersonDto> Get()
    {
        return uow.Person().GetAll()); // Currently returns Person instead of PersonD
    }

What I want to do is transform the result of the query from type Person to type PersonDto using AutoMapper before WebAPI converts the result to JSON.

Does anybody know how I can do this? I am aware, I could apply Mapper.Map after the GetAll() call and then convert back to IQueryable, however this would result in the entire table being returned and mapped before the OData filter is applied (not good!).

It would appear that this question ASP.NET Web API return queryable DTOs? covers the same issue (see second response for a better answer), where the suggestion is to use AutoMapper at the end of the chain using a custom MediaTypeFormatter, however I have no idea how to do that based on the example I have seen.

Any help will be gratefully received!

-- Further Info

I've looked at the source code for IQueryable, but unfortunately there I can't see any way of utilising the code for this purpose. I have managed to write an additional filter which appears to work, however it isn't certainly isn't elegant.

public class PersonToPersonDtoConvertAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(System.Web.Http.Filters.HttpActionExecutedContext actionExecutedContext)
    {
        HttpResponseMessage response = actionExecutedContext.Response;

        if (response != null)
        {
            ObjectContent responseContent = response.Content as ObjectContent;
            var query = (responseContent.Value as IQueryable<Student>).ToList();
            response.Content = new ObjectContent<IEnumerable<StudentResource>>(query.ToList().Select(Mapper.Map<Person, PersonDto>), responseContent.Formatter);
        }
    }
}

Then I have decorated the action like

    [Queryable]
    [PersonToPersonDtoConvert]
    public IQueryable<Person> Get()
    {
        return uow.GetRepo<IRepository<Person>>().GetAll();
    }
Community
  • 1
  • 1
user460667
  • 1,870
  • 3
  • 19
  • 26

3 Answers3

31

Use the AutoMapper's Queryable Extensions.

First, define the mapping.

// Old AutoMapper API
// Mapper.CreateMap<Person, PersonDto>();

// Current AutoMapper API
Mapper.Initialize(cfg => 
   cfg.CreateMap<Person, PersonDto>()
);

Then you can use something like this:

[EnableQuery]
public IQueryable<PersonDto> Get() {
    // Old AutoMapper API
    // return this.dbContext.Persons.Project().To<PersonDto>();

    // New AutoMapper API
    return this.dbContext.Persons.ProjectTo<PersonDto>();
}

Edit 04/2019: Updated to reflect current AutoMapper API.

alik
  • 2,244
  • 3
  • 31
  • 44
  • 4
    !!!This should be the accepted answer!!! This will work applying the filters from ODATA to the SQL query like you'd expect with a regular ODATA query with EF. You don't need any other Config in WebApi. – Daniel Gimenez Apr 21 '15 at 15:51
  • Wait! this is not enough! whilst `IQueryable` will help, if the caller passes in an Query options like `$filter`,`$select` or `$expand` then all the persons will be loaded into memory first and _then_ the query options will be applied, instead you need to [UseAsDataSource](https://docs.automapper.org/en/stable/Expression-Translation-(UseAsDataSource).html#expression-translation-useasdatasource) – Chris Schaller Feb 11 '22 at 06:10
30

There is a better solution. Try this:

public virtual IQueryable<PersonDto> Get(ODataQueryOptions<Person> query)
{
    var people = query.ApplyTo(uow.Person().GetAll());
    return ConvertToDtos(people);
}

This will make sure the query runs on Person instead of PersonDTO. If you want the conversion to happen through an attribute instead of in code, you'll still want to implement an action filter similar to what you put up.

Youssef Moussaoui
  • 12,187
  • 2
  • 41
  • 37
  • 1
    OMG I can't give you enough up-votes! Thanks for finding that for me! For others, you can find this example in the MS docs for [adding OData to your WebAPI](http://www.asp.net/web-api/overview/odata-support-in-aspnet-web-api/supporting-odata-query-options) about half-way down the page. You use `ODataQueryOptions` *instead of* `[Queryable]` – Eric Falsken Mar 05 '13 at 17:55
  • Tried everything and I always get Not Acceptable 406 status code. when returning Dto :( – Bart Calixto Feb 22 '14 at 17:07
  • 1
    You need to create both objects on ModelBuilder for this to work. Failing to do so will result in 406 Not Acceptable. Example: `builder.EntitySet("Persons");` and `builder.EntitySet("PersonsDto");` – Bart Calixto Feb 22 '14 at 17:26
  • 2
    This is not the way to work wiith DTO's in automapper. Take a look at alik's answer for the correct way to do so. This solutuin might work, but you're not taking full benefit of Automapper here. Even if you're not using Automapper, i still think this isn't the way to go. – Frederik Prijck Sep 17 '15 at 05:28
  • 1
    I agree. The solutions below are better. You should be applying the query to your DTOs if you're able to. – Youssef Moussaoui Sep 24 '15 at 02:19
  • This solution seems to work the best. Using Query Analyzer, this approach queries the database according to the filters specified in the ODataQueryOptions and only brings back what is asked. Using the solutions by alik and Ben Ripley, the entire table is brought back from the database and then filtered out before it goes over the wire. I should mention that I'm using asp.net core – Chris Aug 16 '19 at 21:08
23

IMHO the accepted solution is not correct. Generally speaking, if your service is using DTOs, you don't want to expose the underlying Entities (Person) to the service. Why would you query against the Person model and return PersonDTO objects?

Since you're already using it, Automapper has Queryable Extensions which allows you to expose only your DTOs and have the filtering applied to the underlying type at the data source. For example:

public IQueryable<PersonDto> Get(ODataQueryOptions<PersonDto> options) {
    Mapper.CreateMap<Person, PersonDto>();
    var persons = _personRepository.GetPersonsAsQueryable();
    var personsDTOs = persons.Project().To<PersonDto>();  // magic happens here...

    return options.ApplyTo(personsDTOs);
}

Regarding eagerly loading navigation properties...

@philreed: I couldn't put a decent response in the comment so I added it here. There was a post on how to do this here but I'm getting 403s today. Hopefully that's temporary.

Basically, you examine the Select and Expand clauses for your navigation property. If it is present, then you tell EF to eagerly load via IQueryable<T> Include extension method.

Controller

public IQueryable<MyDto> GetMyDtos(ODataQueryOptions<MyDto> options)
{   
  var eagerlyLoad = options.IsNavigationPropertyExpected(t => t.MyNavProperty);
  var queryable = _myDtoService.GetMyDtos(eagerlyLoad);

  // _myDtoService will eagerly load to prevent select N+1 problems
  // return (eagerlyLoad) ? efResults.Include(t => t.MyNavProperty) : efResults;

  return queryable;
}

Extension method

public static class ODataQueryOptionsExtensions
{
  public static bool IsNavigationPropertyExpected<TSource, TKey>(this ODataQueryOptions<TSource> source, Expression<Func<TSource, TKey>> keySelector)
  {
    if (source == null) { throw new ArgumentNullException("source"); }
    if (keySelector == null) { throw new ArgumentNullException("keySelector"); }

    var returnValue = false;
    var propertyName = (keySelector.Body as MemberExpression ?? ((UnaryExpression)keySelector.Body).Operand as MemberExpression).Member.Name;
    var expandProperties = source.SelectExpand == null || string.IsNullOrWhiteSpace(source.SelectExpand.RawExpand) ? new List<string>().ToArray() : source.SelectExpand.RawExpand.Split(',');
    var selectProperties = source.SelectExpand == null || string.IsNullOrWhiteSpace(source.SelectExpand.RawSelect) ? new List<string>().ToArray() : source.SelectExpand.RawSelect.Split(',');

    returnValue = returnValue ^ expandProperties.Contains<string>(propertyName);
    returnValue = returnValue ^ selectProperties.Contains<string>(propertyName);

    return returnValue;
  }
}
Ben Ripley
  • 2,115
  • 21
  • 33
  • Why do I get: "Can't resolve this to Queryable Expression" when I try this? – keft Jun 10 '15 at 15:56
  • You're correct Ben. Automapper's Queryable Extentions are pretty bad-ass. The accepted answer is incorrect, it even is to be called bad. Your answer is close to correct. But I think Alik's answer is abit closer. There is no need for the ODataQueryOptions object, it happens for you when using the attribute alik specifies. – Frederik Prijck Sep 17 '15 at 05:29
  • 1
    @FrederikPrijck, you're right. In this case, they produce the same functionality. I use ODataQueryOptions if I need to examine the query passed in and perform additional operations depending on query options. Eg. Forcing EF to eager-load a navigation property to improve SQL performance. – Ben Ripley Sep 17 '15 at 17:20
  • Ye It can come in hand. But not in ur example. Basicly when using OData it's the client who requests the navigation property. So in general there is no need to know this server-side. But yes performance wise you might reach a point where it does help you. :-) – Frederik Prijck Sep 18 '15 at 12:44
  • @BenRipley In your comment above you mention examining the query to force EF to eager-load navigation properties. Do you have any example on how you are doing this? I'm currently stuck on something similar when trying to apply $expand on a DTO property that is marked as `ExplicitExpansion` in my AutoMapper map. – philreed Mar 29 '16 at 10:44
  • 1
    @philreed I updated my answer b/c I couldn't fit a decent response in a comment. Hope that helps. – Ben Ripley Mar 29 '16 at 13:56
  • @BenRipley Thanks Ben, I have a similar extension method to get a list of properties to expand but I wonder how you handle more complex $expand requests or nested $expands: my SO Question: http://stackoverflow.com/questions/36284142/using-dtos-with-odata-web-api – philreed Mar 29 '16 at 16:00
  • 1
    @philreed I probably wouldn't. Your use case looks more complicated than mine. I've only ever had the need to go one *expand* deep. Good luck. ;) – Ben Ripley Mar 29 '16 at 16:45