OData / AspNetCoreOData

ASP.NET Core OData: A server library built upon ODataLib and ASP.NET Core
Other
454 stars 161 forks source link

Instance Annotations for properties with $select query option #1198

Open robertrutkowski opened 6 months ago

robertrutkowski commented 6 months ago

Describe the bug I would like to add instance annotation to a property (OwnerId) by including an additional property (OwnerIdName) as its value. This setup works as expected without using the $select query option. However, when $select is applied, the resourceContext.ResourceInstance does not include the OwnerIdName property, leading to a null reference error because the OwnerIdName is not fetched and thus cannot be added as an instance annotation.

I am seeking advice on how to ensure OwnerIdName is included as an instance annotation for OwnerId when $select is applied, or how to work around this issue

Code I've created:

Below i've attached my codes, result without $select and error with $select param. Additionally, i've also added codes to my repo.

public class CustomODataSerializerProvider : ODataSerializerProvider
    {
        private IServiceProvider _rootProvider;

        public CustomODataSerializerProvider(IServiceProvider rootContainer) : base(rootContainer)
        {
            _rootProvider = rootContainer;
        }

        public override IODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference edmType)
        {
            if (edmType.Definition.TypeKind == EdmTypeKind.Entity)
            {
                return new CustomODataResourceSerializer(this);
            }

            return base.GetEdmTypeSerializer(edmType);
        }
    }
    public class CustomODataResourceSerializer(IODataSerializerProvider provider) : ODataResourceSerializer(provider)
    {
        public override ODataResource CreateResource(SelectExpandNode selectExpandNode, ResourceContext resourceContext)
        {
            var resource = base.CreateResource(selectExpandNode, resourceContext);
            if (resource != null)
            {
                var resourceContextPropDictionary = GetPropertiesDictionary(resourceContext.ResourceInstance);

                foreach (var prop in resource.Properties)
                {
                    var propNameToLower = prop.Name.ToLower();

                    if (resourceContextPropDictionary.TryGetValue($"{propNameToLower}name", out object lookupNamePropValue))
                    {
                        prop.InstanceAnnotations.Add(new ODataInstanceAnnotation("lookup.name", new ODataPrimitiveValue(lookupNamePropValue)));
                    }
                }
            }
            return resource;
        }

        private Dictionary<string, object> GetPropertiesDictionary(object obj)
        {
            Dictionary<string, object> propertiesDictionary = new Dictionary<string, object>();
            PropertyInfo[] properties = obj.GetType().GetProperties();

            foreach (var property in properties)
            {
                object value = property.GetValue(obj);
                propertiesDictionary.Add(property.Name.ToLower(), value);
            }

            return propertiesDictionary;
        }
    }
    public static IEdmModel CreateEdmModel()
    {
        var builder = new ODataConventionModelBuilder();

        var accountConfiguration = builder.EntitySet<Account>("Accounts").EntityType;
        accountConfiguration.Ignore(x => x.OwnerIdName);

        return builder.GetEdmModel();
    }
builder.Services.AddControllers()
    .AddOData(opt => opt.AddRouteComponents("odata", EdmModelBuilder.CreateEdmModel(), 
            builder => builder.AddSingleton<IODataSerializerProvider, CustomODataSerializerProvider>()).EnableQueryFeatures());
    public class Account
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public Guid OwnerId { get; set; }
        public string OwnerIdName { get; set; }
    }

    public class AccountsController() : ODataController
    {
        private static readonly IList<Account> accounts =
        [
            new() { Id = Guid.NewGuid(), Name = "Interstellar Mining Corp", OwnerId = Guid.NewGuid(), OwnerIdName = "User 1" },
            new() { Id = Guid.NewGuid(), Name = "Quantum Communications", OwnerId = Guid.NewGuid(), OwnerIdName = "User 2" },
            new() { Id = Guid.NewGuid(), Name = "Galaxy Graphics", OwnerId = Guid.NewGuid(), OwnerIdName = "User 3" }
        ];

        [HttpGet]
        [EnableQuery]
        public IEnumerable<Account> Get()
        {
            return accounts;
        }
    }

Result without $select param:

{
    "@odata.context": "https://localhost:7243/odata/$metadata#Accounts",
    "value": [
        {
            "Id": "fb752e65-4649-447d-859e-bc3d5349edee",
            "Name": "Interstellar Mining Corp",
            "OwnerId@lookup.name": "User 1",
            "OwnerId": "f04b6814-f409-467e-84bf-f993bff69d7b"
        },
        {
            "Id": "1bbfc7fe-04ae-4b4a-adfc-b77e8251cd67",
            "Name": "Quantum Communications",
            "OwnerId@lookup.name": "User 2",
            "OwnerId": "80d96bd6-74cd-4970-b071-dc10264b3aeb"
        },
        {
            "Id": "7c50381d-0007-48b1-9e24-b04c249f7004",
            "Name": "Galaxy Graphics",
            "OwnerId@lookup.name": "User 3",
            "OwnerId": "a71672ac-9e39-446a-bf1d-99775d2178a8"
        }
    ]
}

Error with $select param (line 23 in file CustomODataResourceSerializer.cs):

System.ArgumentNullException: 'Cannot create an ODataPrimitiveValue from null; use ODataNullValue instead.'
xuzhg commented 5 months ago

@robertrutkowski When you use $select, by default only the select properties (maybe the key) are included in the final result. It's for perf and the purpose of $select.

So, in your scenario, you should customize the default Select binder to include the extra properties in the final result.

You can see my changes based on your repro at https://github.com/xuzhg/WebApiSample/blob/main/v8.x/OData.Annotations.Example/readme.md

Let us know any other questions.