WebApi icon indicating copy to clipboard operation
WebApi copied to clipboard

$count=true and $select not working on IQueryable

Open bhagyesh20 opened this issue 6 years ago • 6 comments

$count and $select are not working on IQueryable.

AspNet.Core.WebApi

*Which assemblies and versions are known to be affected Microsoft.AspNet.WebApi.Core

Reproduce steps

  1. Create a controller derived from controllerbase
  2. Expose a route
  3. Decorate the route with EnableQuery (no EdmModel has been defined)
  4. In the startup class enable OData by dependency injection
  5. Add the following options in the route builder .Filter().OrderBy().Expand().Select().Count();
  6. Validate the count attribute by exposing a static list
  7. The $count=true attribute should return the count of the entityset that is exposed, instead it is returning the full entity set

The count of the queryable entity set

What would happen if there wasn't a bug.

https://localhost:xxxx/api/controller/all?$count=true should return the count of the exposed queryable, instead is returning the full queryable entityset

What is actually happening.

The result is the full exposed queryable entityset

Additional detail

Optional, details of the root cause if known. Delete this section if you have no additional details to add.

bhagyesh20 avatar May 02 '19 15:05 bhagyesh20

I think you looking for all/$count to get just count For example: https://analytics.dev.azure.com/analytics-demo/PartsUnlimited/_odata/v3.0-preview/Areas/$count returns just number

all?$count=true returns count with collection as annotation @odata.count http://docs.oasis-open.org/odata/odata/v4.01/cs01/part1-protocol/odata-v4.01-cs01-part1-protocol.html#_Toc505771213

For example: https://analytics.dev.azure.com/analytics-demo/PartsUnlimited/_odata/v3.0-preview/Areas?$count=true

returns (notice: "@odata.count": 3,)

{
    "@odata.context": "https://analytics.dev.azure.com/analytics-demo/PartsUnlimited/_odata/v3.0-preview/$metadata#Areas",
    "@odata.count": 3,
     "value": [
        {
            "ProjectSK": "5b8b5828-46d6-43ae-9b35-0426d4dba095",
            "AreaSK": "c96994c5-3ee5-4b16-a73f-46c4637fb576",
            "AreaId": "c96994c5-3ee5-4b16-a73f-46c4637fb576",
            "AreaName": "PUL",
            "Number": 55,
            "AreaPath": "PartsUnlimited\\PUL",
            "AreaLevel1": "PartsUnlimited",
            "AreaLevel2": "PUL",
            "Depth": 1,
            "AnalyticsUpdatedDate": "2019-04-26T21:46:25.3933333Z"
        },
        {
            "ProjectSK": "5b8b5828-46d6-43ae-9b35-0426d4dba095",
            "AreaSK": "6377a239-4c08-4718-a4d6-7fb40c25e368",
            "AreaId": "6377a239-4c08-4718-a4d6-7fb40c25e368",
            "AreaName": "PartsUnlimited",
            "Number": 53,
            "AreaPath": "PartsUnlimited",
            "AreaLevel1": "PartsUnlimited",
            "Depth": 0,
            "AnalyticsUpdatedDate": "2019-04-26T21:45:39.9366667Z"
        },
        {
            "ProjectSK": "5b8b5828-46d6-43ae-9b35-0426d4dba095",
            "AreaSK": "abc6d6d9-c0e0-4de7-9ea6-ca9a2c8bc4cc",
            "AreaId": "abc6d6d9-c0e0-4de7-9ea6-ca9a2c8bc4cc",
            "AreaName": "PUL-DB",
            "Number": 54,
            "AreaPath": "PartsUnlimited\\PUL-DB",
            "AreaLevel1": "PartsUnlimited",
            "AreaLevel2": "PUL-DB",
            "Depth": 1,
            "AnalyticsUpdatedDate": "2019-04-26T21:45:50.1566667Z"
        }
    ]
}

Please, keep in mind that to get OData annotations as part of output you need to use proper OData serialization. To do that you need to derive your controller from ODataController or at least add [ODataFormatting] and [ODataRouting] attributes

kosinsky avatar May 16 '19 14:05 kosinsky

