18

I have a basic WebApi service setup with a database first EF DataModel set up. I am running the nightly builds of WebApi, EF6, and the WebApi OData packages. (WebApi: 5.1.0-alpha1, EF: 6.1.0-alpha1, WebApi OData: 5.1.0-alpha1)

The database has two tables: Product and Supplier. A Product can have one Supplier. A Supplier can have multiple Products.

I have also created two DTO classes:

public class Supplier
{
    [Key]
    public int Id { get; set; }

    public string Name { get; set; }

    public virtual IQueryable<Product> Products { get; set; }
}

public class Product
{
    [Key]
    public int Id { get; set; }

    public string Name { get; set; }
}

I have set up my WebApiConfig as follows:

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

    oDataModelBuilder.EntitySet<Product>("product");
    oDataModelBuilder.EntitySet<Supplier>("supplier");

    config.Routes.MapODataRoute(routeName: "oData",
        routePrefix: "odata",
        model: oDataModelBuilder.GetEdmModel());
}

I have set up my two controllers as follows:

public class ProductController : ODataController
{
    [HttpGet]
    [Queryable]
    public IQueryable<Product> Get()
    {
        var context = new ExampleContext();

        var results = context.EF_Products
            .Select(x => new Product() { Id = x.ProductId, Name = x.ProductName});

        return results as IQueryable<Product>;
    }
}

public class SupplierController : ODataController
{
    [HttpGet]
    [Queryable]
    public IQueryable<Supplier> Get()
    {
        var context = new ExampleContext();

        var results = context.EF_Suppliers
            .Select(x => new Supplier() { Id = x.SupplierId, Name = x.SupplierName });

        return results as IQueryable<Supplier>;
    }
}

Here is the metadata that gets returned. As you can see, the navigation properties are set up correctly:

<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx">
 <edmx:DataServices m:DataServiceVersion="3.0" m:MaxDataServiceVersion="3.0" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
  <Schema Namespace="StackOverflowExample.Models" xmlns="http://schemas.microsoft.com/ado/2009/11/edm">
   <EntityType Name="Product">
    <Key>
     <PropertyRef Name="Id" />
    </Key>
    <Property Name="Id" Type="Edm.Int32" Nullable="false" />
    <Property Name="Name" Type="Edm.String" />
   </EntityType>
   <EntityType Name="Supplier">
    <Key>
     <PropertyRef Name="Id" />
    </Key>
    <Property Name="Id" Type="Edm.Int32" Nullable="false" />
    <Property Name="Name" Type="Edm.String" />
    <NavigationProperty Name="Products" Relationship="StackOverflowExample.Models.StackOverflowExample_Models_Supplier_Products_StackOverflowExample_Models_Product_ProductsPartner" ToRole="Products" FromRole="ProductsPartner" />
   </EntityType>
   <Association Name="StackOverflowExample_Models_Supplier_Products_StackOverflowExample_Models_Product_ProductsPartner">
    <End Type="StackOverflowExample.Models.Product" Role="Products" Multiplicity="*" />
    <End Type="StackOverflowExample.Models.Supplier" Role="ProductsPartner" Multiplicity="0..1" />
   </Association>
  </Schema>
  <Schema Namespace="Default" xmlns="http://schemas.microsoft.com/ado/2009/11/edm">
   <EntityContainer Name="Container" m:IsDefaultEntityContainer="true">
    <EntitySet Name="product" EntityType="StackOverflowExample.Models.Product" />
    <EntitySet Name="supplier" EntityType="StackOverflowExample.Models.Supplier" />
     <AssociationSet Name="StackOverflowExample_Models_Supplier_Products_StackOverflowExample_Models_Product_ProductsPartnerSet" Association="StackOverflowExample.Models.StackOverflowExample_Models_Supplier_Products_StackOverflowExample_Models_Product_ProductsPartner">
      <End Role="ProductsPartner" EntitySet="supplier" />
      <End Role="Products" EntitySet="product" />
     </AssociationSet>
    </EntityContainer>
   </Schema>
  </edmx:DataServices>
</edmx:Edmx>

So the normal array of odata queries work fine: /odata/product?$filter=Name+eq+'Product1' and /odata/supplier?$select=Id for example all work fine.

The problem is when I attempt to work with $expand. If I were to do /odata/supplier?$expand=Products, I of course get an error:

"The specified type member 'Products' is not supported in LINQ to Entities. Only initializers, entity members, and entity navigation properties are supported."

