Search code examples
entity-frameworkodata

How to support OData query syntax but return non-Edm models


Exposing my EF models to an API always seemed wrong. I'd like my API to return a custom entity model to the caller but use EF on the back.

So I may have PersonRestEntity and a controller for CRUD ops against that and a Person EF code-first entity behind in and map values.

When I do this, I can no longer use the following to allow ~/people?$top=10 etc. in the URL

[EnableQuery]
public IQueryable<Person> Get(ODataQueryOptions<Person> query) { ... }

Because that exposes Person which is private DB implementation.

How can I have my cake and eat it?


Solution

  • I found a way. The trick is not to just return the IQueryable from the controller, because you need to materialise the query first. This doesn't mean materialising the whole set into RAM, the query is still run at the database, but by explicitly applying the query and materialising the results you can return mapped entities thereafter.

    Define this action, specifying the DbSet entity type:

    public async Task<HttpResponseMessage> Get(ODataQueryOptions<Person> oDataQuery)
    

    And then apply the query manually to the DbSet<Person> like so:

    var queryable = oDataQuery.ApplyTo(queryableDbSet);
    

    Then use the following to run the query and turn the results into the collection of entities you publicly expose:

    var list = await queryable.ToListAsync(cancellationToken);
    return list
        .OfType<Person>()
        .Select(p => MyEntityMapper.MapToRestEntity(p));
    

    Then you can return the list in an HttpResponseMessage as normal.

    That's it, though obviously where the property names between the entities don't match or are absent on either class, there's going to be some issues, so its probably best to ensure the properties you want to include in query options are named the same in both entities.

    Else, I guess you could choose to not support filters and just allow $top and $skip and impose a default order yourself. This can be achieved like so, making sure to order the queryable first, then skip, then top. Something like:

    IQueryable queryable = people
        .GetQueryable(operationContext)
        .OrderBy(r => r.Name);
    
    if (oDataQuery.Skip != null)
        queryable = oDataQuery.Skip.ApplyTo(queryable, new System.Web.OData.Query.ODataQuerySettings());                
    
    if (oDataQuery.Top != null)
        queryable = oDataQuery.Top.ApplyTo(queryable, new System.Web.OData.Query.ODataQuerySettings());
    
    var list = await queryable.ToListAsync(operationContext.CreateToken());
    
    return list
        .OfType<Person>()
        .Select(i => this.BuildPersonEntity(i));
    

    More information:

    If you simply use the non-generic ODataQueryOptions you get

    Cannot create an EDM model as the action 'Get' on controller 'People' has a return type 'System.Net.Http.HttpResponseMessage' that does not implement IEnumerable

    And other errors occur under different circumstances.