I'm experiencing a similar issue with OData V7.2.1 and V7.2.2, Versioning V3.2.4, and ApiExplorer V3.2.3.

Controller has both [ODataFormatting] and [ODataRouting] attributes, also tried changing the ControllerBase to ODataController.

All $select, $filter, $top, $skip, etc works fine for me.

I'm just experiencing issues using $count, which doesn't return the count value alongside the result array.

endpoint: "templates/v1?$count=true"

JamesMartinJoubert avatar Oct 21 '19 09:10 JamesMartinJoubert

Hi,

Also hitting the same problem. These all work as expected:

http://localhost:51496/odata/Donations http://localhost:51496/odata/Donations?$top=1 http://localhost:51496/odata/Donations?$skip=5

This gives just a list of items (the same result as http://localhost:51496/odata/Donations)

http://localhost:51496/odata/Donations?$count=true

This gives a 404:

http://localhost:51496/odata/Donations/$count

I've even tried with a custom [EnableQueryAttribute]:

        public override IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions)
        {
            if(queryOptions.Count?.Value == true) //When $count is in the query, this is true
            {
                Request.ODataFeature().TotalCountFunc = queryable.Cast<object>().LongCount; //But no count value is returned in the result
            }
            return base.ApplyQuery(queryable, queryOptions);
        }

I've also got ".Filter().OrderBy().Expand().Select().Count();" set and the controller inherits from ODataController with [ODataFormatting] and [ODataRouting] attributes.

badcommandorfilename avatar Nov 26 '19 05:11 badcommandorfilename

OK!

After quite a lot of fun, I've figured out the cause (for me at least). #1595 made this quite hard, but fortunately I was able to debug the source locally.

Summary: In my case, I needed to make changes to the EdmModel during the query. [EnableQueryAttribute] even has this handy overridable method:

public virtual IEdmModel GetModel

The documentation says:

Override this method to customize the EDM model used for querying!

So when you use a custom IEdmModel with the [EnableQueryAttribute], OData will surely use the same model for the ODataEdmTypeSerializer... right? ... Right?

Details:

The problem starts here:

Hidden away in DefaultODataSerializerProvider is:

internal ODataSerializer GetODataPayloadSerializerImpl(Type type, Func<IEdmModel> modelFunction, ODataPath path, Type errorType)
{
...
            IEdmModel model = modelFunction();

            // if it is not a special type, assume it has a corresponding EdmType.
            ClrTypeCache typeMappingCache = model.GetTypeMappingCache();
            IEdmTypeReference edmType = typeMappingCache.GetEdmType(type, model);

            if (edmType != null)
            {
                bool isCountRequest = path != null && path.Segments.LastOrDefault() is CountSegment;
                bool isRawValueRequest = path != null && path.Segments.LastOrDefault() is ValueSegment;

                if (((edmType.IsPrimitive() || edmType.IsEnum()) && isRawValueRequest) || isCountRequest)
                {
                    return _rootContainer.GetRequiredService<ODataRawValueSerializer>();
                }
                else
                {
                    return GetEdmTypeSerializer(edmType);
                }
            }
            else
            {
                return null;
            }
}

Notice the "isCountRequest" - this is the bit that tells the serializer to add "@odata.count" to the response! So great, as long as the query type is found in IEdmModel model = modelFunction();, the ODataSerializer will know to print it out!

Ok... I see it gets the model lazily through modelFunction() - surely this is how it connects to

public virtual IEdmModel GetModel 

LOL NOPE! Let's have a look at the source of this Func<IEdmModel>:

    public partial class DefaultODataSerializerProvider : ODataSerializerProvider
    {
        /// <inheritdoc />
        /// <remarks>This signature uses types that are AspNetCore-specific.</remarks>
        public override ODataSerializer GetODataPayloadSerializer(Type type, HttpRequest request)
        {
            // Using a Func<IEdmModel> to delay evaluation of the model.
            return GetODataPayloadSerializerImpl(type, () => request.GetModel(), request.ODataFeature().Path, typeof(SerializableError));
        }
    }