Update: I keep getting the same questions so I am adding more information. Yes, the navigation properties are set up correctly as can be seen in the metadata information I posted above.

This is not related to methods being missing on the controller. If I were to create a class that implements IODataRoutingConvention, /odata/supplier(1)/product would be parsed out as "~/entityset/key/navigation" just fine.

If I were to bypass my DTOs completely and just return the EF generated classes, $expand works out of the box.

Update 2: If I change my Product class to the following:

public class Product
{
    [Key]
    public int Id { get; set; }

    public string Name { get; set; }

    public virtual Supplier Supplier { get; set; }
}

and then change the ProductController to this:

public class ProductController : ODataController
{
    [HttpGet]
    [Queryable]
    public IQueryable<Product> Get()
    {
        var context = new ExampleContext();

        return context.EF_Products
            .Select(x => new Product() 
            { 
                Id = x.ProductId, 
                Name = x.ProductName, 
                Supplier = new Supplier() 
                {
                    Id = x.EF_Supplier.SupplierId, 
                    Name = x.EF_Supplier.SupplierName 
                } 
            });
    }
}

If I were to call /odata/product I would get back what I expected. An array of Products with the Supplier field not returned in the response. The sql query generated joins and selects from the Suppliers table, which would make sense to me if not for the next query results.

If I were to call /odata/product?$select=Id, I would get back what I would expect. But $select translates to a sql query that does not join to the suppliers table.

/odata/product?$expand=Product fails with a different error:

"The argument to DbIsNullExpression must refer to a primitive, enumeration or reference type."

If I change my Product Controller to the following:

public class ProductController : ODataController
{
    [HttpGet]
    [Queryable]
    public IQueryable<Product> Get()
    {
        var context = new ExampleContext();

        return context.EF_Products
            .Select(x => new Product() 
            { 
                Id = x.ProductId, 
                Name = x.ProductName, 
                Supplier = new Supplier() 
                {
                    Id = x.EF_Supplier.SupplierId, 
                    Name = x.EF_Supplier.SupplierName 
                } 
            })
            .ToList()
            .AsQueryable();
    }
}

/odata/product, /odata/product?$select=Id, and /odata/product?$expand=Supplier return the correct results, but obviously the .ToList() defeats the purpose a bit.

I can try to modify the Product Controller to only call .ToList() when an $expand query is passed, like so:

    [HttpGet]
    public IQueryable<Product> Get(ODataQueryOptions queryOptions)
    {
        var context = new ExampleContext();

        if (queryOptions.SelectExpand == null)
        {
            var results = context.EF_Products
                .Select(x => new Product()
                {
                    Id = x.ProductId,
                    Name = x.ProductName,
                    Supplier = new Supplier()
                    {
                        Id = x.EF_Supplier.SupplierId,
                        Name = x.EF_Supplier.SupplierName
                    }
                });

            IQueryable returnValue = queryOptions.ApplyTo(results);

            return returnValue as IQueryable<Product>;
        }
        else
        {
            var results = context.EF_Products
                .Select(x => new Product()
                {
                    Id = x.ProductId,
                    Name = x.ProductName,
                    Supplier = new Supplier()
                    {
                        Id = x.EF_Supplier.SupplierId,
                        Name = x.EF_Supplier.SupplierName
                    }
                })
                .ToList()
                .AsQueryable();

            IQueryable returnValue = queryOptions.ApplyTo(results);

            return returnValue as IQueryable<Product>;
        }
    }
}

Unfortunately, when I call /odata/product?$select=Id or /odata/product?$expand=Supplier it throws a serialization error because returnValue can't be cast to IQueryable. I can be cast though if I call /odata/product.

What is the work around here? Do I just have to skip trying to use my own DTOs or can/should I roll my own implementation of $expand and $select?