Lead us to:

        public static IEdmModel GetModel(this HttpRequest request)
        {
            if (request == null)
            {
                throw Error.ArgumentNull("request");
            }

            return request.GetRequestContainer().GetRequiredService<IEdmModel>();
        }

So the IEdmModel used by the serializer is a different one to the one set in [EnableQueryAttribute]!!!! (Actually it's slightly worse than this - if the type isn't in the EdmModel, we get a null ODataSerializer back. Only because WebApi falls back to the default JsonSerializer do we get a result at all!)

Fix/Workaround: The first thing I tried was to configure the RequestContainer as described in https://docs.microsoft.com/en-us/odata/webapi/dependencyinjection . This didn't really work for me because I only want to make changes to the IEdmModel used and still use the default IODataRoutingConventions etc.

I put together a simple IServiceProvider hook:

        class EdmModelServiceProvider : IServiceProvider
        {
            public readonly IServiceProvider ServiceProvider;
            public readonly IEdmModel EdmModel;
            public EdmModelServiceProvider(IServiceProvider serviceProvider, IEdmModel edmModel)
            {
                ServiceProvider = serviceProvider;
                EdmModel = edmModel;
            }

            public object GetService(Type serviceType)
            {
                if(serviceType == typeof(IEdmModel))
                {
                    return EdmModel;
                }
                return ServiceProvider.GetService(serviceType);
            }
        }

But how to register it? Fortunately, if you use the ODataFeature() extension methods you can modify the RequestContainer (actually an IServiceProvider):

///In CustomEnableQueryAttribute
        public override void ValidateQuery(HttpRequest request, ODataQueryOptions queryOptions)
        {
            //FYI, this is called before ApplyQuery
            var container = request.GetRequestContainer();
            var f = request.ODataFeature();
            f.RequestContainer = new EdmModelServiceProvider(container, MyEdmModel);
        }

Set up your custom IEdmModel and register it here before the serializer checks the model! NB: this lets you do some very cool things like return Anonymous Types in OData Queries =)

badcommandorfilename avatar Nov 28 '19 05:11 badcommandorfilename

OK!

After quite a lot of fun, I've figured out the cause (for me at least). #1595 made this quite hard, but fortunately I was able to debug the source locally.

Summary: In my case, I needed to make changes to the EdmModel during the query. [EnableQueryAttribute] even has this handy overridable method:

public virtual IEdmModel GetModel

The documentation says:

Override this method to customize the EDM model used for querying!

So when you use a custom IEdmModel with the [EnableQueryAttribute], OData will surely use the same model for the ODataEdmTypeSerializer... right? ... Right?

Details:

The problem starts here:

Hidden away in DefaultODataSerializerProvider is:

internal ODataSerializer GetODataPayloadSerializerImpl(Type type, Func<IEdmModel> modelFunction, ODataPath path, Type errorType)
{
...
            IEdmModel model = modelFunction();

            // if it is not a special type, assume it has a corresponding EdmType.
            ClrTypeCache typeMappingCache = model.GetTypeMappingCache();
            IEdmTypeReference edmType = typeMappingCache.GetEdmType(type, model);

            if (edmType != null)
            {
                bool isCountRequest = path != null && path.Segments.LastOrDefault() is CountSegment;
                bool isRawValueRequest = path != null && path.Segments.LastOrDefault() is ValueSegment;

                if (((edmType.IsPrimitive() || edmType.IsEnum()) && isRawValueRequest) || isCountRequest)
                {
                    return _rootContainer.GetRequiredService<ODataRawValueSerializer>();
                }
                else
                {
                    return GetEdmTypeSerializer(edmType);
                }
            }
            else
            {
                return null;
            }
}

Notice the "isCountRequest" - this is the bit that tells the serializer to add "@odata.count" to the response! So great, as long as the query type is found in IEdmModel model = modelFunction();, the ODataSerializer will know to print it out!

Ok... I see it gets the model lazily through modelFunction() - surely this is how it connects to

public virtual IEdmModel GetModel 

LOL NOPE! Let's have a look at the source of this Func:

    public partial class DefaultODataSerializerProvider : ODataSerializerProvider
    {
        /// <inheritdoc />
        /// <remarks>This signature uses types that are AspNetCore-specific.</remarks>
        public override ODataSerializer GetODataPayloadSerializer(Type type, HttpRequest request)
        {
            // Using a Func<IEdmModel> to delay evaluation of the model.
            return GetODataPayloadSerializerImpl(type, () => request.GetModel(), request.ODataFeature().Path, typeof(SerializableError));
        }
    }

Lead us to:

        public static IEdmModel GetModel(this HttpRequest request)
        {
            if (request == null)
            {
                throw Error.ArgumentNull("request");
            }

            return request.GetRequestContainer().GetRequiredService<IEdmModel>();
        }

So the IEdmModel used by the serializer is a different one to the one set in [EnableQueryAttribute]!!!! (Actually it's slightly worse than this - if the type isn't in the EdmModel, we get a null ODataSerializer back. Only because WebApi falls back to the default JsonSerializer do we get a result at all!)

Fix/Workaround: The first thing I tried was to configure the RequestContainer as described in https://docs.microsoft.com/en-us/odata/webapi/dependencyinjection . This didn't really work for me because I only want to make changes to the IEdmModel used and still use the default IODataRoutingConventions etc.

I put together a simple IServiceProvider hook:

        class EdmModelServiceProvider : IServiceProvider
        {
            public readonly IServiceProvider ServiceProvider;
            public readonly IEdmModel EdmModel;
            public EdmModelServiceProvider(IServiceProvider serviceProvider, IEdmModel edmModel)
            {
                ServiceProvider = serviceProvider;
                EdmModel = edmModel;
            }

            public object GetService(Type serviceType)
            {
                if(serviceType == typeof(IEdmModel))
                {
                    return EdmModel;
                }
                return ServiceProvider.GetService(serviceType);
            }
        }

But how to register it? Fortunately, if you use the ODataFeature() extension methods you can modify the RequestContainer (actually an IServiceProvider):

///In CustomEnableQueryAttribute
        public override void ValidateQuery(HttpRequest request, ODataQueryOptions queryOptions)
        {
            //FYI, this is called before ApplyQuery
            var container = request.GetRequestContainer();
            var f = request.ODataFeature();
            f.RequestContainer = new EdmModelServiceProvider(container, MyEdmModel);
        }

Set up your custom IEdmModel and register it here before the serializer checks the model! NB: this lets you do some very cool things like return Anonymous Types in OData Queries =)

OK!

After quite a lot of fun, I've figured out the cause (for me at least). #1595 made this quite hard, but fortunately I was able to debug the source locally.

Summary: In my case, I needed to make changes to the EdmModel during the query. [EnableQueryAttribute] even has this handy overridable method:

public virtual IEdmModel GetModel

The documentation says:

Override this method to customize the EDM model used for querying!

So when you use a custom IEdmModel with the [EnableQueryAttribute], OData will surely use the same model for the ODataEdmTypeSerializer... right? ... Right?

Details:

The problem starts here:

Hidden away in DefaultODataSerializerProvider is:

internal ODataSerializer GetODataPayloadSerializerImpl(Type type, Func<IEdmModel> modelFunction, ODataPath path, Type errorType)
{
...
            IEdmModel model = modelFunction();

            // if it is not a special type, assume it has a corresponding EdmType.
            ClrTypeCache typeMappingCache = model.GetTypeMappingCache();
            IEdmTypeReference edmType = typeMappingCache.GetEdmType(type, model);

            if (edmType != null)
            {
                bool isCountRequest = path != null && path.Segments.LastOrDefault() is CountSegment;
                bool isRawValueRequest = path != null && path.Segments.LastOrDefault() is ValueSegment;

                if (((edmType.IsPrimitive() || edmType.IsEnum()) && isRawValueRequest) || isCountRequest)
                {
                    return _rootContainer.GetRequiredService<ODataRawValueSerializer>();
                }
                else
                {
                    return GetEdmTypeSerializer(edmType);
                }
            }
            else
            {
                return null;
            }
}