Schandlich
  • 693
  • 1
  • 4
  • 21
  • Did you create the DTO classes yourself or are you using the EF's ability to to generate the model from the Database? – Rafi Oct 11 '13 at 17:32
  • Myself. The ones that are generated on the model are prepended with "EF_". ExampleContext contains a DbSet named EF_Products for example. This is not my exact use case of course. The fact that the DTOs match their EF created counterparts is just for the convenience of example. – Schandlich Oct 11 '13 at 17:55
  • Does your DTO have the relevant navigation property? – Mike Wasson Oct 12 '13 at 17:09
  • Sorry, I meant on the DTO instances themselves. e.g., here it doesn't look like Supplier gets a Products list assigned to it: .Select(x => new Supplier() { Id = x.SupplierId, Name = x.SupplierName }); – Mike Wasson Oct 14 '13 at 21:06
  • @MikeWasson That is correct. Even if I do assign it, it still fails with the same error. :/ – Schandlich Oct 14 '13 at 23:39
  • @MikeWasson Actually, I think you might be on the right track – Schandlich Oct 15 '13 at 14:54
  • @Schandlich So did you ever figure out what the problem was? Having the same problem with "The argument to DbIsNullExpression must refer to a primitive, enumeration or reference type." – AmITheRWord Jan 09 '14 at 09:31
  • 1
    @AmITheRWord Yes and no. There are number of different ways that I have found to get around it. None of them perfect. It is more complicated now in my current architecture because I have my ef entities projecting into another dto which my service then projects into it's own dto. In the end the pattern I use is not really a complete answer to this so I don't want to post it here really. If you want to send me an email and I can provide you more information, feel free to though at luke.sigler at outlook dot com. – Schandlich Jan 10 '14 at 16:57

4 Answers4

1

The underlying issue was fixed in EF 6.1.0. See https://entityframework.codeplex.com/workitem/826.

divega
  • 6,320
  • 1
  • 31
  • 31
0

You have not set up the entity relationships in your web-api. You need to add more methods to your controllers.

I assume the following url's don't work as well: /odata/product(1)/Supplier This is because the relationship isn't set.

Add the following method to your controller and I think it should solve the issue:

// GET /Products(1)/Supplier
public Supplier GetSupplier([FromODataUri] int key)
{
    var context = new ExampleContext();
    Product product = context.EF_Products.FirstOrDefault(p => p.ID == key);
    if (product == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }
    return product.Supplier;
}

I think that matched your naming. Fix them as needed. Look at http://www.asp.net/web-api/overview/odata-support-in-aspnet-web-api/working-with-entity-relations for more info. Your model structure is very similar.

Rafi
  • 2,433
  • 1
  • 25
  • 33
  • That does not match my problem. /Products(1)/Supplier is not what I am trying to accomplish. /Products(1)?$expand=Supplier is what I am trying to accomplish. – Schandlich Oct 11 '13 at 19:19
  • I think the point is that /Products(1)/Supplier probably isn't working for you for the same reason /Products(1)$expand=Supplier isn't working. Have you tried this approach? It sounds like relationships aren't correctly set up in model. Or does /Products(1)/Supplier work for you? – Jen S Oct 12 '13 at 02:43
  • @JenS My controller doesn't implement that method but that is unrelated. The navigation relationship is there. See updated question above. – Schandlich Oct 14 '13 at 00:22
  • Just a minor observation: the error message and the request URL refers to "Products" in expand clause: odata/supplier?$expand=Products. But according to your metadata you must use "product". Did you try that instead, e.g. $expand=product? – Vagif Abilov Oct 15 '13 at 12:59
  • @VagifAbilov Tried that. :/ It is based on the name of the Property in this case; not the entity. – Schandlich Oct 15 '13 at 14:53
0

You should use an ICollection navigation property instead of an IQueryable. These types are very different. Not sure that's your problem, but worth fixing.

tne
  • 7,071
  • 2
  • 45
  • 68
  • You mentioned in the other comment thread that "Even if I do assign [the navigation property], it still fails with the same error". However, that was before you tried the switch to the `ICollection`. This makes me think: How did you do it? With `.AsQueryable`? If the actual navigation property in the entity is also defined as `IQueryable` I would also encourage you to change it if you haven't done so already ([Relevant answer](http://stackoverflow.com/a/9252442/2266481)). The error you're seeing explicitly mentions the type of the property, which is why I'd like to make sure it's not it. – tne Oct 15 '13 at 18:37
  • Nevermind; You already mentioned that bypassing your DTOs worked. – tne Oct 15 '13 at 18:39
0

The $expand command only works if the controller action has a MaxExpansionDepth argument added to the Queryable attribute that is greater than 0.

[Queryable(MaxExpansionDepth = 1)]
Scott Corbett
  • 380
  • 1
  • 7
  • Can you back that up with any documentation? The MaxExpansionDepth default value is 1. And as you can see in my question, bypassing the DTOs allowed $expand to work without setting this value. – Schandlich Dec 05 '13 at 21:32