Notice the "isCountRequest" - this is the bit that tells the serializer to add "@odata.count" to the response! So great, as long as the query type is found in IEdmModel model = modelFunction();, the ODataSerializer will know to print it out!

Ok... I see it gets the model lazily through modelFunction() - surely this is how it connects to

public virtual IEdmModel GetModel 

LOL NOPE! Let's have a look at the source of this Func:

    public partial class DefaultODataSerializerProvider : ODataSerializerProvider
    {
        /// <inheritdoc />
        /// <remarks>This signature uses types that are AspNetCore-specific.</remarks>
        public override ODataSerializer GetODataPayloadSerializer(Type type, HttpRequest request)
        {
            // Using a Func<IEdmModel> to delay evaluation of the model.
            return GetODataPayloadSerializerImpl(type, () => request.GetModel(), request.ODataFeature().Path, typeof(SerializableError));
        }
    }

Lead us to:

        public static IEdmModel GetModel(this HttpRequest request)
        {
            if (request == null)
            {
                throw Error.ArgumentNull("request");
            }

            return request.GetRequestContainer().GetRequiredService<IEdmModel>();
        }

So the IEdmModel used by the serializer is a different one to the one set in [EnableQueryAttribute]!!!! (Actually it's slightly worse than this - if the type isn't in the EdmModel, we get a null ODataSerializer back. Only because WebApi falls back to the default JsonSerializer do we get a result at all!)

Fix/Workaround: The first thing I tried was to configure the RequestContainer as described in https://docs.microsoft.com/en-us/odata/webapi/dependencyinjection . This didn't really work for me because I only want to make changes to the IEdmModel used and still use the default IODataRoutingConventions etc.

I put together a simple IServiceProvider hook:

        class EdmModelServiceProvider : IServiceProvider
        {
            public readonly IServiceProvider ServiceProvider;
            public readonly IEdmModel EdmModel;
            public EdmModelServiceProvider(IServiceProvider serviceProvider, IEdmModel edmModel)
            {
                ServiceProvider = serviceProvider;
                EdmModel = edmModel;
            }

            public object GetService(Type serviceType)
            {
                if(serviceType == typeof(IEdmModel))
                {
                    return EdmModel;
                }
                return ServiceProvider.GetService(serviceType);
            }
        }

But how to register it? Fortunately, if you use the ODataFeature() extension methods you can modify the RequestContainer (actually an IServiceProvider):

///In CustomEnableQueryAttribute
        public override void ValidateQuery(HttpRequest request, ODataQueryOptions queryOptions)
        {
            //FYI, this is called before ApplyQuery
            var container = request.GetRequestContainer();
            var f = request.ODataFeature();
            f.RequestContainer = new EdmModelServiceProvider(container, MyEdmModel);
        }

Set up your custom IEdmModel and register it here before the serializer checks the model! NB: this lets you do some very cool things like return Anonymous Types in OData Queries =)

Hello, I'm sorry for reviving an old thread but I'm experiencing a similar issue and I do not quite understand where the problem comes from. Just like you, I am having problems using the $count=true parameter and the /$count route, everything else works fine. From what I have read on the Internet, your comment seems to have the only solution/workaround to the problem, but I don't really get it. Could you explain it like I'm 5 please?

Thank you beforehand

rtrevinnoc avatar Dec 16 '22 00:12 rtrevinnoc

Hi @rtrevinnoc - This is pretty old and I don't have the reproduction case anymore, but IF your problem is the same as mine:

  • The EdmModel is used as the source of truth about what kind of object OData is working with
  • When the OData service bootstraps, different components either copy or reference it to decide how to format/serialize/aggregate/count etc as needed
  • If you update the EdmModel or try to override parts of it, some of the services might not be using the same EdmModel anymore.
  • This can cause unexpected behavior: in my case the serializer thought the response should be one type but the aggregation service couldn't figure out what type it was when I used $count)

Having said all that, this was for a very old version of OData so things probably have changed. If you've got a specific issue, better to open a ticket describing it in detail and reference this as a possible cause. Good luck!

badcommandorfilename avatar Dec 27 '22 21:12 badcommandorfilename