diff --git a/AspNetCoreOData.sln b/AspNetCoreOData.sln index 06a9b8f0a..a6e6b4a6c 100644 --- a/AspNetCoreOData.sln +++ b/AspNetCoreOData.sln @@ -29,6 +29,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ODataAlternateKeySample", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BenchmarkServer", "sample\BenchmarkServer\BenchmarkServer.csproj", "{8346AD1B-00E3-462D-B6B1-9AA3C2FB2850}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ODataMiniApi", "sample\ODataMiniApi\ODataMiniApi.csproj", "{53645862-60E1-403A-B9BB-911D3801010F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +77,10 @@ Global {8346AD1B-00E3-462D-B6B1-9AA3C2FB2850}.Debug|Any CPU.Build.0 = Debug|Any CPU {8346AD1B-00E3-462D-B6B1-9AA3C2FB2850}.Release|Any CPU.ActiveCfg = Release|Any CPU {8346AD1B-00E3-462D-B6B1-9AA3C2FB2850}.Release|Any CPU.Build.0 = Release|Any CPU + {53645862-60E1-403A-B9BB-911D3801010F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53645862-60E1-403A-B9BB-911D3801010F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53645862-60E1-403A-B9BB-911D3801010F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53645862-60E1-403A-B9BB-911D3801010F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -90,6 +96,7 @@ Global {647EFCFA-55A7-4F0A-AD40-4B6EB1BFCFFA} = {B1F86961-6958-4617-ACA4-C231F95AE099} {7B153669-A42F-4511-8BDB-587B3B27B2F3} = {B1F86961-6958-4617-ACA4-C231F95AE099} {8346AD1B-00E3-462D-B6B1-9AA3C2FB2850} = {B1F86961-6958-4617-ACA4-C231F95AE099} + {53645862-60E1-403A-B9BB-911D3801010F} = {B1F86961-6958-4617-ACA4-C231F95AE099} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {540C9752-AAC0-49EA-BA60-78490C90FF86} diff --git a/pipelines/azure_pipelines_nightly.yml b/pipelines/azure_pipelines_nightly.yml index ae465ab28..36c75f05b 100644 --- a/pipelines/azure_pipelines_nightly.yml +++ b/pipelines/azure_pipelines_nightly.yml @@ -24,6 +24,8 @@ variables: value: anycpu - name: ProductBinPath value: $(Build.SourcesDirectory)\bin\$(BuildPlatform)\$(BuildConfiguration) +- group: OData-ESRP-CodeSigning + extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: @@ -125,10 +127,16 @@ extends: displayName: Use .NET Core SDK 2.x inputs: version: 2.x - - task: EsrpCodeSigning@1 + - task: EsrpCodeSigning@5 displayName: ESRP CodeSigning - WebAPI OData Product Signing inputs: - ConnectedServiceName: 'ESRP CodeSigning - OData' + ConnectedServiceName: $(ODataEsrpConnectionServiceName) + AppRegistrationClientId: '$(ODataEsrpAppRegistrationClientId)' + AppRegistrationTenantId: '$(ODataEsrpAppRegistrationTenantId)' + AuthAKVName: $(ODataEsrpAuthAKVName) + AuthCertName: $(ODataEsrpAuthCertName) + AuthSignCertName: $(ODataEsrpAuthSignCertName) + ServiceEndpointUrl: '$(ODataEsrpServiceEndpointUrl)' FolderPath: $(ProductBinPath) Pattern: Microsoft.AspNetCore.OData.dll,Microsoft.AspNetCore.OData.NewtonsoftJson.dll signConfigType: inlineSignParams @@ -216,10 +224,16 @@ extends: inputs: command: custom arguments: pack $(Build.SourcesDirectory)\src\Microsoft.AspNetCore.OData.NewtonsoftJson.Release.nuspec -NonInteractive -OutputDirectory $(Build.ArtifactStagingDirectory)\Nuget -Properties Configuration=$(BuildConfiguration);ProductRoot=$(ProductBinPath);SourcesRoot=$(Build.SourcesDirectory);VersionFullSemantic=$(VersionFullSemantic);NightlyBuildVersion=$(VersionNugetNightlyBuild);VersionNuGetSemantic=$(VersionNuGetSemantic) -Verbosity Detailed -Symbols -SymbolPackageFormat snupkg - - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@1 + - task: EsrpCodeSigning@5 displayName: ESRP CodeSigning - WebAPI OData Packages Signing inputs: - ConnectedServiceName: ESRP CodeSigning - OData + ConnectedServiceName: $(ODataEsrpConnectionServiceName) + AppRegistrationClientId: '$(ODataEsrpAppRegistrationClientId)' + AppRegistrationTenantId: '$(ODataEsrpAppRegistrationTenantId)' + AuthAKVName: $(ODataEsrpAuthAKVName) + AuthCertName: $(ODataEsrpAuthCertName) + AuthSignCertName: $(ODataEsrpAuthSignCertName) + ServiceEndpointUrl: '$(ODataEsrpServiceEndpointUrl)' FolderPath: $(Build.ArtifactStagingDirectory)\Nuget Pattern: '*.nupkg' signConfigType: inlineSignParams diff --git a/sample/ODataMiniApi/AppDb.cs b/sample/ODataMiniApi/AppDb.cs new file mode 100644 index 000000000..53cdebf2c --- /dev/null +++ b/sample/ODataMiniApi/AppDb.cs @@ -0,0 +1,180 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.EntityFrameworkCore; + +namespace ODataMiniApi; + +public class AppDb : DbContext +{ + public AppDb(DbContextOptions options) : base(options) { } + + public DbSet Schools => Set(); + + public DbSet Students => Set(); + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasKey(x => x.SchoolId); + modelBuilder.Entity().HasKey(x => x.StudentId); + modelBuilder.Entity().OwnsOne(x => x.MailAddress); + + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().OwnsOne(x => x.Info); + } +} + +static class AppDbExtension +{ + public static void MakeSureDbCreated(this WebApplication app) + { + using (var scope = app.Services.CreateScope()) + { + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + + if (context.Schools.Count() == 0) + { + #region Students + var students = new List + { + // Mercury school + new Student { SchoolId = 1, StudentId = 10, FirstName = "Spens", LastName = "Alex", FavoriteSport = "Soccer", Grade = 87, BirthDay = new DateOnly(2009, 11, 15) }, + new Student { SchoolId = 1, StudentId = 11, FirstName = "Jasial", LastName = "Eaine", FavoriteSport = "Basketball", Grade = 45, BirthDay = new DateOnly(1989, 8, 3) }, + new Student { SchoolId = 1, StudentId = 12, FirstName = "Niko", LastName = "Rorigo", FavoriteSport = "Soccer", Grade = 78, BirthDay = new DateOnly(2019, 5, 5) }, + new Student { SchoolId = 1, StudentId = 13, FirstName = "Roy", LastName = "Rorigo", FavoriteSport = "Tennis", Grade = 67, BirthDay = new DateOnly(1975, 11, 4) }, + new Student { SchoolId = 1, StudentId = 14, FirstName = "Zaral", LastName = "Clak", FavoriteSport = "Basketball", Grade = 54, BirthDay = new DateOnly(2008, 1, 4) }, + + // Venus school + new Student { SchoolId = 2, StudentId = 20, FirstName = "Hugh", LastName = "Briana", FavoriteSport = "Basketball", Grade = 78, BirthDay = new DateOnly(1959, 5, 6) }, + new Student { SchoolId = 2, StudentId = 21, FirstName = "Reece", LastName = "Len", FavoriteSport = "Basketball", Grade = 45, BirthDay = new DateOnly(2004, 2, 5) }, + new Student { SchoolId = 2, StudentId = 22, FirstName = "Javanny", LastName = "Jay", FavoriteSport = "Soccer", Grade = 87, BirthDay = new DateOnly(2003, 6, 5) }, + new Student { SchoolId = 2, StudentId = 23, FirstName = "Ketty", LastName = "Oak", FavoriteSport = "Tennis", Grade = 99, BirthDay = new DateOnly(1998, 7, 25) }, + + // Earth School + new Student { SchoolId = 3, StudentId = 30, FirstName = "Mike", LastName = "Wat", FavoriteSport = "Tennis", Grade = 93, BirthDay = new DateOnly(1999, 5, 15) }, + new Student { SchoolId = 3, StudentId = 31, FirstName = "Sam", LastName = "Joshi", FavoriteSport = "Soccer", Grade = 78, BirthDay = new DateOnly(2000, 6, 23) }, + new Student { SchoolId = 3, StudentId = 32, FirstName = "Kerry", LastName = "Travade", FavoriteSport = "Basketball", Grade = 89, BirthDay = new DateOnly(2001, 2, 6) }, + new Student { SchoolId = 3, StudentId = 33, FirstName = "Pett", LastName = "Jay", FavoriteSport = "Tennis", Grade = 63, BirthDay = new DateOnly(1998, 11, 7) }, + + // Mars School + new Student { SchoolId = 4, StudentId = 40, FirstName = "Mike", LastName = "Wat", FavoriteSport = "Soccer", Grade = 64, BirthDay = new DateOnly(2011, 11, 15) }, + new Student { SchoolId = 4, StudentId = 41, FirstName = "Sam", LastName = "Joshi", FavoriteSport = "Basketball", Grade = 98, BirthDay = new DateOnly(2005, 6, 6) }, + new Student { SchoolId = 4, StudentId = 42, FirstName = "Kerry", LastName = "Travade", FavoriteSport = "Soccer", Grade = 88, BirthDay = new DateOnly(2011, 5, 13) }, + + // Jupiter School + new Student { SchoolId = 5, StudentId = 50, FirstName = "David", LastName = "Padron", FavoriteSport = "Tennis", Grade = 77, BirthDay = new DateOnly(2015, 12, 3) }, + new Student { SchoolId = 5, StudentId = 53, FirstName = "Jeh", LastName = "Brook", FavoriteSport = "Basketball", Grade = 69, BirthDay = new DateOnly(2014, 10, 15) }, + new Student { SchoolId = 5, StudentId = 54, FirstName = "Steve", LastName = "Johnson", FavoriteSport = "Soccer", Grade = 100, BirthDay = new DateOnly(1995, 3, 2) }, + + // Saturn School + new Student { SchoolId = 6, StudentId = 60, FirstName = "John", LastName = "Haney", FavoriteSport = "Soccer", Grade = 99, BirthDay = new DateOnly(2008, 12, 1) }, + new Student { SchoolId = 6, StudentId = 61, FirstName = "Morgan", LastName = "Frost", FavoriteSport = "Tennis", Grade = 17, BirthDay = new DateOnly(2009, 11, 4) }, + new Student { SchoolId = 6, StudentId = 62, FirstName = "Jennifer", LastName = "Viles", FavoriteSport = "Basketball", Grade = 54, BirthDay = new DateOnly(1989, 3, 15) }, + + // Uranus School + new Student { SchoolId = 7, StudentId = 72, FirstName = "Matt", LastName = "Dally", FavoriteSport = "Basketball", Grade = 77, BirthDay = new DateOnly(2011, 11, 4) }, + new Student { SchoolId = 7, StudentId = 73, FirstName = "Kevin", LastName = "Vax", FavoriteSport = "Basketball", Grade = 93, BirthDay = new DateOnly(2012, 5, 12) }, + new Student { SchoolId = 7, StudentId = 76, FirstName = "John", LastName = "Clarey", FavoriteSport = "Soccer", Grade = 95, BirthDay = new DateOnly(2008, 8, 8) }, + + // Neptune School + new Student { SchoolId = 8, StudentId = 81, FirstName = "Adam", LastName = "Singh", FavoriteSport = "Tennis", Grade = 92, BirthDay = new DateOnly(2006, 6, 23) }, + new Student { SchoolId = 8, StudentId = 82, FirstName = "Bob", LastName = "Joe", FavoriteSport = "Soccer", Grade = 88, BirthDay = new DateOnly(1978, 11, 15) }, + new Student { SchoolId = 8, StudentId = 84, FirstName = "Martin", LastName = "Dalton", FavoriteSport = "Tennis", Grade = 77, BirthDay = new DateOnly(2017, 5, 14) }, + + // Pluto School + new Student { SchoolId = 9, StudentId = 91, FirstName = "Michael", LastName = "Wu", FavoriteSport = "Soccer", Grade = 97, BirthDay = new DateOnly(2022, 9, 22) }, + new Student { SchoolId = 9, StudentId = 93, FirstName = "Rachel", LastName = "Wottle", FavoriteSport = "Soccer", Grade = 81, BirthDay = new DateOnly(2022, 10, 5) }, + new Student { SchoolId = 9, StudentId = 97, FirstName = "Aakash", LastName = "Aarav", FavoriteSport = "Soccer", Grade = 98, BirthDay = new DateOnly(2003, 3, 15) }, + + // Shyline high School + new Student { SchoolId = 10, StudentId = 101, FirstName = "Steve", LastName = "Chu", FavoriteSport = "Soccer", Grade = 77, BirthDay = new DateOnly(2002, 11, 12) }, + new Student { SchoolId = 10, StudentId = 123, FirstName = "Wash", LastName = "Dish", FavoriteSport = "Tennis", Grade = 81, BirthDay = new DateOnly(2002, 12, 5) }, + new Student { SchoolId = 10, StudentId = 106, FirstName = "Ren", LastName = "Wu", FavoriteSport = "Soccer", Grade = 88, BirthDay = new DateOnly(2003, 3, 15) } + }; + + foreach (var s in students) + { + context.Students.Add(s); + } + #endregion + + #region Schools + var schools = new List + { + new School { SchoolId = 1, SchoolName = "Mercury Middle School", MailAddress = new Address { ApartNum = 241, City = "Kirk", Street = "156TH AVE", ZipCode = "98051" } }, + new HighSchool { SchoolId = 2, SchoolName = "Venus High School", MailAddress = new Address { ApartNum = 543, City = "AR", Street = "51TH AVE PL", ZipCode = "98043" }, NumberOfStudents = 1187, PrincipalName = "Venus TT" }, + new School { SchoolId = 3, SchoolName = "Earth University", MailAddress = new Address { ApartNum = 101, City = "Belly", Street = "24TH ST", ZipCode = "98029" } }, + new School { SchoolId = 4, SchoolName = "Mars Elementary School ", MailAddress = new Address { ApartNum = 123, City = "Issaca", Street = "Mars Rd", ZipCode = "98023" } }, + new School { SchoolId = 5, SchoolName = "Jupiter College", MailAddress = new Address { ApartNum = 443, City = "Redmond", Street = "Sky Freeway", ZipCode = "78123" } }, + new School { SchoolId = 6, SchoolName = "Saturn Middle School", MailAddress = new Address { ApartNum = 11, City = "Moon", Street = "187TH ST", ZipCode = "68133" } }, + new HighSchool { SchoolId = 7, SchoolName = "Uranus High School", MailAddress = new Address { ApartNum = 123, City = "Greenland", Street = "Sun Street", ZipCode = "88155" }, NumberOfStudents = 886, PrincipalName = "Uranus Sun" }, + new School { SchoolId = 8, SchoolName = "Neptune Elementary School", MailAddress = new Address { ApartNum = 77, City = "BadCity", Street = "Moon way", ZipCode = "89155" } }, + new School { SchoolId = 9, SchoolName = "Pluto University", MailAddress = new Address { ApartNum = 12004, City = "Sahamish", Street = "Universals ST", ZipCode = "10293" } }, + new HighSchool { SchoolId =10, SchoolName = "Shyline High School", MailAddress = new Address { ApartNum = 4004, City = "Sammamish", Street = "8TH ST", ZipCode = "98029"}, NumberOfStudents = 976, PrincipalName = "Laly Fort" } + }; + + foreach (var s in schools) + { + s.Students = students.Where(std => std.SchoolId == s.SchoolId).ToList(); + + context.Schools.Add(s); + } + #endregion + + context.SaveChanges(); + } + + if (context.Customers.Count() == 0) + { + #region Customers and Orders + + var customers = new List + { + new Customer { Id = 1, Name = "Alice", Info = new Info { Email = "alice@example.com", Phone = "123-456-7819" }, + Orders = [ + new Order { Id = 11, Amount = 9}, + new Order { Id = 12, Amount = 19}, + ] }, + new Customer { Id = 2, Name = "Johnson", Info = new Info { Email = "johnson@abc.com", Phone = "233-468-7289" }, + Orders = [ + new Order { Id = 21, Amount =8}, + new Order { Id = 22, Amount = 76}, + ] }, + new Customer { Id = 3, Name = "Peter", Info = new Info { Email = "peter@earth.org", Phone = "223-656-7889" }, + Orders = [ + new Order { Id = 32, Amount = 7 } + ] }, + + new Customer { Id = 4, Name = "Sam", Info = new Info { Email = "sam@ms.edu", Phone = "245-876-0989" }, + Orders = [ + new Order { Id = 41, Amount = 5 }, + new Order { Id = 42, Amount = 32} + ] } + }; + + foreach (var s in customers) + { + context.Customers.Add(s); + foreach (var o in s.Orders) + { + context.Orders.Add(o); + } + } + #endregion + + context.SaveChanges(); + } + } + } +} diff --git a/sample/ODataMiniApi/AppModels.cs b/sample/ODataMiniApi/AppModels.cs new file mode 100644 index 000000000..59e26fd09 --- /dev/null +++ b/sample/ODataMiniApi/AppModels.cs @@ -0,0 +1,109 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ODataMiniApi; + +public class EdmModelBuilder +{ + public static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("Schools"); + builder.ComplexType
(); + builder.ComplexType(); + + builder.EntitySet("Customers"); + builder.EntitySet("Orders"); + builder.ComplexType(); + + var action = builder.EntityType().Action("RateByName"); + action.Parameter("name"); + action.Parameter("age"); + action.Returns(); + + builder.EntityType().Collection.Action("Rating").Parameter("p"); + + return builder.GetEdmModel(); + } +} + +public class School +{ + public int SchoolId { get; set; } + + public string SchoolName { get; set; } + + public Address MailAddress { get; set; } + + public virtual IList Students { get; set; } +} + +public class HighSchool : School +{ + // Additional properties specific to HighSchool + public int NumberOfStudents { get; set; } + public string PrincipalName { get; set; } +} + +public class Student +{ + public int StudentId { get; set; } + + public string FirstName { get; set; } + + public string LastName { get; set; } + + public string FavoriteSport { get; set; } + + public int Grade { get; set; } + + public int SchoolId { get; set; } + + public DateOnly BirthDay { get; set; } +} + +[ComplexType] +public class Address +{ + public int ApartNum { get; set; } + + public string City { get; set; } + + public string Street { get; set; } + + public string ZipCode { get; set; } +} + + +public class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } + + public Info Info { get; set; } + + //[AutoExpand] + public List Orders { get; set; } +} + +public class Order +{ + public int Id { get; set; } + + public int Amount { get; set; } +} + +public class Info +{ + public string Email { get; set; } + public string Phone { get; set; } +} \ No newline at end of file diff --git a/sample/ODataMiniApi/CustomersAndOrders.http b/sample/ODataMiniApi/CustomersAndOrders.http new file mode 100644 index 000000000..85f620bd5 --- /dev/null +++ b/sample/ODataMiniApi/CustomersAndOrders.http @@ -0,0 +1,132 @@ +@ODataMiniApi_HostAddress = http://localhost:5177 + +### + +GET {{ODataMiniApi_HostAddress}}/customers + +### [AutoExpand] +### 4.01 Delta Payload Get "odata." prefix even in 4.01? +### + +### + +GET {{ODataMiniApi_HostAddress}}/v0/customers + +### + +GET {{ODataMiniApi_HostAddress}}/v00/customers?$select=name&$expand=info&$top=2&$orderby=id +### + +GET {{ODataMiniApi_HostAddress}}/v1/customers?$select=name,info&$top=2&$orderby=id + +### +GET {{ODataMiniApi_HostAddress}}/v1/customers?$select=name&$top=2&$orderby=id&$expand=info +### +GET {{ODataMiniApi_HostAddress}}/v1/customers?$select=name,info&$top=2&$orderby=id%20desc + +### + +GET {{ODataMiniApi_HostAddress}}/v2/customers?$select=name&$top=2&$orderby=id + +### +GET {{ODataMiniApi_HostAddress}}/v2/customers?$select=name&$top=2&$orderby=id&$expand=orders($select=amount) +### +GET {{ODataMiniApi_HostAddress}}/v2/customers?$select=name,info&$top=2&$orderby=id%20desc + +### +# GET {{ODataMiniApi_HostAddress}}/v11/customers?$select=name&$expand=info // throw exception because 'info' is not navigation property +GET {{ODataMiniApi_HostAddress}}/v11/customers?$select=name,info + +### +GET {{ODataMiniApi_HostAddress}}/v3/customers + +### +GET {{ODataMiniApi_HostAddress}}/v5/customers +// throw exception since the delegate needs parameters, but we cannot easily provide the parameters. + + +### $filter=amount gt 15 +GET {{ODataMiniApi_HostAddress}}/v0/?$filter=amount%20gt%2015 + +### +GET {{ODataMiniApi_HostAddress}}/v1/orders?$filter=amount%20gt%2015 +### +GET {{ODataMiniApi_HostAddress}}/v2/orders?$filter=amount%20gt%2015 + + +### + +PATCH {{ODataMiniApi_HostAddress}}/v1/customers/1 +Content-Type: application/json + +{ + "name": "kerry" +} + +### + +POST {{ODataMiniApi_HostAddress}}/v1/customers/1/rateByName +Content-Type: application/json + +{ + "name": "kerry", + "age":16 +} + +### + +# You will get the following response: +# +#{ +# "@odata.context": "http://localhost:5177/$metadata#Edm.String", +# "value": "EdmActionName: 'Rating': rate based on '8'" +#} + +POST {{ODataMiniApi_HostAddress}}/v1/customers/rating +Content-Type: application/json + +{ + "p": 8 +} + +### + +# The following is the test case for patch deltaset (changes) to an entity set. +# You will get the following response: +# +#{ +# "@odata.context": "http://localhost:5177/$metadata#Edm.String", +# "value": "Patch : '4' to customers" +#} + +PATCH {{ODataMiniApi_HostAddress}}/v1/customers +Content-Type: application/json + +{ + "@odata.context":"http://localhost/v1/$metadata#Customers/$delta", + "value":[ + { + "@odata.id":"Customers(42)", + "Name":"Microsoft" + }, + { + "@odata.context":"http://localhost/v1/$metadata#Customers/$deletedLink", + "source":"Customers(32)", + "relationship":"Orders", + "target":"Orders(12)" + }, + { + "@odata.context":"http://localhost/v1/$metadata#Customers/$link", + "source":"Customers(22)", + "relationship":"Orders", + "target":"Orders(2)" + }, + { + "@odata.context":"http://localhost/v1/$metadata#Customers/$deletedEntity", + "id":"Customers(12)", + "reason":"deleted" + } + ] +} + +### \ No newline at end of file diff --git a/sample/ODataMiniApi/Endpoints/CustomersEndpoints.cs b/sample/ODataMiniApi/Endpoints/CustomersEndpoints.cs new file mode 100644 index 000000000..28a90e423 --- /dev/null +++ b/sample/ODataMiniApi/Endpoints/CustomersEndpoints.cs @@ -0,0 +1,211 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.EntityFrameworkCore; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace ODataMiniApi.Students; + +/// +/// Add customers endpoint +/// +public static class CustomersEndpoints +{ + public static IEndpointRouteBuilder MapCustomersEndpoints(this IEndpointRouteBuilder app, IEdmModel model) + { + app.MapGet("/customers", (AppDb db) => db.Customers.Include(s => s.Orders)) + //.WithODa + .WithODataResult() // default: built the complex type property by default? + // If enable Query, define them as entity type + // If no query, define them as complex type? + .WithODataModel(model) + .WithODataVersion(ODataVersion.V401) + .WithODataBaseAddressFactory(c => new Uri("http://abc.com")); + //.WithODataServices(c => c.AddSingleton; + + // app.MapGet("v0/customers", (AppDb db) => Results.Extensions.AsOData(db.Customers.Include(s => s.Orders))); + + //app.MapGet("v00/customers", (AppDb db, ODataQueryOptions queryOptions) => + //{ + // db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; // This line seems required otherwise it will throw exception + // var result = queryOptions.ApplyTo(db.Customers.Include(s => s.Orders)); + // return Results.Extensions.AsOData(result); + //}); + + app.MapGet("v1/customers", (AppDb db, ODataQueryOptions queryOptions) => + { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; // This line seems required otherwise it will throw exception + return queryOptions.ApplyTo(db.Customers.Include(s => s.Orders)); + }) + .WithODataResult() + .WithODataModel(model) + ; + + // Be noted, [ODataModelConfiguration] configures the 'Info' as complex type + // So, in this case, the 'Info' property on 'Customer' is a structural property, not a navigation property. + app.MapGet("v11/customers", (AppDb db, [ODataModelConfiguration] ODataQueryOptions queryOptions) => + { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return queryOptions.ApplyTo(db.Customers.Include(s => s.Orders)); + }); + + app.MapGet("v2/customers", (AppDb db) => + { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return db.Customers.Include(s => s.Orders); + }) + .AddODataQueryEndpointFilter() + .WithODataResult() + //.WithODataModel(model) + ; + + //app.MapGet("v3/customers", (AppDb db) => + //{ + // db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + // return Results.Extensions.AsOData(db.Customers.Include(s => s.Orders)); + //}) + // .AddODataQueryEndpointFilter() + + // //.WithODataModel(model) + // ; + + // To discuss? To provide the MapODataGet, MapODataPost, MapODataPatch.... + // It seems we cannot generate the Delegate easily. + //app.MapODataGet("v5/customers", (AppDb db) => + //{ + // db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + // return db.Customers.Include(s => s.Orders); + //}); + + app.MapPatch("v1/customers/{id}", (AppDb db, int id, Delta delta) => + { + Customer customer = db.Customers.FirstOrDefault(s => s.Id == id); + if (customer == null) + { + return null; // should return Results.NotFound(); + }; + + delta.Patch(customer); + + return customer; + }) + .WithODataResult() + .WithODataModel(model) + .WithODataPathFactory( + (h, t) => + { + string idStr = h.GetRouteValue("id") as string; + int id = int.Parse(idStr); + IEdmEntitySet customers = model.FindDeclaredEntitySet("Customers"); + + IDictionary keysValues = new Dictionary(); + keysValues["Id"] = id; + return new ODataPath(new EntitySetSegment(customers), new KeySegment(keysValues, customers.EntityType, customers)); + }); + + app.MapPost("v1/customers/{id}/rateByName", (AppDb db, int id, ODataActionParameters parameters) => + { + Customer customer = db.Customers.FirstOrDefault(s => s.Id == id); + if (customer == null) + { + return null; // should return Results.NotFound(); + }; + + return $"{customer.Name}: {System.Text.Json.JsonSerializer.Serialize(parameters)}"; + }) + .WithODataResult() + .WithODataModel(model) + .WithODataPathFactory( + (h, t) => + { + string idStr = h.GetRouteValue("id") as string; + int id = int.Parse(idStr); + IEdmEntitySet customers = model.FindDeclaredEntitySet("Customers"); + + IDictionary keysValues = new Dictionary(); + keysValues["Id"] = id; + + IEdmAction action = model.SchemaElements.OfType().First(a => a.Name == "RateByName"); + + return new ODataPath(new EntitySetSegment(customers), + new KeySegment(keysValues, customers.EntityType, customers), + new OperationSegment(action, null) + ); + }); + + app.MapPost("v1/customers/rating", (AppDb db, ODataUntypedActionParameters parameters) => + { + return $"EdmActionName: '{parameters.Action.Name}': rate based on '{parameters["p"]}'"; + }) + .WithODataResult() + .WithODataModel(model) + .WithODataPathFactory( + (h, t) => + { + IEdmEntitySet customers = model.FindDeclaredEntitySet("Customers"); + IEdmAction action = model.SchemaElements.OfType().First(a => a.Name == "Rating"); + + return new ODataPath(new EntitySetSegment(customers), + new OperationSegment(action, null) + ); + }); + + // DeltaSet + app.MapPatch("v1/customers", (AppDb db, DeltaSet changes) => + { + return $"Patch : '{changes.Count}' to customers"; + }) + .WithODataResult() + .WithODataModel(model) + .WithODataPathFactory( + (h, t) => + { + IEdmEntitySet customers = model.FindDeclaredEntitySet("Customers"); + return new ODataPath(new EntitySetSegment(customers)); + }); + return app; + } + + // Scenarios using groups + public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder app, IEdmModel model) + { + var group = app.MapGroup("") + .WithODataResult() + .WithODataModel(model); + + group.MapGet("v0/orders", (AppDb db) => db.Orders) + ; + + group.MapGet("v1/orders", (AppDb db, ODataQueryOptions queryOptions) => + { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; // This line seems required otherwise it will throw exception + return queryOptions.ApplyTo(db.Orders); + }) + // .WithODataResult() + // .WithODataModel(model) + ; + + group.MapGet("v2/orders", (AppDb db) => + { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return db.Orders; + }) + .AddODataQueryEndpointFilter() + //.WithODataResult() + //.WithODataModel(model) + ; + + return app; + } + +} diff --git a/sample/ODataMiniApi/MetadataHandler.cs b/sample/ODataMiniApi/MetadataHandler.cs new file mode 100644 index 000000000..0ee73b0cf --- /dev/null +++ b/sample/ODataMiniApi/MetadataHandler.cs @@ -0,0 +1,90 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Text; +using System.Text.Json; +using System.Xml; +using Microsoft.AspNetCore.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Csdl; +using Microsoft.OData.Edm.Validation; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace ODataMiniApi; + + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] +public class ODataModelConfigurationAttribute : Attribute, IODataModelConfiguration +{ + public ODataModelBuilder Apply(HttpContext context, ODataModelBuilder builder, Type clrType) + { + if (clrType == typeof(Customer)) + { + builder.AddComplexType(typeof(Info)); + } + + return builder; + } +} + +/* +public class CustomizedMetadataHandler : ODataMetadataHandler +{ + protected override async ValueTask WriteAsJsonAsync(HttpContext context, IEdmModel model) + { + context.Response.ContentType = "application/json"; + + JsonWriterOptions options = new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = false, + SkipValidation = false + }; + + // we can't use response body directly since ODL writes the JSON CSDL using Synchronous operations. + using (MemoryStream memStream = new MemoryStream()) + { + using (Utf8JsonWriter jsonWriter = new Utf8JsonWriter(memStream, options)) + { + CsdlJsonWriterSettings settings = new CsdlJsonWriterSettings(); + settings.IsIeee754Compatible = true; + IEnumerable errors; + bool ok = CsdlWriter.TryWriteCsdl(model, jsonWriter, settings, out errors); + jsonWriter.Flush(); + } + + memStream.Seek(0, SeekOrigin.Begin); + string output = new StreamReader(memStream).ReadToEnd(); + await context.Response.WriteAsync(output).ConfigureAwait(false); + } + } + + protected override async ValueTask WriteAsXmlAsync(HttpContext context, IEdmModel model) + { + context.Response.ContentType = "application/xml"; + + using (StringWriter sw = new StringWriter()) + { + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Encoding = Encoding.UTF8; + settings.Indent = false; + + using (XmlWriter xw = XmlWriter.Create(sw, settings)) + { + IEnumerable errors; + CsdlWriter.TryWriteCsdl(model, xw, CsdlTarget.OData, out errors); + xw.Flush(); + } + + string output = sw.ToString(); + await context.Response.WriteAsync(output).ConfigureAwait(false); + } + } +} +*/ \ No newline at end of file diff --git a/sample/ODataMiniApi/ODataMetadataApi.http b/sample/ODataMiniApi/ODataMetadataApi.http new file mode 100644 index 000000000..471b1a5f0 --- /dev/null +++ b/sample/ODataMiniApi/ODataMetadataApi.http @@ -0,0 +1,70 @@ +@ODataMiniApi_HostAddress = http://localhost:5177 + +### +GET {{ODataMiniApi_HostAddress}}/v1/$document + +## +// The above request gets the following result: +//{ +// "@odata.context": "http://localhost:5177/$metadata", +// "value": [ +// { +// "name": "Schools", +// "kind": "EntitySet", +// "url": "Schools" +// }, +// { +// "name": "Customers", +// "kind": "EntitySet", +// "url": "Customers" +// }, +// { +// "name": "Orders", +// "kind": "EntitySet", +// "url": "Orders" +// } +// ] +//} + +### +GET {{ODataMiniApi_HostAddress}}/v2/$document + +## +// The above request gets the following result (has customized context URL) +//{ +// "@odata.context": "http://localhost:5177/v2/$metadata", +// "value": [ +// { +// "name": "Schools", +// "kind": "EntitySet", +// "url": "Schools" +// }, +// { +// "name": "Customers", +// "kind": "EntitySet", +// "url": "Customers" +// }, +// { +// "name": "Orders", +// "kind": "EntitySet", +// "url": "Orders" +// } +// ] +//} + +### +GET {{ODataMiniApi_HostAddress}}/v1/$metadata + +### +GET {{ODataMiniApi_HostAddress}}/v1/$metadata?$format=application/xml + +# The above two requests get the CSDL-XML file (No-indent by default) + +### +GET {{ODataMiniApi_HostAddress}}/v1/$metadata?$format=application/json + +### +GET {{ODataMiniApi_HostAddress}}/v1/$metadata +Accept: application/json + +# The above two requests get the CSDL-JSON file \ No newline at end of file diff --git a/sample/ODataMiniApi/ODataMiniApi.csproj b/sample/ODataMiniApi/ODataMiniApi.csproj new file mode 100644 index 000000000..eb483896e --- /dev/null +++ b/sample/ODataMiniApi/ODataMiniApi.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + disable + enable + + + + + + + + + + + + diff --git a/sample/ODataMiniApi/ODataMiniApi.http b/sample/ODataMiniApi/ODataMiniApi.http new file mode 100644 index 000000000..bcb21d0d3 --- /dev/null +++ b/sample/ODataMiniApi/ODataMiniApi.http @@ -0,0 +1,93 @@ +@ODataMiniApi_HostAddress = http://localhost:5177 + +### + +GET {{ODataMiniApi_HostAddress}}/giveschools?$select=SchoolName + +### + +# It's wrong to use $expand=MailAddress +# because the MailAddress is complex type in the pre-built edm model. +GET {{ODataMiniApi_HostAddress}}/myschools?$select=schoolName&$expand=MailAddress + +### + +# be noted: if use the model directly, the mailAddress is configured as complex property +GET {{ODataMiniApi_HostAddress}}/myschools?$select=schoolName,MailAddress + +### + +GET {{ODataMiniApi_HostAddress}}/schools?$select=schoolName,schoolid&$expand=MailAddress&$top=1 + +### + +GET {{ODataMiniApi_HostAddress}}/schools?$select=schoolName,schoolid,MailAddress&$top=1&$count=true + +### + +GET {{ODataMiniApi_HostAddress}}/v1/$odata?$format=application/json + +### +GET {{ODataMiniApi_HostAddress}}/v1/$odata?$format=application/xml + +### +GET {{ODataMiniApi_HostAddress}}/customized/$odata + +### +GET {{ODataMiniApi_HostAddress}}/v1/$document +### +GET {{ODataMiniApi_HostAddress}}/v1/$metadata?$format=application/json + +### + +# We need to figure out what payload should look like? +# Since it's a collection, how to add '@odata.count' property into the collection? +# Or use the PageResult +GET {{ODataMiniApi_HostAddress}}/schoolspage?$select=schoolName,MailAddress&$count=true + +### + +GET {{ODataMiniApi_HostAddress}}/schoolspage?$count=true + +### + +GET {{ODataMiniApi_HostAddress}}/odata/students + +### +GET {{ODataMiniApi_HostAddress}}/odata/students?$select=FirstName&$top=3 + +### + +@id=1 +GET {{ODataMiniApi_HostAddress}}/schools/{{id}}?$select=SchoolName + + +### + +GET {{ODataMiniApi_HostAddress}}/odata/schools + +### + +GET {{ODataMiniApi_HostAddress}}/giveschools1 + +### + +GET {{ODataMiniApi_HostAddress}}/giveschools2 + +### +GET {{ODataMiniApi_HostAddress}}/giveschools2?$select=schoolName + +### + +GET {{ODataMiniApi_HostAddress}}/giveschools3 + +### +GET {{ODataMiniApi_HostAddress}}/giveschools3?$select=schoolName + +### + +GET {{ODataMiniApi_HostAddress}}/getschools1 + +### + +GET {{ODataMiniApi_HostAddress}}/odata/getschools1 \ No newline at end of file diff --git a/sample/ODataMiniApi/Program.cs b/sample/ODataMiniApi/Program.cs new file mode 100644 index 000000000..e7acc2fb6 --- /dev/null +++ b/sample/ODataMiniApi/Program.cs @@ -0,0 +1,171 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.OData; +using Microsoft.AspNetCore.OData.Query; +using ODataMiniApi; +using ODataMiniApi.Students; +using Microsoft.OData.Edm; +using Microsoft.AspNetCore.OData.Results; +using System.Text.Json; +using Microsoft.AspNetCore.OData.Query.Expressions; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddDbContext(options => options.UseInMemoryDatabase("SchoolStudentList")); + +// In pre-built OData, MailAddress and Student are complex types +IEdmModel model = EdmModelBuilder.GetEdmModel(); + + +builder.Services.ConfigureHttpJsonOptions(options => { + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.IncludeFields = true; +}); + +builder.Services.AddOData(q => q.EnableAll()); + +var app = builder.Build(); +app.MakeSureDbCreated(); + +ODataMiniMetadata me = new ODataMiniMetadata(); +me.Services = services => +{ + services.AddSingleton(); +}; + +app.MapGet("test", () => "hello world").WithODataResult(); + +// Group +var group = app.MapGroup("") +// .WithOData2(metadata => metadata.IsODataFormat = true, services => services.AddSingleton(new FilterBinder())) + ; + +group.MapGet("v1", () => "hello v1") + .AddEndpointFilter(async (efiContext, next) => + { + var endpoint = efiContext.HttpContext.GetEndpoint(); + app.Logger.LogInformation("----Before calling"); + var result = await next(efiContext); + app.Logger.LogInformation($"----After calling, {result?.GetType().Name}"); + return result; + } + ).Finally(v => + { + v.Metadata.Add(new School()); + }); +group.MapGet("v2", () => "hello v2"); + +// +app.MapGet("/giveschools1", (AppDb db) => +{ + return db.Schools.Include(s => s.Students); +}); + +app.MapGet("/giveschools2", (AppDb db, ODataQueryOptions options) => +{ + return options.ApplyTo(db.Schools); +}) + .WithODataResult(); + +app.MapGet("/giveschools3", (AppDb db) => +{ + return db.Schools.Include(s => s.Students); +}) + .WithODataResult() + .AddODataQueryEndpointFilter(querySetup: q => q.PageSize = 3, validationSetup: q => q.MaxSkip = 4); + +//app.MapGet("/odata/schools", (AppDb db) => +//{ +// return Results.Extensions.OData(db.Schools.Include(s => s.Students)); +//}); + +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) +{ WriteIndented = true }; +app.MapGet("/", () => + Results.Json(new School { SchoolName = "Walk dog", SchoolId = 8 }, options)); + +// OData $metadata, I use $odata for routing. +#region ODataMetadata +//app.MapODataMetadata1("/v1/$odata", model); +//app.MapODataMetadata("/customized/$odata", model, new CustomizedMetadataHandler()); + +app.MapODataServiceDocument("v1/$document", model); + +app.MapODataServiceDocument("v2/$document", model) + .WithODataBaseAddressFactory(c => new Uri("http://localhost:5177/v2")); + +app.MapODataMetadata("v1/$metadata", model); + +#endregion + +#region School Endpoints + +app.MapGet("/myschools", (AppDb db) => +{ + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return db.Schools; +}) + .WithODataModel(model) + // .AddODataQueryEndpointFilter(new ODataQueryEndpointFilter(app.Services.GetRequiredService())); + .AddODataQueryEndpointFilter(querySetup: q => q.PageSize = 3, validationSetup: q => q.MaxSkip = 4); + +// use the server side pagesize? +app.MapGet("/schoolspage", (AppDb db) => +{ + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return new PageResult(db.Schools, new Uri("/schoolspage?$skip=2", UriKind.Relative), count: db.Schools.Count()); + //return db.Schools; +}) + .WithODataModel(model) + .AddODataQueryEndpointFilter(querySetup: q => q.PageSize = 3); + +app.MapGet("/schools", (AppDb db, ODataQueryOptions options) => { + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return options.ApplyTo(db.Schools); +}).WithODataModel(model); + +// Use the model built on the fly +app.MapGet("/schools/{id}", async (int id, AppDb db, ODataQueryOptions options) => { + + School school = await db.Schools.Include(c => c.MailAddress) + .Include(c => c.Students).FirstOrDefaultAsync(s => s.SchoolId == id); + if (school == null) + { + return Results.NotFound($"Cannot find school with id '{id}'"); + } + else + { + return Results.Ok(options.ApplyTo(school, new ODataQuerySettings())); + } +}); + +// Use the model built on the fly +app.MapGet("/customized/schools", (AppDb db, ODataQueryOptions options) => +{ + db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + return options.ApplyTo(db.Schools); +}); + +#endregion + +app.MapCustomersEndpoints(model); + +app.MapOrdersEndpoints(model); + +app.MapSchoolEndpoints(model); + +// Endpoints for students +app.MapStudentEndpoints(); + +app.Run(); + +/// +/// This is required in E2E test to identify the assembly. +/// +public partial class Program +{ } diff --git a/sample/ODataMiniApi/Properties/launchSettings.json b/sample/ODataMiniApi/Properties/launchSettings.json new file mode 100644 index 000000000..74ab92ec0 --- /dev/null +++ b/sample/ODataMiniApi/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:27886", + "sslPort": 0 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5177", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/sample/ODataMiniApi/Schools/SchoolEndpoints.cs b/sample/ODataMiniApi/Schools/SchoolEndpoints.cs new file mode 100644 index 000000000..c72291692 --- /dev/null +++ b/sample/ODataMiniApi/Schools/SchoolEndpoints.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData; +using Microsoft.EntityFrameworkCore; +using Microsoft.OData.Edm; + +namespace ODataMiniApi.Students; + +/// +/// Add school endpoint +/// +public static class SchoolEndpoints +{ + public static IEndpointRouteBuilder MapSchoolEndpoints(this IEndpointRouteBuilder app, IEdmModel model) + { + app.MapGet("/getschools1", (AppDb db) => db.Schools.Include(s => s.Students)); + + app.MapGet("/odata/getschools1", (AppDb db) => db.Schools.Include(s => s.Students)) + .WithODataResult(); + + app.MapGet("/odata/getschools2", (AppDb db) => db.Schools.Include(s => s.Students)) + .WithODataModel(model); + + app.MapGet("/odata/getschools2", (AppDb db) => db.Schools.Include(s => s.Students)) + .WithODataResult() + .WithODataModel(model); + return app; + } + +} diff --git a/sample/ODataMiniApi/Students/StudentEndpoints.cs b/sample/ODataMiniApi/Students/StudentEndpoints.cs new file mode 100644 index 000000000..058073802 --- /dev/null +++ b/sample/ODataMiniApi/Students/StudentEndpoints.cs @@ -0,0 +1,209 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.EntityFrameworkCore; + +namespace ODataMiniApi.Students; + +/// +/// Add student endpoint to support CRUD operations. +/// The codes are implemented as simple as possible. Please file issues to us for any issues. +/// +public static class StudentEndpoints +{ + public static async Task GetStudentByIdAsync(int id, AppDb db, ODataQueryOptions options) + { + Student student = await db.Students.FirstOrDefaultAsync(s => s.StudentId == id); + if (student == null) + { + return Results.NotFound($"Cannot find student with id '{id}'"); + } + else + { + return Results.Ok(options.ApplyTo(student, new ODataQuerySettings())); + } + } + + public static async Task PatchStudentByIdAsync(int id, AppDb db, [FromBody] IDictionary properties) + { + // TODO: need to support Delta to replace IDictionary in the next step + Student oldStudent = await db.Students.FirstOrDefaultAsync(s => s.StudentId == id); + if (oldStudent == null) + { + return Results.NotFound($"Cannot find student with id '{id}'"); + } + + int oldSchoolId = oldStudent.SchoolId; + + var studentProperties = typeof(Student).GetProperties(); + foreach (var property in properties) + { + PropertyInfo propertyInfo = studentProperties.FirstOrDefault(p => string.Equals(p.Name, property.Key, StringComparison.OrdinalIgnoreCase)); + if (propertyInfo == null) + { + return Results.BadRequest($"Cannot find property '{property.Key}' on student"); + } + + // For simplicity + if (propertyInfo.PropertyType == typeof(string)) + { + propertyInfo.SetValue(oldStudent, property.Value.ConvertToString()); + } + else if (propertyInfo.PropertyType == typeof(int)) + { + propertyInfo.SetValue(oldStudent, property.Value.ConvertToInt()); + } + else if (propertyInfo.PropertyType == typeof(DateOnly)) + { + propertyInfo.SetValue(oldStudent, property.Value.ConvertToDateOnly()); + } + } + + if (oldSchoolId != oldStudent.SchoolId) + { + School school = await db.Schools.Include(c => c.Students).FirstOrDefaultAsync(c => c.SchoolId == oldSchoolId); + school.Students.Remove(oldStudent); + + school = await db.Schools.Include(c => c.Students).FirstOrDefaultAsync(c => c.SchoolId == oldStudent.SchoolId); + if (school == null) + { + return Results.NotFound($"Cannot find school using the school Id '{oldStudent.SchoolId}' that the new student provides."); + } + else + { + school.Students.Add(oldStudent); + } + } + + await db.SaveChangesAsync(); + + return Results.Ok(oldStudent); + } + + public static async Task DeleteStudentByIdAsync(int id, AppDb db) + { + Student student = await db.Students.FirstOrDefaultAsync(s => s.StudentId == id); + if (student == null) + { + return Results.NotFound($"Cannot find student with id '{id}'"); + } + else + { + db.Students.Remove(student); + School school = await db.Schools.Include(c => c.Students).FirstOrDefaultAsync(c => c.SchoolId == student.SchoolId); + school.Students.Remove(student); + await db.SaveChangesAsync(); + return Results.Ok(); + } + } + + public static IEndpointRouteBuilder MapStudentEndpoints(this IEndpointRouteBuilder app) + { + // Let's use the group to build the student endpoints + var students = app.MapGroup("/odata"); + + // GET http://localhost:5177/odata/students?$select=lastName&$top=3 + students.MapGet("/students", async (AppDb db, ODataQueryOptions options) => + { + await db.Students.ToListAsync(); + return options.ApplyTo(db.Students); + }); + + //GET http://localhost:5177/odata/students/12?select=lastName + students.MapGet("/students/{id}", GetStudentByIdAsync); + + //GET http://localhost:5177/odata/students(12)?select=lastName + students.MapGet("/students({id})", GetStudentByIdAsync); + + // POST http://localhost:5177/odata/students + // Content-Type: application/json + // BODY: + /* +{ + +"firstName": "Soda", +"lastName": "Yu", +"favoriteSport": "Soccer", +"grade": 7, +"schoolId": 3, +"birthDay": "1977-11-04" +} + */ + students.MapPost("/students", async (Student student, AppDb db) => + { + int studentId = db.Students.Max(s => s.StudentId) + 1; + student.StudentId = studentId; + School school = await db.Schools.Include(c => c.Students).FirstOrDefaultAsync(c => c.SchoolId == student.SchoolId); + if (school == null) + { + return Results.NotFound($"Cannot find school using the school Id '{student.SchoolId}' that the new student provides."); + } + else + { + school.Students.Add(student); + } + + db.Students.Add(student); + await db.SaveChangesAsync(); + + return Results.Created($"/odata/students/{studentId}", student); + }); + + // PATCH http://localhost:5177/odata/students/10 + // Content-Type: application/json + // BODY: + /* +{ + "firstName": "Sokuda", + "lastName": "Yu", + "schoolId": 4 +} + */ + students.MapPatch("/students({id})", PatchStudentByIdAsync); + students.MapPatch("/students/{id}", PatchStudentByIdAsync); + + // DELETE http://localhost:5177/odata/students/10 + students.MapDelete("/students({id})", DeleteStudentByIdAsync); + students.MapDelete("/students/{id}", DeleteStudentByIdAsync); + return app; + } + + private static string ConvertToString(this object value) + { + if (value is JsonElement json && json.ValueKind == JsonValueKind.String) + { + return json.GetString(); + } + + throw new InvalidCastException($"Cannot convert '{value}' to string"); + } + + private static int ConvertToInt(this object value) + { + if (value is JsonElement json && json.ValueKind == JsonValueKind.Number) + { + return json.GetInt32(); + } + + throw new InvalidCastException($"Cannot convert '{value}' to int"); + } + + private static DateOnly ConvertToDateOnly(this object value) + { + if (value is JsonElement json && json.ValueKind == JsonValueKind.String) + { + string str = json.GetString(); + return DateOnly.Parse(str); + } + + throw new InvalidCastException($"Cannot convert '{value}' to DateOnly"); + } +} diff --git a/sample/ODataMiniApi/appsettings.Development.json b/sample/ODataMiniApi/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/sample/ODataMiniApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/sample/ODataMiniApi/appsettings.json b/sample/ODataMiniApi/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/sample/ODataMiniApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/sample/ODataMiniApi/readme.md b/sample/ODataMiniApi/readme.md new file mode 100644 index 000000000..6516f02ef --- /dev/null +++ b/sample/ODataMiniApi/readme.md @@ -0,0 +1,308 @@ +# ASP.NET Core OData (8.x) Minimal API Sample + +--- +This is an ASP.NET Core OData 8.x minimal API project. + +Minimal APIs are a simplified approach for building fast HTTP APIs with ASP.NET Core. You can build fully functioning REST endpoints with minimal code and configuration. Skip traditional scaffolding and avoid unnecessary controllers by fluently declaring API routes and actions. + + +## Basic endpoints + +1) GET `http://localhost:5177/schools` + +```json +[ + { + "schoolId": 1, + "schoolName": "Mercury Middle School", + "mailAddress": { + "apartNum": 241, + "city": "Kirk", + "street": "156TH AVE", + "zipCode": "98051" + }, + "students": null + }, + { + "schoolId": 2, + ... + } + ... +] +``` + +2) GET `http://localhost:5177/schools?$expand=mailaddress&$select=schoolName&$top=2` + +```json +[ + { + "MailAddress": { + "ApartNum": 241, + "City": "Kirk", + "Street": "156TH AVE", + "ZipCode": "98051" + }, + "SchoolName": "Mercury Middle School" + }, + { + "MailAddress": { + "ApartNum": 543, + "City": "AR", + "Street": "51TH AVE PL", + "ZipCode": "98043" + }, + "SchoolName": "Venus High School" + } +] +``` + +3) GET `http://localhost:5177/schools/5?$expand=students($top=1)&$select=schoolName` + +```json +{ + "Students": [ + { + "StudentId": 50, + "FirstName": "David", + "LastName": "Padron", + "FavoriteSport": "Tennis", + "Grade": 77, + "SchoolId": 5, + "BirthDay": "2015-12-03" + } + ], + "SchoolName": "Jupiter College" +} +``` + +4) GET `http://localhost:5177/customized/schools?$select=schoolName,mailAddress&$orderby=schoolName&$top=1` + +This endpoint uses the `OData model` from configuration, which builds `Address` as complex type, so we can use `$select` + +```json +[ + { + "SchoolName": "Earth University", + "MailAddress": { + "ApartNum": 101, + "City": "Belly", + "Street": "24TH ST", + "ZipCode": "98029" + } + } +] +``` + +## Student CRUD endpoints + +I grouped student endpoints under `odata` group intentionally. + +1) GET `http://localhost:5177/odata/students?$select=lastName&$top=3` + +```json +[ + { + "LastName": "Alex" + }, + { + "LastName": "Eaine" + }, + { + "LastName": "Rorigo" + } +] +``` + +2) POST `http://localhost:5177/odata/students` with the following body: +Content-Type: application/json + +```json +{ + + "firstName": "Sokuda", + "lastName": "Yu", + "favoriteSport": "Soccer", + "grade": 7, + "schoolId": 3, + "birthDay": "1977-11-04" +} +``` + +Check using `http://localhost:5177/schools/3`, you can see a new student added: + +```json +[ + "schoolId": 3, + "schoolName": "Earth University", + "mailAddress": { + "apartNum": 101, + "city": "Belly", + "street": "24TH ST", + "zipCode": "98029" + }, + "students": [ + ... + { + "studentId": 98, + "firstName": "Sokuda", + "lastName": "Yu", + "favoriteSport": "Soccer", + "grade": 7, + "schoolId": 3, + "birthDay": "1977-11-04" + } + ] +} +``` + +3) Patch `http://localhost:5177/odata/students/10` +Content-Type: application/json + +```json +{ + + "firstName": "Sokuda", + "lastName": "Yu", + "schoolId": 4 +} +``` + +This will change the student, and also move the student from `Schools(1)` to `Schools(4)` + +4) Delete `http://localhost:5177/odata/students/10` + +This will delete the `Students(10)` + + +## OData CSDL metadata + +I built one metadata endpoint to return the CSDL representation of 'customized' OData. + +I use '$odata' to return the metadata. + +Try: GET http://localhost:5177/customized/$odata, You can get CSDL XML representation: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +You can use 'Accept' request header or '$format' or 'format' query option to specify JSON or XML format, by default it's XML format. + +Try: GET http://localhost:5177/customized/$odata, You can get CSDL XML representation: +Request Header: +Accept: application/json + +You can get: + +```json +{ + "$Version": "4.0", + "$EntityContainer": "Default.Container", + "ODataMiniApi": { + "School": { + "$Kind": "EntityType", + "$Key": [ + "SchoolId" + ], + "SchoolId": { + "$Type": "Edm.Int32" + }, + "SchoolName": { + "$Nullable": true + }, + "MailAddress": { + "$Type": "ODataMiniApi.Address", + "$Nullable": true + }, + "Students": { + "$Collection": true, + "$Type": "ODataMiniApi.Student", + "$Nullable": true + } + }, + "Address": { + "$Kind": "ComplexType", + "ApartNum": { + "$Type": "Edm.Int32" + }, + "City": { + "$Nullable": true + }, + "Street": { + "$Nullable": true + }, + "ZipCode": { + "$Nullable": true + } + }, + "Student": { + "$Kind": "ComplexType", + "StudentId": { + "$Type": "Edm.Int32" + }, + "FirstName": { + "$Nullable": true + }, + "LastName": { + "$Nullable": true + }, + "FavoriteSport": { + "$Nullable": true + }, + "Grade": { + "$Type": "Edm.Int32" + }, + "SchoolId": { + "$Type": "Edm.Int32" + }, + "BirthDay": { + "$Type": "Edm.Date" + } + } + }, + "Default": { + "Container": { + "$Kind": "EntityContainer", + "Schools": { + "$Collection": true, + "$Type": "ODataMiniApi.School" + } + } + } +} +``` diff --git a/sample/ODataRoutingSample/Controllers/v1/CustomersController.cs b/sample/ODataRoutingSample/Controllers/v1/CustomersController.cs index 12976d088..1b3a5fd2d 100644 --- a/sample/ODataRoutingSample/Controllers/v1/CustomersController.cs +++ b/sample/ODataRoutingSample/Controllers/v1/CustomersController.cs @@ -41,7 +41,7 @@ public CustomersController(MyDataContext context) // For example: http://localhost:5000/v1/customers?$apply=groupby((Name), aggregate($count as count))&$orderby=name desc [HttpGet] - [EnableQuery] + [EnableQuery(PageSize = 2)] public IActionResult Get() { return Ok(GetCustomers()); diff --git a/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Nightly.nuspec b/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Nightly.nuspec index 020c8713b..bb62202a6 100644 --- a/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Nightly.nuspec +++ b/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Nightly.nuspec @@ -4,8 +4,8 @@ Microsoft.AspNetCore.OData.NewtonsoftJson Microsoft ASP.NET Core OData opt-in component to support Newtonsoft.Json serializer converters $VersionFullSemantic$-Nightly$NightlyBuildVersion$ - OData (.NET Foundation) - © .NET Foundation and Contributors. All rights reserved. + Microsoft + © Microsoft Corporation. All rights reserved. This package contains customized Newtonsoft.Json serializer converters to support OData serialization. This package contains customized Newtonsoft.Json serializer converters to support OData serialization. en-US diff --git a/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Release.nuspec b/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Release.nuspec index f306e2ecd..9032c0ada 100644 --- a/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Release.nuspec +++ b/src/Microsoft.AspNetCore.OData.NewtonsoftJson.Release.nuspec @@ -4,8 +4,8 @@ Microsoft.AspNetCore.OData.NewtonsoftJson Microsoft ASP.NET Core OData opt-in component to support Newtonsoft.Json serializer converters $VersionNuGetSemantic$ - OData (.NET Foundation) - © .NET Foundation and Contributors. All rights reserved. + Microsoft + © Microsoft Corporation. All rights reserved. This package contains customized Newtonsoft.Json serializer converters to support OData serialization. This package contains customized Newtonsoft.Json serializer converters to support OData serialization. en-US diff --git a/src/Microsoft.AspNetCore.OData.NewtonsoftJson/Microsoft.AspNetCore.OData.NewtonsoftJson.csproj b/src/Microsoft.AspNetCore.OData.NewtonsoftJson/Microsoft.AspNetCore.OData.NewtonsoftJson.csproj index 155705a93..527ec6904 100644 --- a/src/Microsoft.AspNetCore.OData.NewtonsoftJson/Microsoft.AspNetCore.OData.NewtonsoftJson.csproj +++ b/src/Microsoft.AspNetCore.OData.NewtonsoftJson/Microsoft.AspNetCore.OData.NewtonsoftJson.csproj @@ -16,8 +16,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Microsoft.AspNetCore.OData.Nightly.nuspec b/src/Microsoft.AspNetCore.OData.Nightly.nuspec index 65c906528..005a3378f 100644 --- a/src/Microsoft.AspNetCore.OData.Nightly.nuspec +++ b/src/Microsoft.AspNetCore.OData.Nightly.nuspec @@ -4,8 +4,8 @@ Microsoft.AspNetCore.OData Microsoft ASP.NET Core 8.x for OData v4.0 $VersionFullSemantic$-Nightly$NightlyBuildVersion$ - OData (.NET Foundation) - © .NET Foundation and Contributors. All rights reserved. + Microsoft + © Microsoft Corporation. All rights reserved. This package contains everything you need to create OData v4.0 endpoints using ASP.NET Core MVC Core 8.x to support OData query syntax for your Web APIs. This package contains everything you need to create OData v4.0 endpoints using ASP.NET Core. en-US diff --git a/src/Microsoft.AspNetCore.OData.Release.nuspec b/src/Microsoft.AspNetCore.OData.Release.nuspec index 970c81ba4..ca94f8d9b 100644 --- a/src/Microsoft.AspNetCore.OData.Release.nuspec +++ b/src/Microsoft.AspNetCore.OData.Release.nuspec @@ -4,8 +4,8 @@ Microsoft.AspNetCore.OData Microsoft ASP.NET Core 8.x for OData v4.0 $VersionNuGetSemantic$ - OData (.NET Foundation) - © .NET Foundation and Contributors. All rights reserved. + Microsoft + © Microsoft Corporation. All rights reserved. This package contains everything you need to create OData v4.0 endpoints using ASP.NET Core MVC Core 8.x to support OData query syntax for your Web APIs. This package contains everything you need to create OData v4.0 endpoints using ASP.NET Core MVC. en-US diff --git a/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs b/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs index 4be2d89aa..e1d4b6ef9 100644 --- a/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs @@ -8,6 +8,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Linq; @@ -40,7 +41,141 @@ public static bool IsDynamicTypeWrapper(this Type type) public static bool IsSelectExpandWrapper(this Type type, out Type entityType) => IsTypeWrapper(typeof(SelectExpandWrapper<>), type, out entityType); - public static bool IsComputeWrapper(this Type type, out Type entityType) => IsTypeWrapper(typeof(ComputeWrapper<>), type, out entityType); + /// + /// Determines whether the specified type is a or a custom implementation + /// that inherits from and implements both + /// and . + /// + /// The type to check. + /// The entity type if the specified type is a or a custom implementation; otherwise, null. + /// + /// true if the specified type is a or a custom implementation + /// that meets the criteria; otherwise, false. + /// + public static bool IsComputeWrapper(this Type typeToCheck, out Type entityType) + { + entityType = null; + if (typeToCheck == null) + { + return false; + } + + bool isComputeWrapper = false; + + if (typeToCheck.IsGenericType) + { + Type genericTypeDefinition = typeToCheck.GetGenericTypeDefinition(); + + // Default implementation + if (genericTypeDefinition == typeof(ComputeWrapper<>)) + { + Debug.Assert( + typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.ImplementsInterface(typeof(IComputeWrapper<>)) + && genericTypeDefinition.ImplementsInterface(typeof(IGroupByWrapper<,>)), + "ComputeWrapper must inherit from DynamicTypeWrapper and implement IComputeWrapper and IGroupByWrapper"); + + isComputeWrapper = true; + } + // Custom implementation + // Must inherit from DynamicTypeWrapper + // Must implement IComputeWrapper and IGroupByWrapper + else if (typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.ImplementsInterface(typeof(IComputeWrapper<>)) + && genericTypeDefinition.ImplementsInterface(typeof(IGroupByWrapper<,>))) + { + isComputeWrapper = true; + } + + if (isComputeWrapper) + { + entityType = typeToCheck.GetGenericArguments()[0]; + } + } + + return isComputeWrapper; + } + + /// + /// Determines whether the specified type is a or a custom implementation + /// that inherits from and implements both + /// and . + /// + /// The type to check. + /// + /// true if the specified type is a or a custom implementation + /// that meets the criteria; otherwise, false. + /// + public static bool IsFlatteningWrapper(this Type typeToCheck) + { + if (typeToCheck == null) + { + return false; + } + + if (typeToCheck.IsGenericType) + { + Type genericTypeDefinition = typeToCheck.GetGenericTypeDefinition(); + + Func isFlatteningWrapperFunc = () => typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.ImplementsInterface(typeof(IFlatteningWrapper<>)) + && genericTypeDefinition.ImplementsInterface(typeof(IGroupByWrapper<,>)); + // Default implementation + if (genericTypeDefinition == typeof(FlatteningWrapper<>)) + { + Debug.Assert( + typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.ImplementsInterface(typeof(IFlatteningWrapper<>)) + && genericTypeDefinition.ImplementsInterface(typeof(IGroupByWrapper<,>)), + "FlatteningWrapper must inherit from DynamicTypeWrapper and implement IFlatteningWrapper and IGroupByWrapper"); + + return true; + } + + // Custom implementation + // Must inherit from DynamicTypeWrapper + // Must implement IFlatteningWrapper and IGroupByWrapper + return typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.ImplementsInterface(typeof(IFlatteningWrapper<>)) + && genericTypeDefinition.ImplementsInterface(typeof(IGroupByWrapper<,>)); + } + + return false; + } + + /// + /// Determines whether the specified type is a or a custom implementation + /// that inherits from and implements . + /// + /// The type to check. + /// + /// true if the specified type is a or a custom implementation + /// that meets the criteria; otherwise, false. + /// + public static bool IsGroupByWrapper(this Type typeToCheck) + { + if (typeToCheck == null || typeToCheck.IsValueType || typeToCheck == typeof(string)) + { + return false; + } + + // Default implementation + if (typeof(GroupByWrapper).IsAssignableFrom(typeToCheck)) + { + Debug.Assert( + typeof(DynamicTypeWrapper).IsAssignableFrom(typeToCheck) + && typeToCheck.ImplementsInterface(typeof(IGroupByWrapper<,>)), + "GroupByWrapper must inherit from DynamicTypeWrapper and implement IGroupByWrapper"); + + return true; + } + + // Custom implementation + // Must inherit from DynamicTypeWrapper + // Must implement IGroupByWrapper + return typeof(DynamicTypeWrapper).IsAssignableFrom(typeToCheck) && + typeToCheck.ImplementsInterface(typeof(IGroupByWrapper<,>)); + } private static bool IsTypeWrapper(Type wrappedType, Type type, out Type entityType) { @@ -250,6 +385,23 @@ Type collectionInterface return false; } + /// + /// Determines whether the specified represents an type. + /// + /// The to evaluate. + /// True if the type is an enumeration; false otherwise. + public static bool IsAsyncEnumerableType(Type clrType) + { + if (clrType == null) + { + throw Error.ArgumentNull(nameof(clrType)); + } + + return + (clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) || + (clrType.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))); + } + internal static bool IsDictionary(Type clrType) { if (clrType == null) @@ -382,6 +534,11 @@ internal static Type GetTaskInnerTypeOrSelf(Type type) return type.GetGenericArguments().First(); } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + return type.GetGenericArguments().First(); + } + return type; } @@ -431,4 +588,71 @@ private static Type GetInnerGenericType(Type interfaceType) return null; } + + /// + /// Determines whether the specified type inherits from a given generic base type. + /// + /// The type to examine. + /// The open generic type definition to check against (e.g., typeof(Base<>)). + /// true if inherits from ; otherwise, false. + public static bool InheritsFromGenericBase(this Type typeToCheck, Type genericBaseType) + { + if (typeToCheck == null || genericBaseType == null || !genericBaseType.IsGenericTypeDefinition) + return false; + + Type baseType = typeToCheck.BaseType; + + while (baseType != null && baseType != typeof(object)) + { + if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == genericBaseType) + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + /// + /// Determines whether the specified target type implements the given interface type. + /// + /// The type to check for implementation of the interface. + /// The interface type to check against. + /// + /// true if the target type implements the specified interface type; otherwise, false. + /// + /// + /// This method supports both generic and non-generic interfaces. For generic interfaces, it checks if the target type + /// implements any interface that matches the generic type definition of the specified interface type. + /// + public static bool ImplementsInterface(this Type targetType, Type interfaceType) + { + if (targetType == null || interfaceType == null) + { + return false; + } + + if (interfaceType.IsGenericTypeDefinition) // Generic interface (e.g., I<>) + { + Type[] implementedInterfaces = targetType.GetInterfaces(); + for (int i = 0; i < implementedInterfaces.Length; i++) + { + Type implementedInterface = implementedInterfaces[i]; + + if (implementedInterface.IsGenericType && + implementedInterface.GetGenericTypeDefinition() == interfaceType) + { + return true; + } + } + } + else // Non-generic interface + { + return interfaceType.IsAssignableFrom(targetType); + } + + return false; + } } diff --git a/src/Microsoft.AspNetCore.OData/Deltas/DeltaOfT.cs b/src/Microsoft.AspNetCore.OData/Deltas/DeltaOfT.cs index 84a0180c1..61e6b230a 100644 --- a/src/Microsoft.AspNetCore.OData/Deltas/DeltaOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Deltas/DeltaOfT.cs @@ -15,8 +15,11 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Serialization; +using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; using Microsoft.AspNetCore.OData.Abstracts; using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Extensions; namespace Microsoft.AspNetCore.OData.Deltas; @@ -426,6 +429,17 @@ public void CopyChangedValues(T original) } } + /// + /// Binds the and to generate the . + /// + /// The HttpContext. + /// The parameter info. + /// The built + public static async ValueTask> BindAsync(HttpContext context, ParameterInfo parameter) + { + return await context.BindODataParameterAsync>(parameter); + } + /// /// Copies the unchanged property values from the underlying entity (accessible via ) /// to the entity. diff --git a/src/Microsoft.AspNetCore.OData/Deltas/DeltaSetOfT.cs b/src/Microsoft.AspNetCore.OData/Deltas/DeltaSetOfT.cs index 1334a72b7..c6acce777 100644 --- a/src/Microsoft.AspNetCore.OData/Deltas/DeltaSetOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Deltas/DeltaSetOfT.cs @@ -8,7 +8,11 @@ using System; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Extensions; namespace Microsoft.AspNetCore.OData.Deltas; @@ -29,6 +33,17 @@ public class DeltaSet : Collection, IDeltaSet, ITypedDelta whe /// public Type ExpectedClrType => typeof(T); + /// + /// Binds the and to generate the . + /// + /// The HttpContext. + /// The parameter info. + /// The built + public static async ValueTask> BindAsync(HttpContext context, ParameterInfo parameter) + { + return await context.BindODataParameterAsync>(parameter); + } + #region Exclude unfinished APIs #if false /// @@ -100,5 +115,5 @@ protected virtual T GetOriginal(IDeltaSetItem deltaItem, IEnumerable originalSet return null; } #endif -#endregion + #endregion } diff --git a/src/Microsoft.AspNetCore.OData/Edm/IODataModelConfiguration.cs b/src/Microsoft.AspNetCore.OData/Edm/IODataModelConfiguration.cs new file mode 100644 index 000000000..2ddedb38b --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Edm/IODataModelConfiguration.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.Edm; + +/// +/// Defines a contract used to apply extra logic on the model builder. +/// +public interface IODataModelConfiguration +{ + /// + /// Applies model configurations using the provided builder. + /// + /// The HttpContext. + /// The builder used to apply configurations. + /// The top CLR type. + /// The model builder or a totally new builder to use. + ODataModelBuilder Apply(HttpContext context, ODataModelBuilder builder, Type clrType); +} + diff --git a/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs b/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs index 3f19f95eb..4e151c32d 100644 --- a/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs @@ -5,10 +5,22 @@ // //------------------------------------------------------------------------------ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Results; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData.Extensions; @@ -75,4 +87,233 @@ public static ODataOptions ODataOptions(this HttpContext httpContext) return httpContext.RequestServices?.GetService>()?.Value; } + + internal static bool IsMinimalEndpoint(this HttpContext httpContext) + { + if (httpContext == null) + { + throw Error.ArgumentNull(nameof(httpContext)); + } + + // Check if the endpoint is a minimal endpoint. + var endpoint = httpContext.GetEndpoint(); + return endpoint?.Metadata.GetMetadata() != null; + } + + internal static IEdmModel GetOrCreateEdmModel(this HttpContext httpContext, Type clrType, ParameterInfo parameter = null) + { + if (httpContext == null) + { + throw Error.ArgumentNull(nameof(httpContext)); + } + + // P1. Get model from the request if it's configured/cached, used it. + IODataFeature odataFeature = httpContext.ODataFeature(); + IEdmModel model = odataFeature.Model; + if (model is not null) + { + return model; + } + + // P2. Retrieve it from metadata if 'WithODataModel(model)' called/cached. + var endpoint = httpContext.GetEndpoint(); + var odataMiniMetadata = endpoint.Metadata.GetMetadata(); + model = odataMiniMetadata?.Model; + if (model is not null) + { + odataFeature.Model = model; + return model; + } + + // 3.Ok, we don't have the model configured, let's build the model on the fly + bool isQueryCompositionMode = parameter != null ? true : false; + + IAssemblyResolver resolver = httpContext.RequestServices.GetService() ?? new DefaultAssemblyResolver(); + ODataModelBuilder builder = new ODataConventionModelBuilder(resolver, isQueryCompositionMode); + + EntityTypeConfiguration entityTypeConfiguration = builder.AddEntityType(clrType); + builder.AddEntitySet(clrType.Name, entityTypeConfiguration); + + // Do the model configuration if the configuration service is registered. + // First, let's check the configuration on the parameter as attribute (provided using parameterInfo) + var modelConfig = parameter?.GetCustomAttributes() + .FirstOrDefault(c => c is IODataModelConfiguration) as IODataModelConfiguration; + + // Then, check the configuration on the globle + modelConfig = modelConfig ?? httpContext.RequestServices.GetService(); + if (modelConfig is not null) + { + builder = modelConfig.Apply(httpContext, builder, clrType); + } + + model = builder.GetEdmModel(); + + // Add the model into the cache + if (odataMiniMetadata is not null) + { + // make sure the 'ServiceProvider' is built after the model configuration. + odataMiniMetadata.Model = model; + } + + // Cached it into the ODataFeature() + odataFeature.Model = model; + return model; + } + + internal static ODataPath GetOrCreateODataPath(this HttpContext httpContext, Type clrType) + { + if (httpContext == null) + { + throw Error.ArgumentNull(nameof(httpContext)); + } + + // 1. Get model for the request if it's configured/cached, used it. + IODataFeature odataFeature = httpContext.ODataFeature(); + if (odataFeature.Path is not null) + { + return odataFeature.Path; + } + + IEdmModel model = httpContext.GetOrCreateEdmModel(clrType); + + // 2. Retrieve it from metadata? + var endpoint = httpContext.GetEndpoint(); + var odataMiniMetadata = endpoint.Metadata.GetMetadata(); + var pathFactory = odataMiniMetadata?.PathFactory ?? ODataMiniMetadata.DefaultPathFactory; + + var path = pathFactory.Invoke(httpContext, clrType); + odataFeature.Path = path; + + return path; + } + + internal static IServiceProvider GetOrCreateServiceProvider(this HttpContext httpContext) + { + if (httpContext == null) + { + throw Error.ArgumentNull(nameof(httpContext)); + } + + // 1. Get service provider for the request if it's configured/cached, used it. + IODataFeature odataFeature = httpContext.ODataFeature(); + if (odataFeature.Services is not null) + { + return odataFeature.Services; + } + + // 2. Retrieve it from metadata? + var endpoint = httpContext.GetEndpoint(); + var odataMiniMetadata = endpoint.Metadata.GetMetadata(); + if (odataMiniMetadata is not null) + { + odataFeature.Services = odataMiniMetadata.ServiceProvider; + return odataFeature.Services; + } + + return null; + } + + internal static async ValueTask BindODataParameterAsync(this HttpContext httpContext, ParameterInfo parameter) + where T : class + { + ArgumentNullException.ThrowIfNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(parameter, nameof(parameter)); + + var endpoint = httpContext.GetEndpoint(); + ODataMiniMetadata metadata = endpoint.Metadata.GetMetadata(); + if (metadata is null || metadata.Model is null || metadata.PathFactory is null) + { + throw new ODataException(SRResources.ODataMustBeSetOnMinimalAPIEndpoint); + } + + Type parameterType = parameter.ParameterType; + + IEdmModel model = metadata.Model; + + IODataFeature oDataFeature = httpContext.ODataFeature(); + oDataFeature.Model = model; + oDataFeature.Services = httpContext.GetOrCreateServiceProvider(); + oDataFeature.Path = metadata.PathFactory(httpContext, parameterType); + HttpRequest request = httpContext.Request; + IList toDispose = new List(); + Uri baseAddress = httpContext.GetInputBaseAddress(metadata); + + ODataVersion version = ODataResult.GetODataVersion(request, metadata); + + object result = null; + try + { + result = await ODataInputFormatter.ReadFromStreamAsync( + parameterType, + defaultValue: null, + baseAddress, + version, + request, + toDispose).ConfigureAwait(false); + + foreach (IDisposable obj in toDispose) + { + obj.Dispose(); + } + } + catch (Exception ex) + { + throw new ODataException(Error.Format(SRResources.BindParameterFailedOnMinimalAPIEndpoint, parameter.Name, ex.Message)); + } + + return result as T; + } + + internal static Uri GetInputBaseAddress(this HttpContext httpContext, ODataMiniMetadata options) + { + if (httpContext == null) + { + throw Error.ArgumentNull(nameof(httpContext)); + } + + if (options.BaseAddressFactory is not null) + { + return options.BaseAddressFactory(httpContext); + } + else + { + return ODataInputFormatter.GetDefaultBaseAddress(httpContext.Request); + } + } + + internal static IServiceProvider BuildDefaultServiceProvider(this HttpContext httpContext, IEdmModel model) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(model); + + ODataMiniOptions miniOptions = httpContext.RequestServices.GetService>()?.Value + ?? new ODataMiniOptions(); + + IServiceCollection services = new ServiceCollection(); + + // Inject the core odata services. + services.AddDefaultODataServices(miniOptions.Version); + + // Inject the default query configuration from this options. + services.AddSingleton(sp => miniOptions.QueryConfigurations); + + // Inject the default Web API OData services. + services.AddDefaultWebApiServices(); + + // Set Uri resolver to by default enabling unqualified functions/actions and case insensitive match. + services.AddSingleton(sp => + new UnqualifiedODataUriResolver + { + EnableCaseInsensitive = miniOptions.EnableCaseInsensitive, // by default to enable case insensitive + EnableNoDollarQueryOptions = miniOptions.EnableNoDollarQueryOptions // retrieve it from global setting + }); + + // Inject the Edm model. + // From Current ODL implement, such injection only be used in reader and writer if the input + // model is null. + // How about the model is null? + services.AddSingleton(sp => model); + + return services.BuildServiceProvider(); + } } diff --git a/src/Microsoft.AspNetCore.OData/Extensions/HttpRequestExtensions.cs b/src/Microsoft.AspNetCore.OData/Extensions/HttpRequestExtensions.cs index d786ef7ec..bd79485ce 100644 --- a/src/Microsoft.AspNetCore.OData/Extensions/HttpRequestExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Extensions/HttpRequestExtensions.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.OData.Formatter.Deserialization; using Microsoft.AspNetCore.OData.Query; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.OData; using Microsoft.OData.Edm; @@ -98,6 +99,19 @@ public static TimeZoneInfo GetTimeZoneInfo(this HttpRequest request) throw Error.ArgumentNull(nameof(request)); } + bool isMinimalApi = request.HttpContext.IsMinimalEndpoint(); + if (isMinimalApi) + { + // In minimal API, we don't have ODataOptions, so we check the ODataMiniOptions directly. + ODataMiniOptions miniOptions = request.HttpContext.RequestServices?.GetService>()?.Value; + if (miniOptions is not null) + { + return miniOptions.TimeZone; + } + + return null; + } + return request.ODataOptions()?.TimeZone; } @@ -113,6 +127,19 @@ public static bool IsNoDollarQueryEnable(this HttpRequest request) throw Error.ArgumentNull(nameof(request)); } + bool isMinimalApi = request.HttpContext.IsMinimalEndpoint(); + if (isMinimalApi) + { + // In minimal API, we don't have ODataOptions, so we check the ODataMiniOptions directly. + ODataMiniOptions miniOptions = request.HttpContext.RequestServices?.GetService>()?.Value; + if (miniOptions is not null) + { + return miniOptions.EnableNoDollarQueryOptions; + } + + return false; + } + return request.ODataOptions()?.EnableNoDollarQueryOptions ?? false; } diff --git a/src/Microsoft.AspNetCore.OData/Extensions/ODataQueryFilterInvocationContext.cs b/src/Microsoft.AspNetCore.OData/Extensions/ODataQueryFilterInvocationContext.cs new file mode 100644 index 000000000..12b41a77e --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Extensions/ODataQueryFilterInvocationContext.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Reflection; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OData.Extensions; + +/// +/// Provides an abstraction for wrapping the and the associated with a route handler. +/// +public class ODataQueryFilterInvocationContext +{ + /// + /// The associated with the current route handler. + /// + public MethodInfo MethodInfo { get; init; } + + /// + /// The associated with the current route filter. + /// + public EndpointFilterInvocationContext InvocationContext { get; init; } + + /// + /// Gets the . + /// + public HttpContext HttpContext => InvocationContext.HttpContext; +} diff --git a/src/Microsoft.AspNetCore.OData/Formatter/ODataActionParameters.cs b/src/Microsoft.AspNetCore.OData/Formatter/ODataActionParameters.cs index d97c16413..ca4f6c549 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/ODataActionParameters.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/ODataActionParameters.cs @@ -7,7 +7,12 @@ using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Extensions; namespace Microsoft.AspNetCore.OData.Formatter; @@ -19,4 +24,14 @@ namespace Microsoft.AspNetCore.OData.Formatter; [SuppressMessage("Microsoft.Naming", "CA1710:IdentifiersShouldHaveCorrectSuffix", Justification = "ODataActionParameters is more appropriate here.")] public class ODataActionParameters : Dictionary { + /// + /// Binds the and to generate the . + /// + /// The HttpContext. + /// The parameter info. + /// The built + public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return await context.BindODataParameterAsync(parameter); + } } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs b/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs index baa00320f..b75d9db40 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/ODataOutputFormatterHelper.cs @@ -159,6 +159,7 @@ internal static async Task WriteToStreamAsync( writeContext.QueryOptions = queryOptions; writeContext.SetComputedProperties(queryOptions?.Compute?.ComputeClause); writeContext.Type = type; + writeContext.IsDelta = serializer.ODataPayloadKind == ODataPayloadKind.Delta; //Set the SelectExpandClause on the context if it was explicitly specified. if (selectExpandDifferentFromQueryOptions != null) diff --git a/src/Microsoft.AspNetCore.OData/Formatter/ODataUntypedActionParameters.cs b/src/Microsoft.AspNetCore.OData/Formatter/ODataUntypedActionParameters.cs index 286e28a2c..d13de3617 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/ODataUntypedActionParameters.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/ODataUntypedActionParameters.cs @@ -10,6 +10,11 @@ using System.Collections.Generic; using Microsoft.OData.Edm; using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Deltas; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.Extensions; namespace Microsoft.AspNetCore.OData.Formatter; @@ -34,4 +39,15 @@ public ODataUntypedActionParameters(IEdmAction action) /// Gets the OData action of this parameters. /// public IEdmAction Action { get; } + + /// + /// Binds the and to generate the . + /// + /// The HttpContext. + /// The parameter info. + /// The built + public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return await context.BindODataParameterAsync(parameter); + } } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataEnumSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataEnumSerializer.cs index c90a5d92f..90be5aded 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataEnumSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataEnumSerializer.cs @@ -201,7 +201,7 @@ private static string GetFlagsEnumValue(Enum graphEnum, ClrEnumMemberAnnotation long flagValue = Convert.ToInt64(flag); // Using bitwise operations to check if a flag is set, which is more efficient than Enum.HasFlag - if ((graphValue & flagValue) != 0 && flagValue != 0) + if (flagValue != 0 && (graphValue & flagValue) == flagValue) { IEdmEnumMember flagMember = memberMapAnnotation.GetEdmEnumMember(flag); if (flagMember != null) diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs index aa6050cd3..39b7b4152 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs @@ -134,7 +134,7 @@ private async Task WriteDeltaComplexPropertiesAsync(SelectExpandNode selectExpan IEnumerable complexProperties = selectExpandNode.SelectedComplexProperties.Keys; - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = null; if (resourceContext.EdmObject is TypedEdmEntityObject obj) @@ -477,7 +477,7 @@ private async Task WriteResourceAsync(object graph, ODataWriter writer, ODataSer } await writer.WriteStartAsync(odataDeletedResource).ConfigureAwait(false); - await WriteResourceContent(writer, selectExpandNode, resourceContext, /*isDelta*/ true).ConfigureAwait(false); + await WriteResourceContent(writer, selectExpandNode, resourceContext).ConfigureAwait(false); await writer.WriteEndAsync().ConfigureAwait(false); } else @@ -494,9 +494,8 @@ await writer.WriteEntityReferenceLinkAsync(new ODataEntityReferenceLink } else { - bool isDelta = graph is IDelta || graph is IEdmChangedObject; await writer.WriteStartAsync(resource).ConfigureAwait(false); - await WriteResourceContent(writer, selectExpandNode, resourceContext, isDelta).ConfigureAwait(false); + await WriteResourceContent(writer, selectExpandNode, resourceContext).ConfigureAwait(false); await writer.WriteEndAsync().ConfigureAwait(false); } } @@ -512,11 +511,10 @@ await writer.WriteEntityReferenceLinkAsync(new ODataEntityReferenceLink /// The to use to write the resource contents /// The describing the response graph. /// The context for the resource instance being written. - /// Whether to only write changed properties of the resource - private async Task WriteResourceContent(ODataWriter writer, SelectExpandNode selectExpandNode, ResourceContext resourceContext, bool isDelta) + private async Task WriteResourceContent(ODataWriter writer, SelectExpandNode selectExpandNode, ResourceContext resourceContext) { // TODO: These should be aligned; do we need different methods for delta versus non-delta complex/navigation properties? - if (isDelta) + if (resourceContext.SerializerContext.IsDelta) { await WriteUntypedPropertiesAsync(selectExpandNode, resourceContext, writer).ConfigureAwait(false); await WriteStreamPropertiesAsync(selectExpandNode, resourceContext, writer).ConfigureAwait(false); @@ -1130,7 +1128,7 @@ private async Task WriteComplexPropertiesAsync(SelectExpandNode selectExpandNode return; } - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = resourceContext.EdmObject as IDelta; IEnumerable changedProperties = deltaObject.GetChangedPropertyNames(); @@ -1400,7 +1398,7 @@ private IEnumerable CreateStructuralPropertyBag(SelectExpandNode { IEnumerable structuralProperties = selectExpandNode.SelectedStructuralProperties; - if (null != resourceContext.EdmObject && resourceContext.EdmObject.IsDeltaResource()) + if (resourceContext.EdmObject != null && resourceContext.SerializerContext.IsDelta) { IDelta deltaObject = null; if (resourceContext.EdmObject is TypedEdmEntityObject obj) @@ -1412,8 +1410,8 @@ private IEnumerable CreateStructuralPropertyBag(SelectExpandNode deltaObject = resourceContext.EdmObject as IDelta; } - if(deltaObject != null) - { + if (deltaObject != null) + { IEnumerable changedProperties = deltaObject.GetChangedPropertyNames(); structuralProperties = structuralProperties.Where(p => changedProperties.Contains(p.Name) || p.IsKey()); } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs index 46b61d1a1..4ef338938 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSetSerializer.cs @@ -9,6 +9,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Linq; using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; @@ -97,9 +98,8 @@ public override async Task WriteObjectInlineAsync(object graph, IEdmTypeReferenc throw new SerializationException(Error.Format(SRResources.CannotSerializerNull, ResourceSet)); } - if (writeContext.Type != null && - writeContext.Type.IsGenericType && - writeContext.Type.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>) && + if (writeContext.Type != null && + TypeHelper.IsAsyncEnumerableType(writeContext.Type) && graph is IAsyncEnumerable asyncEnumerable) { await WriteResourceSetAsync(asyncEnumerable, expectedType, writer, writeContext).ConfigureAwait(false); diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs index 626bad565..b2d88a99d 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerContext.cs @@ -44,12 +44,13 @@ public ODataSerializerContext() /// /// Initializes a new instance of the class. /// - /// The resource whose property is being nested. + /// The context for the resource instance being written /// The for the property being nested. /// The complex property being nested or the navigation property being expanded. /// If the resource property is the dynamic complex, the resource property is null. /// /// This constructor is used to construct the serializer context for writing nested and expanded properties. + // TODO: Rename "resource" to "resourceContext" in next major release. public ODataSerializerContext(ResourceContext resource, SelectExpandClause selectExpandClause, IEdmProperty edmProperty) : this(resource, edmProperty, null, null) { @@ -59,22 +60,22 @@ public ODataSerializerContext(ResourceContext resource, SelectExpandClause selec /// /// Initializes a new instance of the class for nested resources. /// - /// The resource whose property is being nested. + /// The context for the resource instance being written. /// The complex property being nested or the navigation property being expanded. /// If the resource property is the dynamic complex, the resource property is null. /// /// The for the property being nested. /// The for the property being nested.> - internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProperty, ODataQueryContext queryContext, SelectItem currentSelectItem) + internal ODataSerializerContext(ResourceContext resourceContext, IEdmProperty edmProperty, ODataQueryContext queryContext, SelectItem currentSelectItem) { - if (resource == null) + if (resourceContext == null) { - throw Error.ArgumentNull("resource"); + throw Error.ArgumentNull($"{nameof(resourceContext)}"); } // Clone the resource's context. Use a helper function so it can // handle platform-specific differences in ODataSerializerContext. - ODataSerializerContext context = resource.SerializerContext; + ODataSerializerContext context = resourceContext.SerializerContext; this.Request = context.Request; Model = context.Model; @@ -89,7 +90,8 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper QueryContext = queryContext; - ExpandedResource = resource; // parent resource + ExpandedResource = resourceContext; // parent resource + IsDelta = context.IsDelta; CurrentSelectItem = currentSelectItem; @@ -107,7 +109,7 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper if (pathSelectItem != null) { SelectExpandClause = pathSelectItem.SelectAndExpand; - NavigationSource = resource.NavigationSource; // Use it's parent navigation source. + NavigationSource = resourceContext.NavigationSource; // Use it's parent navigation source. SetComputedProperties(pathSelectItem.ComputeOption); } @@ -133,7 +135,7 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper } else { - NavigationSource = resource.NavigationSource; + NavigationSource = resourceContext.NavigationSource; } } } @@ -324,6 +326,11 @@ internal bool IsDeltaOfT } } + /// + /// Gets or sets a value indicating whether a delta payload is being serialized. + /// + internal bool IsDelta { get; set; } + /// /// Gets or sets the . /// diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaComplexObject.cs b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaComplexObject.cs index cf78aeacd..d36f0c696 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaComplexObject.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaComplexObject.cs @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.OData.Formatter.Value; /// Represents an with no backing CLR . /// Used to hold the Entry object in the Delta Feed Payload. /// +[Obsolete("EdmDeltaComplexObject is obsolete and will be dropped in the 10.x release. Please use EdmComplexObject instead.")] [NonValidatingParameterBinding] public class EdmDeltaComplexObject : EdmComplexObject { diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaResourceObject.cs b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaResourceObject.cs index 2cc601730..760a5557d 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaResourceObject.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmDeltaResourceObject.cs @@ -18,6 +18,7 @@ namespace Microsoft.AspNetCore.OData.Formatter.Value; /// Represents an with no backing CLR . /// Used to hold the Entry object in the Delta Feed Payload. /// +[Obsolete("EdmDeltaResourceObject is obsolete and will be dropped in the 10.x release. Please use EdmEntityObject instead.")] [NonValidatingParameterBinding] public class EdmDeltaResourceObject : EdmEntityObject, IEdmChangedObject { diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs similarity index 97% rename from src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs rename to src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs index c24223c28..37723f62d 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityCollectionObject.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Value/EdmEntityObjectCollection.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------------- -// +// // Copyright (c) .NET Foundation and Contributors. All rights reserved. // See License.txt in the project root for license information. // diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index b4d565bbc..75b46fbed 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -1058,6 +1058,42 @@ The test type. true/false + + + Determines whether the specified type is a or a custom implementation + that inherits from and implements both + and . + + The type to check. + The entity type if the specified type is a or a custom implementation; otherwise, null. + + true if the specified type is a or a custom implementation + that meets the criteria; otherwise, false. + + + + + Determines whether the specified type is a or a custom implementation + that inherits from and implements both + and . + + The type to check. + + true if the specified type is a or a custom implementation + that meets the criteria; otherwise, false. + + + + + Determines whether the specified type is a or a custom implementation + that inherits from and implements . + + The type to check. + + true if the specified type is a or a custom implementation + that meets the criteria; otherwise, false. + + Return the collection element type. @@ -1151,6 +1187,13 @@ out: the element type of the collection. True if the type is an enumeration; false otherwise. + + + Determines whether the specified represents an type. + + The to evaluate. + True if the type is an enumeration; false otherwise. + Returns type of T if the type implements IEnumerable of T, otherwise, return null. @@ -1158,6 +1201,28 @@ + + + Determines whether the specified type inherits from a given generic base type. + + The type to examine. + The open generic type definition to check against (e.g., typeof(Base<>)). + true if inherits from ; otherwise, false. + + + + Determines whether the specified target type implements the given interface type. + + The type to check for implementation of the interface. + The interface type to check against. + + true if the target type implements the specified interface type; otherwise, false. + + + This method supports both generic and non-generic interfaces. For generic interfaces, it checks if the target type + implements any interface that matches the generic type definition of the specified interface type. + + Utility class for creating and unwrapping instances. @@ -1718,6 +1783,14 @@ The entity to be updated. + + + Binds the and to generate the . + + The HttpContext. + The parameter info. + The built + Copies the unchanged property values from the underlying entity (accessible via ) @@ -1764,6 +1837,14 @@ Gets the expected type of the entity for which the changes are tracked. + + + Binds the and to generate the . + + The HttpContext. + The parameter info. + The built + allows and tracks changes to an object. @@ -2516,6 +2597,20 @@ Gets the whole expand path. + + + Defines a contract used to apply extra logic on the model builder. + + + + + Applies model configurations using the provided builder. + + The HttpContext. + The builder used to apply configurations. + The top CLR type. + The model builder or a totally new builder to use. + Provides the mapping between CLR type and Edm type. @@ -3169,6 +3264,26 @@ The OData path segments. The generated OData link. + + + Provides an abstraction for wrapping the and the associated with a route handler. + + + + + The associated with the current route handler. + + + + + The associated with the current route filter. + + + + + Gets the . + + Provides extension methods for the class. @@ -3992,6 +4107,14 @@ to invoke a particular Action. The Parameter values are stored in the dictionary keyed using the Parameter name. + + + Binds the and to generate the . + + The HttpContext. + The parameter info. + The built + A model binder for ODataParameterValue values. @@ -4234,6 +4357,14 @@ Gets the OData action of this parameters. + + + Binds the and to generate the . + + The HttpContext. + The parameter info. + The built + Contains context information about the resource currently being serialized. @@ -4792,14 +4923,13 @@ The ODataWriter. A task that represents the asynchronous write operation - + Writes the context of a Resource The to use to write the resource contents The describing the response graph. The context for the resource instance being written. - Whether to only write changed properties of the resource @@ -5124,7 +5254,7 @@ Initializes a new instance of the class. - The resource whose property is being nested. + The context for the resource instance being written The for the property being nested. The complex property being nested or the navigation property being expanded. If the resource property is the dynamic complex, the resource property is null. @@ -5135,7 +5265,7 @@ Initializes a new instance of the class for nested resources. - The resource whose property is being nested. + The context for the resource instance being written. The complex property being nested or the navigation property being expanded. If the resource property is the dynamic complex, the resource property is null. @@ -5233,6 +5363,11 @@ Gets or sets the . + + + Gets or sets a value indicating whether a delta payload is being serialized. + + Gets or sets the . @@ -5757,27 +5892,6 @@ Returning DeltaKind of the object within DeltaResourceSet payload - - - Represents an that is a collection of s. - - - - - Initializes a new instance of the class. - - The edm type of the collection. - - - - Initializes a new instance of the class. - - The edm type of the collection. - The list that is wrapped by the new collection. - - - - Represents an with no backing CLR . @@ -5802,6 +5916,27 @@ The of this object. true if this object can be nullable; otherwise, false. + + + Represents an that is a collection of s. + + + + + Initializes a new instance of the class. + + The edm type of the collection. + + + + Initializes a new instance of the class. + + The edm type of the collection. + The list that is wrapped by the new collection. + + + + Represents an with no backing CLR . @@ -6447,6 +6582,135 @@ The given route pattern. The . + + + Extension methods for adding and metadata to a route handler. + + + + + Adds a to the that matches HTTP GET requests to get the OData service document. + + The to add the route to. + The route pattern. + The related Edm model used to generate the service document. + A that can be used to further customize the endpoint. + + + + Adds a to the that matches HTTP GET requests to get the OData metadata. + It uses the Request.Header.ContentType or $format to identify whether it's CSDL-XML or CSDL-JSON. + + The to add the route to. + The route pattern. + The related Edm model. + A that can be used to further customize the endpoint. + + + + Registers the default OData query filter onto the route handler. + + The . + The action to configure validataion settings. + The action to configure query settings. + A that can be used to further customize the route handler. + + + + Registers the default OData query filter onto the route group. + + The . + The action to configure validataion settings. + The action to configure query settings. + A that can be used to further customize the route handler. + + + + Registers an OData query filter onto the route handler. + + The . + The . + A that can be used to further customize the route handler. + + + + Registers an OData query filter onto the route group. + + The . + The . + A that can be used to further customize the route handler. + + + + Registers an OData query filter of type onto the route handler. + + The type of the to register. + The . + A that can be used to further customize the route handler. + + + + Registers an OData query filter of type onto the route group. + + The type of the to register. + The . + A that can be used to further customize the route handler. + + + + Enables OData response annotation to associated with the current endpoint. + + The . + A that can be used to further customize the endpoint. + + + + Enables OData options to associated with the current endpoint. + + The . + The OData minimal options setup. + A that can be used to further customize the endpoint. + + + + Customizes the services used for OData to associated with the current endpoint. + + The . + The services. + A that can be used to further customize the endpoint. + + + + Adds the OData model to associated with the current endpoint. + + The . + The Edm model. + A that can be used to further customize the endpoint. + + + + Adds the base address factory to associated with the current endpoint. + + The . + The base address factory which is used to calculate the base address for OData payload. + A that can be used to further customize the endpoint. + + + + Adds the path factory to associated with the current endpoint. + + The . + The path factory which is used to calculate the OData path associated with current endpoint. + A that can be used to further customize the endpoint. + + + + Configures the OData version to associated with the current endpoint. + + The . + The OData version. + A that can be used to further customize the endpoint. + Sets up default OData options for . @@ -6458,6 +6722,165 @@ The Json Options. + + + Metadata that specifies the OData minimal API. + + + + + Gets or sets the model + + + + + Gets or sets a boolean value indicating to generate OData response. + + + + + Gets or sets the path factory. + + + + + Gets or sets the OData version. + + + + + Gets or sets the base address factory. + + + + + Gets or sets the minimal options. + + + + + Gets or sets the services + + + + + Gets or sets the service provider associated to this metadata. + Be noted, it seems we build and cache the service provider per endpoint. + If it's over-built, let's figure out a better solution for this. + + + + + The options for minimal API scenarios + + + + + Gets the query configurations. for example, enable '$select' or not. + + + + + Gets the OData version. + Please call 'SetVersion()' to config. + + + + + Gets whether or not the OData system query options should be prefixed with '$'. + Please call 'SetNoDollarQueryOptions()' to config. + + + + + Ges whether or not case insensitive. + Please call 'SetCaseInsensitive()' to config. + + + + + Gets TimeZoneInfo for the serialization and deserialization. + Please call 'SetTimeZoneInfo()' to config. + + + + + Config whether or not no '$' sign query option. + + Case insensitive or not. + The current instance to enable further configuration. + + + + Config the case insensitive. + + Case insensitive or not. + The current instance to enable further configuration. + + + + Config the time zone information. + + Case insensitive or not. + The current instance to enable further configuration. + + + + Config the OData version. + + The OData version. + The current instance to enable further configuration. + + + + Enables all OData query options. + + + The current instance to enable further configuration. + + + + Enable $expand query options. + + The current instance to enable further configuration. + + + + Enable $select query options. + + The current instance to enable further configuration. + + + + Enable $filter query options. + + The current instance to enable further configuration. + + + + Enable $orderby query options. + + The current instance to enable further configuration. + + + + Enable $count query options. + + The current instance to enable further configuration. + + + + Enable $skiptoken query option. + + The current instance to enable further configuration. + + + + Sets the maximum value of $top that a client can request. + + The maximum value of $top that a client can request. + The current instance to enable further configuration. + Provides extension methods to add OData services based on . @@ -6739,6 +7162,22 @@ Provides extension methods to add OData services. + + + Adds essential OData services to the specified . + + The to add services to. + A that can be used to further configure the OData services. + + + + Adds essential OData services to the specified . + + The to add services to. + The OData options to configure the services with, + including access to a service provider which you can resolve services from. + A that can be used to further configure the OData services. + Enables query support for actions with an or return @@ -6922,6 +7361,11 @@ Looks up a localized string similar to A binary operator with incompatible types was detected. Found operand types '{0}' and '{1}' for operator kind '{2}'.. + + + Looks up a localized string similar to Cannot bind parameter '{0}'. the error is: {1}.. + + Looks up a localized string similar to The property '{0}' on type '{1}' returned a null value. The input stream contains collection items which cannot be added if the instance is null.. @@ -7274,7 +7718,7 @@ - Looks up a localized string similar to The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{' and '}'.. + Looks up a localized string similar to The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{{' and '}}'.. @@ -7467,9 +7911,14 @@ Looks up a localized string similar to The property '{0}' cannot be used in the $orderby query option.. - + - Looks up a localized string similar to Transformation kind {0} is not supported.. + Looks up a localized string similar to Transformation kind '{0}' is not supported as a child transformation of kind '{1}'.. + + + + + Looks up a localized string similar to Transformation kind {0} is not supported.. @@ -7492,6 +7941,11 @@ Looks up a localized string similar to Unknown function '{0}'.. + + + Looks up a localized string similar to The OData configuration is missing for the minimal API. Please call WithODataModel() and WithODataPathFactory() for the endpoint.. + + Looks up a localized string similar to The operation cannot be completed because no ODataPath is available for the request.. @@ -7572,6 +8026,16 @@ Looks up a localized string similar to The value with type '{0}' must have type '{1}'.. + + + Looks up a localized string similar to The '{0}' property must be set.. + + + + + Looks up a localized string similar to The '{0}' property must be set when the '{1}' property is set.. + + Looks up a localized string similar to The value must be a string.. @@ -7792,6 +8256,16 @@ Looks up a localized string similar to '{0}' is not a resource set type. Only resource set are supported.. + + + Looks up a localized string similar to The type '{0}' does not implement '{1}' interface.. + + + + + Looks up a localized string similar to The type '{0}' does not inherit from '{1}'.. + + Looks up a localized string similar to The type '{0}' of dynamic property '{1}' is not supported.. @@ -8227,7 +8701,7 @@ Represent properties used in groupby and aggregate clauses to make them accessible in further clauses/transformations - + When we have $apply=groupby((Prop1,Prop2, Prop3))&$orderby=Prop1, Prop2 We will have following expression in .GroupBy $it => new AggregationPropertyContainer() { @@ -8243,10 +8717,10 @@ } } when in $orderby (see AggregationBinder CollectProperties method) - Prop1 could be referenced us $it => (string)$it.Value - Prop2 could be referenced us $it => (int)$it.Next.Value - Prop3 could be referenced us $it => (int)$it.Next.Next.Value - Generic type for Value is used to avoid type casts for on primitive types that not supported in EF + Prop1 could be referenced as $it => (string)$it.Value + Prop2 could be referenced as $it => (int)$it.Next.Value + Prop3 could be referenced as $it => (int)$it.Next.Next.Value + Generic type for Value is used to avoid type casts for primitive types that are not supported in EF Also we have 4 use cases and base type have all required properties to support no cast usage. 1. Primitive property with Next @@ -8254,8 +8728,68 @@ 3. Nested property with Next 4. Nested property without Next However, EF doesn't allow to set different properties for the same type in two places in an lambda-expression => using new type with just new name to workaround that issue - - + + + + + Represent properties used in groupby and aggregate clauses of $apply query to make them accessible in further clauses/transformations. + + The type of the group-by wrapper associated with this container. + The concrete type of the aggregation property container, enabling self-referencing. + + When we have + $apply=groupby((Prop1,Prop2,Prop3))&$orderby=Prop1,Prop2 + where implements , + we will have following expression in .GroupBy: + + $it => new AggregationPropertyContainer() { + Name = "Prop1", + Value = $it.Prop1, /* string */ + Next = new AggregationPropertyContainer() { + Name = "Prop2", + Value = $it.Prop2, /* int */ + Next = new LastInChain() { + Name = "Prop3", + Value = $it.Prop3 /* int */ + } + } + } + + When in $orderby, + Prop1 could be referenced as $it => (string)$it.Value, + Prop2 could be referenced as $it => (int)$it.Next.Value, + Prop3 could be referenced as $it => (int)$it.Next.Next.Value. + Generic type for Value is used to avoid type casts for primitive types that are not supported in Entity Framework. + Also, we have 4 use cases and this interface declares all required properties to support no cast usage. + 1). Primitive property with Next + 2). Primitive property without Next + 3). Nested property with Next + 4). Nested property without Next. + However, Entity Framework doesn't allow to set different properties for the same type in two places in a lambda expression. + Using new type with just new name to workaround that issue. + + + + Gets or sets the name of the property. + + + Gets or sets the value of the property. + + + Gets or sets the nested value of the property. + + + Gets or sets the next property container. + + + + Adds the properties in this container to the given dictionary. + + The dictionary to which the properties in this container should be added. + The property mapper to use for mapping + between the names of properties in this container and the names that + should be used when adding the properties to the given dictionary. + A value indicating whether auto-selected properties should be included. @@ -8800,12 +9334,12 @@ The action descriptor for the action being queried on. The EDM model for the given type and request. - + Holds request level query information. - + Gets or sets a value indicating whether query validation was run before action (controller method) is executed. @@ -8814,7 +9348,7 @@ For cases where the run failed before action execution. We will run validation on result. - + Gets or sets the processed query options. @@ -8897,27 +9431,168 @@ The original . The new after the ETag has been applied. - - - Pre flattens properties referenced in aggregate clause to avoid generation of nested queries by EF. - For query like groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) we need to generate - .Select( - $it => new FlattenninWrapper () { - Source = $it, // Will used in groupby stage - Container = new { - Value = $it.B.C - Next = new { - Value = $it.B.D - } + + + The default implementation to bind an OData $apply represented by to an . + + + + + + + + + + + + + + Creates an expression for an aggregate. + + The parameter representing the group. + The aggregate expression. + The element type at the base of the transformation. + The query binder context. + An expression representing the aggregate. + + + + + Creates an expression for an entity set aggregate. + + The parameter representing the group. + The entity set aggregate expression. + The element type at the base of the transformation. + The query binder context. + An expression for the entity set aggregate. + + Generates an expression similar to: + + $it => $it.AsQueryable() + .SelectMany($it => $it.SomeEntitySet) + .GroupBy($gr => new Object()) + .Select($p => new DynamicTypeWrapper() { + Container = new AggregationPropertyContainer() { + Name = "Alias1", + Value = $it.AsQueryable().AggregateMethod1($it => $it.SomePropertyOfSomeEntitySet), + Next = new LastInChain() { + Name = "Alias2", + Value = $p.AsQueryable().AggregateMethod2($it => $it.AnotherPropertyOfSomeEntitySet) + } + } + }) + + + + + + Creates an expression for a property aggregate. + + The parameter representing the group. + The aggregate expression. + The element type at the base of the transformation. + The query binder context. + An expression for a property aggregate. + + Generates an expression similar to: + + $it => $it.AsQueryable().Select($it => $it.SomeProperty).AggregateMethod() + + Example: + + $it => $it.AsQueryable().Sum($it => $it.SomeProperty) + + If the aggregation method is , the method uses a custom aggregation function provided by the caller. + + + + + Creates a list of from a collection of . + + GroupBy nodes. + The query binder context. + A list of representing properties in the GroupBy clause. + + + + Gets a collection of from a . + + The transformation node. + A collection of . + + + + Gets a collection of from a . + + The query binder context. + The . + A collection of aggregate expressions. + + + + Fixes return types for custom aggregation methods. + + The aggregation expressions. + + + + + + Fixes return type for custom aggregation method. + + The aggregation expression + The query binder context. + The + + + + Gets a custom aggregation method for the aggregation expression. + + The aggregation expression. + The query binder context. + The custom method. + + + + Represents the result of flattening properties referenced in the aggregate clause. + + + Flattening properties referenced in an aggregate clause helps prevent the generation of nested queries by Entity Framework. + For example, given a query like: + groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) + the expression is rewritten as: + + .Select($it => new FlatteningWrapper<T> { + Source = $it, + Container = new { + Value = $it.B.C, + Next = new { + Value = $it.B.D } } - ) - Also we need to populate expressions to access B/C and B/D in aggregate stage. It will look like: - B/C : $it.Container.Value - B/D : $it.Container.Next.Value + }) + + A mapping is also maintained between the original properties and their flattened expressions: + B/C → $it.Container.Value + B/D → $it.Container.Next.Value + This mapping is used during the aggregation stage to generate aggregate expressions. + + + + + Gets or sets the context parameter that has been redefined during the flattening process. + + + + + Gets or sets the expression that has been rewritten as part of the flattening process. + + + + + Gets or sets the mapping of single-value nodes to their corresponding flattened expressions. + Example: { { $it.B.C, $it.Value }, { $it.B.D, $it.Next.Value } } - - Query with Select that flattens properties @@ -8998,6 +9673,16 @@ An instance of the . + + + Translates an OData $search represented by to and apply to . + + The given search binder. + The given source. + The search clause. + The query binder context. + The applied result. + Translate an OData $search parse tree represented by to @@ -9009,6 +9694,38 @@ An instance of the . The applied result. + + + Translate an OData $apply parse tree represented by to + an and applies it to an . + + An instance of . + The original . + The OData $apply parse tree. + An instance of the containing the current query context. + The type of wrapper used to create an expression from the $apply parse tree. + The applied result. + + + + Translate an OData parse tree represented by to + an and applies it to an . + + An instance of the . + The original source. + The representing an OData parse tree. + An instance of the containing the current query context. + The type of wrapper used to create an expression from the $apply parse tree. + The modified source. + + + + The default implementation to bind an OData $apply parse tree represented by a to an . + + + + + The base class for all expression binders. @@ -9117,6 +9834,96 @@ The query binder context. The filter binder result. + + + Exposes the ability to translate an OData $apply parse tree represented by a to + an . + + + + + Translates an OData $apply parse tree represented by a to + a LINQ that performs a GroupBy operation. + + The OData $apply parse tree represented by . + An instance of the containing the current query context.. + A representing the GroupBy operation in LINQ. + + Generates an expression similar to: + + $it => new DynamicTypeWrapper() { + GroupByContainer = new AggregationPropertyContainer() { + Name = "Prop1", + Value = $it.Prop1, + Next = new AggregationPropertyContainer() { + Name = "Prop2", + Value = $it.Prop2, + Next = new LastInChain() { + Name = "Prop3", + Value = $it.Prop3 + } + } + } + } + + + + + + Translates an OData $apply parse tree represented by a to + a LINQ that performs a Select operation. + + The OData $apply parse tree represented by . + An instance of the containing the current query context. + A representing the Select operation in LINQ. + + Generates an expression similar to: + + $it => new DynamicTypeWrapper() { + GroupByContainer = $it.Key.GroupByContainer // If groupby clause present + Container = new AggregationPropertyContainer() { + Name = "Alias1", + Value = $it.AsQueryable().Sum(i => i.AggregatableProperty), + Next = new LastInChain() { + Name = "Alias2", + Value = $it.AsQueryable().Sum(i => i.AggregatableProperty) + } + } + } + + + + + + Exposes the ability to translate an OData $apply parse tree represented by a to an . + + + + + Translates an OData $apply parse tree represented by a to + an . + + The OData $apply parse tree represented by . + An instance of the . + + Generates an expression structured similar to: + + $it => new ComputeWrapper<T> { + Instance = $it, + Model = parameterized(IEdmModel), + Container => new AggregationPropertyContainer() { + Name = "Z", + Value = $it.X + $it.Y, + Next = new LastInChain() { + Name = "C", + Value = $it.A * $it.B + } + } + } + + + The generated LINQ expression representing the OData $apply parse tree. + Exposes the ability to translate an OData $filter represented by to the . @@ -9133,6 +9940,50 @@ The filter binder result. reconsider to return "LambdaExpression"? + + + Provides an abstraction for flattening property access expressions within an OData $apply clause + to support efficient translation of aggregation pipelines in LINQ providers like Entity Framework. + + + Entity Framework versions earlier than EF Core 6.0 may generate nested queries when accessing navigation properties + in aggregation clauses. Flattening these properties can help generate flatter, more efficient SQL queries. + This interface allows conditional support for flattening based on the capabilities of the underlying LINQ provider. + + + + + Flattens properties referenced in aggregate clause to avoid generation of nested queries by Entity Framework. + + The OData $apply parse tree represented by . + The original . + An instance of the containing the current query context. + + An containing the modified query source and + additional metadata resulting from the flattening operation. + + + This method generates a Select expression that flattens the properties referenced in the aggregate clause. + Flattening properties helps prevent the generation of nested queries by Entity Framework, + resulting in more efficient SQL generation. + For query like: groupby((A),aggregate(B/C with max as Alias1,B/D with max as Alias2)) + generate an expression similar to: + + $it => FlatteningWrapper<T> { + Source = $it, + Container = new { + Value = $it.B.C + Next = new { + Value = $it.B.D + } + } + } + + Also populate expressions to access B/C and B/D in aggregate stage to look like: + B/C : $it.Container.Value + B/D : $it.Container.Next.Value + + Exposes the ability to translate an OData $orderby represented by to the @@ -9468,6 +10319,23 @@ The query binder context. The LINQ created. + + + Creates an from the . + + The to be bound. + An instance of the . + The for the base element. + The created . + + + + Creates a LINQ that represents the semantics of the . + + They query node to create an expression from. + The query binder context. + The LINQ created. + Recognize $it.Source where $it is FlatteningWrapper @@ -9509,6 +10377,27 @@ The query binder context. Returns null if no aggregations were used so far + + + Wrap a value type with Expression.Convert. + + The to be wrapped. + The wrapped + + + + Wraps expression with a Convert expression to enforce type if needed. + + The source expression. + The wrapped expression, or the original expression, as the case may be. + + Wrapping with a Convert expression is only needed when dealing with (default implementation). + It's needed because inherits from - meaning that + implements "Name" and "Value" properties + indirectly via . + Without Convert({expression}, typeof(AggregationPropertyContainer)), the "Value" property cannot be resolved and translation fails. + + Binds property to the source node. @@ -9876,6 +10765,11 @@ Gets or sets the assembly resolver. + + + Gets or sets the . + + Gets the compute expressions. @@ -9931,6 +10825,35 @@ The parameter name. + + + Sets the specified parameter + + The parameter name. + The parameter expression. + + + + The type of the element in a transformation query. + + + + + A mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + + + + + Ensures that the flattened properties are populated for the given source and query. + + The source parameter expression representing the root of the query. + The queryable object representing the current query context. + + This method populates the dictionary with the flattened properties + from the base query. It is typically used when the binder is applied to an aggregated query to ensure + that the properties are correctly flattened. + + Exposes the ability to translate an OData $select or $expand parse tree represented by to @@ -9978,7 +10901,7 @@ The source . The created . - + Creates an from an name. @@ -9986,8 +10909,9 @@ The that contains the edmProperty. The from which we are creating an . The source . - The $filter query represented by . - The $compute query represented by . + The nested $filter query represented by . + The nested $compute query represented by . + The nested $search query represented by . The created . @@ -10094,18 +11018,6 @@ The . The with derived types if any are present. - - - Gets CLR type returned from the query. - - - - - Checks IQueryable provider for need of EF6 optimization - - - True if EF6 optimization are needed. - This class helps to bind uri functions to CLR. @@ -10202,6 +11114,26 @@ Gets a value representing the total count of the collection. + + + Provides an interface for implementing a filter to run codes before and after a route handler. + + + + + Performs the OData query composition before route handler is executing. + + The OData query filter invocation context. + + + + + Performs the OData query composition after route handler is executed. + + The response value from the route handler. + The OData query filter invocation context. + + Exposes the ability to read and parse the content of a @@ -10485,6 +11417,20 @@ The query context. The built . + + + Gets the . + + The query context. + The built . + + + + Gets the . + + The query context. + The built . + Gets the . @@ -10520,6 +11466,13 @@ The query context. The built . + + + Gets the . + + The query context. + The built . + Gets the . @@ -10555,6 +11508,151 @@ The query context. The built . + + + The default implementation to to run codes before and after a route handler. + This typically is used for minimal api scenario. + + + + + Gets the validation settings. Customers can use this to configure the validation. + + + + + Gets the query settings. Customers can use this to configure each query executing. + + + It could be confusing between DefaultQueryConfigurations and ODataQuerySettings. + DefaultQueryConfigurations is used to config the functionalities for query options. For example: is $filter enabled? + ODataQuerySettings is used to set options for each query executing. + + + + + Implements the core logic associated with the filter given a + and the next filter to call in the pipeline. + + The associated with the current request/response. + The next filter in the pipeline. + An awaitable result of calling the handler and apply any modifications made by filters in the pipeline. + + + + Performs the query composition before route handler is executing. + + The OData query filter invocation context. + The . + + + + Performs the query composition after route handler is executed. + + The response value from the route handler. + The OData query filter invocation context. + The . + + + + Execute the query. + + The response value. + The content as SingleResult.Queryable. + The action context, i.e. action and controller name. + + + + + Validate the select and expand options. + + The query options. + + + + Get a single or default value from a collection. + + The response value as . + The context. + + + + + Applies the query to the given entity based on incoming query from uri and query settings. + + The original entity from the response message. + + The instance constructed based on the incoming request. + + The query settings. + The new entity after the $select and $expand query has been applied to. + + + + Applies the query to the given IQueryable based on incoming query from uri and query settings. By default, + the implementation supports $top, $skip, $orderby and $filter. Override this method to perform additional + query composition of the query. + + The original queryable instance from the response message. + + The instance constructed based on the incoming request. + + The settings. + + + + Get the OData query context. + + The response value. + The content as SingleResult.Queryable. + The action context, i.e. action and controller name. + + + + + Create and validate a new instance of from a query and context during action executed. + Developers can override this virtual method to provide its own . + + The http context. + The query context. + The created . + + + + Get the element type. + + The response value. + The content as SingleResult.Queryable. + The context. + + + + + Validates the OData query in the incoming request. By default, the implementation throws an exception if + the query contains unsupported query parameters. Override this method to perform additional validation of + the query. + + + + The instance constructed based on the incoming request. + + + + + Creates the for action executing validation. + + The OData query filter invocation context. + The created or null if we can't create it during action executing. + + + + Gets the EDM model for the given type and request.Override this method to customize the EDM model used for + querying. + + The CLR type to retrieve a model for. + The action descriptor for the action being queried on. + The EDM model for the given type and request. + This defines a composite OData query options that can be used to perform query composition. @@ -10829,6 +11927,21 @@ The settings to use in query composition. The new after the query has been applied to. + + + Binds the and to generate the . + + The HttpContext. + The parameter info. + The built + + + + Populates metadata for the related and . + + The parameter info. + The endpoint builder that we can add metadata into. + A to bind parameters of type to the OData query from the incoming request. @@ -11161,6 +12274,41 @@ The node to be translated. The translated node. + + + Constant values used in aggregation operation. + + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Parameter name representing the current instance being evaluated in an OData expression. + An implementation of that applies an action filter to @@ -11671,6 +12819,12 @@ The that contains all the query application related settings. The new after the filter query has been applied to. + + + Validate the $search query based on the given . It throws an ODataException if validation failed. + + The instance which contains all the validation settings. + Represents the OData $select and $expand query options. @@ -12421,6 +13575,19 @@ The $orderby query. The validation settings. + + + Provides the interface used to validate a + based on the . + + + + + Validates the OData $search query. + + The $search query. + The validation settings. + Provide the interface used to validate a @@ -12780,6 +13947,68 @@ Throw OData exception. + + + Represents a validator used to validate the results of translating an OData parse tree into expressions. + + + + + Validates that the type representing the expression returned by + + implements and derives from . + + The type representing the expression returned by + . + Thrown if + does not implement the required interfaces or inherit from the required base class. + + + + Validates that the type representing the expression returned by + + implements and derives from . + + The type representing the expression returned by + . + Thrown if + does not implement the required interfaces or inherit from the required base class. + + + + Validates the provided instance to ensure all required properties are set. + + The instance to validate. + Thrown if is null. + Thrown if has a null or empty , + , or . + + + + Validates that the provided type implements and , and inherits from . + + The type representing the flattened expression returned by + . + Thrown if + does not implement the required interfaces or inherit from the required base class. + + + + Validates that the provided type implements and , and inherits from . + + The type representing the flattened expression returned by + . + Thrown if + does not implement the required interfaces or inherit from the required base class. + + + + Validates that the provided type implements and inherits from . + + The type to validate. + Thrown if + does not implement the required interface or inherit from the required base class. + The base for validator context. @@ -13014,9 +14243,17 @@ The $top query. The validation settings. + + + + + + Gets or sets the source object that provides the values used in the compute expression. + + - The Edm Model associated with the wrapper. + Gets or sets the Edm model associated with the wrapper. @@ -13031,10 +14268,10 @@ - Attempts to get the value of the Property called from the underlying Entity. + Attempts to get the value of the property called from the underlying entity. - The name of the Property - The new value of the Property + The name of the property + The new value of the property True if successful @@ -13059,12 +14296,12 @@ - Gets or sets the property container that contains the properties being expanded. + Gets or sets the property container that contains the grouping properties. - Gets or sets the property container that contains the properties being expanded. + Gets or sets the property container that contains the aggregation properties. @@ -13073,6 +14310,55 @@ + + + Represents a wrapper for a source object with computed values in an OData query. + + The type of the source object. + + The source object type can either be the type of the wrapped entity or another instance of + when compute expressions are chained. + For example, in the OData query: + + /Sales?$apply=compute(Amount mul Product/TaxRate as Tax)/compute(Amount add Tax as SalesPrice) + + In the first compute expression, the source object will be the wrapped "Sale" entity. + In the second compute expression, the source object will be an instance of where T is "Sale". + + + + + Gets or sets the source object that provides the values used in the compute expression. + + + + + Gets or sets the Edm model associated with the wrapper. + + + + + Represents the result of flattening properties referenced in aggregate clause of a $apply query. + + The type of the source object that contains the properties to be flattened. + Flattening is necessary to avoid generation of nested queries by Entity Framework. + + + Gets or sets the source object that contains the properties to be flattened. + + + + Represents the result of a $apply query operation. + + The type of the aggregation property container associated with this group. + The type of the group-by wrapper itself, enforcing recursive typing. + + + Gets or sets the property container that contains the grouping properties. + + + Gets or sets the property container that contains the aggregation properties. + Represents the result of a $select and $expand query operation. @@ -13137,6 +14423,11 @@ Supports converting types by using a factory pattern. + + + The mapper provider. + + determines whether the converter instance can convert the specified object type. @@ -13248,6 +14539,21 @@ OData error. + + + A contracts for OData result + + + + + Gets the real value. + + + + + Gets the expected type. + + Represents a result that when executed will produce a Not Found (404) response. @@ -13301,6 +14607,76 @@ + + + Defines a contract that represents the result of OData metadata. + + + + + Gets the static instance since we don't need the instance of it. + + + + + Write an HTTP response reflecting the result. + + The for the current request. + A task that represents the asynchronous execute operation. + + + + Defines an implementation that represents the result of an OData format result. + It's used for minimal API. + + + + + Initializes a new instance of the class. + + The wrappered real value. + + + + Initializes a new instance of the class. + + The wrappered real value. + The expected type. + + + + Gets the value. + + + + + Gets the expected type. + + + + + Writes an HTTP response reflecting the result. + + The for the current request. + A task that represents the asynchronous execute operation. + + + + Defines a contract that represents the result of OData service document. + + + + + Gets the static instance since we don't need the instance of it. + + + + + Write an HTTP response reflecting the result. + + The for the current request. + A task that represents the asynchronous execute operation. + Represents a feed of entities that includes additional information that OData formats support. diff --git a/src/Microsoft.AspNetCore.OData/ODataEndpointConventionBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData/ODataEndpointConventionBuilderExtensions.cs new file mode 100644 index 000000000..4d088b039 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/ODataEndpointConventionBuilderExtensions.cs @@ -0,0 +1,286 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.AspNetCore.OData.Results; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData; + +/// +/// Extension methods for adding and metadata to a route handler. +/// +public static class ODataEndpointConventionBuilderExtensions +{ + /// + /// Adds a to the that matches HTTP GET requests to get the OData service document. + /// + /// The to add the route to. + /// The route pattern. + /// The related Edm model used to generate the service document. + /// A that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapODataServiceDocument( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string pattern, + IEdmModel model) + => endpoints.MapGet(pattern, () => ODataServiceDocumentResult.Instance).WithODataModel(model); + + /// + /// Adds a to the that matches HTTP GET requests to get the OData metadata. + /// It uses the Request.Header.ContentType or $format to identify whether it's CSDL-XML or CSDL-JSON. + /// + /// The to add the route to. + /// The route pattern. + /// The related Edm model. + /// A that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapODataMetadata( + this IEndpointRouteBuilder endpoints, + [StringSyntax("Route")] string pattern, + IEdmModel model) + => endpoints.MapGet(pattern, () => ODataMetadataResult.Instance).WithODataModel(model); + + /// + /// Registers the default OData query filter onto the route handler. + /// + /// The . + /// The action to configure validataion settings. + /// The action to configure query settings. + /// A that can be used to further customize the route handler. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "")] + public static RouteHandlerBuilder AddODataQueryEndpointFilter(this RouteHandlerBuilder builder, + Action validationSetup = default, + Action querySetup = default) + => builder.AddODataQueryEndpointFilterInternal(new ODataQueryEndpointFilter(), validationSetup, querySetup); + + /// + /// Registers the default OData query filter onto the route group. + /// + /// The . + /// The action to configure validataion settings. + /// The action to configure query settings. + /// A that can be used to further customize the route handler. + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "")] + public static RouteGroupBuilder AddODataQueryEndpointFilter(this RouteGroupBuilder builder, + Action validationSetup = default, + Action querySetup = default) => + builder.AddODataQueryEndpointFilterInternal(new ODataQueryEndpointFilter(), validationSetup, querySetup); + + private static TBuilder AddODataQueryEndpointFilterInternal(this TBuilder builder, ODataQueryEndpointFilter queryFilter, + Action validationSetup, + Action querySetup) + where TBuilder : IEndpointConventionBuilder + { + validationSetup?.Invoke(queryFilter.ValidationSettings); + querySetup?.Invoke(queryFilter.QuerySettings); + builder.AddEndpointFilter(queryFilter); + return builder; + } + + /// + /// Registers an OData query filter onto the route handler. + /// + /// The . + /// The . + /// A that can be used to further customize the route handler. + public static RouteHandlerBuilder AddODataQueryEndpointFilter(this RouteHandlerBuilder builder, IODataQueryEndpointFilter queryFilter)=> + builder.AddEndpointFilter(queryFilter); + + /// + /// Registers an OData query filter onto the route group. + /// + /// The . + /// The . + /// A that can be used to further customize the route handler. + public static RouteGroupBuilder AddODataQueryEndpointFilter(this RouteGroupBuilder builder, IODataQueryEndpointFilter queryFilter) => + builder.AddEndpointFilter(queryFilter); + + /// + /// Registers an OData query filter of type onto the route handler. + /// + /// The type of the to register. + /// The . + /// A that can be used to further customize the route handler. + public static RouteHandlerBuilder AddODataQueryEndpointFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteHandlerBuilder builder) + where TFilterType : IODataQueryEndpointFilter => + builder.AddEndpointFilter(); + + /// + /// Registers an OData query filter of type onto the route group. + /// + /// The type of the to register. + /// The . + /// A that can be used to further customize the route handler. + public static RouteGroupBuilder AddODataQueryEndpointFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this RouteGroupBuilder builder) + where TFilterType : IODataQueryEndpointFilter => + builder.AddEndpointFilter(); + + /// + /// Enables OData response annotation to associated with the current endpoint. + /// + /// The . + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataResult(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + { + builder.AddEndpointFilter(async (invocationContext, next) => + { + object result = await next(invocationContext); + + // If it's null or if it's already the ODataResult, simply do nothing + if (result is null || typeof(IResult).IsAssignableFrom(result.GetType())) + { + return result; + } + + // Maybe we have a scenario like: + // First, enable OData result in app.MapGroup(...).WithODataResult(), + // Then, disable OData result for a certain Routehandler by cusomizing the metadaga + var endpoint = invocationContext.HttpContext.GetEndpoint(); + ODataMiniMetadata odataMetadata = endpoint?.Metadata?.GetMetadata(); + if (odataMetadata is not null && odataMetadata.IsODataFormat) + { + // Now, Let's focus on the POCO data result. + // We can figure out more types later, for example, TypedResult, Results, etc. + return new ODataResult(result/*, odataMetadata*/); + } + + return result; + }); + + builder.Add(b => ConfigureODataMetadata(b, m => m.IsODataFormat = true)); + return builder; + } + + /// + /// Enables OData options to associated with the current endpoint. + /// + /// The . + /// The OData minimal options setup. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataOptions(this TBuilder builder, Action setupAction) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(setupAction, nameof(setupAction)); + + builder.Add(b => ConfigureODataMetadata(b, m => setupAction.Invoke(m.Options))); + return builder; + } + + ///// + ///// Enables OData batch to associated with the current endpoint. + ///// + ///// + ///// + ///// + ///// + //public static TBuilder WithODataBatch(this TBuilder builder, ODataBatchHandler handler) where TBuilder : IEndpointConventionBuilder + //{ + // // builder.Add(b => ConfigureODataMetadata(b, m => m.Services = services)); + // return builder; + //} + + /// + /// Customizes the services used for OData to associated with the current endpoint. + /// + /// The . + /// The services. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataServices(this TBuilder builder, Action services) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(services, nameof(services)); + + builder.Add(b => ConfigureODataMetadata(b, m => m.Services = services)); + + //builder.Finally(b => { }); + return builder; + } + + /// + /// Adds the OData model to associated with the current endpoint. + /// + /// The . + /// The Edm model. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataModel(this TBuilder builder, IEdmModel model) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(model, nameof(model)); + + builder.Add(b => ConfigureODataMetadata(b, m => m.Model = model)); + return builder; + } + + /// + /// Adds the base address factory to associated with the current endpoint. + /// + /// The . + /// The base address factory which is used to calculate the base address for OData payload. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataBaseAddressFactory(this TBuilder builder, Func baseAddressFactory) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(baseAddressFactory, nameof(baseAddressFactory)); + + builder.Add(b => ConfigureODataMetadata(b, m => m.BaseAddressFactory = baseAddressFactory)); + return builder; + } + + /// + /// Adds the path factory to associated with the current endpoint. + /// + /// The . + /// The path factory which is used to calculate the OData path associated with current endpoint. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataPathFactory(this TBuilder builder, Func pathFactory) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(pathFactory, nameof(pathFactory)); + + builder.Add(b => ConfigureODataMetadata(b, m => m.PathFactory = pathFactory)); + return builder; + } + + /// + /// Configures the OData version to associated with the current endpoint. + /// + /// The . + /// The OData version. + /// A that can be used to further customize the endpoint. + public static TBuilder WithODataVersion(this TBuilder builder, ODataVersion version) where TBuilder : IEndpointConventionBuilder + { + builder.Add(b => ConfigureODataMetadata(b, m => m.Version = version)); + return builder; + } + + // Will update the odata metadata if existed. + // Otherwise, will create a new metadata and insert into endpoint. + internal static void ConfigureODataMetadata(EndpointBuilder endpointBuilder, Action setupAction) + { + var metadata = endpointBuilder.Metadata.OfType().FirstOrDefault(); + if (metadata is null) + { + metadata = new ODataMiniMetadata(); + + // retrieve the global minimal API OData configuration + ODataMiniOptions options = endpointBuilder.ApplicationServices.GetService>()?.Value; + if (options is not null) + { + metadata.Options.UpdateFrom(options); + } + + endpointBuilder.Metadata.Add(metadata); + } + + setupAction?.Invoke(metadata); + } +} diff --git a/src/Microsoft.AspNetCore.OData/ODataMiniMetadata.cs b/src/Microsoft.AspNetCore.OData/ODataMiniMetadata.cs new file mode 100644 index 000000000..387cfc950 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/ODataMiniMetadata.cs @@ -0,0 +1,136 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData; + +/// +/// Metadata that specifies the OData minimal API. +/// +// Or seperate them into pieces? +public class ODataMiniMetadata +{ + private IServiceProvider _serviceProvider = null; + + /// + /// Gets or sets the model + /// + public IEdmModel Model { get; set; } + + /// + /// Gets or sets a boolean value indicating to generate OData response. + /// + public bool IsODataFormat { get; set; } + + /// + /// Gets or sets the path factory. + /// + public Func PathFactory { get; set; } + + /// + /// Gets or sets the OData version. + /// + public ODataVersion Version + { + get => Options.Version; + set => Options.SetVersion(value); + } + + /// + /// Gets or sets the base address factory. + /// + public Func BaseAddressFactory { get; set; } + + /// + /// Gets or sets the minimal options. + /// + public ODataMiniOptions Options { get; } = new ODataMiniOptions(); + + /// + /// Gets or sets the services + /// + public Action Services { get; set; } + + /// + /// Gets or sets the service provider associated to this metadata. + /// Be noted, it seems we build and cache the service provider per endpoint. + /// If it's over-built, let's figure out a better solution for this. + /// + public IServiceProvider ServiceProvider + { + get + { + if (_serviceProvider == null) + { + _serviceProvider = BuildRouteContainer(); + } + + return _serviceProvider; + } + set => _serviceProvider = value; + } + + internal IServiceProvider BuildRouteContainer() + { + IServiceCollection services = new ServiceCollection(); + + // Inject the core odata services. + services.AddDefaultODataServices(Version); + + // Inject the default query configuration from this options. + services.AddSingleton(sp => this.Options.QueryConfigurations); + + // Inject the default Web API OData services. + services.AddDefaultWebApiServices(); + + // Set Uri resolver to by default enabling unqualified functions/actions and case insensitive match. + services.AddSingleton(sp => + new UnqualifiedODataUriResolver + { + EnableCaseInsensitive = this.Options.EnableCaseInsensitive, // by default to enable case insensitive + EnableNoDollarQueryOptions = this.Options.EnableNoDollarQueryOptions // retrieve it from global setting + }); + + // Inject the Edm model. + // From Current ODL implement, such injection only be used in reader and writer if the input + // model is null. + // How about the model is null? + services.AddSingleton(sp => Model); + + // Inject the customized services. + Services?.Invoke(services); + + return services.BuildServiceProvider(); + } + + internal static ODataPath DefaultPathFactory(HttpContext context, Type elementType) + { + IEdmModel model = context.GetOrCreateEdmModel(elementType); + IEdmType edmType = model.GetEdmType(elementType); + + var entitySet = model.EntityContainer?.EntitySets().FirstOrDefault(e => e.EntityType == edmType); + if (entitySet != null) + { + return new ODataPath(new EntitySetSegment(entitySet)); + } + else + { + entitySet = new EdmEntitySet(model.EntityContainer, elementType.Name, edmType as IEdmEntityType); + return new ODataPath(new EntitySetSegment(entitySet)); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs b/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs new file mode 100644 index 000000000..466d0678a --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs @@ -0,0 +1,200 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.OData; + +namespace Microsoft.AspNetCore.OData; + +/// +/// The options for minimal API scenarios +/// +public class ODataMiniOptions +{ + private DefaultQueryConfigurations _queryConfigurations = new DefaultQueryConfigurations(); + private bool _enableNoDollarQueryOptions = true; + private bool _enableCaseInsensitive = true; + private TimeZoneInfo _timeZone = TimeZoneInfo.Local; + private ODataVersion _version = ODataVersionConstraint.DefaultODataVersion; + + /// + /// Gets the query configurations. for example, enable '$select' or not. + /// + public DefaultQueryConfigurations QueryConfigurations { get => _queryConfigurations; } + + /// + /// Gets the OData version. + /// Please call 'SetVersion()' to config. + /// + public ODataVersion Version { get => _version; } + + /// + /// Gets whether or not the OData system query options should be prefixed with '$'. + /// Please call 'SetNoDollarQueryOptions()' to config. + /// + public bool EnableNoDollarQueryOptions { get => _enableNoDollarQueryOptions; } + + /// + /// Ges whether or not case insensitive. + /// Please call 'SetCaseInsensitive()' to config. + /// + public bool EnableCaseInsensitive { get => _enableCaseInsensitive; } + + /// + /// Gets TimeZoneInfo for the serialization and deserialization. + /// Please call 'SetTimeZoneInfo()' to config. + /// + public TimeZoneInfo TimeZone { get => _timeZone; } + + /// + /// Config whether or not no '$' sign query option. + /// + /// Case insensitive or not. + /// The current instance to enable further configuration. + public ODataMiniOptions SetNoDollarQueryOptions(bool enableNoDollarQueryOptions) + { + _enableNoDollarQueryOptions = enableNoDollarQueryOptions; + return this; + } + + /// + /// Config the case insensitive. + /// + /// Case insensitive or not. + /// The current instance to enable further configuration. + public ODataMiniOptions SetCaseInsensitive(bool enableCaseInsensitive) + { + _enableCaseInsensitive = enableCaseInsensitive; + return this; + } + + /// + /// Config the time zone information. + /// + /// Case insensitive or not. + /// The current instance to enable further configuration. + public ODataMiniOptions SetTimeZoneInfo(TimeZoneInfo tzi) + { + _timeZone = tzi; + return this; + } + + /// + /// Config the OData version. + /// + /// The OData version. + /// The current instance to enable further configuration. + public ODataMiniOptions SetVersion(ODataVersion version) + { + _version = version; + return this; + } + + /// + /// Enables all OData query options. + /// + /// + /// The current instance to enable further configuration. + public ODataMiniOptions EnableAll(int? maxTopValue = null) + { + _queryConfigurations.EnableExpand = true; + _queryConfigurations.EnableSelect = true; + _queryConfigurations.EnableFilter = true; + _queryConfigurations.EnableOrderBy = true; + _queryConfigurations.EnableCount = true; + _queryConfigurations.EnableSkipToken = true; + SetMaxTop(maxTopValue); + return this; + } + + /// + /// Enable $expand query options. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions Expand() + { + _queryConfigurations.EnableExpand = true; + return this; + } + + /// + /// Enable $select query options. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions Select() + { + _queryConfigurations.EnableSelect = true; + return this; + } + + /// + /// Enable $filter query options. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions Filter() + { + _queryConfigurations.EnableFilter = true; + return this; + } + + /// + /// Enable $orderby query options. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions OrderBy() + { + _queryConfigurations.EnableOrderBy = true; + return this; + } + + /// + /// Enable $count query options. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions Count() + { + _queryConfigurations.EnableCount = true; + return this; + } + + /// + /// Enable $skiptoken query option. + /// + /// The current instance to enable further configuration. + public ODataMiniOptions SkipToken() + { + _queryConfigurations.EnableSkipToken = true; + return this; + } + + /// + ///Sets the maximum value of $top that a client can request. + /// + /// The maximum value of $top that a client can request. + /// The current instance to enable further configuration. + public ODataMiniOptions SetMaxTop(int? maxTopValue) + { + if (maxTopValue.HasValue && maxTopValue.Value < 0) + { + throw Error.ArgumentMustBeGreaterThanOrEqualTo(nameof(maxTopValue), maxTopValue, 0); + } + + _queryConfigurations.MaxTop = maxTopValue; + return this; + } + + internal void UpdateFrom(ODataMiniOptions otherOptions) + { + this._queryConfigurations.UpdateAll(otherOptions.QueryConfigurations); + this._version = otherOptions.Version; + this._enableNoDollarQueryOptions = otherOptions.EnableNoDollarQueryOptions; + this._enableCaseInsensitive = otherOptions.EnableCaseInsensitive; + this._timeZone = otherOptions.TimeZone; + } +} diff --git a/src/Microsoft.AspNetCore.OData/ODataServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.OData/ODataServiceCollectionExtensions.cs index b73f5798d..55a817ac6 100644 --- a/src/Microsoft.AspNetCore.OData/ODataServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/ODataServiceCollectionExtensions.cs @@ -5,11 +5,14 @@ // //------------------------------------------------------------------------------ +using System; using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Wrapper; +using Microsoft.AspNetCore.OData.Results; using Microsoft.AspNetCore.OData.Routing; using Microsoft.AspNetCore.OData.Routing.Parser; using Microsoft.AspNetCore.OData.Routing.Template; @@ -26,6 +29,48 @@ namespace Microsoft.AspNetCore.OData; /// public static class ODataServiceCollectionExtensions { + /// + /// Adds essential OData services to the specified . + /// + /// The to add services to. + /// A that can be used to further configure the OData services. + public static IServiceCollection AddOData(this IServiceCollection services) + { + return services.AddOData(opt => { }); + } + + /// + /// Adds essential OData services to the specified . + /// + /// The to add services to. + /// The OData options to configure the services with, + /// including access to a service provider which you can resolve services from. + /// A that can be used to further configure the OData services. + public static IServiceCollection AddOData(this IServiceCollection services, Action setupAction) + { + if (services == null) + { + throw Error.ArgumentNull(nameof(services)); + } + + if (setupAction == null) + { + throw Error.ArgumentNull(nameof(setupAction)); + } + + services.Configure(setupAction); + + services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.Converters.Add(new SelectExpandWrapperConverter()); + options.SerializerOptions.Converters.Add(new PageResultValueConverter()); + options.SerializerOptions.Converters.Add(new DynamicTypeWrapperConverter()); + options.SerializerOptions.Converters.Add(new SingleResultValueConverter()); + }); + + return services; + } + /// /// Enables query support for actions with an or return /// type. To avoid processing unexpected or malicious queries, use the validation settings on @@ -80,6 +125,8 @@ internal static IServiceCollection AddODataCore(this IServiceCollection services services.TryAddEnumerable( ServiceDescriptor.Transient, ODataMvcOptionsSetup>()); + // For Minimal API, we should call 'ConfigureHttpJsonOptions' to config the JsonConverter, + // But, this extension has been introduced since .NET 7 services.TryAddEnumerable( ServiceDescriptor.Transient, ODataJsonOptionsSetup>()); diff --git a/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs b/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs index 1e08d2a2c..b5950aa45 100644 --- a/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs +++ b/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs @@ -258,6 +258,15 @@ internal static string BinaryOperatorNotSupported { } } + /// + /// Looks up a localized string similar to Cannot bind parameter '{0}'. the error is: {1}.. + /// + internal static string BindParameterFailedOnMinimalAPIEndpoint { + get { + return ResourceManager.GetString("BindParameterFailedOnMinimalAPIEndpoint", resourceCulture); + } + } + /// /// Looks up a localized string similar to The property '{0}' on type '{1}' returned a null value. The input stream contains collection items which cannot be added if the instance is null.. /// @@ -889,7 +898,7 @@ internal static string InvalidSegmentInSelectExpandPath { } /// - /// Looks up a localized string similar to The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{' and '}'.. + /// Looks up a localized string similar to The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{{' and '}}'.. /// internal static string InvalidTemplateLiteral { get { @@ -1239,6 +1248,15 @@ internal static string NotSortablePropertyUsedInOrderBy { } } + /// + /// Looks up a localized string similar to Transformation kind '{0}' is not supported as a child transformation of kind '{1}'.. + /// + internal static string NotSupportedChildTransformationKind { + get { + return ResourceManager.GetString("NotSupportedChildTransformationKind", resourceCulture); + } + } + /// /// Looks up a localized string similar to Transformation kind {0} is not supported.. /// @@ -1284,6 +1302,15 @@ internal static string ODataFunctionNotSupported { } } + /// + /// Looks up a localized string similar to The OData configuration is missing for the minimal API. Please call WithODataModel() and WithODataPathFactory() for the endpoint.. + /// + internal static string ODataMustBeSetOnMinimalAPIEndpoint { + get { + return ResourceManager.GetString("ODataMustBeSetOnMinimalAPIEndpoint", resourceCulture); + } + } + /// /// Looks up a localized string similar to The operation cannot be completed because no ODataPath is available for the request.. /// @@ -1428,6 +1455,24 @@ internal static string PropertyMustBeEnum { } } + /// + /// Looks up a localized string similar to The '{0}' property must be set.. + /// + internal static string PropertyMustBeSet { + get { + return ResourceManager.GetString("PropertyMustBeSet", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The '{0}' property must be set when the '{1}' property is set.. + /// + internal static string PropertyMustBeSetWhenAnotherPropertyIsSet { + get { + return ResourceManager.GetString("PropertyMustBeSetWhenAnotherPropertyIsSet", resourceCulture); + } + } + /// /// Looks up a localized string similar to The value must be a string.. /// @@ -1824,6 +1869,24 @@ internal static string TypeMustBeResourceSet { } } + /// + /// Looks up a localized string similar to The type '{0}' does not implement '{1}' interface.. + /// + internal static string TypeMustImplementInterface { + get { + return ResourceManager.GetString("TypeMustImplementInterface", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The type '{0}' does not inherit from '{1}'.. + /// + internal static string TypeMustInheritFromType { + get { + return ResourceManager.GetString("TypeMustInheritFromType", resourceCulture); + } + } + /// /// Looks up a localized string similar to The type '{0}' of dynamic property '{1}' is not supported.. /// diff --git a/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx b/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx index 9939b4be1..e3b9b46bd 100644 --- a/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx +++ b/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx @@ -667,7 +667,7 @@ Cannot Get the Enum Clr member using '{0}'. - The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{' and '}'. + The template string '{0}' of '{1}' is not a valid template literal. And a template literal should wrapper with '{{' and '}}'. Missing the parameter alias '{0}' in the request query string. @@ -748,4 +748,25 @@ The type '{0}' must be an open type. The dynamic properties container property is only expected on open types. + + Transformation kind '{0}' is not supported as a child transformation of kind '{1}'. + + + The type '{0}' does not implement '{1}' interface. + + + The type '{0}' does not inherit from '{1}'. + + + The '{0}' property must be set when the '{1}' property is set. + + + The '{0}' property must be set. + + + The OData configuration is missing for the minimal API. Please call WithODataModel() and WithODataPathFactory() for the endpoint. + + + Cannot bind parameter '{0}'. the error is: {1}. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt index 1a905d55c..c7dc1865b 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Shipped.txt @@ -794,6 +794,16 @@ Microsoft.AspNetCore.OData.Query.ComputeQueryOption.ResultClrType.get -> System. Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validate(Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validator.get -> Microsoft.AspNetCore.OData.Query.Validator.IComputeQueryValidator Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validator.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Name.get -> string +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Name.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.NestedValue.get -> TWrapper +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.NestedValue.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Next.get -> Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Next.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.ToDictionaryCore(System.Collections.Generic.Dictionary dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Value.get -> object +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Value.set -> void Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper.MapProperty(string propertyName) -> string Microsoft.AspNetCore.OData.Query.Container.ITruncatedCollection @@ -891,12 +901,31 @@ Microsoft.AspNetCore.OData.Query.ETag.this[string key].set -> void Microsoft.AspNetCore.OData.Query.ETag Microsoft.AspNetCore.OData.Query.ETag.ApplyTo(System.Linq.IQueryable query) -> System.Linq.IQueryable Microsoft.AspNetCore.OData.Query.ETag.ETag() -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder +Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.AggregationBinder() -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.AggregationFlatteningResult() -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.FlattenedExpression.get -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.FlattenedExpression.set -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.FlattenedPropertiesMapping.get -> System.Collections.Generic.IDictionary +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.FlattenedPropertiesMapping.set -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.RedefinedContextParameter.get -> System.Linq.Expressions.ParameterExpression +Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult.RedefinedContextParameter.set -> void Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions +Microsoft.AspNetCore.OData.Query.Expressions.ComputeBinder +Microsoft.AspNetCore.OData.Query.Expressions.ComputeBinder.ComputeBinder() -> void Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder.FilterBinder() -> void +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder.BindGroupBy(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder.BindSelect(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.IComputeBinder +Microsoft.AspNetCore.OData.Query.Expressions.IComputeBinder.BindCompute(Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder.BindFilter(Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.IFlatteningBinder +Microsoft.AspNetCore.OData.Query.Expressions.IFlatteningBinder.FlattenReferencedProperties(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder.BindOrderBy(Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinderResult Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder @@ -922,6 +951,7 @@ Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ComputedProperti Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.CurrentParameter.get -> System.Linq.Expressions.ParameterExpression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ElementClrType.get -> System.Type Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ElementType.get -> Microsoft.OData.Edm.IEdmType +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.FlattenedExpressionMapping.get -> System.Collections.Generic.IDictionary Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.GetParameter(string name) -> System.Linq.Expressions.ParameterExpression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Model.get -> Microsoft.OData.Edm.IEdmModel Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.NavigationSource.get -> Microsoft.OData.Edm.IEdmNavigationSource @@ -930,8 +960,11 @@ Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.QueryBinderConte Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.QueryBinderContext(Microsoft.OData.Edm.IEdmModel model, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings, System.Type clrType) -> void Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.QuerySettings.get -> Microsoft.AspNetCore.OData.Query.ODataQuerySettings Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.RemoveParameter(string name) -> void +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.SearchBinder.get -> Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.SearchBinder.set -> void Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Source.get -> System.Linq.Expressions.Expression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Source.set -> void +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.TransformationElementType.get -> System.Type Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.FilterBinder.get -> Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.OrderByBinder.get -> Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder @@ -953,7 +986,8 @@ Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption.False = 2 -> Micros Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption.True = 1 -> Microsoft.AspNetCore.OData.Query.HandleNullPropagationOption Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions Microsoft.AspNetCore.OData.Query.ICountOptionCollection -Microsoft.AspNetCore.OData.Query.ICountOptionCollection.TotalCount.get -> long?Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser +Microsoft.AspNetCore.OData.Query.ICountOptionCollection.TotalCount.get -> long? +Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser.CanParse(Microsoft.AspNetCore.Http.HttpRequest request) -> bool Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser.ParseAsync(Microsoft.AspNetCore.Http.HttpRequest request) -> System.Threading.Tasks.Task Microsoft.AspNetCore.OData.Query.ODataQueryContext @@ -1079,6 +1113,7 @@ Microsoft.AspNetCore.OData.Query.SearchQueryOption.RawValue.get -> string Microsoft.AspNetCore.OData.Query.SearchQueryOption.ResultClrType.get -> System.Type Microsoft.AspNetCore.OData.Query.SearchQueryOption.SearchClause.get -> Microsoft.OData.UriParser.SearchClause Microsoft.AspNetCore.OData.Query.SearchQueryOption.SearchQueryOption(string rawValue, Microsoft.AspNetCore.OData.Query.ODataQueryContext context, Microsoft.OData.UriParser.ODataQueryOptionParser queryOptionParser) -> void +Microsoft.AspNetCore.OData.Query.SearchQueryOption.Validate(Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption.ApplyTo(object entity, Microsoft.AspNetCore.OData.Query.ODataQuerySettings settings) -> object Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption.ApplyTo(System.Linq.IQueryable queryable, Microsoft.AspNetCore.OData.Query.ODataQuerySettings settings) -> System.Linq.IQueryable @@ -1149,6 +1184,8 @@ Microsoft.AspNetCore.OData.Query.Validator.IODataQueryValidator Microsoft.AspNetCore.OData.Query.Validator.IODataQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.ODataQueryOptions options, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.Validator.IOrderByQueryValidator Microsoft.AspNetCore.OData.Query.Validator.IOrderByQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.OrderByQueryOption orderByOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void +Microsoft.AspNetCore.OData.Query.Validator.ISearchQueryValidator +Microsoft.AspNetCore.OData.Query.Validator.ISearchQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.SearchQueryOption searchQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.Validator.ISelectExpandQueryValidator Microsoft.AspNetCore.OData.Query.Validator.ISelectExpandQueryValidator.Validate(Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption selectExpandQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.Validator.ISkipQueryValidator @@ -1221,6 +1258,19 @@ Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator.TopQueryValidator() Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper.DynamicTypeWrapper() -> void Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper.TryGetPropertyValue(string propertyName, out object value) -> bool +Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper +Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper.Instance.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper.Instance.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper.Model.get -> Microsoft.OData.Edm.IEdmModel +Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper.Model.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper.Source.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper.Source.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.Container.get -> TContainer +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.Container.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.GroupByContainer.get -> TContainer +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.GroupByContainer.set -> void Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper.ToDictionary() -> System.Collections.Generic.IDictionary Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper.ToDictionary(System.Func propertyMapperProvider) -> System.Collections.Generic.IDictionary @@ -1754,16 +1804,20 @@ static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddODataQuery static Microsoft.AspNetCore.OData.ODataUriFunctions.AddCustomUriFunction(string functionName, Microsoft.OData.UriParser.FunctionSignatureWithReturnType functionSignature, System.Reflection.MethodInfo methodInfo) -> void static Microsoft.AspNetCore.OData.ODataUriFunctions.RemoveCustomUriFunction(string functionName, Microsoft.OData.UriParser.FunctionSignatureWithReturnType functionSignature, System.Reflection.MethodInfo methodInfo) -> bool static Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.CreateErrorResponse(string message, System.Exception exception = null) -> Microsoft.AspNetCore.Mvc.SerializableError +static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type resultClrType) -> System.Linq.IQueryable +static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IComputeBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type resultClrType) -> System.Linq.IQueryable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Collections.IEnumerable query, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Collections.IEnumerable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.IQueryable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder binder, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, bool alreadyOrdered) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, bool alreadyOrdered) -> System.Linq.IQueryable +static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder binder, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.SearchClause searchClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.SearchClause searchClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.IQueryable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinder binder, object source, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> object static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.IQueryable static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.ApplyNullPropagationForFilterBody(System.Linq.Expressions.Expression body, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.GetDynamicPropertyContainer(Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Reflection.PropertyInfo +static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.WrapConvert(System.Linq.Expressions.Expression expression) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions.GetETag(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.Net.Http.Headers.EntityTagHeaderValue entityTagHeaderValue) -> Microsoft.AspNetCore.OData.Query.ETag static Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions.GetETag(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.Net.Http.Headers.EntityTagHeaderValue entityTagHeaderValue) -> Microsoft.AspNetCore.OData.Query.ETag static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.IsSystemQueryOption(string queryOptionName) -> bool @@ -1873,12 +1927,17 @@ virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.CreateQueryOptions virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.GetModel(System.Type elementClrType, Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor actionDescriptor) -> Microsoft.OData.Edm.IEdmModel virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.ValidateQuery(Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions) -> void virtual Microsoft.AspNetCore.OData.Query.ETag.ApplyTo(System.Linq.IQueryable query) -> System.Linq.IQueryable +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.BindGroupBy(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.BindSelect(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.FlattenReferencedProperties(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult +virtual Microsoft.AspNetCore.OData.Query.Expressions.ComputeBinder.BindCompute(Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindCollectionConstantNode(Microsoft.OData.UriParser.CollectionConstantNode node) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindConstantNode(Microsoft.OData.UriParser.ConstantNode constantNode) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindSingleValueFunctionCallNode(Microsoft.OData.UriParser.SingleValueFunctionCallNode node) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder.BindFilter(Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinder.BindOrderBy(Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinderResult virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.Bind(Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAccessExpression(Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression baseElement = null) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAllNode(Microsoft.OData.UriParser.AllNode allNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAnyNode(Microsoft.OData.UriParser.AnyNode anyNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindBinaryOperatorNode(Microsoft.OData.UriParser.BinaryOperatorNode binaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression @@ -1925,12 +1984,13 @@ virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindToLower(Mic virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindToUpper(Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindTrim(Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindUnaryOperatorNode(Microsoft.OData.UriParser.UnaryOperatorNode unaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.CreateOpenPropertyAccessExpression(Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindComputedProperty(System.Linq.Expressions.Expression source, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, string computedProperty, System.Collections.Generic.IList includedProperties) -> void virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindOrderByProperties(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType structuredType, System.Collections.Generic.IList includedProperties, bool isSelectedAll) -> void virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindSelectExpand(Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BuildDynamicProperty(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType structuredType, System.Collections.Generic.IList includedProperties) -> void virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.CreatePropertyNameExpression(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source) -> System.Linq.Expressions.Expression -virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.CreatePropertyValueExpression(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.OData.UriParser.ComputeClause computeClause = null) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.CreatePropertyValueExpression(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.OData.UriParser.ComputeClause computeClause = null, Microsoft.OData.UriParser.SearchClause search = null) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.CreateTotalCountExpression(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, bool? countOption) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.CreateTypeNameExpression(System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmModel model) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.ODataQueryOptions.ApplyTo(object entity, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) -> object diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index e69de29bb..b87ed82ca 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -0,0 +1,94 @@ +Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration +Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration.Apply(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.OData.ModelBuilder.ODataModelBuilder builder, System.Type clrType) -> Microsoft.OData.ModelBuilder.ODataModelBuilder +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.InvocationContext.get -> Microsoft.AspNetCore.Http.EndpointFilterInvocationContext +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.InvocationContext.init -> void +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.MethodInfo.get -> System.Reflection.MethodInfo +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.MethodInfo.init -> void +Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext.ODataQueryFilterInvocationContext() -> void +Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions +Microsoft.AspNetCore.OData.ODataMiniMetadata +Microsoft.AspNetCore.OData.ODataMiniMetadata.BaseAddressFactory.get -> System.Func +Microsoft.AspNetCore.OData.ODataMiniMetadata.BaseAddressFactory.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.IsODataFormat.get -> bool +Microsoft.AspNetCore.OData.ODataMiniMetadata.IsODataFormat.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.Model.get -> Microsoft.OData.Edm.IEdmModel +Microsoft.AspNetCore.OData.ODataMiniMetadata.Model.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.ODataMiniMetadata() -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.Options.get -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniMetadata.PathFactory.get -> System.Func +Microsoft.AspNetCore.OData.ODataMiniMetadata.PathFactory.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.ServiceProvider.get -> System.IServiceProvider +Microsoft.AspNetCore.OData.ODataMiniMetadata.ServiceProvider.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.Services.get -> System.Action +Microsoft.AspNetCore.OData.ODataMiniMetadata.Services.set -> void +Microsoft.AspNetCore.OData.ODataMiniMetadata.Version.get -> Microsoft.OData.ODataVersion +Microsoft.AspNetCore.OData.ODataMiniMetadata.Version.set -> void +Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.Count() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.EnableAll(int? maxTopValue = null) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.EnableCaseInsensitive.get -> bool +Microsoft.AspNetCore.OData.ODataMiniOptions.EnableNoDollarQueryOptions.get -> bool +Microsoft.AspNetCore.OData.ODataMiniOptions.Expand() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.Filter() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.ODataMiniOptions() -> void +Microsoft.AspNetCore.OData.ODataMiniOptions.OrderBy() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.QueryConfigurations.get -> Microsoft.AspNetCore.OData.Query.DefaultQueryConfigurations +Microsoft.AspNetCore.OData.ODataMiniOptions.Select() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetCaseInsensitive(bool enableCaseInsensitive) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetMaxTop(int? maxTopValue) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetNoDollarQueryOptions(bool enableNoDollarQueryOptions) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetTimeZoneInfo(System.TimeZoneInfo tzi) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetVersion(Microsoft.OData.ODataVersion version) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SkipToken() -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.TimeZone.get -> System.TimeZoneInfo +Microsoft.AspNetCore.OData.ODataMiniOptions.Version.get -> Microsoft.OData.ODataVersion +Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter +Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutedAsync(object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter.OnFilterExecutingAsync(Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter +Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ODataQueryEndpointFilter() -> void +Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.QuerySettings.get -> Microsoft.AspNetCore.OData.Query.ODataQuerySettings +Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ValidationSettings.get -> Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings +Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter +Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.SelectExpandWrapperConverter() -> void +Microsoft.AspNetCore.OData.Results.IODataResult +Microsoft.AspNetCore.OData.Results.IODataResult.ExpectedType.get -> System.Type +Microsoft.AspNetCore.OData.Results.IODataResult.Value.get -> object +override Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.CanConvert(System.Type typeToConvert) -> bool +override Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.CreateConverter(System.Type type, System.Text.Json.JsonSerializerOptions options) -> System.Text.Json.Serialization.JsonConverter +static Microsoft.AspNetCore.OData.Deltas.Delta.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> +static Microsoft.AspNetCore.OData.Deltas.DeltaSet.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> +static Microsoft.AspNetCore.OData.Formatter.ODataActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask +static Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, System.Action validationSetup = null, System.Action querySetup = null) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Routing.RouteGroupBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) -> Microsoft.AspNetCore.Routing.RouteGroupBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Routing.RouteGroupBuilder builder, System.Action validationSetup = null, System.Action querySetup = null) -> Microsoft.AspNetCore.Routing.RouteGroupBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Routing.RouteGroupBuilder builder) -> Microsoft.AspNetCore.Routing.RouteGroupBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.MapODataMetadata(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, Microsoft.OData.Edm.IEdmModel model) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.MapODataServiceDocument(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, Microsoft.OData.Edm.IEdmModel model) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataBaseAddressFactory(this TBuilder builder, System.Func baseAddressFactory) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataModel(this TBuilder builder, Microsoft.OData.Edm.IEdmModel model) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataOptions(this TBuilder builder, System.Action setupAction) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataPathFactory(this TBuilder builder, System.Func pathFactory) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataResult(this TBuilder builder) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataServices(this TBuilder builder, System.Action services) -> TBuilder +static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataVersion(this TBuilder builder, Microsoft.OData.ODataVersion version) -> TBuilder +static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> +static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.PopulateMetadata(System.Reflection.ParameterInfo parameter, Microsoft.AspNetCore.Builder.EndpointBuilder builder) -> void +static readonly Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.MapperProvider -> System.Func +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ApplyQuery(object entity, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) -> object +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ApplyQuery(System.Linq.IQueryable queryable, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) -> System.Linq.IQueryable +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.CreateAndValidateQueryOptions(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.OData.Query.ODataQueryContext queryContext) -> Microsoft.AspNetCore.OData.Query.ODataQueryOptions +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.CreateQueryOptionsOnExecuting(Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> Microsoft.AspNetCore.OData.Query.ODataQueryOptions +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ExecuteQuery(object responseValue, System.Linq.IQueryable singleResultCollection, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> object +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.GetModel(System.Type elementClrType, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> Microsoft.OData.Edm.IEdmModel +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.InvokeAsync(Microsoft.AspNetCore.Http.EndpointFilterInvocationContext invocationContext, Microsoft.AspNetCore.Http.EndpointFilterDelegate next) -> System.Threading.Tasks.ValueTask +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.OnFilterExecutedAsync(object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.OnFilterExecutingAsync(Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) -> System.Threading.Tasks.ValueTask +virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ValidateQuery(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions) -> void \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs b/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs index 313506b0c..1a8938ee7 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.OData.Query.Container; /// /// Represent properties used in groupby and aggregate clauses to make them accessible in further clauses/transformations /// -/// +/// /// When we have $apply=groupby((Prop1,Prop2, Prop3))&$orderby=Prop1, Prop2 /// We will have following expression in .GroupBy /// $it => new AggregationPropertyContainer() { @@ -32,10 +32,10 @@ namespace Microsoft.AspNetCore.OData.Query.Container; /// } /// } /// when in $orderby (see AggregationBinder CollectProperties method) -/// Prop1 could be referenced us $it => (string)$it.Value -/// Prop2 could be referenced us $it => (int)$it.Next.Value -/// Prop3 could be referenced us $it => (int)$it.Next.Next.Value -/// Generic type for Value is used to avoid type casts for on primitive types that not supported in EF +/// Prop1 could be referenced as $it => (string)$it.Value +/// Prop2 could be referenced as $it => (int)$it.Next.Value +/// Prop3 could be referenced as $it => (int)$it.Next.Next.Value +/// Generic type for Value is used to avoid type casts for primitive types that are not supported in EF /// /// Also we have 4 use cases and base type have all required properties to support no cast usage. /// 1. Primitive property with Next @@ -43,9 +43,8 @@ namespace Microsoft.AspNetCore.OData.Query.Container; /// 3. Nested property with Next /// 4. Nested property without Next /// However, EF doesn't allow to set different properties for the same type in two places in an lambda-expression => using new type with just new name to workaround that issue -/// -/// -internal class AggregationPropertyContainer : NamedProperty +/// +internal class AggregationPropertyContainer : NamedProperty, IAggregationPropertyContainer { public GroupByWrapper NestedValue { @@ -59,7 +58,7 @@ public GroupByWrapper NestedValue } } - public AggregationPropertyContainer Next { get; set; } + public IAggregationPropertyContainer Next { get; set; } public override void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected) @@ -99,9 +98,9 @@ public static Expression CreateNextNamedPropertyContainer(IList memberBindings = new List(); - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Name"), property.Name)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNameProperty), property.Name)); if (property.Value.Type == typeof(GroupByWrapper)) { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("NestedValue"), property.Value)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNestedValueProperty), property.Value)); } else { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Value"), property.Value)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerValueProperty), property.Value)); } if (next != null) { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Next"), next)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNextProperty), next)); } if (property.NullCheck != null) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs new file mode 100644 index 000000000..b2c1f4722 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs @@ -0,0 +1,75 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.Query.Container; + +/// +/// Represent properties used in groupby and aggregate clauses of $apply query to make them accessible in further clauses/transformations. +/// +/// The type of the group-by wrapper associated with this container. +/// The concrete type of the aggregation property container, enabling self-referencing. +/// +/// When we have +/// $apply=groupby((Prop1,Prop2,Prop3))&$orderby=Prop1,Prop2 +/// where implements , +/// we will have following expression in .GroupBy: +/// +/// $it => new AggregationPropertyContainer() { +/// Name = "Prop1", +/// Value = $it.Prop1, /* string */ +/// Next = new AggregationPropertyContainer() { +/// Name = "Prop2", +/// Value = $it.Prop2, /* int */ +/// Next = new LastInChain() { +/// Name = "Prop3", +/// Value = $it.Prop3 /* int */ +/// } +/// } +/// } +/// +/// When in $orderby, +/// Prop1 could be referenced as $it => (string)$it.Value, +/// Prop2 could be referenced as $it => (int)$it.Next.Value, +/// Prop3 could be referenced as $it => (int)$it.Next.Next.Value. +/// Generic type for Value is used to avoid type casts for primitive types that are not supported in Entity Framework. +/// Also, we have 4 use cases and this interface declares all required properties to support no cast usage. +/// 1). Primitive property with Next +/// 2). Primitive property without Next +/// 3). Nested property with Next +/// 4). Nested property without Next. +/// However, Entity Framework doesn't allow to set different properties for the same type in two places in a lambda expression. +/// Using new type with just new name to workaround that issue. +/// +public interface IAggregationPropertyContainer + where TWrapper : IGroupByWrapper + where TContainer : IAggregationPropertyContainer +{ + /// Gets or sets the name of the property. + string Name { get; set; } + + /// Gets or sets the value of the property. + object Value { get; set; } + + /// Gets or sets the nested value of the property. + TWrapper NestedValue { get; set; } + + /// Gets or sets the next property container. + IAggregationPropertyContainer Next { get; set; } + + /// + /// Adds the properties in this container to the given dictionary. + /// + /// The dictionary to which the properties in this container should be added. + /// The property mapper to use for mapping + /// between the names of properties in this container and the names that + /// should be used when adding the properties to the given dictionary. + /// A value indicating whether auto-selected properties should be included. + void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/DefaultQueryConfigurations.cs b/src/Microsoft.AspNetCore.OData/Query/DefaultQueryConfigurations.cs index df1743743..0888e9cf5 100644 --- a/src/Microsoft.AspNetCore.OData/Query/DefaultQueryConfigurations.cs +++ b/src/Microsoft.AspNetCore.OData/Query/DefaultQueryConfigurations.cs @@ -16,4 +16,16 @@ public class DefaultQueryConfigurations : DefaultQuerySettings { // We will add other query settings, for example, $compute, $search here // In the next major release, we should remove the inheritance from 'DefaultQuerySettings'. + + internal DefaultQueryConfigurations UpdateAll(DefaultQueryConfigurations options) + { + EnableExpand = options.EnableExpand; + EnableSelect = options.EnableSelect; + EnableFilter = options.EnableFilter; + EnableOrderBy = options.EnableOrderBy; + EnableCount = options.EnableCount; + EnableSkipToken = options.EnableSkipToken; + MaxTop = options.MaxTop; + return this; + } } diff --git a/src/Microsoft.AspNetCore.OData/Query/EnableQueryAttribute.cs b/src/Microsoft.AspNetCore.OData/Query/EnableQueryAttribute.cs index 1ddc21247..35fa57521 100644 --- a/src/Microsoft.AspNetCore.OData/Query/EnableQueryAttribute.cs +++ b/src/Microsoft.AspNetCore.OData/Query/EnableQueryAttribute.cs @@ -827,28 +827,28 @@ public virtual IEdmModel GetModel( Contract.Assert(model != null); return model; } +} +/// +/// Holds request level query information. +/// +internal class RequestQueryData +{ /// - /// Holds request level query information. + /// Gets or sets a value indicating whether query validation was run before action (controller method) is executed. /// - private class RequestQueryData - { - /// - /// Gets or sets a value indicating whether query validation was run before action (controller method) is executed. - /// - /// - /// Marks if the query validation was run before the action execution. This is not always possible. - /// For cases where the run failed before action execution. We will run validation on result. - /// - public bool QueryValidationRunBeforeActionExecution { get; set; } - - /// - /// Gets or sets the processed query options. - /// - /// - /// Stores the processed query options to be used later if OnActionExecuting was able to verify the query. - /// This is because ValidateQuery internally modifies query options (expands are prime example of this). - /// - public ODataQueryOptions ProcessedQueryOptions { get; set; } - } -} + /// + /// Marks if the query validation was run before the action execution. This is not always possible. + /// For cases where the run failed before action execution. We will run validation on result. + /// + public bool QueryValidationRunBeforeActionExecution { get; set; } + + /// + /// Gets or sets the processed query options. + /// + /// + /// Stores the processed query options to be used later if OnActionExecuting was able to verify the query. + /// This is because ValidateQuery internally modifies query options (expands are prime example of this). + /// + public ODataQueryOptions ProcessedQueryOptions { get; set; } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs index cfbc3a8af..50fdc5c28 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs @@ -7,7 +7,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; using System.Diagnostics.Contracts; using System.Globalization; using System.Linq; @@ -18,611 +18,694 @@ using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.OData; using Microsoft.OData.Edm; -using Microsoft.OData.ModelBuilder; using Microsoft.OData.UriParser; using Microsoft.OData.UriParser.Aggregation; namespace Microsoft.AspNetCore.OData.Query.Expressions; -[SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Justification = "Relies on many ODataLib classes.")] -internal class AggregationBinder : TransformationBinderBase +/// +/// The default implementation to bind an OData $apply represented by to an . +/// +public class AggregationBinder : QueryBinder, IAggregationBinder, IFlatteningBinder { - private const string GroupByContainerProperty = "GroupByContainer"; - private TransformationNode _transformation; + /// + public virtual Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context) + { + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } - private IEnumerable _aggregateExpressions; - private IEnumerable _groupingProperties; + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } - private Type _groupByClrType; + LambdaExpression groupByLambda = null; + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); - internal AggregationBinder(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, - IEdmModel model, TransformationNode transformation) - : base(settings, assembliesResolver, elementType, model) - { - Contract.Assert(transformation != null); + if (groupingProperties?.Any() == true) + { + // Generates the expression: + // $it => new DynamicTypeWrapper() { + // GroupByContainer => new AggregationPropertyContainer() { + // Name = "Prop1", + // Value = $it.Prop1, + // Next = new AggregationPropertyContainer() { + // Name = "Prop2", + // Value = $it.Prop2, // int + // Next = new LastInChain() { + // Name = "Prop3", + // Value = $it.Prop3 + // } + // } + // } + // } - _transformation = transformation; + List properties = CreateGroupByMemberAssignments(groupingProperties, context); - switch (transformation.Kind) - { - case TransformationNodeKind.Aggregate: - var aggregateClause = this._transformation as AggregateTransformationNode; - _aggregateExpressions = FixCustomMethodReturnTypes(aggregateClause.AggregateExpressions); - ResultClrType = typeof(NoGroupByAggregationWrapper); - break; - case TransformationNodeKind.GroupBy: - var groupByClause = this._transformation as GroupByTransformationNode; - _groupingProperties = groupByClause.GroupingProperties; - if (groupByClause.ChildTransformations != null) + PropertyInfo wrapperProperty = typeof(GroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + List wrapperTypeMemberAssignments = new List(capacity: 1) { - if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) - { - var aggregationNode = (AggregateTransformationNode)groupByClause.ChildTransformations; - _aggregateExpressions = FixCustomMethodReturnTypes(aggregationNode.AggregateExpressions); - } - else - { - throw new NotImplementedException(); - } - } + Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties)) + }; - _groupByClrType = typeof(GroupByWrapper); - ResultClrType = typeof(AggregationWrapper); - break; - default: - throw new NotSupportedException(String.Format(CultureInfo.InvariantCulture, - SRResources.NotSupportedTransformationKind, transformation.Kind)); + groupByLambda = Expression.Lambda( + Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wrapperTypeMemberAssignments), + context.CurrentParameter); } - - _groupByClrType = _groupByClrType ?? typeof(NoGroupByWrapper); - } - - private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessor) - { - if (propertyAccessor.Type == typeof(object)) + else { - return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessor); + // No GroupBy properties + // .GroupBy($it => new NoGroupByWrapper()) + groupByLambda = Expression.Lambda(Expression.New(typeof(NoGroupByWrapper)), context.CurrentParameter); } - return propertyAccessor; + return groupByLambda; } - private IEnumerable FixCustomMethodReturnTypes(IEnumerable aggregateExpressions) + /// + public virtual Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context) { - return aggregateExpressions.Select(x => + if (transformationNode == null) { - var ae = x as AggregateExpression; - return ae != null ? FixCustomMethodReturnType(ae) : x; - }); - } + throw Error.ArgumentNull(nameof(transformationNode)); + } - private AggregateExpression FixCustomMethodReturnType(AggregateExpression expression) - { - if (expression.Method != AggregationMethod.Custom) + if (context == null) { - return expression; + throw Error.ArgumentNull(nameof(context)); } - var customMethod = GetCustomMethod(expression); - - // var typeReference = customMethod.ReturnType.GetEdmPrimitiveTypeReference(); - var typeReference = Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); - - return new AggregateExpression(expression.Expression, expression.MethodDefinition, expression.Alias, typeReference); - } - - private MethodInfo GetCustomMethod(AggregateExpression expression) - { - var propertyLambda = Expression.Lambda(BindAccessor(expression.Expression), this.LambdaParameter); - Type inputType = propertyLambda.Body.Type; + // Generates the expression: + // $it => new DynamicTypeWrapper() { + // GroupByContainer = $it.Key.GroupByContainer // If groupby section present + // Container => new AggregationPropertyContainer() { + // Name = "Alias1", + // Value = $it.AsQueryable().Sum(i => i.AggregatableProperty), + // Next = new LastInChain() { + // Name = "Alias2", + // Value = $it.AsQueryable().Sum(i => i.AggregatableProperty) + // } + // } + // } + + Type groupByClrType = transformationNode.Kind == TransformationNodeKind.GroupBy ? typeof(GroupByWrapper) : typeof(NoGroupByWrapper); + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupByClrType, context.TransformationElementType); + Type resultClrType = transformationNode.Kind == TransformationNodeKind.Aggregate ? typeof(NoGroupByAggregationWrapper) : typeof(AggregationWrapper); + ParameterExpression groupingParameter = Expression.Parameter(groupingType, "$it"); + + IEnumerable aggregateExpressions = GetAggregateExpressions(transformationNode, context); + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); - string methodToken = expression.MethodDefinition.MethodLabel; - var customFunctionAnnotations = Model.GetAnnotationValue(Model); + List wrapperTypeMemberAssignments = new List(); - MethodInfo customMethod; - if (!customFunctionAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + // Setting GroupByContainer property when we have GroupBy properties + if (groupingProperties?.Any() == true) { - throw new ODataException( - Error.Format( - SRResources.AggregationNotSupportedForType, - expression.Method, - expression.Expression, - inputType)); - } - - return customMethod; - } + PropertyInfo wrapperProperty = resultClrType.GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); - public IQueryable Bind(IQueryable query) - { - PreprocessQuery(query); + wrapperTypeMemberAssignments.Add( + Expression.Bind(wrapperProperty, + Expression.Property(Expression.Property(groupingParameter, "Key"), QueryConstants.GroupByWrapperGroupByContainerProperty))); + } - query = FlattenReferencedProperties(query); + // Setting Container property when we have aggregation clauses + if (aggregateExpressions != null) + { + List properties = new List(); + foreach (AggregateExpressionBase aggregateExpression in aggregateExpressions) + { + properties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpression.Alias), + CreateAggregateExpression(groupingParameter, aggregateExpression, context.TransformationElementType, context))); + } - // Answer is query.GroupBy($it => new DynamicType1() {...}).Select($it => new DynamicType2() {...}) - // We are doing Grouping even if only aggregate was specified to have a IQuaryable after aggregation - IQueryable grouping = BindGroupBy(query); + PropertyInfo wrapperProperty = resultClrType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + } - IQueryable result = BindSelect(grouping); + MemberInitExpression body = Expression.MemberInit( + Expression.New(resultClrType), + wrapperTypeMemberAssignments); - return result; + return Expression.Lambda(body, groupingParameter); } - /// - /// Pre flattens properties referenced in aggregate clause to avoid generation of nested queries by EF. - /// For query like groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) we need to generate - /// .Select( - /// $it => new FlattenninWrapper () { - /// Source = $it, // Will used in groupby stage - /// Container = new { - /// Value = $it.B.C - /// Next = new { - /// Value = $it.B.D - /// } - /// } - /// } - /// ) - /// Also we need to populate expressions to access B/C and B/D in aggregate stage. It will look like: - /// B/C : $it.Container.Value - /// B/D : $it.Container.Next.Value - /// - /// - /// Query with Select that flattens properties - private IQueryable FlattenReferencedProperties(IQueryable query) + /// + public virtual AggregationFlatteningResult FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context) { - if (_aggregateExpressions != null - && _aggregateExpressions.OfType().Any(e => e.Method != AggregationMethod.VirtualPropertyCount) - && _groupingProperties != null - && _groupingProperties.Any() - && (FlattenedPropertyContainer == null || !FlattenedPropertyContainer.Any())) + if (transformationNode == null) { - var wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(this.ElementType); - var sourceProperty = wrapperType.GetProperty("Source"); - List wta = new List(); - wta.Add(Expression.Bind(sourceProperty, this.LambdaParameter)); - - var aggrregatedPropertiesToFlatten = _aggregateExpressions.OfType().Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); - // Generated Select will be stack like, meaning that first property in the list will be deepest one - // For example if we add $it.B.C, $it.B.D, select will look like - // new { - // Value = $it.B.C - // Next = new { - // Value = $it.B.D - // } - // } - // We are generated references (in currentContainerExpression) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) - // We have proper match we need insert properties in reverse order - // After this - // properties = { $it.B.D, $it.B.C} - // _preFlattendMAp = { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } - var properties = new NamedPropertyExpression[aggrregatedPropertiesToFlatten.Count]; - var aliasIdx = aggrregatedPropertiesToFlatten.Count - 1; - var aggParam = Expression.Parameter(wrapperType, "$it"); - var currentContainerExpression = Expression.Property(aggParam, GroupByContainerProperty); - foreach (var aggExpression in aggrregatedPropertiesToFlatten) - { - var alias = "Property" + aliasIdx.ToString(CultureInfo.CurrentCulture); // We just need unique alias, we aren't going to use it - - // Add Value = $it.B.C - var propAccessExpression = BindAccessor(aggExpression.Expression); - var type = propAccessExpression.Type; - propAccessExpression = WrapConvert(propAccessExpression); - properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propAccessExpression); - - // Save $it.Container.Next.Value for future use - UnaryExpression flatAccessExpression = Expression.Convert( - Expression.Property(currentContainerExpression, "Value"), - type); - currentContainerExpression = Expression.Property(currentContainerExpression, "Next"); - _preFlattenedMap.Add(aggExpression.Expression, flatAccessExpression); - aliasIdx--; - } - - var wrapperProperty = ResultClrType.GetProperty(GroupByContainerProperty); - - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - - var flatLambda = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wta), LambdaParameter); + throw Error.ArgumentNull(nameof(transformationNode)); + } - query = ExpressionHelpers.Select(query, flatLambda, this.ElementType); + if (query == null) + { + throw Error.ArgumentNull(nameof(query)); + } - // We applied flattening let .GroupBy know about it. - this.LambdaParameter = aggParam; + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); } - return query; - } + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); + // Aggregate expressions to flatten - excludes VirtualPropertyCount ($count) + List aggregateExpressions = GetAggregateExpressions(transformationNode, context)?.OfType() + .Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); - private Dictionary _preFlattenedMap = new Dictionary(); + if ((aggregateExpressions?.Count ?? 0) == 0 || groupingProperties?.Any() != true) + { + return null; + } - private IQueryable BindSelect(IQueryable grouping) - { - // Should return following expression - // .Select($it => New DynamicType2() - // { - // GroupByContainer = $it.Key.GroupByContainer // If groupby section present - // Container => new AggregationPropertyContainer() { - // Name = "Alias1", - // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty), - // Next = new LastInChain() { - // Name = "Alias2", - // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty) - // } - // } - // }) - var groupingType = typeof(IGrouping<,>).MakeGenericType(this._groupByClrType, this.ElementType); - ParameterExpression accum = Expression.Parameter(groupingType, "$it"); + Type wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(context.TransformationElementType); + PropertyInfo sourceProperty = wrapperType.GetProperty(QueryConstants.FlatteningWrapperSourceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(sourceProperty, context.CurrentParameter) + }; + + // Generated Select will be stack-like; meaning that first property in the list will be deepest one + // For example if we add $it.B.C, $it.B.D, Select will look like + // $it => new FlatteningWrapper() { + // Source = $it, + // Container = new { + // Value = $it.B.C + // Next = new { + // Value = $it.B.D + // } + // } + // } + + // We are generating references (in containerExpression) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) + // We have proper match we need insert properties in reverse order + + int aliasIdx = aggregateExpressions.Count - 1; + NamedPropertyExpression[] properties = new NamedPropertyExpression[aggregateExpressions.Count]; + + AggregationFlatteningResult flatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedPropertiesMapping = new Dictionary(aggregateExpressions.Count) + }; - List wrapperTypeMemberAssignments = new List(); + MemberExpression containerExpression = Expression.Property(flatteningResult.RedefinedContextParameter, QueryConstants.GroupByWrapperGroupByContainerProperty); - // Setting GroupByContainer property when previous step was grouping - if (this._groupingProperties != null && this._groupingProperties.Any()) + for (int i = 0; i < aggregateExpressions.Count; i++) { - var wrapperProperty = this.ResultClrType.GetProperty(GroupByContainerProperty); - - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, Expression.Property(Expression.Property(accum, "Key"), GroupByContainerProperty))); + AggregateExpression aggregateExpression = aggregateExpressions[i]; + + string alias = string.Concat("Property", aliasIdx.ToString(CultureInfo.CurrentCulture)); // We just need unique alias, we aren't going to use it + + // Add Value = $it.B.C + Expression propertyAccessExpression = BindAccessExpression(aggregateExpression.Expression, context); + Type type = propertyAccessExpression.Type; + propertyAccessExpression = WrapConvert(propertyAccessExpression); + properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propertyAccessExpression); + + // Save $it.Container.Next.Value for future use + UnaryExpression flattenedAccessExpression = Expression.Convert( + Expression.Property( + // Convert necessary because the Value property is declared on inherited NamedProperty class + Expression.Convert(containerExpression, typeof(AggregationPropertyContainer)), + QueryConstants.AggregationPropertyContainerValueProperty), + type); + containerExpression = Expression.Property(containerExpression, QueryConstants.AggregationPropertyContainerNextProperty); + flatteningResult.FlattenedPropertiesMapping.Add(aggregateExpression.Expression, flattenedAccessExpression); + aliasIdx--; } - // Setting Container property when we have aggregation clauses - if (_aggregateExpressions != null) - { - var properties = new List(); - foreach (var aggExpression in _aggregateExpressions) - { - properties.Add(new NamedPropertyExpression(Expression.Constant(aggExpression.Alias), CreateAggregationExpression(accum, aggExpression, this.ElementType))); - } + PropertyInfo wrapperProperty = wrapperType.GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); - var wrapperProperty = ResultClrType.GetProperty("Container"); - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - } + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - var initilizedMember = - Expression.MemberInit(Expression.New(ResultClrType), wrapperTypeMemberAssignments); - var selectLambda = Expression.Lambda(initilizedMember, accum); + flatteningResult.FlattenedExpression = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); - var result = ExpressionHelpers.Select(grouping, selectLambda, groupingType); - return result; + return flatteningResult; } - private List CreateSelectMemberAssigments(Type type, MemberExpression propertyAccessor, - IEnumerable properties) + private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessExpression) { - var wrapperTypeMemberAssignments = new List(); - if (_groupingProperties != null) + if (propertyAccessExpression.Type == typeof(object)) { - foreach (var node in properties) - { - var nodePropertyAccessor = Expression.Property(propertyAccessor, node.Name); - var member = type.GetMember(node.Name).Single(); - if (node.Expression != null) - { - wrapperTypeMemberAssignments.Add(Expression.Bind(member, nodePropertyAccessor)); - } - else - { - var memberType = (member as PropertyInfo).PropertyType; - var expr = Expression.MemberInit(Expression.New(memberType), - CreateSelectMemberAssigments(memberType, nodePropertyAccessor, node.ChildTransformations)); - wrapperTypeMemberAssignments.Add(Expression.Bind(member, expr)); - } - } + return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessExpression); } - return wrapperTypeMemberAssignments; + return propertyAccessExpression; } - private Expression CreateAggregationExpression(ParameterExpression accum, AggregateExpressionBase expression, Type baseType) + /// + /// Creates an expression for an aggregate. + /// + /// The parameter representing the group. + /// The aggregate expression. + /// The element type at the base of the transformation. + /// The query binder context. + /// An expression representing the aggregate. + /// + private Expression CreateAggregateExpression(ParameterExpression groupingParameter, AggregateExpressionBase aggregateExpression, Type baseType, QueryBinderContext context) { - switch (expression.AggregateKind) + switch (aggregateExpression.AggregateKind) { case AggregateExpressionKind.PropertyAggregate: - return CreatePropertyAggregateExpression(accum, expression as AggregateExpression, baseType); + return CreatePropertyAggregateExpression(groupingParameter, aggregateExpression as AggregateExpression, baseType, context); case AggregateExpressionKind.EntitySetAggregate: - return CreateEntitySetAggregateExpression(accum, expression as EntitySetAggregateExpression, baseType); + return CreateEntitySetAggregateExpression(groupingParameter, aggregateExpression as EntitySetAggregateExpression, baseType, context); default: - throw new ODataException(Error.Format(SRResources.AggregateKindNotSupported, expression.AggregateKind)); + throw new ODataException(Error.Format(SRResources.AggregateKindNotSupported, aggregateExpression.AggregateKind)); } } + /// + /// Creates an expression for an entity set aggregate. + /// + /// The parameter representing the group. + /// The entity set aggregate expression. + /// The element type at the base of the transformation. + /// The query binder context. + /// An expression for the entity set aggregate. + /// + /// Generates an expression similar to: + /// + /// $it => $it.AsQueryable() + /// .SelectMany($it => $it.SomeEntitySet) + /// .GroupBy($gr => new Object()) + /// .Select($p => new DynamicTypeWrapper() { + /// Container = new AggregationPropertyContainer() { + /// Name = "Alias1", + /// Value = $it.AsQueryable().AggregateMethod1($it => $it.SomePropertyOfSomeEntitySet), + /// Next = new LastInChain() { + /// Name = "Alias2", + /// Value = $p.AsQueryable().AggregateMethod2($it => $it.AnotherPropertyOfSomeEntitySet) + /// } + /// } + /// }) + /// + /// private Expression CreateEntitySetAggregateExpression( - ParameterExpression accum, EntitySetAggregateExpression expression, Type baseType) + ParameterExpression groupingParameter, + EntitySetAggregateExpression entitySetAggregateExpression, + Type baseType, + QueryBinderContext context) { - // Should return following expression - // $it => $it.AsQueryable() - // .SelectMany($it => $it.SomeEntitySet) - // .GroupBy($gr => new Object()) - // .Select($p => new DynamicTypeWrapper() - // { - // AliasOne = $p.AsQueryable().AggMethodOne($it => $it.SomePropertyOfSomeEntitySet), - // AliasTwo = $p.AsQueryable().AggMethodTwo($it => $it.AnotherPropertyOfSomeEntitySet), - // ... - // AliasN = ... , // A nested expression of this same format. - // ... - // }) - List wrapperTypeMemberAssignments = new List(); - var asQueryableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); - Expression asQueryableExpression = Expression.Call(null, asQueryableMethod, accum); + MethodInfo asQueryableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); + Expression asQueryableExpression = Expression.Call(null, asQueryableMethod, groupingParameter); // Create lambda to access the entity set from expression - var source = BindAccessor(expression.Expression.Source); - string propertyName = Model.GetClrPropertyName(expression.Expression.NavigationProperty); + Expression source = BindAccessExpression(entitySetAggregateExpression.Expression.Source, context); + string propertyName = context.Model.GetClrPropertyName(entitySetAggregateExpression.Expression.NavigationProperty); - var property = Expression.Property(source, propertyName); + MemberExpression property = Expression.Property(source, propertyName); - var baseElementType = source.Type; - var selectedElementType = property.Type.GenericTypeArguments.Single(); + Type baseElementType = source.Type; + Type selectedElementType = property.Type.GenericTypeArguments.Single(); // Create method to get property collections to aggregate - MethodInfo selectManyMethod - = ExpressionHelperMethods.EnumerableSelectManyGeneric.MakeGenericMethod(baseElementType, selectedElementType); + MethodInfo selectManyMethod = ExpressionHelperMethods.EnumerableSelectManyGeneric.MakeGenericMethod(baseElementType, selectedElementType); - // Create the lambda that access the property in the selectMany clause. - var selectManyParam = Expression.Parameter(baseElementType, "$it"); - var propertyExpression = Expression.Property(selectManyParam, expression.Expression.NavigationProperty.Name); + // Create the lambda that access the property in the SelectMany clause. + ParameterExpression selectManyParam = Expression.Parameter(baseElementType, "$it"); + MemberExpression propertyExpression = Expression.Property(selectManyParam, entitySetAggregateExpression.Expression.NavigationProperty.Name); // Collection selector body is IQueryable, we need to adjust the type to IEnumerable, to match the SelectMany signature // therefore the delegate type is specified explicitly - var collectionSelectorLambdaType = typeof(Func<,>).MakeGenericType( + Type collectionSelectorLambdaType = typeof(Func<,>).MakeGenericType( source.Type, typeof(IEnumerable<>).MakeGenericType(selectedElementType)); - var selectManyLambda = Expression.Lambda(collectionSelectorLambdaType, propertyExpression, selectManyParam); + LambdaExpression selectManyLambda = Expression.Lambda(collectionSelectorLambdaType, propertyExpression, selectManyParam); // Get expression to get collection of entities - var entitySet = Expression.Call(null, selectManyMethod, asQueryableExpression, selectManyLambda); + MethodCallExpression entitySet = Expression.Call(null, selectManyMethod, asQueryableExpression, selectManyLambda); // Getting method and lambda expression of groupBy - var groupKeyType = typeof(object); + Type groupKeyType = typeof(object); MethodInfo groupByMethod = ExpressionHelperMethods.EnumerableGroupByGeneric.MakeGenericMethod(selectedElementType, groupKeyType); - var groupByLambda = Expression.Lambda( + LambdaExpression groupByLambda = Expression.Lambda( Expression.New(groupKeyType), Expression.Parameter(selectedElementType, "$gr")); // Group entities in a single group to apply select - var groupedEntitySet = Expression.Call(null, groupByMethod, entitySet, groupByLambda); + MethodCallExpression groupedEntitySet = Expression.Call(null, groupByMethod, entitySet, groupByLambda); - var groupingType = typeof(IGrouping<,>).MakeGenericType(groupKeyType, selectedElementType); - ParameterExpression innerAccum = Expression.Parameter(groupingType, "$p"); + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupKeyType, selectedElementType); + ParameterExpression innerGroupingParameter = Expression.Parameter(groupingType, "$p"); // Nested properties // Create dynamicTypeWrapper to encapsulate the aggregate result - var properties = new List(); - foreach (var aggExpression in expression.Children) + List properties = new List(); + foreach (AggregateExpressionBase aggregateExpression in entitySetAggregateExpression.Children) { - properties.Add(new NamedPropertyExpression(Expression.Constant(aggExpression.Alias), CreateAggregationExpression(innerAccum, aggExpression, selectedElementType))); + properties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpression.Alias), + CreateAggregateExpression(innerGroupingParameter, aggregateExpression, selectedElementType, context))); } - var nestedResultType = typeof(EntitySetAggregationWrapper); - var wrapperProperty = nestedResultType.GetProperty("Container"); + Type nestedResultType = typeof(EntitySetAggregationWrapper); + PropertyInfo wrapperProperty = nestedResultType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - var initializedMember = - Expression.MemberInit(Expression.New(nestedResultType), wrapperTypeMemberAssignments); - var selectLambda = Expression.Lambda(initializedMember, innerAccum); + MemberInitExpression initializedMember = Expression.MemberInit(Expression.New(nestedResultType), wrapperTypeMemberAssignments); + LambdaExpression selectLambda = Expression.Lambda(initializedMember, innerGroupingParameter); // Get select method - MethodInfo selectMethod = - ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( - groupingType, - selectLambda.Body.Type); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + groupingType, + selectLambda.Body.Type); return Expression.Call(null, selectMethod, groupedEntitySet, selectLambda); } - private Expression CreatePropertyAggregateExpression(ParameterExpression accum, AggregateExpression expression, Type baseType) + /// + /// Creates an expression for a property aggregate. + /// + /// The parameter representing the group. + /// The aggregate expression. + /// The element type at the base of the transformation. + /// The query binder context. + /// An expression for a property aggregate. + /// + /// Generates an expression similar to: + /// + /// $it => $it.AsQueryable().Select($it => $it.SomeProperty).AggregateMethod() + /// + /// Example: + /// + /// $it => $it.AsQueryable().Sum($it => $it.SomeProperty) + /// + /// If the aggregation method is , the method uses a custom aggregation function provided by the caller. + /// + private Expression CreatePropertyAggregateExpression( + ParameterExpression groupingParameter, + AggregateExpression aggregateExpression, + Type baseType, + QueryBinderContext context) { - // accumulate type is IGrouping<,baseType> that implements IEnumerable + // groupingParameter type is IGrouping<,baseType> that implements IEnumerable // we need cast it to IEnumerable during expression building (IEnumerable)$it - // however for EF6 we need to use $it.AsQueryable() due to limitations in types of casts that will properly translated - Expression asQuerableExpression = null; - if (ClassicEF) - { - var asQuerableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); - asQuerableExpression = Expression.Call(null, asQuerableMethod, accum); - } - else - { - var queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); - asQuerableExpression = Expression.Convert(accum, queryableType); - } + Type queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); + Expression queryableExpression = Expression.Convert(groupingParameter, queryableType); - // $count is a virtual property, so there's not a propertyLambda to create. - if (expression.Method == AggregationMethod.VirtualPropertyCount) + // $count is a virtual property, so there's no propertyLambda to create. + if (aggregateExpression.Method == AggregationMethod.VirtualPropertyCount) { - var countMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableCountGeneric - : ExpressionHelperMethods.EnumerableCountGeneric).MakeGenericMethod(baseType); - return WrapConvert(Expression.Call(null, countMethod, asQuerableExpression)); + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(baseType); + return WrapConvert(Expression.Call(null, countMethod, queryableExpression)); } - Expression body; - - var lambdaParameter = baseType == this.ElementType ? this.LambdaParameter : Expression.Parameter(baseType, "$it"); - if (!this._preFlattenedMap.TryGetValue(expression.Expression, out body)) + ParameterExpression lambdaParameter = baseType == context.TransformationElementType ? context.CurrentParameter : Expression.Parameter(baseType, "$it"); + if (!(context.FlattenedExpressionMapping?.TryGetValue(aggregateExpression.Expression, out Expression body) == true)) { - body = BindAccessor(expression.Expression, lambdaParameter); + body = BindAccessExpression(aggregateExpression.Expression, context, lambdaParameter); } + LambdaExpression propertyLambda = Expression.Lambda(body, lambdaParameter); - Expression aggregationExpression; + Expression propertyAggregateExpression; - switch (expression.Method) + switch (aggregateExpression.Method) { case AggregationMethod.Min: { - var minMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableMin - : ExpressionHelperMethods.EnumerableMin).MakeGenericMethod(baseType, - propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, minMethod, asQuerableExpression, propertyLambda); + MethodInfo minMethod = ExpressionHelperMethods.EnumerableMin.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, minMethod, queryableExpression, propertyLambda); } + break; + case AggregationMethod.Max: { - var maxMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableMax - : ExpressionHelperMethods.EnumerableMax).MakeGenericMethod(baseType, - propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, maxMethod, asQuerableExpression, propertyLambda); + MethodInfo maxMethod = ExpressionHelperMethods.EnumerableMax.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, maxMethod, queryableExpression, propertyLambda); } + break; + case AggregationMethod.Sum: { - MethodInfo sumGenericMethod; - // For Dynamic properties cast to decimal + // For dynamic properties, cast to decimal Expression propertyExpression = WrapDynamicCastIfNeeded(body); propertyLambda = Expression.Lambda(propertyExpression, lambdaParameter); - if ( - !(ClassicEF - ? ExpressionHelperMethods.QueryableSumGenerics - : ExpressionHelperMethods.EnumerableSumGenerics).TryGetValue(propertyExpression.Type, - out sumGenericMethod)) + if (!ExpressionHelperMethods.EnumerableSumGenerics.TryGetValue(propertyExpression.Type, out MethodInfo sumGenericMethod)) { - throw new ODataException(Error.Format(SRResources.AggregationNotSupportedForType, - expression.Method, expression.Expression, propertyExpression.Type)); + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregateExpression.Method, + aggregateExpression.Expression, + propertyExpression.Type)); } - var sumMethod = sumGenericMethod.MakeGenericMethod(baseType); - aggregationExpression = Expression.Call(null, sumMethod, asQuerableExpression, propertyLambda); + MethodInfo sumMethod = sumGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpression = Expression.Call(null, sumMethod, queryableExpression, propertyLambda); - // For Dynamic properties cast back to object - if (propertyLambda.Type == typeof(object)) + // For dynamic properties, cast back to object + if (body.Type == typeof(object)) { - aggregationExpression = Expression.Convert(aggregationExpression, typeof(object)); + propertyAggregateExpression = Expression.Convert(propertyAggregateExpression, typeof(object)); } } + break; + case AggregationMethod.Average: { - MethodInfo averageGenericMethod; - // For Dynamic properties cast to decimal + // For dynamic properties, cast dynamic to decimal Expression propertyExpression = WrapDynamicCastIfNeeded(body); propertyLambda = Expression.Lambda(propertyExpression, lambdaParameter); - if ( - !(ClassicEF - ? ExpressionHelperMethods.QueryableAverageGenerics - : ExpressionHelperMethods.EnumerableAverageGenerics).TryGetValue(propertyExpression.Type, - out averageGenericMethod)) + if (!ExpressionHelperMethods.EnumerableAverageGenerics.TryGetValue(propertyExpression.Type, out MethodInfo averageGenericMethod)) { - throw new ODataException(Error.Format(SRResources.AggregationNotSupportedForType, - expression.Method, expression.Expression, propertyExpression.Type)); + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregateExpression.Method, + aggregateExpression.Expression, + propertyExpression.Type)); } - var averageMethod = averageGenericMethod.MakeGenericMethod(baseType); - aggregationExpression = Expression.Call(null, averageMethod, asQuerableExpression, propertyLambda); + MethodInfo averageMethod = averageGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpression = Expression.Call(null, averageMethod, queryableExpression, propertyLambda); - // For Dynamic properties cast back to object - if (propertyLambda.Type == typeof(object)) + // For dynamic properties, cast back to object + if (body.Type == typeof(object)) { - aggregationExpression = Expression.Convert(aggregationExpression, typeof(object)); + propertyAggregateExpression = Expression.Convert(propertyAggregateExpression, typeof(object)); } } + break; + case AggregationMethod.CountDistinct: { - // I select the specific field - var selectMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableSelectGeneric - : ExpressionHelperMethods.EnumerableSelectGeneric).MakeGenericMethod(this.ElementType, - propertyLambda.Body.Type); - Expression queryableSelectExpression = Expression.Call(null, selectMethod, asQuerableExpression, - propertyLambda); - - // I run distinct over the set of items - var distinctMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableDistinct - : ExpressionHelperMethods.EnumerableDistinct).MakeGenericMethod(propertyLambda.Body.Type); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + context.TransformationElementType, + propertyLambda.Body.Type); + + Expression queryableSelectExpression = Expression.Call(null, selectMethod, queryableExpression, propertyLambda); + + // Expression to get distinct items + MethodInfo distinctMethod = ExpressionHelperMethods.EnumerableDistinct.MakeGenericMethod(propertyLambda.Body.Type); Expression distinctExpression = Expression.Call(null, distinctMethod, queryableSelectExpression); - // I count the distinct items as the aggregation expression - var countMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableCountGeneric - : ExpressionHelperMethods.EnumerableCountGeneric).MakeGenericMethod(propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, countMethod, distinctExpression); + // Expression to get count of distinct items + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, countMethod, distinctExpression); } + break; + case AggregationMethod.Custom: { - MethodInfo customMethod = GetCustomMethod(expression); - var selectMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableSelectGeneric - : ExpressionHelperMethods.EnumerableSelectGeneric).MakeGenericMethod(this.ElementType, propertyLambda.Body.Type); - var selectExpression = Expression.Call(null, selectMethod, asQuerableExpression, propertyLambda); - aggregationExpression = Expression.Call(null, customMethod, selectExpression); + MethodInfo customMethod = GetCustomMethod(aggregateExpression, context); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric + .MakeGenericMethod(context.TransformationElementType, propertyLambda.Body.Type); + MethodCallExpression queryableSelectExpression = Expression.Call(null, selectMethod, queryableExpression, propertyLambda); + propertyAggregateExpression = Expression.Call(null, customMethod, queryableSelectExpression); } + break; + default: - throw new ODataException(Error.Format(SRResources.AggregationMethodNotSupported, expression.Method)); + throw new ODataException(Error.Format(SRResources.AggregationMethodNotSupported, aggregateExpression.Method)); } - return WrapConvert(aggregationExpression); + return WrapConvert(propertyAggregateExpression); } - private IQueryable BindGroupBy(IQueryable query) + /// + /// Creates a list of from a collection of . + /// + /// GroupBy nodes. + /// The query binder context. + /// A list of representing properties in the GroupBy clause. + private List CreateGroupByMemberAssignments(IEnumerable groupByNodes, QueryBinderContext context) { - LambdaExpression groupLambda = null; - Type elementType = query.ElementType; - if (_groupingProperties != null && _groupingProperties.Any()) + List properties = new List(); + + foreach (GroupByPropertyNode groupByNode in groupByNodes) { - // Generates expression - // .GroupBy($it => new DynamicTypeWrapper() - // { - // GroupByContainer => new AggregationPropertyContainer() { - // Name = "Prop1", - // Value = $it.Prop1, - // Next = new AggregationPropertyContainer() { - // Name = "Prop2", - // Value = $it.Prop2, // int - // Next = new LastInChain() { - // Name = "Prop3", - // Value = $it.Prop3 - // } - // } - // } - // }) - List properties = CreateGroupByMemberAssignments(_groupingProperties); - - var wrapperProperty = typeof(GroupByWrapper).GetProperty(GroupByContainerProperty); - List wta = new List(); - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - groupLambda = Expression.Lambda(Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wta), LambdaParameter); + string propertyName = groupByNode.Name; + + if (groupByNode.Expression != null) + { + properties.Add(new NamedPropertyExpression( + Expression.Constant(propertyName), + WrapConvert(BindAccessExpression(groupByNode.Expression, context)))); + } + else + { + PropertyInfo wrapperProperty = typeof(GroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + + List wrapperTypeMemberAssignments = new List(capacity: 1) + { + Expression.Bind( + wrapperProperty, + AggregationPropertyContainer.CreateNextNamedPropertyContainer( + CreateGroupByMemberAssignments(groupByNode.ChildTransformations, context))) + }; + + properties.Add(new NamedPropertyExpression( + Expression.Constant(propertyName), + Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wrapperTypeMemberAssignments))); + } } - else + + return properties; + } + + /// + /// Gets a collection of from a . + /// + /// The transformation node. + /// A collection of . + private static IEnumerable GetGroupingProperties(TransformationNode transformationNode) + { + if (transformationNode.Kind == TransformationNodeKind.GroupBy) { - // We do not have properties to aggregate - // .GroupBy($it => new NoGroupByWrapper()) - groupLambda = Expression.Lambda(Expression.New(this._groupByClrType), this.LambdaParameter); + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + + return groupByClause.GroupingProperties; } - return ExpressionHelpers.GroupBy(query, groupLambda, elementType, this._groupByClrType); + return null; } - private List CreateGroupByMemberAssignments(IEnumerable nodes) + /// + /// Gets a collection of from a . + /// + /// The query binder context. + /// The . + /// A collection of aggregate expressions. + private IEnumerable GetAggregateExpressions(TransformationNode transformationNode, QueryBinderContext context) { - var properties = new List(); - foreach (var grpProp in nodes) + Contract.Assert(transformationNode != null); + Contract.Assert(context != null); + + IEnumerable aggregateExpressions = null; + + switch (transformationNode.Kind) { - var propertyName = grpProp.Name; - if (grpProp.Expression != null) - { - properties.Add(new NamedPropertyExpression(Expression.Constant(propertyName), WrapConvert(BindAccessor(grpProp.Expression)))); - } - else - { - var wrapperProperty = typeof(GroupByWrapper).GetProperty(GroupByContainerProperty); - List wta = new List(); - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(CreateGroupByMemberAssignments(grpProp.ChildTransformations)))); - properties.Add(new NamedPropertyExpression(Expression.Constant(propertyName), Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wta))); - } + case TransformationNodeKind.Aggregate: + AggregateTransformationNode aggregateClause = transformationNode as AggregateTransformationNode; + aggregateExpressions = FixCustomMethodReturnTypes(aggregateClause.AggregateExpressions, context); + + break; + + case TransformationNodeKind.GroupBy: + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + if (groupByClause.ChildTransformations != null) + { + if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) + { + AggregateTransformationNode aggregationNode = groupByClause.ChildTransformations as AggregateTransformationNode; + aggregateExpressions = FixCustomMethodReturnTypes(aggregationNode.AggregateExpressions, context); + } + else + { + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedChildTransformationKind, + groupByClause.ChildTransformations.Kind, + transformationNode.Kind)); + } + } + + break; + + default: + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedTransformationKind, + transformationNode.Kind)); } - return properties; + return aggregateExpressions; + } + + /// + /// Fixes return types for custom aggregation methods. + /// + /// The aggregation expressions. + /// + /// + private IEnumerable FixCustomMethodReturnTypes(IEnumerable aggregateExpressions, QueryBinderContext context) + { + return aggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpression = exp as AggregateExpression; + + return aggregationExpression?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpression, context) : exp; + }); + } + + /// + /// Fixes return type for custom aggregation method. + /// + /// The aggregation expression + /// The query binder context. + /// The + private AggregateExpression FixCustomMethodReturnType(AggregateExpression aggregationExpression, QueryBinderContext context) + { + Debug.Assert(aggregationExpression != null, $"{nameof(aggregationExpression)} != null"); + Debug.Assert(aggregationExpression.Method == AggregationMethod.Custom, $"{nameof(aggregationExpression)}.Method == {nameof(AggregationMethod.Custom)}"); + + MethodInfo customMethod = GetCustomMethod(aggregationExpression, context); + + IEdmPrimitiveTypeReference typeReference = context.Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); + + return new AggregateExpression(aggregationExpression.Expression, aggregationExpression.MethodDefinition, aggregationExpression.Alias, typeReference); + } + + /// + /// Gets a custom aggregation method for the aggregation expression. + /// + /// The aggregation expression. + /// The query binder context. + /// The custom method. + private MethodInfo GetCustomMethod(AggregateExpression aggregationExpression, QueryBinderContext context) + { + LambdaExpression propertyLambda = Expression.Lambda(BindAccessExpression(aggregationExpression.Expression, context), context.CurrentParameter); + Type inputType = propertyLambda.Body.Type; + + string methodToken = aggregationExpression.MethodDefinition.MethodLabel; + CustomAggregateMethodAnnotation customMethodAnnotations = context.Model.GetAnnotationValue(context.Model); + + MethodInfo customMethod; + if (!customMethodAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + { + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregationExpression.Method, + aggregationExpression.Expression, + inputType)); + } + + return customMethod; } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationFlatteningResult.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationFlatteningResult.cs new file mode 100644 index 000000000..340d1c1e8 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationFlatteningResult.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq.Expressions; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData.Query.Expressions; + +/// +/// Represents the result of flattening properties referenced in the aggregate clause. +/// +/// +/// Flattening properties referenced in an aggregate clause helps prevent the generation of nested queries by Entity Framework. +/// For example, given a query like: +/// groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) +/// the expression is rewritten as: +/// +/// .Select($it => new FlatteningWrapper<T> { +/// Source = $it, +/// Container = new { +/// Value = $it.B.C, +/// Next = new { +/// Value = $it.B.D +/// } +/// } +/// }) +/// +/// A mapping is also maintained between the original properties and their flattened expressions: +/// B/C → $it.Container.Value +/// B/D → $it.Container.Next.Value +/// This mapping is used during the aggregation stage to generate aggregate expressions. +/// +public class AggregationFlatteningResult +{ + /// + /// Gets or sets the context parameter that has been redefined during the flattening process. + /// + public ParameterExpression RedefinedContextParameter { get; set; } + + /// + /// Gets or sets the expression that has been rewritten as part of the flattening process. + /// + public Expression FlattenedExpression { get; set; } + + /// + /// Gets or sets the mapping of single-value nodes to their corresponding flattened expressions. + /// Example: { { $it.B.C, $it.Value }, { $it.B.D, $it.Next.Value } } + /// + public IDictionary FlattenedPropertiesMapping { get; set; } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs index 963f255c8..900f618e0 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs @@ -11,7 +11,9 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Microsoft.AspNetCore.OData.Query.Validator; using Microsoft.OData.UriParser; +using Microsoft.OData.UriParser.Aggregation; namespace Microsoft.AspNetCore.OData.Query.Expressions; @@ -321,6 +323,53 @@ public static object ApplyBind(this ISelectExpandBinder binder, object source, S return projectionLambda.Compile().DynamicInvoke(source); } + /// + /// Translates an OData $search represented by to and apply to . + /// + /// The given search binder. + /// The given source. + /// The search clause. + /// The query binder context. + /// The applied result. + public static Expression ApplyBind(this ISearchBinder binder, Expression source, SearchClause searchClause, QueryBinderContext context) + { + if (binder == null) + { + throw Error.ArgumentNull(nameof(binder)); + } + + if (source == null) + { + throw Error.ArgumentNull(nameof(source)); + } + + if (searchClause == null) + { + throw Error.ArgumentNull(nameof(searchClause)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + Expression filterExp = binder.BindSearch(searchClause, context); + + Type elementType = context.ElementClrType; + + MethodInfo whereMethod; + if (typeof(IQueryable).IsAssignableFrom(source.Type)) + { + whereMethod = ExpressionHelperMethods.QueryableWhereGeneric.MakeGenericMethod(elementType); + } + else + { + whereMethod = ExpressionHelperMethods.EnumerableWhereGeneric.MakeGenericMethod(elementType); + } + + return Expression.Call(whereMethod, source, filterExp); + } + /// /// Translate an OData $search parse tree represented by to /// an and applies it to an . @@ -355,4 +404,138 @@ public static IQueryable ApplyBind(this ISearchBinder binder, IQueryable source, Expression searchExp = binder.BindSearch(searchClause, context); return ExpressionHelpers.Where(source, searchExp, context.ElementClrType); } + + /// + /// Translate an OData $apply parse tree represented by to + /// an and applies it to an . + /// + /// An instance of . + /// The original . + /// The OData $apply parse tree. + /// An instance of the containing the current query context. + /// The type of wrapper used to create an expression from the $apply parse tree. + /// The applied result. + public static IQueryable ApplyBind(this IAggregationBinder binder, IQueryable source, TransformationNode transformationNode, QueryBinderContext context, out Type resultClrType) + { + if (binder == null) + { + throw Error.ArgumentNull(nameof(binder)); + } + + if (source == null) + { + throw Error.ArgumentNull(nameof(source)); + } + + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + // Ensure that the flattened properties are populated for the current query context. + context.EnsureFlattenedProperties(context.CurrentParameter, source); + + // ApplyBind may be called multiple times if there are multiple groupby transformations + // e.g., $apply=groupby((a,b),aggregate(c))/groupby((d),aggregate(e)) + // In this case, the first groupby will be applied to the original source, + // and the second groupby will be applied to the result of the first groupby + // There would be no reason to flatten the properties again if they were already flattened + if (binder is IFlatteningBinder flatteningBinder + && (context.FlattenedProperties == null || context.FlattenedProperties.Count == 0)) + { + AggregationFlatteningResult flatteningResult = flatteningBinder.FlattenReferencedProperties( + transformationNode, + source, + context); + + if (flatteningResult?.FlattenedExpression != null) + { + Type originalTransformationElementType = context.TransformationElementType; + + QueryBinderValidator.ValidateFlatteningResult(flatteningResult); + context.FlattenedExpressionMapping = flatteningResult.FlattenedPropertiesMapping; + context.SetParameter(QueryConstants.DollarThis, flatteningResult.RedefinedContextParameter); + + LambdaExpression flattenedLambda = flatteningResult.FlattenedExpression as LambdaExpression; + Contract.Assert(flattenedLambda != null, $"{nameof(flattenedLambda)} != null"); + Type flattenedType = flattenedLambda.Body.Type; + QueryBinderValidator.ValidateFlattenedExpressionType(flattenedType); + + source = ExpressionHelpers.Select(source, flattenedLambda, originalTransformationElementType); + } + } + + // We are aiming for: query.GroupBy($it => new DynamicType1 {...}).Select($it => new DynamicType2 {...}) + // We are doing Grouping even if only aggregate was specified to have a IQueryable after aggregation + + LambdaExpression groupByLambda = binder.BindGroupBy(transformationNode, context) as LambdaExpression; + Contract.Assert(groupByLambda != null, $"{nameof(groupByLambda)} != null"); + Type groupByType = groupByLambda.Body.Type; + QueryBinderValidator.ValidateGroupByExpressionType(groupByType); + + // Invoke GroupBy method + IQueryable grouping = ExpressionHelpers.GroupBy(source, groupByLambda, source.ElementType, groupByType); + + LambdaExpression selectLambda = binder.BindSelect(transformationNode, context) as LambdaExpression; + Contract.Assert(selectLambda != null, $"{nameof(selectLambda)} != null"); + resultClrType = selectLambda.Body.Type; + QueryBinderValidator.ValidateSelectExpressionType(resultClrType); + + // Invoke Select method + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupByType, context.TransformationElementType); + + IQueryable result = ExpressionHelpers.Select(grouping, selectLambda, groupingType); + + return result; + } + + /// + /// Translate an OData parse tree represented by to + /// an and applies it to an . + /// + /// An instance of the . + /// The original source. + /// The representing an OData parse tree. + /// An instance of the containing the current query context. + /// The type of wrapper used to create an expression from the $apply parse tree. + /// The modified source. + public static IQueryable ApplyBind(this IComputeBinder binder, IQueryable source, ComputeTransformationNode computeTransformationNode, QueryBinderContext context, out Type resultClrType) + { + if (binder == null) + { + throw Error.ArgumentNull(nameof(binder)); + } + + if (source == null) + { + throw Error.ArgumentNull(nameof(source)); + } + + if (computeTransformationNode == null) + { + throw Error.ArgumentNull(nameof(computeTransformationNode)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + // Ensure that the flattened properties are populated for the current query context. + context.EnsureFlattenedProperties(context.CurrentParameter, source); + + LambdaExpression computeLambda = binder.BindCompute(computeTransformationNode, context) as LambdaExpression; + Contract.Assert(computeLambda != null, $"{nameof(computeLambda)} != null"); + resultClrType = computeLambda.Body.Type; + QueryBinderValidator.ValidateComputeExpressionType(resultClrType); + + IQueryable result = ExpressionHelpers.Select(source, computeLambda, context.TransformationElementType); + + return result; + } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs index 7e726d8b4..b864490ea 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs @@ -7,85 +7,57 @@ using System; using System.Collections.Generic; -using System.Diagnostics.Contracts; -using System.Linq; using System.Linq.Expressions; +using System.Reflection; using Microsoft.AspNetCore.OData.Query.Container; using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.OData.Edm; -using Microsoft.OData.ModelBuilder; using Microsoft.OData.UriParser; using Microsoft.OData.UriParser.Aggregation; namespace Microsoft.AspNetCore.OData.Query.Expressions; -internal class ComputeBinder : TransformationBinderBase -{ - private ComputeTransformationNode _transformation; - private IEdmModel _model; - - internal ComputeBinder(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, - IEdmModel model, ComputeTransformationNode transformation) - : base(settings, assembliesResolver, elementType, model) - { - Contract.Assert(transformation != null); - - _transformation = transformation; - _model = model; - - this.ResultClrType = typeof(ComputeWrapper<>).MakeGenericType(this.ElementType); - } - public IQueryable Bind(IQueryable query) +/// +/// The default implementation to bind an OData $apply parse tree represented by a to an . +/// +public class ComputeBinder : QueryBinder, IComputeBinder +{ + /// + public virtual Expression BindCompute(ComputeTransformationNode computeTransformationNode, QueryBinderContext context) { - PreprocessQuery(query); - // compute(X add Y as Z, A mul B as C) adds new properties to the output - // Should return following expression - // .Select($it => new ComputeWrapper { - // Instance = $it, - // Model = parametrized(IEdmModel), - // Container => new AggregationPropertyContainer() { - // Name = "Z", - // Value = $it.X + $it.Y, - // Next = new LastInChain() { - // Name = "C", - // Value = $it.A * $it.B - // } - // }) - - List wrapperTypeMemberAssignments = new List(); + // NOTE: compute(X add Y as Z, A mul B as C) adds new properties to the output + Type wrapperType = typeof(ComputeWrapper<>).MakeGenericType(context.TransformationElementType); // Set Instance property - var wrapperProperty = this.ResultClrType.GetProperty("Instance"); - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, this.LambdaParameter)); - var properties = new List(); - foreach (var computeExpression in this._transformation.Expressions) + PropertyInfo wrapperInstanceProperty = wrapperType.GetProperty(QueryConstants.ComputeWrapperInstanceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(wrapperInstanceProperty, context.CurrentParameter) + }; + + List properties = new List(); + foreach (ComputeExpression computeExpression in computeTransformationNode.Expressions) { - properties.Add(new NamedPropertyExpression(Expression.Constant(computeExpression.Alias), CreateComputeExpression(computeExpression))); + properties.Add( + new NamedPropertyExpression(Expression.Constant(computeExpression.Alias), + WrapConvert(BindAccessExpression(computeExpression.Expression, context)))); } // Initialize property 'Model' on the wrapper class. - // source = new Wrapper { Model = parameterized(a-edm-model) } + // source = new Wrapper { Model = parameterized(IEdmModel) } // Always parameterize as EntityFramework does not let you inject non primitive constant values (like IEdmModel). - wrapperProperty = this.ResultClrType.GetProperty("Model"); - var wrapperPropertyValueExpression = LinqParameterContainer.Parameterize(typeof(IEdmModel), _model); - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, wrapperPropertyValueExpression)); + PropertyInfo wrapperModelProperty = wrapperType.GetProperty(QueryConstants.ComputeWrapperModelProperty); + Expression wrapperModelPropertyValueExpression = LinqParameterContainer.Parameterize(typeof(IEdmModel), context.Model); + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperModelProperty, wrapperModelPropertyValueExpression)); // Set new compute properties - wrapperProperty = ResultClrType.GetProperty("Container"); - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - - var initilizedMember = - Expression.MemberInit(Expression.New(ResultClrType), wrapperTypeMemberAssignments); - var selectLambda = Expression.Lambda(initilizedMember, this.LambdaParameter); + PropertyInfo wrapperContainerProperty = wrapperType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); + wrapperTypeMemberAssignments.Add(Expression.Bind( + wrapperContainerProperty, + AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - var result = ExpressionHelpers.Select(query, selectLambda, this.ElementType); - return result; - } - - private Expression CreateComputeExpression(ComputeExpression expression) - { - Expression body = BindAccessor(expression.Expression); - return WrapConvert(body); + return Expression.Lambda( + Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs index e616cb6a8..1e6ee7f89 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs @@ -30,6 +30,7 @@ namespace Microsoft.AspNetCore.OData.Query.Expressions; /// /// The base class for all expression binders. /// +[Obsolete("ExpressionBinderBase is obsolete and will be dropped in the 10.x release. Please use QueryBinder instead.")] public abstract class ExpressionBinderBase { #region Properties @@ -793,7 +794,7 @@ private static void ValidateAllStringArguments(string functionName, Expression[] /// private bool IsFlatteningSource(Expression source) { - var member = source as MemberExpression; + MemberExpression member = source as MemberExpression; return member != null && this.Parameter.Type.IsGenericType && this.Parameter.Type.GetGenericTypeDefinition() == typeof(FlatteningWrapper<>) @@ -812,11 +813,17 @@ private static MethodCallExpression SkipFilters(MethodCallExpression expression) private static void CollectContainerAssignments(Expression source, MethodCallExpression expression, Dictionary result) { - CollectAssigments(result, Expression.Property(source, "GroupByContainer"), ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, "GroupByContainer")); - CollectAssigments(result, Expression.Property(source, "Container"), ExtractContainerExpression(expression, "Container")); + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperGroupByContainerProperty), + ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, QueryConstants.GroupByWrapperGroupByContainerProperty)); + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperContainerProperty), + ExtractContainerExpression(expression, QueryConstants.GroupByWrapperContainerProperty)); } - private static void CollectAssigments(IDictionary flattenPropertyContainer, Expression source, MemberInitExpression expression, string prefix = null) + private static void CollectAssignments(IDictionary flattenPropertyContainer, Expression source, MemberInitExpression expression, string prefix = null) { if (expression == null) { @@ -827,18 +834,18 @@ private static void CollectAssigments(IDictionary flattenPro Type resultType = null; MemberInitExpression nextExpression = null; Expression nestedExpression = null; - foreach (var expr in expression.Bindings.OfType()) + foreach (MemberAssignment expr in expression.Bindings.OfType()) { - var initExpr = expr.Expression as MemberInitExpression; - if (initExpr != null && expr.Member.Name == "Next") + MemberInitExpression initExpr = expr.Expression as MemberInitExpression; + if (initExpr != null && expr.Member.Name == QueryConstants.AggregationPropertyContainerNextProperty) { nextExpression = initExpr; } - else if (expr.Member.Name == "Name") + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerNameProperty) { nameToAdd = (expr.Expression as ConstantExpression).Value as string; } - else if (expr.Member.Name == "Value" || expr.Member.Name == "NestedValue") + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerValueProperty || expr.Member.Name == QueryConstants.AggregationPropertyContainerNestedValueProperty) { resultType = expr.Expression.Type; if (resultType == typeof(object) && expr.Expression.NodeType == ExpressionType.Convert) @@ -860,23 +867,23 @@ private static void CollectAssigments(IDictionary flattenPro if (typeof(GroupByWrapper).IsAssignableFrom(resultType)) { - flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, "NestedValue")); + flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty)); } else { - flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, "Value"), resultType)); + flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), resultType)); } if (nextExpression != null) { - CollectAssigments(flattenPropertyContainer, Expression.Property(source, "Next"), nextExpression, prefix); + CollectAssignments(flattenPropertyContainer, Expression.Property(source, QueryConstants.AggregationPropertyContainerNextProperty), nextExpression, prefix); } if (nestedExpression != null) { - var nestedAccessor = ((nestedExpression as MemberInitExpression).Bindings.First() as MemberAssignment).Expression as MemberInitExpression; - var newSource = Expression.Property(Expression.Property(source, "NestedValue"), "GroupByContainer"); - CollectAssigments(flattenPropertyContainer, newSource, nestedAccessor, nameToAdd); + MemberInitExpression nestedAccessor = ((nestedExpression as MemberInitExpression).Bindings.First() as MemberAssignment).Expression as MemberInitExpression; + MemberExpression newSource = Expression.Property(Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty), QueryConstants.GroupByWrapperGroupByContainerProperty); + CollectAssignments(flattenPropertyContainer, newSource, nestedAccessor, nameToAdd); } } @@ -887,13 +894,13 @@ private static MemberInitExpression ExtractContainerExpression(MethodCallExpress return null; } - var memberInitExpression = ((expression.Arguments[1] as UnaryExpression).Operand as LambdaExpression).Body as MemberInitExpression; + MemberInitExpression memberInitExpression = ((expression.Arguments[1] as UnaryExpression).Operand as LambdaExpression).Body as MemberInitExpression; if (memberInitExpression != null) { - var containerAssigment = memberInitExpression.Bindings.FirstOrDefault(m => m.Member.Name == containerName) as MemberAssignment; - if (containerAssigment != null) + MemberAssignment containerAssignment = memberInitExpression.Bindings.FirstOrDefault(m => m.Member.Name == containerName) as MemberAssignment; + if (containerAssignment != null) { - return containerAssigment.Expression as MemberInitExpression; + return containerAssignment.Expression as MemberInitExpression; } } return null; @@ -991,17 +998,17 @@ internal string GetFullPropertyPath(SingleValueNode node) switch (node.Kind) { case QueryNodeKind.SingleComplexNode: - var complexNode = (SingleComplexNode)node; + SingleComplexNode complexNode = (SingleComplexNode)node; path = complexNode.Property.Name; parent = complexNode.Source; break; case QueryNodeKind.SingleValuePropertyAccess: - var propertyNode = ((SingleValuePropertyAccessNode)node); + SingleValuePropertyAccessNode propertyNode = ((SingleValuePropertyAccessNode)node); path = propertyNode.Property.Name; parent = propertyNode.Source; break; case QueryNodeKind.SingleNavigationNode: - var navNode = ((SingleNavigationNode)node); + SingleNavigationNode navNode = ((SingleNavigationNode)node); path = navNode.NavigationProperty.Name; parent = navNode.Source; break; @@ -1009,7 +1016,7 @@ internal string GetFullPropertyPath(SingleValueNode node) if (parent != null) { - var parentPath = GetFullPropertyPath(parent); + string parentPath = GetFullPropertyPath(parent); if (parentPath != null) { path = parentPath + "\\" + path; @@ -1052,7 +1059,7 @@ internal static Expression GetPropertyExpression(Expression source, string prope { string[] propertyNameParts = propertyPath.Split('\\'); Expression propertyValue = source; - foreach (var propertyName in propertyNameParts) + foreach (string propertyName in propertyNameParts) { propertyValue = Expression.Property(propertyValue, propertyName); } @@ -1214,7 +1221,7 @@ internal IDictionary GetFlattenedProperties(ParameterExpress return null; } - var expression = BaseQuery.Expression as MethodCallExpression; + MethodCallExpression expression = BaseQuery.Expression as MethodCallExpression; if (expression == null) { return null; @@ -1229,14 +1236,14 @@ internal IDictionary GetFlattenedProperties(ParameterExpress return null; } - var result = new Dictionary(); + Dictionary result = new Dictionary(); CollectContainerAssignments(source, expression, result); if (this.HasInstancePropertyContainer) { - var instanceProperty = Expression.Property(source, "Instance"); + MemberExpression instanceProperty = Expression.Property(source, "Instance"); if (typeof(DynamicTypeWrapper).IsAssignableFrom(instanceProperty.Type)) { - var computeExpression = expression.Arguments.FirstOrDefault() as MethodCallExpression; + MethodCallExpression computeExpression = expression.Arguments.FirstOrDefault() as MethodCallExpression; computeExpression = SkipFilters(computeExpression); if (computeExpression != null) { diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs index f4433bead..936311ed3 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs @@ -186,7 +186,7 @@ public static Expression CreateBinaryExpression(BinaryOperatorKind binaryOperato } else { - throw Error.NotSupported(SRResources.QueryNodeBindingNotSupported, binaryOperator, typeof(ExpressionBinderBase).Name); + throw Error.NotSupported(SRResources.QueryNodeBindingNotSupported, binaryOperator, typeof(QueryBinder).Name); } } } @@ -373,7 +373,7 @@ private static IEnumerable ExtractValueFromNullableArguments(IEnumer public static Expression ExtractValueFromNullableExpression(Expression source) { - return Nullable.GetUnderlyingType(source.Type) != null ? Expression.Property(source, "Value") : source; + return Nullable.GetUnderlyingType(source.Type) != null ? Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty) : source; } public static Expression BindHas(Expression left, Expression flag, ODataQuerySettings querySettings) @@ -731,13 +731,13 @@ public static Expression BindCastToStringType(Expression source) // Entity Framework doesn't have ToString method for enum types. // Convert enum types to their underlying numeric types. sourceValue = Expression.Convert( - Expression.Property(source, "Value"), + Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), Enum.GetUnderlyingType(TypeHelper.GetUnderlyingTypeOrSelf(source.Type))); } else { // Entity Framework has ToString method for numeric types. - sourceValue = Expression.Property(source, "Value"); + sourceValue = Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty); } // Entity Framework doesn't have ToString method for nullable numeric types. diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs new file mode 100644 index 000000000..164ca968a --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq.Expressions; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Expressions; + +/// +/// Exposes the ability to translate an OData $apply parse tree represented by a to +/// an . +/// +public interface IAggregationBinder +{ + /// + /// Translates an OData $apply parse tree represented by a to + /// a LINQ that performs a GroupBy operation. + /// + /// The OData $apply parse tree represented by . + /// An instance of the containing the current query context.. + /// A representing the GroupBy operation in LINQ. + /// + /// Generates an expression similar to: + /// + /// $it => new DynamicTypeWrapper() { + /// GroupByContainer = new AggregationPropertyContainer() { + /// Name = "Prop1", + /// Value = $it.Prop1, + /// Next = new AggregationPropertyContainer() { + /// Name = "Prop2", + /// Value = $it.Prop2, + /// Next = new LastInChain() { + /// Name = "Prop3", + /// Value = $it.Prop3 + /// } + /// } + /// } + /// } + /// + /// + Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context); + + /// + /// Translates an OData $apply parse tree represented by a to + /// a LINQ that performs a Select operation. + /// + /// The OData $apply parse tree represented by . + /// An instance of the containing the current query context. + /// A representing the Select operation in LINQ. + /// + /// Generates an expression similar to: + /// + /// $it => new DynamicTypeWrapper() { + /// GroupByContainer = $it.Key.GroupByContainer // If groupby clause present + /// Container = new AggregationPropertyContainer() { + /// Name = "Alias1", + /// Value = $it.AsQueryable().Sum(i => i.AggregatableProperty), + /// Next = new LastInChain() { + /// Name = "Alias2", + /// Value = $it.AsQueryable().Sum(i => i.AggregatableProperty) + /// } + /// } + /// } + /// + /// + Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/IComputeBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/IComputeBinder.cs new file mode 100644 index 000000000..c1b815a15 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/IComputeBinder.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq.Expressions; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Expressions; + +/// +/// Exposes the ability to translate an OData $apply parse tree represented by a to an . +/// +public interface IComputeBinder +{ + /// + /// Translates an OData $apply parse tree represented by a to + /// an . + /// + /// The OData $apply parse tree represented by . + /// An instance of the . + /// + /// Generates an expression structured similar to: + /// + /// $it => new ComputeWrapper<T> { + /// Instance = $it, + /// Model = parameterized(IEdmModel), + /// Container => new AggregationPropertyContainer() { + /// Name = "Z", + /// Value = $it.X + $it.Y, + /// Next = new LastInChain() { + /// Name = "C", + /// Value = $it.A * $it.B + /// } + /// } + /// } + /// + /// + /// The generated LINQ expression representing the OData $apply parse tree. + Expression BindCompute(ComputeTransformationNode computeTransformationNode, QueryBinderContext context); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/IFlatteningBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/IFlatteningBinder.cs new file mode 100644 index 000000000..a9ab36bbc --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/IFlatteningBinder.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Expressions; + +/// +/// Provides an abstraction for flattening property access expressions within an OData $apply clause +/// to support efficient translation of aggregation pipelines in LINQ providers like Entity Framework. +/// +/// +/// Entity Framework versions earlier than EF Core 6.0 may generate nested queries when accessing navigation properties +/// in aggregation clauses. Flattening these properties can help generate flatter, more efficient SQL queries. +/// This interface allows conditional support for flattening based on the capabilities of the underlying LINQ provider. +/// +public interface IFlatteningBinder +{ + /// + /// Flattens properties referenced in aggregate clause to avoid generation of nested queries by Entity Framework. + /// + /// The OData $apply parse tree represented by . + /// The original . + /// An instance of the containing the current query context. + /// + /// An containing the modified query source and + /// additional metadata resulting from the flattening operation. + /// + /// + /// This method generates a Select expression that flattens the properties referenced in the aggregate clause. + /// Flattening properties helps prevent the generation of nested queries by Entity Framework, + /// resulting in more efficient SQL generation. + /// For query like: groupby((A),aggregate(B/C with max as Alias1,B/D with max as Alias2)) + /// generate an expression similar to: + /// + /// $it => FlatteningWrapper<T> { + /// Source = $it, + /// Container = new { + /// Value = $it.B.C + /// Next = new { + /// Value = $it.B.D + /// } + /// } + /// } + /// + /// Also populate expressions to access B/C and B/D in aggregate stage to look like: + /// B/C : $it.Container.Value + /// B/D : $it.Container.Next.Value + /// + AggregationFlatteningResult FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs index e04421476..fcb47a5e0 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs @@ -329,7 +329,7 @@ public virtual Expression BindDynamicPropertyAccessQueryNode(SingleValueOpenProp return GetFlattenedPropertyExpression(GetFullPropertyPath(openNode), context) ?? Expression.Property(Bind(openNode.Source, context), openNode.Name); } - if (context.ComputedProperties.TryGetValue(openNode.Name, out var computedProperty)) + if (context.ComputedProperties.TryGetValue(openNode.Name, out ComputeExpression computedProperty)) { return Bind(computedProperty.Expression, context); } @@ -970,6 +970,128 @@ public virtual Expression BindCollectionConstantNode(CollectionConstantNode node return Expression.Constant(castedList, listType); } + + /// + /// Creates an from the . + /// + /// The to be bound. + /// An instance of the . + /// The for the base element. + /// The created . + public virtual Expression BindAccessExpression(QueryNode node, QueryBinderContext context, Expression baseElement = null) + { + if (node == null) + { + throw Error.ArgumentNull(nameof(node)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + switch (node.Kind) + { + case QueryNodeKind.ResourceRangeVariableReference: + return context.CurrentParameter.Type.IsFlatteningWrapper() + ? (Expression)Expression.Property(context.CurrentParameter, QueryConstants.FlatteningWrapperSourceProperty) + : context.CurrentParameter; + + case QueryNodeKind.SingleValuePropertyAccess: + SingleValuePropertyAccessNode singleValueNode = node as SingleValuePropertyAccessNode; + return CreatePropertyAccessExpression( + BindAccessExpression(singleValueNode.Source, context, baseElement), + context, + singleValueNode.Property, + GetFullPropertyPath(singleValueNode)); + + case QueryNodeKind.AggregatedCollectionPropertyNode: + AggregatedCollectionPropertyNode aggregatedCollectionNode = node as AggregatedCollectionPropertyNode; + return CreatePropertyAccessExpression( + BindAccessExpression(aggregatedCollectionNode.Source, context, baseElement), + context, + aggregatedCollectionNode.Property); + + case QueryNodeKind.SingleComplexNode: + SingleComplexNode singleComplexNode = node as SingleComplexNode; + return CreatePropertyAccessExpression( + BindAccessExpression(singleComplexNode.Source, context, baseElement), + context, + singleComplexNode.Property, + GetFullPropertyPath(singleComplexNode)); + + case QueryNodeKind.SingleValueOpenPropertyAccess: + SingleValueOpenPropertyAccessNode openNode = node as SingleValueOpenPropertyAccessNode; + return GetFlattenedPropertyExpression(openNode.Name, context) ?? CreateOpenPropertyAccessExpression(openNode, context); + + case QueryNodeKind.None: + case QueryNodeKind.SingleNavigationNode: + SingleNavigationNode navigationNode = node as SingleNavigationNode; + return CreatePropertyAccessExpression( + BindAccessExpression(navigationNode.Source, context), + context, + navigationNode.NavigationProperty, + GetFullPropertyPath(navigationNode)); + + case QueryNodeKind.BinaryOperator: + BinaryOperatorNode binaryNode = node as BinaryOperatorNode; + Expression leftExpression = BindAccessExpression(binaryNode.Left, context, baseElement); + Expression rightExpression = BindAccessExpression(binaryNode.Right, context, baseElement); + return ExpressionBinderHelper.CreateBinaryExpression( + binaryNode.OperatorKind, + leftExpression, + rightExpression, + liftToNull: true, + context.QuerySettings); + + case QueryNodeKind.Convert: + ConvertNode convertNode = node as ConvertNode; + return CreateConvertExpression(convertNode, BindAccessExpression(convertNode.Source, context, baseElement), context); + + case QueryNodeKind.CollectionNavigationNode: + return baseElement ?? context.CurrentParameter; + + case QueryNodeKind.SingleValueFunctionCall: + return BindSingleValueFunctionCallNode(node as SingleValueFunctionCallNode, context); + + case QueryNodeKind.Constant: + return BindConstantNode(node as ConstantNode, context); + + case QueryNodeKind.SingleResourceCast: + SingleResourceCastNode singleResourceCastNode = node as SingleResourceCastNode; + return BindSingleResourceCastNode(singleResourceCastNode, context); + + default: + throw Error.NotSupported( + SRResources.QueryNodeBindingNotSupported, + node.Kind, + typeof(AggregationBinder).Name); + } + } + + /// + /// Creates a LINQ that represents the semantics of the . + /// + /// They query node to create an expression from. + /// The query binder context. + /// The LINQ created. + public virtual Expression CreateOpenPropertyAccessExpression(SingleValueOpenPropertyAccessNode openNode, QueryBinderContext context) + { + Expression source = BindAccessExpression(openNode.Source, context); + + // First check that property exists in source + // It's the case when we are apply transformation based on earlier transformation + if (source.Type.GetProperty(openNode.Name) != null) + { + return Expression.Property(source, openNode.Name); + } + + // Property doesn't exists go for dynamic properties dictionary + PropertyInfo prop = GetDynamicPropertyContainer(openNode, context); + MemberExpression propertyAccessExpression = Expression.Property(source, prop.Name); + + return CreateDynamicPropertyAccessExpression(propertyAccessExpression, openNode.Name, context); + } #endregion #region Private helper methods @@ -990,12 +1112,12 @@ private static void ValidateAllStringArguments(string functionName, Expression[] /// true/false. private static bool IsFlatteningSource(Expression source, QueryBinderContext context) { - var member = source as MemberExpression; + MemberExpression member = source as MemberExpression; return member != null - && context.CurrentParameter.Type.IsGenericType - && context.CurrentParameter.Type.GetGenericTypeDefinition() == typeof(FlatteningWrapper<>) + && context.CurrentParameter.Type.IsFlatteningWrapper() && member.Expression == context.CurrentParameter; } + #endregion #region Protected methods @@ -1060,12 +1182,12 @@ protected static PropertyInfo GetDynamicPropertyContainer(CollectionOpenProperty /// Returns null if no aggregations were used so far protected Expression GetFlattenedPropertyExpression(string propertyPath, QueryBinderContext context) { - if (context == null || context.FlattenedProperties == null || !context.FlattenedProperties.Any()) + if (context?.FlattenedProperties is null || context.FlattenedProperties.Count == 0) { return null; } - if (context.FlattenedProperties.TryGetValue(propertyPath, out var expression)) + if (context.FlattenedProperties.TryGetValue(propertyPath, out Expression expression)) { return expression; } @@ -1074,6 +1196,27 @@ protected Expression GetFlattenedPropertyExpression(string propertyPath, QueryBi // TODO: sam xu, return null? // throw new ODataException(Error.Format(SRResources.PropertyOrPathWasRemovedFromContext, propertyPath)); } + + /// + /// Wrap a value type with Expression.Convert. + /// + /// The to be wrapped. + /// The wrapped + protected static Expression WrapConvert(Expression expression) + { + if (expression == null) + { + throw Error.ArgumentNull(nameof(expression)); + } + + // Expression that we are generating looks like Value = $it.PropertyName where Value is defined as object and PropertyName can be value + // Proper .NET expression must look like as Value = (object) $it.PropertyName for proper boxing or AccessViolationException will be thrown + // Cast to object isn't translatable by EF6 as a result skipping (object) in that case + // Update: We have removed support for EF6 + return (!expression.Type.IsValueType) + ? expression + : Expression.Convert(expression, typeof(object)); + } #endregion internal static string GetFullPropertyPath(SingleValueNode node) @@ -1083,22 +1226,22 @@ internal static string GetFullPropertyPath(SingleValueNode node) switch (node.Kind) { case QueryNodeKind.SingleComplexNode: - var complexNode = (SingleComplexNode)node; + SingleComplexNode complexNode = (SingleComplexNode)node; path = complexNode.Property.Name; parent = complexNode.Source; break; case QueryNodeKind.SingleValuePropertyAccess: - var propertyNode = (SingleValuePropertyAccessNode)node; + SingleValuePropertyAccessNode propertyNode = (SingleValuePropertyAccessNode)node; path = propertyNode.Property.Name; parent = propertyNode.Source; break; case QueryNodeKind.SingleNavigationNode: - var navNode = (SingleNavigationNode)node; + SingleNavigationNode navNode = (SingleNavigationNode)node; path = navNode.NavigationProperty.Name; parent = navNode.Source; break; case QueryNodeKind.SingleValueOpenPropertyAccess: - var openPropertyNode = (SingleValueOpenPropertyAccessNode)node; + SingleValueOpenPropertyAccessNode openPropertyNode = (SingleValueOpenPropertyAccessNode)node; path = openPropertyNode.Name; parent = openPropertyNode.Source; break; @@ -1106,7 +1249,7 @@ internal static string GetFullPropertyPath(SingleValueNode node) if (parent != null) { - var parentPath = GetFullPropertyPath(parent); + string parentPath = GetFullPropertyPath(parent); if (parentPath != null) { path = parentPath + "\\" + path; @@ -1140,36 +1283,43 @@ internal Expression CreatePropertyAccessExpression(Expression source, QueryBinde } else { - // return GetFlattenedPropertyExpression(propertyPath, context) - // ?? ConvertNonStandardPrimitives(GetPropertyExpression(source, (!propertyPath.Contains("\\", StringComparison.Ordinal) ? "Instance\\" : String.Empty) + propertyName), context); + Expression propertyAccessExpression = propertyAccessExpression = GetFlattenedPropertyExpression(propertyPath, context); + if (propertyAccessExpression == null) + { + Expression propertyExpression = source.Type.IsComputeWrapper(out _) + // e.g., if source.Type is ComputeWrapper, for Amount property, the valid source will be $it.Instance + ? GetPropertyExpression(source, QueryConstants.ComputeWrapperInstanceProperty) + : source; - bool isAggregated = context.ElementClrType == typeof(AggregationWrapper); + propertyExpression = GetPropertyExpression(propertyExpression, propertyName); + propertyAccessExpression = ConvertNonStandardPrimitives(propertyExpression, context); + } - return GetFlattenedPropertyExpression(propertyPath, context) - ?? ConvertNonStandardPrimitives(GetPropertyExpression(source, propertyName, isAggregated), context); + return propertyAccessExpression; } } - internal static Expression GetPropertyExpression(Expression source, string propertyPath, bool isAggregated = false) + private static Expression GetPropertyExpression(Expression source, string propertyPath) { string[] propertyNameParts = propertyPath.Split('\\'); Expression propertyValue = source; - foreach (var propertyName in propertyNameParts) - { - // Trying to fix problem with $apply and $orderby. https://github.com/OData/AspNetCoreOData/issues/420 - if (isAggregated) + foreach (string propertyName in propertyNameParts) + { + propertyValue = Expression.Property(propertyValue, propertyName); + + // Consider a scenario like: + // Sales?$apply=compute(Amount mul Product/TaxRate as Tax)/compute(Amount add Tax as Total,Amount div 10 as Discount)/compute(Total sub Discount as SalePrice) + // To get to the Amount property, we'll need the expression $it->Instance->Instance->Amount + // $it is of type ComputeWrapper (where T1 is of type ComputeWrapper) + // $it->Instance is of type ComputeWrapper (where T2 is of type Sale) + // $it->Instance->Instance is of type Sale + // $it=>Instance->Instance->Amount is the correct member expression for Amount property + if (propertyValue.Type.IsComputeWrapper(out _)) { - propertyValue = Expression.Property(propertyValue, "Values"); - var propertyInfo = typeof(Dictionary).GetProperty("Item"); - var arguments = new List { Expression.Constant(propertyName) }; - - propertyValue = Expression.MakeIndex(propertyValue, propertyInfo, arguments); - } - else - { - propertyValue = Expression.Property(propertyValue, propertyName); + propertyValue = GetPropertyExpression(propertyValue, QueryConstants.ComputeWrapperInstanceProperty); } } + return propertyValue; } @@ -1410,6 +1560,196 @@ internal Expression BindCastToEnumType(Type sourceType, Type targetClrType, Quer } } + internal static IDictionary GetFlattenedProperties(ParameterExpression source, QueryBinderContext context, IQueryable query) + { + if (!query.ElementType.IsGroupByWrapper()) + { + return null; + } + + MethodCallExpression expression = query.Expression as MethodCallExpression; + if (expression == null) + { + return null; + } + + // After $apply we could have other clauses, like $filter, $orderby etc. + // Skip all filter expressions + expression = SkipFilters(expression); + + if (expression == null) + { + return null; + } + + Dictionary flattenedPropertiesMap = new Dictionary(); + CollectContainerAssignments(source, expression, flattenedPropertiesMap); + if (query?.ElementType?.IsComputeWrapper(out _) == true) + { + MemberExpression instanceProperty = Expression.Property(source, QueryConstants.ComputeWrapperInstanceProperty); + if (typeof(DynamicTypeWrapper).IsAssignableFrom(instanceProperty.Type)) + { + MethodCallExpression computeExpression = expression.Arguments.FirstOrDefault() as MethodCallExpression; + computeExpression = SkipFilters(computeExpression); + if (computeExpression != null) + { + CollectContainerAssignments(instanceProperty, computeExpression, flattenedPropertiesMap); + } + } + } + + return flattenedPropertiesMap; + } + + private static MethodCallExpression SkipFilters(MethodCallExpression expression) + { + while (expression.Method.Name == "Where") + { + expression = expression.Arguments.FirstOrDefault() as MethodCallExpression; + } + + return expression; + } + + private static void CollectContainerAssignments(Expression source, MethodCallExpression expression, Dictionary result) + { + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperGroupByContainerProperty), + ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, QueryConstants.GroupByWrapperGroupByContainerProperty)); + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperContainerProperty), + ExtractContainerExpression(expression, QueryConstants.GroupByWrapperContainerProperty)); + } + + private static void CollectAssignments(IDictionary flattenPropertyContainer, Expression source, MemberInitExpression expression, string prefix = null) + { + if (expression == null) + { + return; + } + + string nameToAdd = null; + Type resultType = null; + MemberInitExpression nextExpression = null; + Expression nestedExpression = null; + foreach (MemberAssignment expr in expression.Bindings.OfType()) + { + MemberInitExpression initExpr = expr.Expression as MemberInitExpression; + if (initExpr != null && expr.Member.Name == QueryConstants.AggregationPropertyContainerNextProperty) + { + nextExpression = initExpr; + } + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerNameProperty) + { + nameToAdd = (expr.Expression as ConstantExpression).Value as string; + } + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerValueProperty || expr.Member.Name == QueryConstants.AggregationPropertyContainerNestedValueProperty) + { + resultType = expr.Expression.Type; + if (resultType == typeof(object) && expr.Expression.NodeType == ExpressionType.Convert) + { + resultType = ((UnaryExpression)expr.Expression).Operand.Type; + } + + if (resultType.IsGroupByWrapper()) + { + nestedExpression = expr.Expression; + } + } + } + + if (prefix != null) + { + nameToAdd = prefix + "\\" + nameToAdd; + } + + if (resultType.IsGroupByWrapper()) + { + flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty)); + } + else + { + source = ConvertToAggregationPropertyContainerIfNeeded(source); + + flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), resultType)); + } + + if (nextExpression != null) + { + CollectAssignments(flattenPropertyContainer, Expression.Property(source, QueryConstants.AggregationPropertyContainerNextProperty), nextExpression, prefix); + } + + if (nestedExpression != null) + { + MemberInitExpression nestedAccessor = ((nestedExpression as MemberInitExpression).Bindings.First() as MemberAssignment).Expression as MemberInitExpression; + MemberExpression newSource = Expression.Property(Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty), QueryConstants.GroupByWrapperGroupByContainerProperty); + CollectAssignments(flattenPropertyContainer, newSource, nestedAccessor, nameToAdd); + } + } + + private static MemberInitExpression ExtractContainerExpression(MethodCallExpression expression, string containerName) + { + if (expression == null || expression.Arguments.Count < 2) + { + return null; + } + + MemberInitExpression memberInitExpression = ((expression.Arguments[1] as UnaryExpression).Operand as LambdaExpression).Body as MemberInitExpression; + if (memberInitExpression != null) + { + MemberAssignment containerAssignment = memberInitExpression.Bindings.FirstOrDefault(m => m.Member.Name == containerName) as MemberAssignment; + if (containerAssignment != null) + { + return containerAssignment.Expression as MemberInitExpression; + } + } + + return null; + } + + /// + /// Wraps expression with a Convert expression to enforce type if needed. + /// + /// The source expression. + /// The wrapped expression, or the original expression, as the case may be. + /// + /// Wrapping with a Convert expression is only needed when dealing with (default implementation). + /// It's needed because inherits from - meaning that + /// implements "Name" and "Value" properties + /// indirectly via . + /// Without Convert({expression}, typeof(AggregationPropertyContainer)), the "Value" property cannot be resolved and translation fails. + /// + private static Expression ConvertToAggregationPropertyContainerIfNeeded(Expression source) + { + // NOTE: We should reconsider the inheritance of AggregationPropertyContainer from NamedProperty for the following reasons: + // 1) Unnecessary complexity for aggregation scenarios + // - The NamedProperty type, which inherits from PropertyContainer, was designed primarily for SelectExpand scenarios. + // - It introduces a significant amount of logic that is not required for aggregation. + // - Aggregation functionality only depends on the Name and Value properties, as well as the ToDictionaryCore method. + // - The ToDictionaryCore method requires a boolean includeAutoSelected parameter, which is irrelevant in aggregation scenarios and is simply ignored. + // 2) Challenges in exposing wrapper and container types as public API + // - The inheritance structure complicates making essential wrapper types (e.g., GroupByWrapper, FlatteningWrapper) and container types (e.g., AggregationPropertyContainer) public. + // - Exposing these types as part of the public API is necessary to enable subclassing AggregationBinder, instead of forcing developers to implement IAggregationBinder from scratch. + // - However, due to the dependency on NamedProperty, making these types public would require exposing approximately 128 private types from PropertyContainer, which is impractical. + + Debug.Assert(source != null, $"{nameof(source)} != null"); + + Expression targetSource = source; + + do + { + if (targetSource.Type.InheritsFromGenericBase(typeof(NamedProperty<>))) + { + return Expression.Convert(source, typeof(AggregationPropertyContainer)); + } + + } while ((targetSource is MemberExpression memberExpression) && ((targetSource = memberExpression.Expression) != null)); + + return source; + } + private static Expression Any(Expression source, Expression filter) { Contract.Assert(source != null); @@ -1588,10 +1928,10 @@ private static Expression CreateDynamicPropertyAccessExpression( // Handle null propagation if (context.QuerySettings.HandleNullPropagation == HandleNullPropagationOption.True) { - BinaryExpression dictIonaryIsNotNullExpr = Expression.NotEqual(containerPropertyAccessExpr, NullConstant); - BinaryExpression dictIonaryIsNotNullAndContainsKeyExpr = Expression.AndAlso(dictIonaryIsNotNullExpr, containsKeyExpression); + BinaryExpression dictionaryIsNotNullExpr = Expression.NotEqual(containerPropertyAccessExpr, NullConstant); + BinaryExpression dictionaryIsNotNullAndContainsKeyExpr = Expression.AndAlso(dictionaryIsNotNullExpr, containsKeyExpression); return Expression.Condition( - dictIonaryIsNotNullAndContainsKeyExpr, + dictionaryIsNotNullAndContainsKeyExpr, dictionaryIndexExpr, NullConstant); } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs index ea03fe6e1..8a95c90c4 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs @@ -62,8 +62,8 @@ public QueryBinderContext(IEdmModel model, ODataQuerySettings querySettings, Typ ElementType = Model.GetEdmTypeReference(ElementClrType)?.Definition; - // Check if element type is null and not of AggregationWrapper type and not of NoGroupByAggregationWrapper type. - if (ElementType == null && ElementClrType != typeof(AggregationWrapper) && ElementClrType != typeof(NoGroupByAggregationWrapper)) + // Check if element type is null and not of DynamicTypeWrapper or its derived types. + if (ElementType == null && !typeof(DynamicTypeWrapper).IsAssignableFrom(ElementClrType)) { throw new ODataException(Error.Format(SRResources.ClrTypeNotInModel, ElementClrType.FullName)); } @@ -72,8 +72,8 @@ public QueryBinderContext(IEdmModel model, ODataQuerySettings querySettings, Typ // Here: // $this -> instance in EmailAddresses // $it -> instance in Customers - // When we process $select=..., we create QueryBindContext, the input clrType is "Customer". - // When we process nested $filter, we create another QueryBindContext, the input clrType is "string". + // When we process $select=..., we create QueryBinderContext, the input clrType is "Customer". + // When we process nested $filter, we create another QueryBinderContext, the input clrType is "string". ParameterExpression thisParameters = Expression.Parameter(clrType, DollarIt); _lambdaParameters = new Dictionary(); @@ -121,6 +121,8 @@ public QueryBinderContext(QueryBinderContext context, ODataQuerySettings querySe IsNested = true; EnableSkipToken = context.EnableSkipToken; + AssembliesResolver = context.AssembliesResolver; + SearchBinder = context.SearchBinder; } /// @@ -143,6 +145,11 @@ public QueryBinderContext(QueryBinderContext context, ODataQuerySettings querySe /// public IAssemblyResolver AssembliesResolver { get; set; } + /// + /// Gets or sets the . + /// + public ISearchBinder SearchBinder { get; set; } + /// /// Gets the compute expressions. /// @@ -209,6 +216,33 @@ public void RemoveParameter(string name) } } + /// + /// Sets the specified parameter + /// + /// The parameter name. + /// The parameter expression. + internal void SetParameter(string name, ParameterExpression parameterExpr) + { + if (name != null) + { + _lambdaParameters[name] = parameterExpr; + } + } + + #region Aggregation + + /// + /// The type of the element in a transformation query. + /// + public Type TransformationElementType { get { return this.CurrentParameter.Type; } } + + /// + /// A mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + /// + public IDictionary FlattenedExpressionMapping { get; internal set; } + + #endregion Aggregation + internal (string, ParameterExpression) HandleLambdaParameters(IEnumerable rangeVariables) { ParameterExpression lambdaIt = null; @@ -270,19 +304,27 @@ internal void AddComputedProperties(IEnumerable computedPrope return; } - foreach (var property in computedProperties) + foreach (ComputeExpression property in computedProperties) { ComputedProperties[property.Alias] = property; } } + /// + /// Ensures that the flattened properties are populated for the given source and query. + /// + /// The source parameter expression representing the root of the query. + /// The queryable object representing the current query context. + /// + /// This method populates the dictionary with the flattened properties + /// from the base query. It is typically used when the binder is applied to an aggregated query to ensure + /// that the properties are correctly flattened. + /// internal void EnsureFlattenedProperties(ParameterExpression source, IQueryable query) { - TransformationBinderBase binder = new TransformationBinderBase(this.QuerySettings, this.AssembliesResolver, this.ElementClrType, this.Model) + if (query != null) { - BaseQuery = query - }; - - this.FlattenedProperties = binder.GetFlattenedProperties(source); + this.FlattenedProperties = QueryBinder.GetFlattenedProperties(source, this, query); + } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/SelectExpandBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/SelectExpandBinder.cs index 62b592515..a09dcbea0 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/SelectExpandBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/SelectExpandBinder.cs @@ -12,6 +12,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Common; using Microsoft.AspNetCore.OData.Edm; using Microsoft.AspNetCore.OData.Query.Container; @@ -190,10 +191,12 @@ public virtual Expression CreatePropertyNameExpression(QueryBinderContext contex /// The that contains the edmProperty. /// The from which we are creating an . /// The source . - /// The $filter query represented by . - /// The $compute query represented by . + /// The nested $filter query represented by . + /// The nested $compute query represented by . + /// The nested $search query represented by . /// The created . - public virtual Expression CreatePropertyValueExpression(QueryBinderContext context, IEdmStructuredType elementType, IEdmProperty edmProperty, Expression source, FilterClause filterClause, ComputeClause computeClause = null) + public virtual Expression CreatePropertyValueExpression(QueryBinderContext context, IEdmStructuredType elementType, + IEdmProperty edmProperty, Expression source, FilterClause filterClause, ComputeClause computeClause = null, SearchClause search = null) { if (context == null) { @@ -270,15 +273,7 @@ public class Child : Father } Expression filterResult = nullablePropertyValue; - - ODataQuerySettings newSettings = new ODataQuerySettings(); - newSettings.CopyFrom(context.QuerySettings); - newSettings.HandleNullPropagation = HandleNullPropagationOption.True; - QueryBinderContext binderContext = new QueryBinderContext(context, newSettings, clrElementType); - if (computeClause != null) - { - binderContext.AddComputedProperties(computeClause.ComputedItems); - } + QueryBinderContext subContext = CreateSubContext(context, computeClause, clrElementType); if (isCollection) { @@ -286,16 +281,16 @@ public class Child : Father // TODO: Implement proper support for $select/$expand after $apply // Expression filterPredicate = FilterBinder.Bind(null, filterClause, clrElementType, queryContext, querySettings); - filterResult = FilterBinder.ApplyBind(filterSource, filterClause, binderContext); + filterResult = FilterBinder.ApplyBind(filterSource, filterClause, subContext); nullablePropertyType = filterResult.Type; } else if (settings.HandleReferenceNavigationPropertyExpandFilter) { - LambdaExpression filterLambdaExpression = FilterBinder.BindFilter(filterClause, binderContext) as LambdaExpression; + LambdaExpression filterLambdaExpression = FilterBinder.BindFilter(filterClause, subContext) as LambdaExpression; if (filterLambdaExpression == null) { - throw new ODataException(Error.Format(SRResources.ExpandFilterExpressionNotLambdaExpression, edmProperty.Name, "LambdaExpression")); + throw new ODataException(Error.Format(SRResources.ExpandFilterExpressionNotLambdaExpression, edmProperty.Name, nameof(LambdaExpression))); } ParameterExpression filterParameter = filterLambdaExpression.Parameters.First(); @@ -322,6 +317,39 @@ public class Child : Father } } + // If both $search and $filter are specified in the same request, only those items satisfying both criteria are returned + // apply $search + if (search != null && context.SearchBinder != null && IsAvailableODataQueryOption(context.QuerySettings, AllowedQueryOptions.Search)) + { + bool isCollection = edmProperty.Type.IsCollection(); + if (isCollection) + { + // only apply $search on collection + IEdmTypeReference edmElementType = edmProperty.Type.AsCollection().ElementType(); + Type clrElementType = model.GetClrType(edmElementType); + if (clrElementType == null) + { + throw new ODataException(Error.Format(SRResources.MappingDoesNotContainResourceType, edmElementType.FullName())); + } + + QueryBinderContext subContext = CreateSubContext(context, computeClause, clrElementType); + Expression searchResult = context.SearchBinder.ApplyBind(nullablePropertyValue, search, subContext); + nullablePropertyType = searchResult.Type; + if (settings.HandleNullPropagation == HandleNullPropagationOption.True) + { + // create expression similar to: 'nullablePropertyValue == null ? null : filterResult' + nullablePropertyValue = Expression.Condition( + test: Expression.Equal(nullablePropertyValue, Expression.Constant(value: null)), + ifTrue: Expression.Constant(value: null, type: nullablePropertyType), + ifFalse: searchResult); + } + else + { + nullablePropertyValue = searchResult; + } + } + } + if (settings.HandleNullPropagation == HandleNullPropagationOption.True) { // create expression similar to: 'source == null ? null : propertyValue' @@ -808,7 +836,7 @@ private Expression BuildPropertyContainer(QueryBinderContext context, Expression foreach (IEdmStructuralProperty propertyToInclude in autoSelectedProperties) { Expression propertyName = CreatePropertyNameExpression(context, structuredType, propertyToInclude, source); - Expression propertyValue = CreatePropertyValueExpression(context, structuredType, propertyToInclude, source, filterClause: null, computeClause: null); + Expression propertyValue = CreatePropertyValueExpression(context, structuredType, propertyToInclude, source, filterClause: null, computeClause: null, search: null); includedProperties.Add(new NamedPropertyExpression(propertyName, propertyValue) { AutoSelected = true @@ -864,7 +892,7 @@ internal void BuildExpandedProperty(QueryBinderContext context, Expression sourc // Expression: // source.NavigationProperty - Expression propertyValue = CreatePropertyValueExpression(context, structuredType, navigationProperty, source, expandedItem.FilterOption, expandedItem.ComputeOption); + Expression propertyValue = CreatePropertyValueExpression(context, structuredType, navigationProperty, source, expandedItem.FilterOption, expandedItem.ComputeOption, expandedItem.SearchOption); // Sub select and expand could be null if the expanded navigation property is not further projected or expanded. SelectExpandClause subSelectExpandClause = GetOrCreateSelectExpandClause(navigationProperty, expandedItem); @@ -940,7 +968,7 @@ internal void BuildSelectedProperty(QueryBinderContext context, Expression sourc Expression propertyValue; if (pathSelectItem == null) { - propertyValue = CreatePropertyValueExpression(context, structuredType, structuralProperty, source, filterClause: null, computeClause: null); + propertyValue = CreatePropertyValueExpression(context, structuredType, structuralProperty, source, filterClause: null, computeClause: null, search: null); includedProperties.Add(new NamedPropertyExpression(propertyName, propertyValue)); return; } @@ -950,7 +978,7 @@ internal void BuildSelectedProperty(QueryBinderContext context, Expression sourc // TODO: Process $compute in the $select ahead. // $compute=... - propertyValue = CreatePropertyValueExpression(context, structuredType, structuralProperty, source, pathSelectItem.FilterOption, pathSelectItem.ComputeOption); + propertyValue = CreatePropertyValueExpression(context, structuredType, structuralProperty, source, pathSelectItem.FilterOption, pathSelectItem.ComputeOption, pathSelectItem.SearchOption); Type propertyValueType = propertyValue.Type; if (propertyValueType == typeof(char[]) || propertyValueType == typeof(byte[])) { @@ -1191,10 +1219,7 @@ private Expression AddOrderByQueryForSource(QueryBinderContext context, Expressi if (orderbyClause != null && IsAvailableODataQueryOption(context.QuerySettings, AllowedQueryOptions.OrderBy)) { // TODO: Implement proper support for $select/$expand after $apply - ODataQuerySettings newSettings = new ODataQuerySettings(); - newSettings.CopyFrom(context.QuerySettings); - newSettings.HandleNullPropagation = HandleNullPropagationOption.True; - QueryBinderContext binderContext = new QueryBinderContext(context, newSettings, elementType); + QueryBinderContext binderContext = CreateSubContext(context, null, elementType); source = OrderByBinder.ApplyBind(source, orderbyClause, binderContext, false); } @@ -1202,6 +1227,21 @@ private Expression AddOrderByQueryForSource(QueryBinderContext context, Expressi return source; } + private QueryBinderContext CreateSubContext(QueryBinderContext context, ComputeClause computeClause, Type clrElementType, + HandleNullPropagationOption option = HandleNullPropagationOption.True) + { + ODataQuerySettings newSettings = new ODataQuerySettings(); + newSettings.CopyFrom(context.QuerySettings); + newSettings.HandleNullPropagation = option; + QueryBinderContext binderContext = new QueryBinderContext(context, newSettings, clrElementType); + if (computeClause != null) + { + binderContext.AddComputedProperties(computeClause.ComputedItems); + } + + return binderContext; + } + private static Expression GetNullCheckExpression(IEdmStructuralProperty propertyToInclude, Expression propertyValue, SelectExpandClause projection) { @@ -1235,7 +1275,7 @@ private Expression GetNullCheckExpression(QueryBinderContext context, IEdmNaviga Expression keysNullCheckExpression = null; foreach (var key in propertyToExpand.ToEntityType().Key()) { - var propertyValueExpression = CreatePropertyValueExpression(context, propertyToExpand.ToEntityType(), key, propertyValue, filterClause: null); + var propertyValueExpression = CreatePropertyValueExpression(context, propertyToExpand.ToEntityType(), key, propertyValue, filterClause: null, search: null); var keyExpression = Expression.Equal( propertyValueExpression, Expression.Constant(null, propertyValueExpression.Type)); diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs deleted file mode 100644 index bccb96d71..000000000 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs +++ /dev/null @@ -1,190 +0,0 @@ -//----------------------------------------------------------------------------- -// -// Copyright (c) .NET Foundation and Contributors. All rights reserved. -// See License.txt in the project root for license information. -// -//------------------------------------------------------------------------------ - -using System; -using System.Diagnostics.Contracts; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using Microsoft.AspNetCore.OData.Edm; -using Microsoft.AspNetCore.OData.Query.Wrapper; -using Microsoft.OData.Edm; -using Microsoft.OData.ModelBuilder; -using Microsoft.OData.UriParser; - -namespace Microsoft.AspNetCore.OData.Query.Expressions; - -internal class TransformationBinderBase : ExpressionBinderBase -{ - internal TransformationBinderBase(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, - IEdmModel model) : base(model, assembliesResolver, settings) - { - Contract.Assert(elementType != null); - LambdaParameter = Expression.Parameter(elementType, "$it"); - } - - protected Type ElementType { get { return this.LambdaParameter.Type; } } - - protected ParameterExpression LambdaParameter { get; set; } - - protected bool ClassicEF { get; private set; } - - /// - /// Gets CLR type returned from the query. - /// - public Type ResultClrType - { - get; protected set; - } - - /// - /// Checks IQueryable provider for need of EF6 optimization - /// - /// - /// True if EF6 optimization are needed. - internal virtual bool IsClassicEF(IQueryable query) - { - var providerNS = query.Provider.GetType().Namespace; - return (providerNS == HandleNullPropagationOptionHelper.ObjectContextQueryProviderNamespaceEF6 - || providerNS == HandleNullPropagationOptionHelper.EntityFrameworkQueryProviderNamespace); - } - - protected void PreprocessQuery(IQueryable query) - { - Contract.Assert(query != null); - - this.ClassicEF = IsClassicEF(query); - this.BaseQuery = query; - EnsureFlattenedPropertyContainer(this.LambdaParameter); - } - - protected Expression WrapConvert(Expression expression) - { - // Expression that we are generating looks like Value = $it.PropertyName where Value is defined as object and PropertyName can be value - // Proper .NET expression must look like as Value = (object) $it.PropertyName for proper boxing or AccessViolationException will be thrown - // Cast to object isn't translatable by EF6 as a result skipping (object) in that case - return (this.ClassicEF || !expression.Type.IsValueType) - ? expression - : Expression.Convert(expression, typeof(object)); - } - - public override Expression Bind(QueryNode node) - { - SingleValueNode singleValueNode = node as SingleValueNode; - if (node != null) - { - return BindAccessor(singleValueNode); - } - - throw Error.Argument(nameof(node), SRResources.OnlySingleValueNodeSupported); - } - - protected override ParameterExpression Parameter - { - get - { - return this.LambdaParameter; - } - } - - protected Expression BindAccessor(QueryNode node, Expression baseElement = null) - { - switch (node.Kind) - { - case QueryNodeKind.ResourceRangeVariableReference: - return this.LambdaParameter.Type.IsGenericType && this.LambdaParameter.Type.GetGenericTypeDefinition() == typeof(FlatteningWrapper<>) - ? (Expression)Expression.Property(this.LambdaParameter, "Source") - : this.LambdaParameter; - case QueryNodeKind.SingleValuePropertyAccess: - var propAccessNode = node as SingleValuePropertyAccessNode; - return CreatePropertyAccessExpression(BindAccessor(propAccessNode.Source, baseElement), propAccessNode.Property, GetFullPropertyPath(propAccessNode)); - case QueryNodeKind.AggregatedCollectionPropertyNode: - var aggPropAccessNode = node as AggregatedCollectionPropertyNode; - return CreatePropertyAccessExpression(BindAccessor(aggPropAccessNode.Source, baseElement), aggPropAccessNode.Property); - case QueryNodeKind.SingleComplexNode: - var singleComplexNode = node as SingleComplexNode; - return CreatePropertyAccessExpression(BindAccessor(singleComplexNode.Source, baseElement), singleComplexNode.Property, GetFullPropertyPath(singleComplexNode)); - case QueryNodeKind.SingleValueOpenPropertyAccess: - var openNode = node as SingleValueOpenPropertyAccessNode; - return GetFlattenedPropertyExpression(openNode.Name) ?? CreateOpenPropertyAccessExpression(openNode); - case QueryNodeKind.None: - case QueryNodeKind.SingleNavigationNode: - var navNode = (SingleNavigationNode)node; - return CreatePropertyAccessExpression(BindAccessor(navNode.Source), navNode.NavigationProperty, GetFullPropertyPath(navNode)); - case QueryNodeKind.BinaryOperator: - var binaryNode = (BinaryOperatorNode)node; - var leftExpression = BindAccessor(binaryNode.Left, baseElement); - var rightExpression = BindAccessor(binaryNode.Right, baseElement); - return ExpressionBinderHelper.CreateBinaryExpression(binaryNode.OperatorKind, leftExpression, rightExpression, - liftToNull: true, QuerySettings); - case QueryNodeKind.Convert: - var convertNode = (ConvertNode)node; - return CreateConvertExpression(convertNode, BindAccessor(convertNode.Source, baseElement)); - case QueryNodeKind.CollectionNavigationNode: - return baseElement ?? this.LambdaParameter; - case QueryNodeKind.SingleValueFunctionCall: - return BindSingleValueFunctionCallNode(node as SingleValueFunctionCallNode); - case QueryNodeKind.Constant: - return BindConstantNode(node as ConstantNode); - case QueryNodeKind.SingleResourceCast: - var singleResourceCastNode = node as SingleResourceCastNode; - return BindSingleResourceCastNode(singleResourceCastNode, baseElement); - default: - throw Error.NotSupported(SRResources.QueryNodeBindingNotSupported, node.Kind, - typeof(AggregationBinder).Name); - } - } - - private Expression CreateOpenPropertyAccessExpression(SingleValueOpenPropertyAccessNode openNode) - { - Expression sourceAccessor = BindAccessor(openNode.Source); - - // First check that property exists in source - // It's the case when we are apply transformation based on earlier transformation - if (sourceAccessor.Type.GetProperty(openNode.Name) != null) - { - return Expression.Property(sourceAccessor, openNode.Name); - } - - // Property doesn't exists go for dynamic properties dictionary - PropertyInfo prop = GetDynamicPropertyContainer(openNode); - MemberExpression propertyAccessExpression = Expression.Property(sourceAccessor, prop.Name); - IndexExpression readDictionaryIndexerExpression = Expression.Property(propertyAccessExpression, - DictionaryStringObjectIndexerName, Expression.Constant(openNode.Name)); - MethodCallExpression containsKeyExpression = Expression.Call(propertyAccessExpression, - propertyAccessExpression.Type.GetMethod("ContainsKey"), Expression.Constant(openNode.Name)); - ConstantExpression nullExpression = Expression.Constant(null); - - if (QuerySettings.HandleNullPropagation == HandleNullPropagationOption.True) - { - var dynamicDictIsNotNull = Expression.NotEqual(propertyAccessExpression, Expression.Constant(null)); - var dynamicDictIsNotNullAndContainsKey = Expression.AndAlso(dynamicDictIsNotNull, containsKeyExpression); - return Expression.Condition( - dynamicDictIsNotNullAndContainsKey, - readDictionaryIndexerExpression, - nullExpression); - } - else - { - return Expression.Condition( - containsKeyExpression, - readDictionaryIndexerExpression, - nullExpression); - } - } - - private Expression BindSingleResourceCastNode(SingleResourceCastNode node, Expression baseElement = null) - { - IEdmStructuredTypeReference structured = node.StructuredTypeReference; - Contract.Assert(structured != null, "NS casts can contain only structured types"); - - Type clrType = Model.GetClrType(structured); - - Expression source = Bind(node.Source); - return Expression.TypeAs(source, clrType); - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/Query/IODataQueryEndpointFilter.cs b/src/Microsoft.AspNetCore.OData/Query/IODataQueryEndpointFilter.cs new file mode 100644 index 000000000..f626349fc --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/IODataQueryEndpointFilter.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Extensions; + +namespace Microsoft.AspNetCore.OData.Query; + +/// +/// Provides an interface for implementing a filter to run codes before and after a route handler. +/// +public interface IODataQueryEndpointFilter : IEndpointFilter +{ + /// + /// Performs the OData query composition before route handler is executing. + /// + /// The OData query filter invocation context. + /// + ValueTask OnFilterExecutingAsync(ODataQueryFilterInvocationContext context); + + /// + /// Performs the OData query composition after route handler is executed. + /// + /// The response value from the route handler. + /// The OData query filter invocation context. + /// + ValueTask OnFilterExecutedAsync(object responseValue, ODataQueryFilterInvocationContext context); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContext.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContext.cs index af7e82488..8e1f364b4 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContext.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContext.cs @@ -15,7 +15,6 @@ using Microsoft.AspNetCore.OData.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.OData; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; @@ -116,9 +115,7 @@ public DefaultQueryConfigurations DefaultQueryConfigurations { if (_defaultQueryConfigurations == null) { - _defaultQueryConfigurations = RequestContainer == null - ? GetDefaultQuerySettings() - : RequestContainer.GetRequiredService(); + _defaultQueryConfigurations = GetDefaultQueryConfigurations(); } return _defaultQueryConfigurations; @@ -204,15 +201,24 @@ private void GetPathContext() } } - private DefaultQueryConfigurations GetDefaultQuerySettings() + private DefaultQueryConfigurations GetDefaultQueryConfigurations() { + if (RequestContainer != null) + { + DefaultQueryConfigurations configurations = RequestContainer.GetService(); + if (configurations is not null) + { + return configurations; + } + } + if (Request is null) { return new DefaultQueryConfigurations(); } IOptions odataOptions = Request.HttpContext?.RequestServices?.GetService>(); - if (odataOptions is null || odataOptions.Value is null) + if (odataOptions is null || odataOptions.Value is null) { return new DefaultQueryConfigurations(); } diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs index 256acedf7..2cbc52673 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs @@ -19,7 +19,7 @@ internal static class ODataQueryContextExtensions public static ODataQuerySettings GetODataQuerySettings(this ODataQueryContext context) { ODataQuerySettings returnSettings = new ODataQuerySettings(); - ODataQuerySettings settings = context?.RequestContainer?.GetRequiredService(); + ODataQuerySettings settings = context?.RequestContainer?.GetService(); if (settings != null) { returnSettings.CopyFrom(settings); @@ -31,7 +31,7 @@ public static ODataQuerySettings GetODataQuerySettings(this ODataQueryContext co public static ODataQuerySettings UpdateQuerySettings(this ODataQueryContext context, ODataQuerySettings querySettings, IQueryable query) { ODataQuerySettings updatedSettings = new ODataQuerySettings(); - ODataQuerySettings settings = context?.RequestContainer?.GetRequiredService(); + ODataQuerySettings settings = context?.RequestContainer?.GetService(); if (settings != null) { updatedSettings.CopyFrom(settings); @@ -121,6 +121,40 @@ public static IOrderByBinder GetOrderByBinder(this ODataQueryContext context) return binder ?? new OrderByBinder(); } + /// + /// Gets the . + /// + /// The query context. + /// The built . + public static IAggregationBinder GetAggregationBinder(this ODataQueryContext context) + { + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + IAggregationBinder binder = context.RequestContainer?.GetService(); + + return binder ?? new AggregationBinder(); + } + + /// + /// Gets the . + /// + /// The query context. + /// The built . + public static IComputeBinder GetComputeBinder(this ODataQueryContext context) + { + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + IComputeBinder binder = context.RequestContainer?.GetService(); + + return binder ?? new ComputeBinder(); + } + /// /// Gets the . /// @@ -182,6 +216,18 @@ public static IFilterQueryValidator GetFilterQueryValidator(this ODataQueryConte ?? new FilterQueryValidator(); } + /// + /// Gets the . + /// + /// The query context. + /// The built . + public static ISearchQueryValidator GetSearchQueryValidator(this ODataQueryContext context) + { + // By default, there's no default search query validator. + // Therefore, if the developer doesn't provide an implementation, we just return the null validator. + return context?.RequestContainer?.GetService(); + } + /// /// Gets the . /// diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryEndpointFilter.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryEndpointFilter.cs new file mode 100644 index 000000000..cec49458a --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryEndpointFilter.cs @@ -0,0 +1,535 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.AspNetCore.OData.Results; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder.Config; + +namespace Microsoft.AspNetCore.OData.Query; + +/// +/// The default implementation to to run codes before and after a route handler. +/// This typically is used for minimal api scenario. +/// +public class ODataQueryEndpointFilter : IODataQueryEndpointFilter +{ + /// + /// Gets the validation settings. Customers can use this to configure the validation. + /// + public ODataValidationSettings ValidationSettings { get; } = new ODataValidationSettings(); + + /// + /// Gets the query settings. Customers can use this to configure each query executing. + /// + /// + /// It could be confusing between DefaultQueryConfigurations and ODataQuerySettings. + /// DefaultQueryConfigurations is used to config the functionalities for query options. For example: is $filter enabled? + /// ODataQuerySettings is used to set options for each query executing. + /// + public ODataQuerySettings QuerySettings { get; } = new ODataQuerySettings(); + + /// + /// Implements the core logic associated with the filter given a + /// and the next filter to call in the pipeline. + /// + /// The associated with the current request/response. + /// The next filter in the pipeline. + /// An awaitable result of calling the handler and apply any modifications made by filters in the pipeline. + public virtual async ValueTask InvokeAsync(EndpointFilterInvocationContext invocationContext, EndpointFilterDelegate next) + { + ArgumentNullException.ThrowIfNull(invocationContext); + ArgumentNullException.ThrowIfNull(next); + + var endpoint = invocationContext.HttpContext.GetEndpoint(); + if (endpoint is null) + { + return await next(invocationContext); + } + + // https://github.com/dotnet/aspnetcore/blob/main/src/Http/Routing/src/RouteEndpointDataSource.cs#L171 + // Add MethodInfo and HttpMethodMetadata(if any) as first metadata items as they are intrinsic to the route much like + // the pattern or default display name. This gives visibility to conventions like WithOpenApi() to intrinsic route details + // (namely the MethodInfo) even when applied early as group conventions. + MethodInfo methodInfo = endpoint.Metadata.OfType().FirstOrDefault(); + + // If the endpoint metadata doesn't contain 'MethodInfo', it's a 'RequestDelegate' method. + // Maybe we should not take this into consideration? since the RequestDelegate return type is 'Task', we cannot idendify the real return type from Task? + methodInfo = methodInfo ?? endpoint.RequestDelegate.Method; + + if (methodInfo is null) + { + return await next(invocationContext); + } + + var odataFilterContext = new ODataQueryFilterInvocationContext { MethodInfo = methodInfo, InvocationContext = invocationContext }; + + await OnFilterExecutingAsync(odataFilterContext); + + // calling into next filter or the route handler. + var result = await next(invocationContext); + + return await OnFilterExecutedAsync(result, odataFilterContext); + } + + /// + /// Performs the query composition before route handler is executing. + /// + /// The OData query filter invocation context. + /// The . + public virtual async ValueTask OnFilterExecutingAsync(ODataQueryFilterInvocationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + HttpContext httpContext = context.HttpContext; + + // Use RequestQueryData to save the query validatation before route handler executing. + // This is same logic as EnableQueryAttribute. This is not required. However, it can give a better perf. + RequestQueryData requestQueryData = new RequestQueryData() + { + QueryValidationRunBeforeActionExecution = false, + }; + + httpContext.Items.TryAdd(nameof(RequestQueryData), requestQueryData); + + ODataQueryOptions queryOptions = CreateQueryOptionsOnExecuting(context); + if (queryOptions == null) + { + return; // skip validation if we cannot create the query option on executing. + } + + // Create and validate the query options. + requestQueryData.QueryValidationRunBeforeActionExecution = true; + requestQueryData.ProcessedQueryOptions = queryOptions; + + // We should add async version for the Valiator then we can call them here again to waive the ValueTask.CompletedTask. + ValidateQuery(httpContext, requestQueryData.ProcessedQueryOptions); + + await ValueTask.CompletedTask; + } + + /// + /// Performs the query composition after route handler is executed. + /// + /// The response value from the route handler. + /// The OData query filter invocation context. + /// The . + public virtual async ValueTask OnFilterExecutedAsync(object responseValue, ODataQueryFilterInvocationContext context) + { + if (responseValue == null) + { + return null; + } + + ArgumentNullException.ThrowIfNull(context); + object originalValue = responseValue; + + // TODO: Process the SingleResult and PageResult as return type later + IQueryable singleResultCollection = null; + + bool isODataResult = false; + if (responseValue is ODataResult odataResult) + { + isODataResult = true; + responseValue = odataResult.Value; + } + + if (!QuerySettings.PageSize.HasValue && responseValue != null) + { + GetModelBoundPageSize(context, responseValue, singleResultCollection); + } + + HttpRequest request = context.HttpContext.Request; + bool shouldApplyQuery = responseValue != null && + request.GetEncodedUrl() != null && + (!string.IsNullOrWhiteSpace(request.QueryString.Value) || + QuerySettings.PageSize.HasValue || + QuerySettings.ModelBoundPageSize.HasValue || + singleResultCollection != null); + + if (!shouldApplyQuery) + { + return await ValueTask.FromResult(originalValue); + } + + object queryResult = ExecuteQuery(responseValue, singleResultCollection, context); + + if (isODataResult) + { + return await ValueTask.FromResult(new ODataResult(queryResult)); + } + + return await ValueTask.FromResult(queryResult); + } + + private void GetModelBoundPageSize(ODataQueryFilterInvocationContext context, object responseValue, IQueryable singleResultCollection) + { + ODataQueryContext queryContext; + + try + { + queryContext = GetODataQueryContext(responseValue, singleResultCollection, context); + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException(Error.Format(SRResources.UriQueryStringInvalid, e.Message), e); + } + + ModelBoundQuerySettings querySettings = queryContext.Model.GetModelBoundQuerySettings(queryContext.TargetProperty, + queryContext.TargetStructuredType); + if (querySettings != null && querySettings.PageSize.HasValue) + { + QuerySettings.ModelBoundPageSize = querySettings.PageSize; + } + } + + /// + /// Execute the query. + /// + /// The response value. + /// The content as SingleResult.Queryable. + /// The action context, i.e. action and controller name. + /// + protected virtual object ExecuteQuery(object responseValue, IQueryable singleResultCollection, + ODataQueryFilterInvocationContext context) + { + ODataQueryContext queryContext = GetODataQueryContext(responseValue, singleResultCollection, context); + + // Create and validate the query options. + ODataQueryOptions queryOptions = CreateAndValidateQueryOptions(context.HttpContext, queryContext); + + ODataQuerySettings querySettings = QuerySettings; + + // apply the query + IEnumerable enumerable = responseValue as IEnumerable; + if (enumerable == null || responseValue is string || responseValue is byte[]) + { + // response is not a collection; we only support $select and $expand on single entities. + ValidateSelectExpandOnly(queryOptions); + + if (singleResultCollection == null) + { + // response is a single entity. + return ApplyQuery(entity: responseValue, queryOptions: queryOptions, querySettings); + } + else + { + IQueryable queryable = singleResultCollection as IQueryable; + queryable = ApplyQuery(queryable, queryOptions, querySettings); + return SingleOrDefault(queryable, context); + } + } + else + { + // response is a collection. + IQueryable queryable = enumerable as IQueryable ?? enumerable.AsQueryable(); + queryable = ApplyQuery(queryable, queryOptions, querySettings); + + return queryable; + } + } + + /// + /// Validate the select and expand options. + /// + /// The query options. + internal static void ValidateSelectExpandOnly(ODataQueryOptions queryOptions) + { + if (queryOptions.Filter != null || queryOptions.Count != null || queryOptions.OrderBy != null + || queryOptions.Skip != null || queryOptions.Top != null) + { + throw new ODataException(Error.Format(SRResources.NonSelectExpandOnSingleEntity)); + } + } + + /// + /// Get a single or default value from a collection. + /// + /// The response value as . + /// The context. + /// + internal static object SingleOrDefault(IQueryable queryable, ODataQueryFilterInvocationContext context) + { + var enumerator = queryable.GetEnumerator(); + try + { + var result = enumerator.MoveNext() ? enumerator.Current : null; + + if (enumerator.MoveNext()) + { + throw new InvalidOperationException(Error.Format( + SRResources.SingleResultHasMoreThanOneEntity, + context.MethodInfo.Name, + "MinimalAPI", + "SingleResult")); + } + + return result; + } + finally + { + // Ensure any active/open database objects that were created + // iterating over the IQueryable object are properly closed. + var disposable = enumerator as IDisposable; + if (disposable != null) + { + disposable.Dispose(); + } + } + } + + /// + /// Applies the query to the given entity based on incoming query from uri and query settings. + /// + /// The original entity from the response message. + /// + /// The instance constructed based on the incoming request. + /// + /// The query settings. + /// The new entity after the $select and $expand query has been applied to. + public virtual object ApplyQuery(object entity, ODataQueryOptions queryOptions, ODataQuerySettings querySettings) + { + if (entity == null) + { + throw Error.ArgumentNull("entity"); + } + if (queryOptions == null) + { + throw Error.ArgumentNull("queryOptions"); + } + + return queryOptions.ApplyTo(entity, querySettings); + } + + /// + /// Applies the query to the given IQueryable based on incoming query from uri and query settings. By default, + /// the implementation supports $top, $skip, $orderby and $filter. Override this method to perform additional + /// query composition of the query. + /// + /// The original queryable instance from the response message. + /// + /// The instance constructed based on the incoming request. + /// + /// The settings. + public virtual IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions, ODataQuerySettings querySettings) + { + ArgumentNullException.ThrowIfNull(queryable, nameof(queryable)); + ArgumentNullException.ThrowIfNull(queryOptions, nameof(queryOptions)); + + return queryOptions.ApplyTo(queryable, querySettings); + } + + /// + /// Get the OData query context. + /// + /// The response value. + /// The content as SingleResult.Queryable. + /// The action context, i.e. action and controller name. + /// + private ODataQueryContext GetODataQueryContext(object responseValue, + IQueryable singleResultCollection, + ODataQueryFilterInvocationContext invocationContext) + { + Type elementClrType = GetElementType(responseValue, singleResultCollection, invocationContext); + + IEdmModel model = GetModel(elementClrType, invocationContext); + if (model == null) + { + throw Error.InvalidOperation(SRResources.QueryGetModelMustNotReturnNull); + } + + return new ODataQueryContext(model, elementClrType/*, request.ODataFeature().Path*/); + } + + /// + /// Create and validate a new instance of from a query and context during action executed. + /// Developers can override this virtual method to provide its own . + /// + /// The http context. + /// The query context. + /// The created . + protected virtual ODataQueryOptions CreateAndValidateQueryOptions(HttpContext httpContext, ODataQueryContext queryContext) + { + if (httpContext == null) + { + throw Error.ArgumentNull("httpContext"); + } + + if (queryContext == null) + { + throw Error.ArgumentNull("queryContext"); + } + + RequestQueryData requestQueryData = httpContext.Items[nameof(RequestQueryData)] as RequestQueryData; + + if (requestQueryData != null && requestQueryData.QueryValidationRunBeforeActionExecution) + { + // processed, just return the query option and skip validation. + return requestQueryData.ProcessedQueryOptions; + } + + ODataQueryOptions queryOptions = new ODataQueryOptions(queryContext, httpContext.Request); + + ValidateQuery(httpContext, queryOptions); + + return queryOptions; + } + + /// + /// Get the element type. + /// + /// The response value. + /// The content as SingleResult.Queryable. + /// The context. + /// + internal static Type GetElementType( + object responseValue, + IQueryable singleResultCollection, + ODataQueryFilterInvocationContext context) + { + Contract.Assert(responseValue != null); + + IEnumerable enumerable = responseValue as IEnumerable; + if (enumerable == null) + { + if (singleResultCollection == null) + { + return responseValue.GetType(); + } + + enumerable = singleResultCollection; + } + + Type elementClrType = TypeHelper.GetImplementedIEnumerableType(enumerable.GetType()); + if (elementClrType == null) + { + throw Error.InvalidOperation("The element type cannot be determined because the type of the content is not IEnumerable or IQueryable."); + } + + return elementClrType; + } + + /// + /// Validates the OData query in the incoming request. By default, the implementation throws an exception if + /// the query contains unsupported query parameters. Override this method to perform additional validation of + /// the query. + /// + /// + /// + /// The instance constructed based on the incoming request. + /// + protected virtual void ValidateQuery(HttpContext httpContext, ODataQueryOptions queryOptions) + { + ArgumentNullException.ThrowIfNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(queryOptions, nameof(queryOptions)); + + IQueryCollection queryParameters = httpContext.Request.Query; + foreach (var kvp in queryParameters) + { + if (!queryOptions.IsSupportedQueryOption(kvp.Key) && + kvp.Key.StartsWith("$", StringComparison.Ordinal)) + { + // we don't support any custom query options that start with $ + throw new ODataException(Error.Format(SRResources.CustomQueryOptionNotSupportedWithDollarSign, kvp.Key)); + } + } + + queryOptions.Validate(ValidationSettings); + } + + /// + /// Creates the for action executing validation. + /// + /// The OData query filter invocation context. + /// The created or null if we can't create it during action executing. + protected virtual ODataQueryOptions CreateQueryOptionsOnExecuting(ODataQueryFilterInvocationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + ODataQueryContext queryContext; + + // For these cases few options are supported like IEnumerable, Task>, T, Task + // Other cases where we cannot determine the return type upfront, are not supported + // Like IActionResult, SingleResult. For such cases, the validation is run in OnActionExecuted + // When we have the result. + + // From Minimal API doc + // Minimal endpoints support the following types of return values: + // 1. string -This includes Task and ValueTask. + // 2. T(Any other type) - This includes Task and ValueTask. + // 3. IResult based -This includes Task and ValueTask + + Type returnType = context.MethodInfo.ReturnType; + if (returnType is null) + { + return null; + } + + if (returnType.IsGenericType) + { + Type genericTypeDef = returnType.GetGenericTypeDefinition(); + if (genericTypeDef == typeof(Task<>) || genericTypeDef == typeof(ValueTask<>)) + { + returnType = returnType.GetGenericArguments().First(); + } + } + + if (returnType == typeof(ODataResult)) + { + return null; + } + + TypeHelper.IsCollection(returnType, out Type elementType); + + IEdmModel edmModel = GetModel(elementType, context); + + queryContext = new ODataQueryContext(edmModel, elementType); + + // Get or create the service provider based on the model. It should save into the ODataFeature() in the method. + context.HttpContext.GetOrCreateServiceProvider(); + IODataFeature odataFeature = context.HttpContext.ODataFeature(); + + if (odataFeature.Services is null) + { + // Why? For example, developer overwrites 'GetModel', and in his version, he didn't create the ServiceProvider. + odataFeature.Services = context.HttpContext.BuildDefaultServiceProvider(edmModel); + } + + // Create the query options. + return new ODataQueryOptions(queryContext, context.HttpContext.Request); + } + + /// + /// Gets the EDM model for the given type and request.Override this method to customize the EDM model used for + /// querying. + /// + /// The CLR type to retrieve a model for. + /// The action descriptor for the action being queried on. + /// The EDM model for the given type and request. + protected virtual IEdmModel GetModel(Type elementClrType, ODataQueryFilterInvocationContext context) + { + HttpContext httpContext = context.HttpContext; + + return httpContext.GetOrCreateEdmModel(elementClrType); + } +} + diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptions.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptions.cs index 0408c23bc..67ee155be 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptions.cs @@ -697,7 +697,7 @@ private OrderByQueryOption GenerateDefaultOrderBy(ODataQueryContext context, Lis if (applySortOptions != null) { orderByRaw = String.Join(",", applySortOptions); - return new OrderByQueryOption(orderByRaw, context, Apply.RawValue); + return new OrderByQueryOption(orderByRaw, context, Apply.RawValue, Compute?.RawValue); } else { @@ -749,7 +749,7 @@ private OrderByQueryOption EnsureStableSortOrderBy(OrderByQueryOption orderBy, O if (propertyPathsToAdd.Any()) { var orderByRaw = orderBy.RawValue + "," + String.Join(",", propertyPathsToAdd); - orderBy = new OrderByQueryOption(orderByRaw, context, Apply.RawValue); + orderBy = new OrderByQueryOption(orderByRaw, context, Apply.RawValue, Compute?.RawValue); } } else @@ -762,7 +762,7 @@ private OrderByQueryOption EnsureStableSortOrderBy(OrderByQueryOption orderBy, O // the sort stable but preserving the user's original intent for the major // sort order. var orderByRaw = orderBy.RawValue + "," + string.Join(",", propertiesToAdd.Select(p => p.Name)); - orderBy = new OrderByQueryOption(orderByRaw, context); + orderBy = new OrderByQueryOption(orderByRaw, context, null, Compute?.RawValue); } } diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptionsOfT.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptionsOfT.cs index db5971a55..d8990e045 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptionsOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryOptionsOfT.cs @@ -5,11 +5,18 @@ // //------------------------------------------------------------------------------ +using System; using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Extensions; using Microsoft.Net.Http.Headers; using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData.Query; @@ -18,7 +25,7 @@ namespace Microsoft.AspNetCore.OData.Query; /// Currently this only supports $filter, $orderby, $top, $skip. /// [ODataQueryParameterBinding] -public class ODataQueryOptions : ODataQueryOptions +public class ODataQueryOptions : ODataQueryOptions, IEndpointParameterMetadataProvider { /// /// Initializes a new instance of the class based on the incoming request and some metadata information from @@ -95,6 +102,41 @@ public override IQueryable ApplyTo(IQueryable query, ODataQuerySettings querySet return base.ApplyTo(query, querySettings); } + /// + /// Binds the and to generate the . + /// + /// The HttpContext. + /// The parameter info. + /// The built + public static async ValueTask> BindAsync(HttpContext context, ParameterInfo parameter) + { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(parameter, nameof(parameter)); + + Type entityClrType = typeof(TEntity); + IEdmModel model = context.GetOrCreateEdmModel(entityClrType, parameter); + ODataPath path = context.GetOrCreateODataPath(entityClrType); + context.ODataFeature().Services = context.GetOrCreateServiceProvider(); + + ODataQueryContext entitySetContext = new ODataQueryContext(model, entityClrType, path); + var result = new ODataQueryOptions(entitySetContext, context.Request); + + return await ValueTask.FromResult(result); + } + + /// + /// Populates metadata for the related and . + /// + /// The parameter info. + /// The endpoint builder that we can add metadata into. + public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) + { + // Make sure we have the metadata added into the endpoint. + // Shall we build the 'EdmModel' here? ==> Emm...No, because the 'convention' runs after this population. + // If developer calls 'WithODataModel()', then any model created here will be replaced. So, no need/required to create model here. + ODataEndpointConventionBuilderExtensions.ConfigureODataMetadata(builder, null); + } + private static void ValidateQuery(IQueryable query) { if (query == null) diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs index 39a084493..256988750 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs @@ -146,23 +146,28 @@ public IQueryable ApplyTo(IQueryable query, ODataQuerySettings querySettings) } } - foreach (var transformation in applyClause.Transformations) + foreach (TransformationNode transformation in applyClause.Transformations) { if (transformation.Kind == TransformationNodeKind.Aggregate || transformation.Kind == TransformationNodeKind.GroupBy) { - var binder = new AggregationBinder(querySettings, assembliesResolver, ResultClrType, Context.Model, transformation); - query = binder.Bind(query); - this.ResultClrType = binder.ResultClrType; + QueryBinderContext queryBinderContext = new QueryBinderContext(Context.Model, querySettings, ResultClrType); + IAggregationBinder binder = Context.GetAggregationBinder(); + query = binder.ApplyBind(query, transformation, queryBinderContext, out Type resultClrType); + this.ResultClrType = resultClrType; } else if (transformation.Kind == TransformationNodeKind.Compute) { - var binder = new ComputeBinder(querySettings, assembliesResolver, ResultClrType, Context.Model, (ComputeTransformationNode)transformation); - query = binder.Bind(query); - this.ResultClrType = binder.ResultClrType; + ComputeTransformationNode computeTransformationNode = transformation as ComputeTransformationNode; + + IComputeBinder binder = Context.GetComputeBinder(); + QueryBinderContext binderContext = new QueryBinderContext(Context.Model, querySettings, ResultClrType); + + query = binder.ApplyBind(query, computeTransformationNode, binderContext, out Type resultClrType); + this.ResultClrType = resultClrType; } else if (transformation.Kind == TransformationNodeKind.Filter) { - var filterTransformation = transformation as FilterTransformationNode; + FilterTransformationNode filterTransformation = transformation as FilterTransformationNode; IFilterBinder binder = Context.GetFilterBinder(); QueryBinderContext binderContext = new QueryBinderContext(Context.Model, querySettings, ResultClrType); diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/OrderByQueryOption.cs b/src/Microsoft.AspNetCore.OData/Query/Query/OrderByQueryOption.cs index 94fb56753..00a6a7513 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/OrderByQueryOption.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/OrderByQueryOption.cs @@ -56,7 +56,7 @@ public OrderByQueryOption(string rawValue, ODataQueryContext context, ODataQuery _queryOptionParser = queryOptionParser; } - internal OrderByQueryOption(string rawValue, ODataQueryContext context, string applyRaw) + internal OrderByQueryOption(string rawValue, ODataQueryContext context, string applyRaw, string computeRaw) { if (context == null) { @@ -68,19 +68,27 @@ internal OrderByQueryOption(string rawValue, ODataQueryContext context, string a throw Error.ArgumentNullOrEmpty("rawValue"); } - if (applyRaw == null) - { - throw Error.ArgumentNullOrEmpty("applyRaw"); - } - Context = context; RawValue = rawValue; Validator = context.GetOrderByQueryValidator(); + + Dictionary queryOptions = new Dictionary(); + queryOptions["$orderby"] = rawValue; + if (applyRaw != null) + { + queryOptions["$apply"] = applyRaw; + } + + if (computeRaw != null) + { + queryOptions["$compute"] = computeRaw; + } + _queryOptionParser = new ODataQueryOptionParser( context.Model, context.ElementType, context.NavigationSource, - new Dictionary { { "$orderby", rawValue }, { "$apply", applyRaw } }, + queryOptions, context.RequestContainer); if (context.RequestContainer == null) @@ -89,7 +97,15 @@ internal OrderByQueryOption(string rawValue, ODataQueryContext context, string a _queryOptionParser.Resolver = ODataQueryContext.DefaultCaseInsensitiveResolver; } - _queryOptionParser.ParseApply(); + if (computeRaw != null) + { + _queryOptionParser.ParseCompute(); + } + + if (applyRaw != null) + { + _queryOptionParser.ParseApply(); + } } // This constructor is intended for unit testing only. diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/SearchQueryOption.cs b/src/Microsoft.AspNetCore.OData/Query/Query/SearchQueryOption.cs index 53e1f5f07..f3fa6101d 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/SearchQueryOption.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/SearchQueryOption.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Validator; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; @@ -152,4 +153,23 @@ public IQueryable ApplyTo(IQueryable query, ODataQuerySettings querySettings) return binder.ApplyBind(query, SearchClause, binderContext); } + + /// + /// Validate the $search query based on the given . It throws an ODataException if validation failed. + /// + /// The instance which contains all the validation settings. + public void Validate(ODataValidationSettings validationSettings) + { + if (validationSettings == null) + { + throw Error.ArgumentNull(nameof(validationSettings)); + } + + ISearchQueryValidator validator = Context.GetSearchQueryValidator(); + if (validator != null) + { + // If the developer doesn't provide the search validator, let's ignore the $search validation. + validator.Validate(this, validationSettings); + } + } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/SelectExpandQueryOption.cs b/src/Microsoft.AspNetCore.OData/Query/Query/SelectExpandQueryOption.cs index 551b5c093..b533f1601 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/SelectExpandQueryOption.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/SelectExpandQueryOption.cs @@ -214,6 +214,7 @@ public IQueryable ApplyTo(IQueryable queryable, ODataQuerySettings settings) QueryBinderContext binderContext = new QueryBinderContext(Context.Model, settings, Context.ElementClrType) { NavigationSource = Context.NavigationSource, + SearchBinder = Context.GetSearchBinder(), }; if (Compute != null) @@ -256,6 +257,7 @@ public object ApplyTo(object entity, ODataQuerySettings settings) QueryBinderContext binderContext = new QueryBinderContext(Context.Model, settings, Context.ElementClrType) { NavigationSource = Context.NavigationSource, + SearchBinder = Context.GetSearchBinder() }; if (Compute != null) diff --git a/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs b/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs new file mode 100644 index 000000000..734ef8132 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs @@ -0,0 +1,47 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.Query; + +/// +/// Constant values used in aggregation operation. +/// +internal static class QueryConstants +{ + /// Name for property. + public const string GroupByWrapperContainerProperty = "Container"; + + /// Name for property. + public const string GroupByWrapperGroupByContainerProperty = "GroupByContainer"; + + /// Name for property. + public const string AggregationPropertyContainerNameProperty = "Name"; + + /// Name for property. + public const string AggregationPropertyContainerValueProperty = "Value"; + + /// Name for property. + public const string AggregationPropertyContainerNestedValueProperty = "NestedValue"; + + /// Name for property. + public const string AggregationPropertyContainerNextProperty = "Next"; + + /// Name for property. + public const string FlatteningWrapperSourceProperty = "Source"; + + /// Name for property. + public const string ComputeWrapperInstanceProperty = "Instance"; + + /// Name for property. + public const string ComputeWrapperModelProperty = "Model"; + + /// Parameter name representing the current instance being evaluated in an OData expression. + public const string DollarThis = "$this"; +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/ISearchQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/ISearchQueryValidator.cs new file mode 100644 index 000000000..4f81f5f19 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/Interfaces/ISearchQueryValidator.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.Query.Validator; + +/// +/// Provides the interface used to validate a +/// based on the . +/// +public interface ISearchQueryValidator +{ + /// + /// Validates the OData $search query. + /// + /// The $search query. + /// The validation settings. + void Validate(SearchQueryOption searchQueryOption, ODataValidationSettings validationSettings); +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs index 9c911cb96..1b252a4da 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/ODataQueryValidator.cs @@ -71,6 +71,7 @@ public virtual void Validate(ODataQueryOptions options, ODataValidationSettings if (options.Search != null) { ValidateQueryOptionAllowed(AllowedQueryOptions.Search, validationSettings.AllowedQueryOptions); + options.Search.Validate(validationSettings); } if (options.Count != null || options.Request.IsCountRequest()) diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/QueryBinderValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/QueryBinderValidator.cs new file mode 100644 index 000000000..738fbba0e --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/QueryBinderValidator.cs @@ -0,0 +1,167 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Wrapper; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Validator; + +/// +/// Represents a validator used to validate the results of translating an OData parse tree into expressions. +/// +internal static class QueryBinderValidator +{ + private static readonly Type groupByWrapperInterfaceTypeOfT = typeof(IGroupByWrapper<,>); + private static string groupByWrapperInterfaceTypeOfTName = $"{groupByWrapperInterfaceTypeOfT.Namespace}.{groupByWrapperInterfaceTypeOfT.Name.Split('`')[0]}{{TContainer,TWrapper}}"; + private static string dynamicTypeWrapperName = typeof(DynamicTypeWrapper).FullName; + private static readonly Type flatteningWrapperInterfaceTypeOfT = typeof(IFlatteningWrapper<>); + private static string flatteningWrapperInterfaceTypeOfTName = $"{flatteningWrapperInterfaceTypeOfT.Namespace}.{flatteningWrapperInterfaceTypeOfT.Name.Split('`')[0]}{{T}}"; + private static readonly Type computeWrapperInterfaceTypeOfT = typeof(IComputeWrapper<>); + private static string computeWrapperInterfaceTypeOfTName = $"{computeWrapperInterfaceTypeOfT.Namespace}.{computeWrapperInterfaceTypeOfT.Name.Split('`')[0]}{{T}}"; + + /// + /// Validates that the type representing the expression returned by + /// + /// implements and derives from . + /// + /// The type representing the expression returned by + /// . + /// Thrown if + /// does not implement the required interfaces or inherit from the required base class. + public static void ValidateGroupByExpressionType(Type groupByExpressionType) + { + ValidateTransformationExpressionType(groupByExpressionType); + } + + /// + /// Validates that the type representing the expression returned by + /// + /// implements and derives from . + /// + /// The type representing the expression returned by + /// . + /// Thrown if + /// does not implement the required interfaces or inherit from the required base class. + public static void ValidateSelectExpressionType(Type selectExpressionType) + { + ValidateTransformationExpressionType(selectExpressionType); + } + + /// + /// Validates the provided instance to ensure all required properties are set. + /// + /// The instance to validate. + /// Thrown if is null. + /// Thrown if has a null or empty , + /// , or . + public static void ValidateFlatteningResult(AggregationFlatteningResult flatteningResult) + { + if (flatteningResult == null) + { + throw Error.ArgumentNull(nameof(flatteningResult)); + } + + if (flatteningResult?.FlattenedExpression == null) + { + throw Error.Argument( + nameof(flatteningResult), + SRResources.PropertyMustBeSet, + nameof(flatteningResult.FlattenedExpression)); + } + + if (flatteningResult.RedefinedContextParameter == null) + { + throw Error.Argument( + nameof(flatteningResult), + SRResources.PropertyMustBeSetWhenAnotherPropertyIsSet, + nameof(flatteningResult.RedefinedContextParameter), + nameof(flatteningResult.FlattenedExpression)); + } + + if (flatteningResult.FlattenedPropertiesMapping == null || flatteningResult.FlattenedPropertiesMapping.Count == 0) + { + throw Error.Argument( + nameof(flatteningResult), + SRResources.PropertyMustBeSetWhenAnotherPropertyIsSet, + nameof(flatteningResult.FlattenedPropertiesMapping), + nameof(flatteningResult.FlattenedExpression)); + } + } + + /// + /// Validates that the provided type implements and , and inherits from . + /// + /// The type representing the flattened expression returned by + /// . + /// Thrown if + /// does not implement the required interfaces or inherit from the required base class. + public static void ValidateFlattenedExpressionType(Type flattenedExpressionType) + { + ValidateTransformationExpressionType(flattenedExpressionType); + + // Type must implement IFlatteningWrapper interface + if (!flattenedExpressionType.ImplementsInterface(flatteningWrapperInterfaceTypeOfT)) + { + throw Error.InvalidOperation( + SRResources.TypeMustImplementInterface, + flattenedExpressionType.FullName, + flatteningWrapperInterfaceTypeOfTName); + } + } + + /// + /// Validates that the provided type implements and , and inherits from . + /// + /// The type representing the flattened expression returned by + /// . + /// Thrown if + /// does not implement the required interfaces or inherit from the required base class. + public static void ValidateComputeExpressionType(Type computeExpressionType) + { + ValidateTransformationExpressionType(computeExpressionType); + + // Type must implement IComputeWrapper interface + if (!computeExpressionType.ImplementsInterface(computeWrapperInterfaceTypeOfT)) + { + throw Error.InvalidOperation( + SRResources.TypeMustImplementInterface, + computeExpressionType.FullName, + computeWrapperInterfaceTypeOfTName); + } + } + + /// + /// Validates that the provided type implements and inherits from . + /// + /// The type to validate. + /// Thrown if + /// does not implement the required interface or inherit from the required base class. + private static void ValidateTransformationExpressionType(Type transformationExpressionType) + { + // Type must implement IGroupByWrapper interface + if (!transformationExpressionType.ImplementsInterface(groupByWrapperInterfaceTypeOfT)) + { + throw Error.InvalidOperation( + SRResources.TypeMustImplementInterface, + transformationExpressionType.FullName, + groupByWrapperInterfaceTypeOfTName); + } + + // Type must inherit from DynamicTypeWrapper + if (!transformationExpressionType.IsSubclassOf(typeof(DynamicTypeWrapper))) + { + throw Error.InvalidOperation( + SRResources.TypeMustInheritFromType, + transformationExpressionType.FullName, + dynamicTypeWrapperName); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/AggregationWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/AggregationWrapper.cs index 586bfbe17..9cd9e1322 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/AggregationWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/AggregationWrapper.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(DynamicTypeWrapperConverter))] internal class AggregationWrapper : GroupByWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/ComputeWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/ComputeWrapperOfT.cs index 8c49e79f8..5513ed769 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/ComputeWrapperOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/ComputeWrapperOfT.cs @@ -7,23 +7,28 @@ using System; using System.Collections.Generic; -using System.Diagnostics.Contracts; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.OData.Common; using Microsoft.AspNetCore.OData.Edm; using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.AspNetCore.OData.Query.Container; using Microsoft.OData.Edm; namespace Microsoft.AspNetCore.OData.Query.Wrapper; -internal class ComputeWrapper : GroupByWrapper, IEdmEntityObject +/// +[JsonConverter(typeof(DynamicTypeWrapperConverter))] +internal class ComputeWrapper : GroupByWrapper, IGroupByWrapper, IComputeWrapper, IEdmEntityObject { + /// + /// Gets or sets the source object that provides the values used in the compute expression. + /// public T Instance { get; set; } /// - /// The Edm Model associated with the wrapper. + /// Gets or sets the Edm model associated with the wrapper. /// public IEdmModel Model { get; set; } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs index d85e5b47b..cea955b01 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs @@ -21,10 +21,10 @@ public abstract class DynamicTypeWrapper public abstract Dictionary Values { get; } /// - /// Attempts to get the value of the Property called from the underlying Entity. + /// Attempts to get the value of the property called from the underlying entity. /// - /// The name of the Property - /// The new value of the Property + /// The name of the property + /// The new value of the property /// True if successful [SuppressMessage("Microsoft.Design", "CA1007:UseGenericsWhereAppropriate", Justification = "Generics not appropriate here")] public bool TryGetPropertyValue(string propertyName, out object value) diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapperConverter.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapperConverter.cs index c74cba5be..82291e550 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapperConverter.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapperConverter.cs @@ -47,15 +47,15 @@ public override JsonConverter CreateConverter(Type type, JsonSerializerOptions o if (type.IsGenericType) { // Since 'type' is tested in 'CanConvert()', it must be a generic type - Type generaticType = type.GetGenericTypeDefinition(); + Type genericType = type.GetGenericTypeDefinition(); Type elementType = type.GetGenericArguments()[0]; - if (generaticType == typeof(ComputeWrapper<>)) + if (genericType == typeof(ComputeWrapper<>)) { return (JsonConverter)Activator.CreateInstance(typeof(ComputeWrapperConverter<>).MakeGenericType(new Type[] { elementType })); } - if (generaticType == typeof(FlatteningWrapper<>)) + if (genericType == typeof(FlatteningWrapper<>)) { return (JsonConverter)Activator.CreateInstance(typeof(FlatteningWrapperConverter<>).MakeGenericType(new Type[] { elementType })); } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/EntitySetAggregationWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/EntitySetAggregationWrapper.cs index 1634c893c..7f27cdf3a 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/EntitySetAggregationWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/EntitySetAggregationWrapper.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(DynamicTypeWrapperConverter))] internal class EntitySetAggregationWrapper : GroupByWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs index dcf238ceb..d0dec911b 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs @@ -6,15 +6,15 @@ //------------------------------------------------------------------------------ using System; -using System.Diagnostics.Contracts; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.Query.Container; namespace Microsoft.AspNetCore.OData.Query.Wrapper; -internal class FlatteningWrapper : GroupByWrapper +[JsonConverter(typeof(DynamicTypeWrapperConverter))] +internal class FlatteningWrapper : GroupByWrapper, IGroupByWrapper, IFlatteningWrapper { - // TODO: how to use 'Source'? public T Source { get; set; } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs index badac5249..752a8276c 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs @@ -15,18 +15,19 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; -internal class GroupByWrapper : DynamicTypeWrapper +[JsonConverter(typeof(DynamicTypeWrapperConverter))] +internal class GroupByWrapper : DynamicTypeWrapper, IGroupByWrapper { private Dictionary _values; protected static readonly IPropertyMapper DefaultPropertyMapper = new IdentityPropertyMapper(); /// - /// Gets or sets the property container that contains the properties being expanded. + /// Gets or sets the property container that contains the grouping properties. /// public virtual AggregationPropertyContainer GroupByContainer { get; set; } /// - /// Gets or sets the property container that contains the properties being expanded. + /// Gets or sets the property container that contains the aggregation properties. /// public virtual AggregationPropertyContainer Container { get; set; } @@ -80,7 +81,7 @@ private void EnsureValues() if (this.Container != null) { - _values.MergeWithReplace(this.Container.ToDictionary(DefaultPropertyMapper)); + this._values.MergeWithReplace(this.Container.ToDictionary(DefaultPropertyMapper)); } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/IComputeWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IComputeWrapperOfT.cs new file mode 100644 index 000000000..2110d0cfa --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IComputeWrapperOfT.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.Query.Wrapper; + +/// +/// Represents a wrapper for a source object with computed values in an OData query. +/// +/// The type of the source object. +/// +/// The source object type can either be the type of the wrapped entity or another instance of +/// when compute expressions are chained. +/// For example, in the OData query: +/// +/// /Sales?$apply=compute(Amount mul Product/TaxRate as Tax)/compute(Amount add Tax as SalesPrice) +/// +/// In the first compute expression, the source object will be the wrapped "Sale" entity. +/// In the second compute expression, the source object will be an instance of where T is "Sale". +/// +public interface IComputeWrapper +{ + /// + /// Gets or sets the source object that provides the values used in the compute expression. + /// + public T Instance { get; set; } + + /// + /// Gets or sets the Edm model associated with the wrapper. + /// + public IEdmModel Model { get; set; } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs new file mode 100644 index 000000000..217b2cb56 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.Query.Wrapper; + +/// +/// Represents the result of flattening properties referenced in aggregate clause of a $apply query. +/// +/// The type of the source object that contains the properties to be flattened. +/// Flattening is necessary to avoid generation of nested queries by Entity Framework. +public interface IFlatteningWrapper +{ + /// Gets or sets the source object that contains the properties to be flattened. + T Source { get; set; } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs new file mode 100644 index 000000000..b499779fe --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Query.Container; + +namespace Microsoft.AspNetCore.OData.Query.Wrapper; + +/// +/// Represents the result of a $apply query operation. +/// +/// The type of the aggregation property container associated with this group. +/// The type of the group-by wrapper itself, enforcing recursive typing. +public interface IGroupByWrapper + where TContainer : IAggregationPropertyContainer + where TWrapper : IGroupByWrapper +{ + /// Gets or sets the property container that contains the grouping properties. + TContainer GroupByContainer { get; set; } + + /// Gets or sets the property container that contains the aggregation properties. + TContainer Container { get; set; } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByAggregationWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByAggregationWrapper.cs index 681116680..84e8e313e 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByAggregationWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByAggregationWrapper.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(DynamicTypeWrapperConverter))] internal class NoGroupByAggregationWrapper : GroupByWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByWrapper.cs index b14cf7fc1..c704a3b52 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/NoGroupByWrapper.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(DynamicTypeWrapperConverter))] internal class NoGroupByWrapper : GroupByWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllAndExpandOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllAndExpandOfT.cs index c45fa4025..41f5b62e1 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllAndExpandOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllAndExpandOfT.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal class SelectAllAndExpand : SelectExpandWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllOfT.cs index 3b99c5e19..6b689d627 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectAllOfT.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal class SelectAll : SelectExpandWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapper.cs index 77f79189d..7d4e4965e 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapper.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.OData.Edm; using Microsoft.AspNetCore.OData.Formatter.Value; using Microsoft.AspNetCore.OData.Query.Container; @@ -14,6 +15,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal abstract class SelectExpandWrapper : IEdmEntityObject, ISelectExpandWrapper { private static readonly IPropertyMapper DefaultPropertyMapper = new IdentityPropertyMapper(); diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperConverter.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperConverter.cs index f83b1162e..8af2af691 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperConverter.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperConverter.cs @@ -16,8 +16,11 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; /// /// Supports converting types by using a factory pattern. /// -internal class SelectExpandWrapperConverter : JsonConverterFactory +public class SelectExpandWrapperConverter : JsonConverterFactory { + /// + /// The mapper provider. + /// public static readonly Func MapperProvider = (IEdmModel model, IEdmStructuredType type) => new JsonPropertyNameMapper(model, type); @@ -37,12 +40,12 @@ public override bool CanConvert(Type typeToConvert) /* We can use the following codes to limit the compare. * But, use the above ISelectExpandWrapper can unblock the new type later. - Type generaticType = typeToConvert.GetGenericTypeDefinition(); - if (generaticType == typeof(SelectSome<>) || - generaticType == typeof(SelectSomeAndInheritance<>) || - generaticType == typeof(SelectAllAndExpand<>) || - generaticType == typeof(SelectAll<>) || - generaticType == typeof(SelectExpandWrapper<>)) + Type genericType = typeToConvert.GetGenericTypeDefinition(); + if (genericType == typeof(SelectSome<>) || + genericType == typeof(SelectSomeAndInheritance<>) || + genericType == typeof(SelectAllAndExpand<>) || + genericType == typeof(SelectAll<>) || + genericType == typeof(SelectExpandWrapper<>)) { return true; } @@ -65,30 +68,30 @@ public override JsonConverter CreateConverter(Type type, JsonSerializerOptions o } // Since 'type' is tested in 'CanConvert()', it must be a generic type - Type generaticType = type.GetGenericTypeDefinition(); + Type genericType = type.GetGenericTypeDefinition(); Type entityType = type.GetGenericArguments()[0]; - if (generaticType == typeof(SelectSome<>)) + if (genericType == typeof(SelectSome<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SelectSomeConverter<>).MakeGenericType(new Type[] { entityType })); } - if (generaticType == typeof(SelectSomeAndInheritance<>)) + if (genericType == typeof(SelectSomeAndInheritance<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SelectSomeAndInheritanceConverter<>).MakeGenericType(new Type[] { entityType })); } - if (generaticType == typeof(SelectAll<>)) + if (genericType == typeof(SelectAll<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SelectAllConverter<>).MakeGenericType(new Type[] { entityType })); } - if (generaticType == typeof(SelectAllAndExpand<>)) + if (genericType == typeof(SelectAllAndExpand<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SelectAllAndExpandConverter<>).MakeGenericType(new Type[] { entityType })); } - if (generaticType == typeof(SelectExpandWrapper<>)) + if (genericType == typeof(SelectExpandWrapper<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SelectExpandWrapperConverter<>).MakeGenericType(new Type[] { entityType })); } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperOfT.cs index 3d8da440b..f46bc2c9a 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectExpandWrapperOfT.cs @@ -25,6 +25,7 @@ property selection combination possible. */ /// Represents a container class that contains properties that are either selected or expanded using $select and $expand. /// /// The element being selected and expanded. +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal class SelectExpandWrapper : SelectExpandWrapper { /// diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeAndInheritanceOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeAndInheritanceOfT.cs index 73c408e62..0e49df79b 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeAndInheritanceOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeAndInheritanceOfT.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal class SelectSomeAndInheritance : SelectExpandWrapper { } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeOfT.cs index bd23a0eb7..bc4805c84 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/SelectSomeOfT.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper; +[JsonConverter(typeof(SelectExpandWrapperConverter))] internal class SelectSome : SelectAllAndExpand { } diff --git a/src/Microsoft.AspNetCore.OData/Results/IODataResult.cs b/src/Microsoft.AspNetCore.OData/Results/IODataResult.cs new file mode 100644 index 000000000..d6247ceaa --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Results/IODataResult.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; + +namespace Microsoft.AspNetCore.OData.Results; + +/// +/// A contracts for OData result +/// +public interface IODataResult +{ + /// + /// Gets the real value. + /// + object Value { get; } + + /// + /// Gets the expected type. + /// + Type ExpectedType { get; } +} + diff --git a/src/Microsoft.AspNetCore.OData/Results/ODataMetadataResult.cs b/src/Microsoft.AspNetCore.OData/Results/ODataMetadataResult.cs new file mode 100644 index 000000000..25d5fa24e --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Results/ODataMetadataResult.cs @@ -0,0 +1,133 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Microsoft.OData; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.Results; + +/// +/// Defines a contract that represents the result of OData metadata. +/// +internal class ODataMetadataResult : IResult +{ + /// + /// Gets the static instance since we don't need the instance of it. + /// + public static ODataMetadataResult Instance = new ODataMetadataResult(); + + /// + /// Write an HTTP response reflecting the result. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext, nameof(httpContext)); + + var endpoint = httpContext.GetEndpoint(); + ODataMiniMetadata metadata = endpoint.Metadata.GetMetadata(); + IEdmModel model = metadata.Model; + + IServiceProvider sp = GetServiceProvider(httpContext); + ODataMetadataSerializer serializer = sp.GetService() ?? new ODataMetadataSerializer(); + + ODataMessageWriterSettings writerSettings = sp.GetService() ?? new ODataMessageWriterSettings(); + writerSettings.BaseUri = GetBaseAddress(httpContext, metadata); + + writerSettings.ODataUri = new ODataUri + { + ServiceRoot = writerSettings.BaseUri, + }; + writerSettings.Version = metadata.Version; + + HttpResponse response = httpContext.Response; + + SetResponseHeader(httpContext, metadata); + + IODataResponseMessageAsync responseMessage = ODataMessageWrapperHelper.Create(response.Body, response.Headers, sp); + + await using (ODataMessageWriter messageWriter = new ODataMessageWriter(responseMessage, writerSettings, model)) + { + ODataSerializerContext writeContext = new ODataSerializerContext(); + await serializer.WriteObjectAsync(model, typeof(IEdmModel), messageWriter, writeContext).ConfigureAwait(false); + } + } + + private IServiceProvider GetServiceProvider(HttpContext httpContext) + { + var endpoint = httpContext.GetEndpoint(); + ODataMiniMetadata metadata = endpoint.Metadata.GetMetadata(); + return metadata.ServiceProvider; + } + + private static void SetResponseHeader(HttpContext httpContext, ODataMiniMetadata metadata) + { + ODataVersion version = ODataResult.GetODataVersion(httpContext.Request, metadata); + + // Add version header. + httpContext.Response.Headers["OData-Version"] = ODataUtils.ODataVersionToString(version); + + if (IsJson(httpContext)) + { + httpContext.Response.ContentType = "application/json"; + } + else + { + httpContext.Response.ContentType = "application/xml"; + } + } + + internal static bool IsJson(HttpContext context) + { + var acceptHeaders = context.Request.Headers.Accept; + if (acceptHeaders.Any(h => h.Contains("application/json", StringComparison.OrdinalIgnoreCase))) + { + // If Accept header set on Request, we use it. + return true; + } + else if (acceptHeaders.Any(h => h.Contains("application/xml", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + StringValues formatValues; + bool dollarFormat = context.Request.Query.TryGetValue("$format", out formatValues) || context.Request.Query.TryGetValue("format", out formatValues); + if (dollarFormat) + { + if (formatValues.Any(h => h.Contains("application/json", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + else if (formatValues.Any(h => h.Contains("application/xml", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + return false; + } + + private Uri GetBaseAddress(HttpContext httpContext, ODataMiniMetadata metadata) + { + if (metadata.BaseAddressFactory is not null) + { + return metadata.BaseAddressFactory(httpContext); + } + + return ODataOutputFormatter.GetDefaultBaseAddress(httpContext.Request); + } +} + diff --git a/src/Microsoft.AspNetCore.OData/Results/ODataResult.cs b/src/Microsoft.AspNetCore.OData/Results/ODataResult.cs new file mode 100644 index 000000000..80b37f685 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Results/ODataResult.cs @@ -0,0 +1,180 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Microsoft.OData; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.Results; + +/// +/// Defines an implementation that represents the result of an OData format result. +/// It's used for minimal API. +/// +internal class ODataResult : IODataResult, IResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrappered real value. + public ODataResult(object value) + : this(value, value?.GetType()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The wrappered real value. + /// The expected type. + public ODataResult(object value, Type expectedType) + { + Value = value; + ExpectedType = expectedType; + } + + /// + /// Gets the value. + /// + public object Value { get; } + + /// + /// Gets the expected type. + /// + public Type ExpectedType { get; } + + /// + /// Writes an HTTP response reflecting the result. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public virtual async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + Type type = Value.GetType(); + if (type == null) + { + throw Error.ArgumentNull(nameof(type)); + } + type = TypeHelper.GetTaskInnerTypeOrSelf(type); + + if (!TypeHelper.IsCollection(type, out Type elementType)) + { + elementType = type; + } + + HttpRequest request = httpContext.Request; + if (request == null) + { + throw Error.InvalidOperation(SRResources.WriteToResponseAsyncMustHaveRequest); + } + + IEdmModel model = httpContext.GetOrCreateEdmModel(elementType); + + if (elementType.IsSelectExpandWrapper(out Type elementType1)) + { + elementType = elementType1; + } + + IEdmType edmType = model.GetEdmType(elementType); + + var endpoint = httpContext.GetEndpoint(); + ODataMiniMetadata metadata = endpoint.Metadata.GetMetadata(); + + var pathFactory = metadata?.PathFactory ?? ODataMiniMetadata.DefaultPathFactory; + + IODataFeature odataFeature = httpContext.ODataFeature(); + odataFeature.Path = pathFactory(httpContext, elementType); + + HttpResponse response = httpContext.Response; + Uri baseAddress = GetBaseAddress(httpContext, metadata); + MediaTypeHeaderValue contentType = GetContentType(response.Headers[HeaderNames.ContentType].FirstOrDefault()); + + if (odataFeature.Services is null) + { + odataFeature.Services = metadata.ServiceProvider; + } + + IODataSerializerProvider serializerProvider = odataFeature.Services.GetRequiredService(); + + ODataVersion version = GetODataVersion(request, metadata); + + // Add version header. + WriteResponseHeaders(httpContext, metadata, version); + + await ODataOutputFormatterHelper.WriteToStreamAsync( + type, + Value, + model, + version, + baseAddress, + contentType, + request, + request.Headers, + serializerProvider).ConfigureAwait(false); + } + + private void WriteResponseHeaders(HttpContext context, ODataMiniMetadata metadata, ODataVersion version) + { + context.Response.Headers[HeaderNames.ContentType] = "application/json"; + context.Response.Headers["OData-Version"] = ODataUtils.ODataVersionToString(version); + } + + private Uri GetBaseAddress(HttpContext httpContext, ODataMiniMetadata options) + { + if (options.BaseAddressFactory is not null) + { + return options.BaseAddressFactory(httpContext); + } + + return ODataOutputFormatter.GetDefaultBaseAddress(httpContext.Request); + } + + internal static ODataVersion GetODataVersion(HttpRequest request, ODataMiniMetadata options) + { + ODataVersion? version = request.ODataMaxServiceVersion() ?? + request.ODataMinServiceVersion() ?? + request.ODataServiceVersion(); + + if (version is not null) + { + return version.Value; + } + + if (options is not null) + { + return options.Version; + } + + return ODataVersionConstraint.DefaultODataVersion; + } + + private MediaTypeHeaderValue GetContentType(string contentTypeValue) + { + MediaTypeHeaderValue contentType = null; + if (!string.IsNullOrEmpty(contentTypeValue)) + { + MediaTypeHeaderValue.TryParse(contentTypeValue, out contentType); + } + + return contentType; + } +} + diff --git a/src/Microsoft.AspNetCore.OData/Results/ODataServiceDocumentResult.cs b/src/Microsoft.AspNetCore.OData/Results/ODataServiceDocumentResult.cs new file mode 100644 index 000000000..97b73bbf5 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Results/ODataServiceDocumentResult.cs @@ -0,0 +1,82 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.Results; + +/// +/// Defines a contract that represents the result of OData service document. +/// +internal class ODataServiceDocumentResult : IResult +{ + /// + /// Gets the static instance since we don't need the instance of it. + /// + public static ODataServiceDocumentResult Instance = new ODataServiceDocumentResult(); + + /// + /// Write an HTTP response reflecting the result. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext, nameof(httpContext)); + + var endpoint = httpContext.GetEndpoint(); + ODataMiniMetadata metadata = endpoint.Metadata.GetMetadata(); + IServiceProvider sp = metadata.ServiceProvider; + IEdmModel model = metadata.Model; + + ODataServiceDocument serviceDocument = model.GenerateServiceDocument(); + + ODataServiceDocumentSerializer serializer = sp.GetService() ?? new ODataServiceDocumentSerializer(); + + ODataMessageWriterSettings writerSettings = sp.GetService() ?? new ODataMessageWriterSettings(); + writerSettings.BaseUri = GetBaseAddress(httpContext, metadata); + + writerSettings.ODataUri = new ODataUri + { + ServiceRoot = writerSettings.BaseUri, + }; + + HttpResponse response = httpContext.Response; + + ODataVersion version = ODataResult.GetODataVersion(httpContext.Request, metadata); + httpContext.Response.ContentType = "application/json"; + + // Add version header. + httpContext.Response.Headers["OData-Version"] = ODataUtils.ODataVersionToString(version); + + IODataResponseMessageAsync responseMessage = ODataMessageWrapperHelper.Create(response.Body, response.Headers, sp); + + await using (ODataMessageWriter messageWriter = new ODataMessageWriter(responseMessage, writerSettings, model)) + { + ODataSerializerContext writeContext = new ODataSerializerContext(); + await serializer.WriteObjectAsync(serviceDocument, typeof(ODataServiceDocument), messageWriter, writeContext).ConfigureAwait(false); + } + } + + private Uri GetBaseAddress(HttpContext httpContext, ODataMiniMetadata metadata) + { + if (metadata.BaseAddressFactory is not null) + { + return metadata.BaseAddressFactory(httpContext); + } + + return ODataOutputFormatter.GetDefaultBaseAddress(httpContext.Request); + } +} + diff --git a/src/Microsoft.AspNetCore.OData/Results/PageResultOfT.cs b/src/Microsoft.AspNetCore.OData/Results/PageResultOfT.cs index d7607f149..bd54b23b1 100644 --- a/src/Microsoft.AspNetCore.OData/Results/PageResultOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Results/PageResultOfT.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.Serialization; +using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.OData.Results; @@ -23,6 +24,7 @@ namespace Microsoft.AspNetCore.OData.Results; /// [SuppressMessage("Microsoft.Naming", "CA1710:IdentifiersShouldHaveCorrectSuffix", Justification = "Collection suffix not appropriate")] [DataContract] +[JsonConverter(typeof(PageResultValueConverter))] public class PageResult : PageResult, IEnumerable { /// diff --git a/src/Microsoft.AspNetCore.OData/Results/PageResultValueConverter.cs b/src/Microsoft.AspNetCore.OData/Results/PageResultValueConverter.cs index 53811754c..2d70f747d 100644 --- a/src/Microsoft.AspNetCore.OData/Results/PageResultValueConverter.cs +++ b/src/Microsoft.AspNetCore.OData/Results/PageResultValueConverter.cs @@ -21,8 +21,8 @@ public override bool CanConvert(Type typeToConvert) return false; } - Type generaticType = typeToConvert.GetGenericTypeDefinition(); - return generaticType == typeof(PageResult<>); + Type genericType = typeToConvert.GetGenericTypeDefinition(); + return genericType == typeof(PageResult<>); } /// @@ -34,10 +34,10 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) { // Since 'type' is tested in 'CanConvert()', it must be a generic type - Type generaticType = type.GetGenericTypeDefinition(); + Type genericType = type.GetGenericTypeDefinition(); Type entityType = type.GetGenericArguments()[0]; - if (generaticType == typeof(PageResult<>)) + if (genericType == typeof(PageResult<>)) { return (JsonConverter)Activator.CreateInstance(typeof(PageResultConverter<>).MakeGenericType(new Type[] { entityType })); } @@ -57,21 +57,26 @@ public override PageResult Read(ref Utf8JsonReader reader, Type typeToC public override void Write(Utf8JsonWriter writer, PageResult value, JsonSerializerOptions options) { writer.WriteStartObject(); - writer.WritePropertyName("items"); + writer.WritePropertyName(ConvertName(options, "items")); JsonSerializer.Serialize(writer, value.Items, options); if (value.NextPageLink != null) { - writer.WritePropertyName("nextpagelink"); + writer.WritePropertyName(ConvertName(options, "nextpagelink")); writer.WriteStringValue(value.NextPageLink.OriginalString); } if (value.Count != null) { - writer.WritePropertyName("count"); + writer.WritePropertyName(ConvertName(options, "count")); writer.WriteNumberValue(value.Count.Value); } writer.WriteEndObject(); } + + private static string ConvertName(JsonSerializerOptions options, string name) + => options != null && options.PropertyNamingPolicy != null + ? options.PropertyNamingPolicy.ConvertName(name) + : name; } diff --git a/src/Microsoft.AspNetCore.OData/Results/SingleResultOfT.cs b/src/Microsoft.AspNetCore.OData/Results/SingleResultOfT.cs index 4b823a8c7..c17a86395 100644 --- a/src/Microsoft.AspNetCore.OData/Results/SingleResultOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Results/SingleResultOfT.cs @@ -6,6 +6,7 @@ //------------------------------------------------------------------------------ using System.Linq; +using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.OData.Results; @@ -14,6 +15,7 @@ namespace Microsoft.AspNetCore.OData.Results; /// [EnableQuery]. /// /// The type of the data in the data source. +[JsonConverter(typeof(SingleResultValueConverter))] public sealed class SingleResult : SingleResult { /// diff --git a/src/Microsoft.AspNetCore.OData/Results/SingleResultValueConverter.cs b/src/Microsoft.AspNetCore.OData/Results/SingleResultValueConverter.cs index 3e71a9fd7..b43710e42 100644 --- a/src/Microsoft.AspNetCore.OData/Results/SingleResultValueConverter.cs +++ b/src/Microsoft.AspNetCore.OData/Results/SingleResultValueConverter.cs @@ -23,8 +23,8 @@ public override bool CanConvert(Type typeToConvert) return false; } - Type generaticType = typeToConvert.GetGenericTypeDefinition(); - return generaticType == typeof(SingleResult<>); + Type genericType = typeToConvert.GetGenericTypeDefinition(); + return genericType == typeof(SingleResult<>); } /// @@ -36,10 +36,10 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) { // Since 'type' is tested in 'CanConvert()', it must be a generic type - Type generaticType = type.GetGenericTypeDefinition(); + Type genericType = type.GetGenericTypeDefinition(); Type entityType = type.GetGenericArguments()[0]; - if (generaticType == typeof(SingleResult<>)) + if (genericType == typeof(SingleResult<>)) { return (JsonConverter)Activator.CreateInstance(typeof(SingleResultConverter<>).MakeGenericType(new Type[] { entityType })); } diff --git a/src/Microsoft.AspNetCore.OData/Routing/Conventions/AttributeRoutingConvention.cs b/src/Microsoft.AspNetCore.OData/Routing/Conventions/AttributeRoutingConvention.cs index d2e95bec0..bf48ea591 100644 --- a/src/Microsoft.AspNetCore.OData/Routing/Conventions/AttributeRoutingConvention.cs +++ b/src/Microsoft.AspNetCore.OData/Routing/Conventions/AttributeRoutingConvention.cs @@ -199,7 +199,7 @@ private SelectorModel CreateActionSelectorModel(string prefix, IEdmModel model, // Whether we throw exception or mark it as warning is a design pattern. // throw new ODataException(warning); - _logger.LogWarning(warning); + _logger.LogWarning("{Message}", warning); return null; } } diff --git a/src/Microsoft.AspNetCore.OData/Routing/ODataPathSegmentHandler.cs b/src/Microsoft.AspNetCore.OData/Routing/ODataPathSegmentHandler.cs index 33374213f..0442cc2f3 100644 --- a/src/Microsoft.AspNetCore.OData/Routing/ODataPathSegmentHandler.cs +++ b/src/Microsoft.AspNetCore.OData/Routing/ODataPathSegmentHandler.cs @@ -318,7 +318,7 @@ internal static string ConvertKeysToString(IEnumerable - TranslateNode(keyValuePair.Value).EscapeBackSlashUriString()).ToArray()); + TranslateNode(keyValuePair.Value)).ToArray()); } } @@ -328,7 +328,7 @@ internal static string ConvertKeysToString(IEnumerable (keyValuePair.Key + "=" + - TranslateNode(keyValuePair.Value).EscapeBackSlashUriString())).ToArray()); + TranslateNode(keyValuePair.Value))).ToArray()); } internal static string TranslateNode(object node) @@ -364,6 +364,17 @@ internal static string TranslateNode(object node) return parameterAliasNode.Alias; } - return ODataUriUtils.ConvertToUriLiteral(node, ODataVersion.V4); + string uriLiteral = ODataUriUtils.ConvertToUriLiteral(node, ODataVersion.V4); + + if (node is string && uriLiteral.Length > 2) + { + // ODataUriUtils.ConvertToUriLiteral does not encoded the value. + // The result for keys to use on the wire (like odata.id, odata.readlink, odata.editLink and Location header) should be encoded, + // but still wrapped in unencoded ' + // This to allign with how ODLs UriParser construct OData ID Uri's + return '\'' + Uri.EscapeDataString(uriLiteral.Substring(1, uriLiteral.Length - 2)) + '\''; + } + + return uriLiteral; } } diff --git a/src/Microsoft.AspNetCore.OData/Routing/Template/KeySegmentTemplate.cs b/src/Microsoft.AspNetCore.OData/Routing/Template/KeySegmentTemplate.cs index 57ac84bd9..aa7512181 100644 --- a/src/Microsoft.AspNetCore.OData/Routing/Template/KeySegmentTemplate.cs +++ b/src/Microsoft.AspNetCore.OData/Routing/Template/KeySegmentTemplate.cs @@ -208,7 +208,7 @@ public override bool TryTranslate(ODataTemplateTranslateContext context) ILoggerFactory loggerFactory = context.HttpContext?.RequestServices?.GetService(); if (loggerFactory != null) { - loggerFactory.CreateLogger().LogError(message, ex); + loggerFactory.CreateLogger().LogError(ex, "{Message}", message); return false; } else diff --git a/src/Microsoft.AspNetCore.OData/Routing/Template/SegmentTemplateHelpers.cs b/src/Microsoft.AspNetCore.OData/Routing/Template/SegmentTemplateHelpers.cs index 27179176f..a4f6906d8 100644 --- a/src/Microsoft.AspNetCore.OData/Routing/Template/SegmentTemplateHelpers.cs +++ b/src/Microsoft.AspNetCore.OData/Routing/Template/SegmentTemplateHelpers.cs @@ -100,7 +100,7 @@ public static IList Match(ODataTemplateTranslateConte ILoggerFactory loggerFactory = context.HttpContext?.RequestServices?.GetService(); if (loggerFactory != null) { - loggerFactory.CreateLogger("ODataFunctionParameterMatcher").LogError(message, ex); + loggerFactory.CreateLogger("ODataFunctionParameterMatcher").LogError(ex, "{Message}", message); return null; } else diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs new file mode 100644 index 000000000..bdb82636c --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs @@ -0,0 +1,107 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +[Route("default")] +[Route("custom")] +[Route("nonflattening")] +public class InMemorySalesController : ODataController +{ + private readonly DollarApplyDbContext db; + + public InMemorySalesController(DollarApplyDbContext db) + { + this.db = db; + DollarApplyDbContextInitializer.SeedDatabase(this.db); + } + + [EnableQuery] + [Route("Sales")] + public ActionResult> GetInMemorySales() + { + return db.Sales; + } +} + +[Route("defaultsql")] +[Route("customsql")] +[Route("nonflatteningsql")] +public class SqlSalesController : ODataController +{ + private readonly DollarApplySqlDbContext db; + + public SqlSalesController(DollarApplySqlDbContext db) + { + this.db = db; + DollarApplyDbContextInitializer.SeedDatabase(this.db); + } + + [EnableQuery] + [HttpGet("Sales")] + public ActionResult> GetSqlSales() + { + return db.Sales; + } +} + +[Route("default")] +[Route("custom")] +[Route("nonflattening")] +public class InMemoryProductsController : ODataController +{ + private readonly DollarApplyDbContext db; + + public InMemoryProductsController(DollarApplyDbContext db) + { + this.db = db; + DollarApplyDbContextInitializer.SeedDatabase(this.db); + } + + [EnableQuery] + [Route("Products")] + public ActionResult> Get() + { + return db.Products; + } +} + +[Route("defaultsql")] +[Route("customsql")] +[Route("nonflatteningsql")] +public class SqlProductsController : ODataController +{ + private readonly DollarApplySqlDbContext db; + + public SqlProductsController(DollarApplySqlDbContext db) + { + this.db = db; + DollarApplyDbContextInitializer.SeedDatabase(this.db); + } + + [EnableQuery] + [Route("Products")] + public ActionResult> Get() + { + return db.Products; + } +} + +public class EmployeesController : ODataController +{ + [EnableQuery] + public ActionResult> Get() + { + return DataSource.Employees; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs new file mode 100644 index 000000000..6525cda4a --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +internal static class DollarApplyCustomMethods +{ + public static double StdDev(IEnumerable values) + { + var count = values.Count(); + if (count <= 0) + { + return 0; + } + + var average = values.Average(); + var sumOfSquaresOfDifferences = values.Sum(value => (value - average) * (value - average)); + + return Math.Sqrt((double)sumOfSquaresOfDifferences / (count - 1)); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs new file mode 100644 index 000000000..2b904130b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs @@ -0,0 +1,67 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +public class Category +{ + public string Id { get; set; } + public string Name { get; set; } + public List Products { get; set; } +} + +public class Product +{ + public string Id { get; set; } + public string Name { get; set; } + public Category Category { get; set; } + public decimal TaxRate { get; set; } + public List Sales { get; set; } +} + +public class Customer +{ + public string Id { get; set; } + public string Name { get; set; } + public List Sales { get; set; } +} + +public class Sale +{ + public int Id { get; set; } + public int Year { get; set; } + public string Quarter { get; set; } + public Customer Customer { get; set; } + public Product Product { get; set; } + public decimal Amount { get; set; } +} + +public class Employee +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal BaseSalary { get; set; } + public Address Address { get; set; } + public Company Company { get; set; } + public Dictionary DynamicProperties { get; set; } +} + +public class Address +{ + public string City { get; set; } + public string State { get; set; } +} + +public class Company +{ + [Key] + public string Name { get; set; } + public Employee VP { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs new file mode 100644 index 000000000..fd2b1ad06 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs @@ -0,0 +1,230 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +public class DollarApplyDbContext : DbContext +{ + public DollarApplyDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Categories { get; set; } + + public DbSet Products { get; set; } + + public DbSet Customers { get; set; } + + public DbSet Sales { get; set; } +} + +public class DollarApplySqlDbContext : DbContext +{ + public DollarApplySqlDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(d => d.Property(p => p.Id).ValueGeneratedNever()); + } + + public virtual DbSet Categories { get; set; } + + public virtual DbSet Products { get; set; } + + public virtual DbSet Customers { get; set; } + + public virtual DbSet Sales { get; set; } +} + +public class DataSource +{ + private static readonly Company company; + private static readonly List employees; + + static DataSource() + { + company = new Company + { + Name = "Northwind Traders" + }; + + employees = new List + { + new Employee + { + Id = 1, + Name = "Nancy Davolio", + BaseSalary = 1300, + Address = new Address + { + City = "Seattle", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 250 }, + { "Gender", "Female" } + } + }, + new Employee + { + Id = 2, + Name = "Andrew Fuller", + BaseSalary = 1500, + Address = new Address + { + City = "Tacoma", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 190 }, + { "Gender", "Male" } + } + }, + new Employee + { + Id = 3, + Name = "Janet Leverling", + BaseSalary = 1100, + Address = new Address + { + City = "Kirkland", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 370 }, + { "Gender", "Female" } + } + }, + new Employee + { + Id = 9, + Name = "Anne Dodsworth", + BaseSalary = 1000, + Address = new Address + { + City = "London", + State = "UK" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 310 }, + { "Gender", "Female" } + } + }, + }; + + company.VP = employees.First(e => e.Id == 2); + } + + public static List Employees => employees; +} + +internal static class DollarApplyDbContextInitializer +{ + public static void SeedDatabase(DollarApplyDbContext db) + { + db.Database.EnsureCreated(); + + if (!db.Sales.Any()) + { + var (categories, products, customers, sales) = Generate(); + + db.Categories.AddRange(categories); + db.Products.AddRange(products); + db.Customers.AddRange(customers); + db.Sales.AddRange(sales); + + db.SaveChanges(); + } + } + + public static void SeedDatabase(DollarApplySqlDbContext db) + { + db.Database.EnsureCreated(); + + if (!db.Sales.Any()) + { + var (categories, products, customers, sales) = Generate(); + + db.Categories.AddRange(categories); + db.Products.AddRange(products); + db.Customers.AddRange(customers); + db.Sales.AddRange(sales); + + db.SaveChanges(); + } + } + + private static (Category[] Categories, Product[] Products, Customer[] Customers, Sale[] Sales) Generate() + { + var pg1Category = new Category { Id = "PG1", Name = "Food" }; + var pg2Category = new Category { Id = "PG2", Name = "Non-Food" }; + + Category[] categories = new[] + { + pg1Category, + pg2Category + }; + + var p1Product = new Product { Id = "P1", Name = "Sugar", Category = pg1Category, TaxRate = 0.06m }; + var p2Product = new Product { Id = "P2", Name = "Coffee", Category = pg1Category, TaxRate = 0.06m }; + var p3Product = new Product { Id = "P3", Name = "Paper", Category = pg2Category, TaxRate = 0.14m }; + var p4Product = new Product { Id = "P4", Name = "Pencil", Category = pg2Category, TaxRate = 0.14m }; + + Product[] products = new[] + { + p1Product, + p2Product, + p3Product, + p4Product + }; + + var c1Customer = new Customer { Id = "C1", Name = "Joe" }; + var c2Customer = new Customer { Id = "C2", Name = "Sue" }; + var c3Customer = new Customer { Id = "C3", Name = "Sue" }; + var c4Customer = new Customer { Id = "C4", Name = "Luc" }; + + Customer[] customers = new[] + { + c1Customer, + c2Customer, + c3Customer, + c4Customer + }; + + Sale[] sales = new[] + { + new Sale { Id = 1, Year = 2022, Quarter = "2022-1", Customer = c1Customer, Product = p3Product, Amount = 1 }, + new Sale { Id = 2, Year = 2022, Quarter = "2022-2", Customer = c1Customer, Product = p1Product, Amount = 2 }, + new Sale { Id = 3, Year = 2022, Quarter = "2022-3", Customer = c1Customer, Product = p2Product, Amount = 4 }, + new Sale { Id = 4, Year = 2022, Quarter = "2022-1", Customer = c2Customer, Product = p2Product, Amount = 8 }, + new Sale { Id = 5, Year = 2022, Quarter = "2022-4", Customer = c2Customer, Product = p3Product, Amount = 4 }, + new Sale { Id = 6, Year = 2022, Quarter = "2022-2", Customer = c3Customer, Product = p1Product, Amount = 2 }, + new Sale { Id = 7, Year = 2022, Quarter = "2022-3", Customer = c3Customer, Product = p3Product, Amount = 1 }, + new Sale { Id = 8, Year = 2022, Quarter = "2022-4", Customer = c3Customer, Product = p3Product, Amount = 2 }, + }; + + return (categories, products, customers, sales); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs new file mode 100644 index 000000000..b36ce5e1f --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs @@ -0,0 +1,47 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +public class DollarApplyEdmModel +{ + public static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + + builder.EntitySet("Categories"); + builder.EntitySet("Products"); + builder.EntitySet("Customers"); + builder.EntitySet("Sales"); + builder.EntitySet("Employees"); + builder.ComplexType
(); + builder.Singleton("Company"); + + var model = builder.GetEdmModel(); + + var stdevMethodAnnotation = new CustomAggregateMethodAnnotation(); + var stdevMethod = new Dictionary + { + { + typeof(decimal), + typeof(DollarApplyCustomMethods).GetMethod("StdDev", BindingFlags.Static | BindingFlags.Public) + } + }; + + stdevMethodAnnotation.AddMethod("custom.stdev", stdevMethod); + model.SetAnnotationValue(model, stdevMethodAnnotation); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs new file mode 100644 index 000000000..3ba5c7023 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs @@ -0,0 +1,2582 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Expressions; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.E2E.Tests.Extensions; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply; + +public class DollarApplyTests : WebApiTestBase +{ + public DollarApplyTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = DollarApplyEdmModel.GetEdmModel(); + + string connectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Integrated Security=True;Initial Catalog=DollarApplySqlDbContext"; + services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddDbContext(options => options.UseSqlServer(connectionString)); + services.ConfigureControllers( + typeof(InMemorySalesController), + typeof(SqlSalesController), + typeof(EmployeesController), + typeof(InMemoryProductsController), + typeof(SqlProductsController)); + + services.AddControllers().AddOData(options => + { + options.EnableQueryFeatures(); + options.EnableAttributeRouting = true; + + // Due to how route matching works, `defaultsql` and `customsql` must be registered before `default` and `custom` + options.AddRouteComponents("defaultsql", model); + options.AddRouteComponents("customsql", model, (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddSingleton(); + }); + options.AddRouteComponents("nonflatteningsql", model, (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddSingleton(); + }); + + options.AddRouteComponents("default", model); + options.AddRouteComponents("custom", model, (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddSingleton(); + }); + options.AddRouteComponents("nonflattening", model, (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddSingleton(); + }); + }).AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new TestGroupByWrapperConverter()); + }); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Equal("2022-1", Assert.IsType(result[0]).Value("Quarter")); + Assert.Equal("2022-2", Assert.IsType(result[1]).Value("Quarter")); + Assert.Equal("2022-3", Assert.IsType(result[2]).Value("Quarter")); + Assert.Equal("2022-4", Assert.IsType(result[3]).Value("Quarter")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("2022", resultAt0.Value("Year")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("2022", resultAt1.Value("Year")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("2022", resultAt2.Value("Year")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal("2022", resultAt3.Value("Year")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleNestedPropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name,Customer/Id))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + } + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByHybridNestedPropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name,Customer/Id))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedAndNonNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Product/Name))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Amount with sum as SumAmount)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(24m, Assert.Single(result).Value("SumAmount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Product/TaxRate with min as MinTaxRate)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(0.06m, Assert.Single(result).Value("MinTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.Single(result); + Assert.Equal(3m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate($count as SalesCount)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(8, Assert.Single(result).Value("SalesCount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregateNestedPropertyWithCountDistinctAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Product/Name with countdistinct as DistinctProducts)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, Assert.Single(result).Value("DistinctProducts")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(9m, resultAt0.Value("SumAmount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(5m, resultAt2.Value("SumAmount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(6m, resultAt3.Value("SumAmount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(0.06m, resultAt0.Value("MinTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(0.14m, resultAt3.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(4.5m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2.5m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(3m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt3.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(9m, resultAt0.Value("SumAmount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(5m, resultAt2.Value("SumAmount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(6m, resultAt3.Value("SumAmount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(0.06m, resultAt0.Value("MinTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(0.14m, resultAt3.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(4.5m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2.5m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(3m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt3.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"SumAmount\":8(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":4(?:\\.0+)?,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":12(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":2(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":2(?:\\.0+)?,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":6(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + Assert.Matches("\\{\"SumAmount\":1(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":8(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":3(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":1(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":8(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":1\\.5(?:0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"SumAmount\":8(?:\\.0+)?,\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":16(?:\\.0+)?,\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":2(?:\\.0+)?,\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":4(?:\\.0+)?,\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByHybridNestedPropertiesAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"SumAmount\":1(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":6(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":8(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":3(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByHybridNestedPropertiesAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.06,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MinTaxRate\":0\\.14,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByHybridNestedPropertiesAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":1(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":3(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":8(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":4(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.06,\"AverageAmount\":2(?:\\.0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"MaxTaxRate\":0\\.14,\"AverageAmount\":1\\.5(?:0+)?,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with sum as SumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"SumAmount\":1(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"SumAmount\":4(?:\\.0+)?,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"SumAmount\":4(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"SumAmount\":8(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"SumAmount\":6(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"SumAmount\":1(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"MinTaxRate\":0\\.14,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"MinTaxRate\":0\\.06,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"MinTaxRate\":0\\.06,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"MinTaxRate\":0\\.06,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"MinTaxRate\":0\\.14,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"MinTaxRate\":0\\.14,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregateMultiplePropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"MaxTaxRate\":0\\.14,\"AverageAmount\":1(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"MaxTaxRate\":0\\.06,\"AverageAmount\":2(?:\\.0+)?,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"MaxTaxRate\":0\\.06,\"AverageAmount\":4(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"MaxTaxRate\":0\\.06,\"AverageAmount\":8(?:\\.0+)?,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"MaxTaxRate\":0\\.14,\"AverageAmount\":3(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"MaxTaxRate\":0\\.14,\"AverageAmount\":1(?:\\.0+)?,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("SalesCount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("SalesCount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2, resultAt3.Value("SalesCount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(2, resultAt0.Value("SalesCount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2, resultAt2.Value("SalesCount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(2, resultAt3.Value("SalesCount")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"SalesCount\":4,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":2,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":2,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"SalesCount\":4,\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}\\}\\}", content); + Assert.Matches("\\{\"SalesCount\":4,\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultipleHybridNestedPropertiesAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}}\\}", content); + Assert.Matches("\\{\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C1\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C2\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}}\\}", content); + Assert.Matches("\\{\"SalesCount\":1,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}}\\}", content); + Assert.Matches("\\{\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C3\"\\},\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedAndNonNestedPropertiesAndAggregateDollarCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Product/Name),aggregate($count as SalesCount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"SalesCount\":1,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"SalesCount\":2,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"SalesCount\":1,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"SalesCount\":1,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"SalesCount\":2,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"SalesCount\":1,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregateNestedPropertyWithCountDistinctAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate(Product/Name with countdistinct as DistinctProducts))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("DistinctProducts")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(1, resultAt1.Value("DistinctProducts")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("DistinctProducts")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(1, resultAt3.Value("DistinctProducts")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiplePropertiesAndAggregateNestedPropertyWithCountDistinctAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter,Year),aggregate(Product/Name with countdistinct as DistinctProducts))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("DistinctProducts")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(1, resultAt1.Value("DistinctProducts")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("DistinctProducts")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(1, resultAt3.Value("DistinctProducts")); + Assert.Equal(2022, resultAt3.Value("Year")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregatePrimitivePropertyWithCustomAggregateFunctionAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Amount with custom.stdev as StdDev)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2.32992949004287, Assert.Single(result).Value("StdDev")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestAggregateNestedPropertyWithCustomAggregateFunctionAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=aggregate(Product/TaxRate with custom.stdev as StdDev)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(0.042761798705987904, Assert.Single(result).Value("StdDev")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByMultiNestedPropertyAndAggregatePrimitivePropertyWithCustomAggregateFunctionAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with custom.stdev as StdDev))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"StdDev\":1\\.4142135623730951,\"Product\":\\{\"Category\":\\{\"Name\":\"Non-Food\"\\}}\\}", content); + Assert.Matches("\\{\"StdDev\":2\\.8284271247461903,\"Product\":\\{\"Category\":\\{\"Name\":\"Food\"\\}}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByNestedPropertyAndAggregateNestedPropertyWithCustomAggregateFunctionAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Customer/Id),aggregate(Product/TaxRate with custom.stdev as StdDev))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(0.046188021535170064, resultAt0.Value("StdDev")); + Assert.Equal("C2", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(0.0565685424949238, resultAt1.Value("StdDev")); + Assert.Equal("C3", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(0.046188021535170064, resultAt2.Value("StdDev")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByDynamicPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Gender))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("Female", Assert.IsType(result[0]).Value("Gender")); + Assert.Equal("Male", Assert.IsType(result[1]).Value("Gender")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestAggregateSingleDynamicPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=aggregate(Commission with average as AverageCommission)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(280, Assert.Single(result).Value("AverageCommission")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByDynamicPrimitivePropertyAndAggregateDynamicPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Gender),aggregate(Commission with sum as SumCommission))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Female", resultAt0.Value("Gender")); + Assert.Equal(930m, resultAt0.Value("SumCommission")); + Assert.Equal("Male", resultAt1.Value("Gender")); + Assert.Equal(190m, resultAt1.Value("SumCommission")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByDynamicPrimitivePropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Female", resultAt0.Value("Gender")); + Assert.Equal(1300m, resultAt0.Value("MaxSalary")); + Assert.Equal("Male", resultAt1.Value("Gender")); + Assert.Equal(1500m, resultAt1.Value("MaxSalary")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyThenGroupByPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate(Amount with min as MinAmount))/groupby((Quarter))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Equal("2022-1", Assert.IsType(result[0]).Value("Quarter")); + Assert.Equal("2022-2", Assert.IsType(result[1]).Value("Quarter")); + Assert.Equal("2022-3", Assert.IsType(result[2]).Value("Quarter")); + Assert.Equal("2022-4", Assert.IsType(result[3]).Value("Quarter")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeNestedStringPropertyLengthThenAggregateComputedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength with sum as CombinedProductNameLength)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(42, Assert.Single(result).Value("CombinedProductNameLength")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeNestedStringPropertyLengthThenAggregateSumOfComputedAndPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength add Id with sum as CombinedProductNameLength)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(78, Assert.IsType(Assert.Single(result)).Value("CombinedProductNameLength")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregatePrimitiveAndComputedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/groupby((Product/Name),aggregate(Id with sum as Total, ProductNameLength with max as MaxProductNameLength))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"MaxProductNameLength\":5,\"Total\":21,\"Product\":\\{\"Name\":\"Paper\"\\}\\}", content); + Assert.Matches("\\{\"MaxProductNameLength\":5,\"Total\":8,\"Product\":\\{\"Name\":\"Sugar\"\\}\\}", content); + Assert.Matches("\\{\"MaxProductNameLength\":6,\"Total\":7,\"Product\":\\{\"Name\":\"Coffee\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByMultipleNestedPropertiesThenGroupByNestedPropertyAndAggregateNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Address/City,Address/State))/groupby((Address/State),aggregate(Address/City with max as MaxCity))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal("London", resultAt1.Value("MaxCity")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAggregateMultiNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Company/VP/Address/City,Company/VP/Address/State))/groupby((Company/VP/Address/State),aggregate(Company/VP/Address/City with max as MaxCity))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + var address = Assert.IsType(vp.GetValue("Address")); + Assert.Equal("WA", address.Value("State")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAggregateMultiNestedPropertyWithMaxAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/BaseSalary),aggregate(Company/VP/Name with max as MaxName))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal("Andrew Fuller", resultAt0.Value("MaxName")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + Assert.Equal(1500m, vp.Value("BaseSalary")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAverageMultiNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/Name),aggregate(Company/VP/BaseSalary with average as AverageBaseSalary))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal(1500m, resultAt0.Value("AverageBaseSalary")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + Assert.Equal("Andrew Fuller", vp.Value("Name")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByNestedPropertyAndAggregatePrimitivePropertyThenGroupByNestedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Address/State),aggregate(BaseSalary with min as MinBaseSalary))/groupby((Address/State))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var addressAt0 = Assert.IsType(result[0]).GetValue("Address"); + var addressAt1 = Assert.IsType(result[1]).GetValue("Address"); + Assert.Equal("WA", Assert.IsType(addressAt0).Value("State")); + Assert.Equal("UK", Assert.IsType(addressAt1).Value("State")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeStringPropertyLengthThenAggregateComputedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen with sum as TotalLen)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(55, Assert.IsType(Assert.Single(result)).Value("TotalLen")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeStringPropertyLengthThenAggregateSumOfComputedPropertyAndPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen add Id with sum as TotalLen)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(70, Assert.IsType(Assert.Single(result)).Value("TotalLen")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregateComputedAndPrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(length(Address/State) as StateLen)/groupby((Address/State),aggregate(Id with sum as Total,StateLen with max as MaxStateLen))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal(2, resultAt0.Value("MaxStateLen")); + Assert.Equal(6, resultAt0.Value("Total")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal(2, resultAt1.Value("MaxStateLen")); + Assert.Equal(9, resultAt1.Value("Total")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeStringPropertyLengthThenGroupByComputedPropertyAndAggregatePrimitivePropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(length(Name) as NameLen)/groupby((NameLen),aggregate(Id with sum as Total))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal(13, resultAt0.Value("NameLen")); + Assert.Equal(3, resultAt0.Value("Total")); + Assert.Equal(15, resultAt1.Value("NameLen")); + Assert.Equal(3, resultAt1.Value("Total")); + Assert.Equal(14, resultAt2.Value("NameLen")); + Assert.Equal(9, resultAt2.Value("Total")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregateComputedAndNestedPropertiesAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(length(Address/City) as CityLength)/groupby((Address/State),aggregate(Address/City with max as MaxCity,Address/City with min as MinCity,CityLength with max as MaxCityLen))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal(8, resultAt0.Value("MaxCityLen")); + Assert.Equal("Kirkland", resultAt0.Value("MinCity")); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal(6, resultAt1.Value("MaxCityLen")); + Assert.Equal("London", resultAt1.Value("MinCity")); + Assert.Equal("London", resultAt1.Value("MaxCity")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyThenOrderByAggregatedPropertyAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))&$orderby=MaxSalary desc"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Male", resultAt0.Value("Gender")); + Assert.Equal(1500m, resultAt0.Value("MaxSalary")); + Assert.Equal("Female", resultAt1.Value("Gender")); + Assert.Equal(1300m, resultAt1.Value("MaxSalary")); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByChainingAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Year,Quarter),aggregate(Amount with average as AverageAmount, Amount with sum as SumAmount))/groupby((Year),aggregate(AverageAmount with average as AnnualAverageAmount,SumAmount with sum as AnnualSumAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Single(result); + Assert.Matches("\\{\"Year\":2022,\"AnnualSumAmount\":24(?:\\.0+)?,\"AnnualAverageAmount\":3(?:\\.0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + public async Task TestComputeWithSubstringAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Employees?$apply=compute(substring(Name, 1, 4) as comp)&$top=1&$count=true"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal(content = "{\"@odata.count\":4,\"value\":[{\"comp\":\"ancy\",\"Id\":1,\"Name\":\"Nancy Davolio\",\"BaseSalary\":1300}]}", content); + } + + #region https://github.com/OData/AspNetCoreOData/issues/420 & Related + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByAndAggregateThenFilterAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Customer/Id),aggregate($count as SalesCount,Amount with sum as SumAmount))&$filter=SumAmount gt 5"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"SumAmount\":12(?:\\.0+)?,\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C2\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":7(?:\\.0+)?,\"SalesCount\":3,\"Customer\":\\{\"Id\":\"C1\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByAndAggregateThenFilterTransformationAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Customer/Id),aggregate($count as SalesCount,Amount with sum as SumAmount))/filter(SumAmount gt 5)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"SumAmount\":12(?:\\.0+)?,\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C2\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":7(?:\\.0+)?,\"SalesCount\":3,\"Customer\":\\{\"Id\":\"C1\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByAndAggregateThenOrderByAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Customer/Id),aggregate($count as SalesCount,Amount with sum as SumAmount))&$orderby=SumAmount desc"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"SumAmount\":12(?:\\.0+)?,\"SalesCount\":2,\"Customer\":\\{\"Id\":\"C2\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":7(?:\\.0+)?,\"SalesCount\":3,\"Customer\":\\{\"Id\":\"C1\"\\}\\}", content); + Assert.Matches("\\{\"SumAmount\":5(?:\\.0+)?,\"SalesCount\":3,\"Customer\":\\{\"Id\":\"C3\"\\}\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestFilterTransformationThenGroupByAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=filter((endswith(Quarter, '-3') eq true))/groupby((Quarter))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Single(result); + Assert.Matches("\\{\"Quarter\":\"2022-3\"\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByThenTopWithCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter))&$count=true&$top=3"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var parsedContent = JObject.Parse(content); + var count = parsedContent.Value("@odata.count"); + Assert.Equal(4, count); + var result = parsedContent.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\"\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\"\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\"\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestGroupByAndAggregateThenOrderByWithCountAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=groupby((Quarter),aggregate($count as SalesCount))&$count=true&$orderby=Quarter"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var parsedContent = JObject.Parse(content); + var count = parsedContent.Value("@odata.count"); + Assert.Equal(4, count); + var result = parsedContent.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"SalesCount\":2\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"SalesCount\":2\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"SalesCount\":2\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"SalesCount\":2\\}", content); + } + + #endregion https://github.com/OData/AspNetCoreOData/issues/420 & Related + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestEntitySetAggregationWorksAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Products?$apply=groupby((Category/Name),aggregate(Sales(Amount with sum as SalesTotal,$count as SalesCount)))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Matches("\\{\"Category\":\\{\"Name\":\"Food\"\\},\"Sales\":\\[\\{\"SalesCount\":4,\"SalesTotal\":16(?:\\.0+)?\\}\\]\\}", content); + Assert.Matches("\\{\"Category\":\\{\"Name\":\"Non-Food\"\\},\"Sales\":\\[\\{\"SalesCount\":4,\"SalesTotal\":8(?:\\.0+)?\\}\\]\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeWithCommonOperatorsAsync(string routePrefix) + { + // Arrange + // Tax = Amount * Product/TaxRate, Discount = Amount / 10, TotalPrice = Amount + Tax, SalesPrice = TotalPrice - Discount + var queryUrl = $"{routePrefix}/Sales?$apply=compute((Amount add (Amount mul Product/TaxRate)) sub (Amount div 10) as SalesPrice)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(8, result.Count); + Assert.Matches("\\{\"SalesPrice\":1\\.04(?:0+)?,\"Id\":1,\"Year\":2022,\"Quarter\":\"2022-1\",\"Amount\":1(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.92(?:0+)?,\"Id\":2,\"Year\":2022,\"Quarter\":\"2022-2\",\"Amount\":2(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":3\\.84(?:0+)?,\"Id\":3,\"Year\":2022,\"Quarter\":\"2022-3\",\"Amount\":4(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":7\\.68(?:0+)?,\"Id\":4,\"Year\":2022,\"Quarter\":\"2022-1\",\"Amount\":8(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":4\\.16(?:0+)?,\"Id\":5,\"Year\":2022,\"Quarter\":\"2022-4\",\"Amount\":4(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.92(?:0+)?,\"Id\":6,\"Year\":2022,\"Quarter\":\"2022-2\",\"Amount\":2(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.04(?:0+)?,\"Id\":7,\"Year\":2022,\"Quarter\":\"2022-3\",\"Amount\":1(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":2\\.08(?:0+)?,\"Id\":8,\"Year\":2022,\"Quarter\":\"2022-4\",\"Amount\":2(?:\\.0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeWithCommonOperatorsThenAggregateAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute((Amount add (Amount mul Product/TaxRate)) sub (Amount div 10) as SalesPrice)/aggregate(SalesPrice with average as AverageSalesPrice)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Single(result); + Assert.Matches("\\{\"AverageSalesPrice\":2\\.96(?:0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeWithCommonOperatorsThenGroupByAggregateAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute((Amount add (Amount mul Product/TaxRate)) sub (Amount div 10) as SalesPrice)/groupby((Quarter),aggregate(SalesPrice with average as QtrAvgSalesPrice))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"QtrAvgSalesPrice\":4\\.36(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"QtrAvgSalesPrice\":1\\.92(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"QtrAvgSalesPrice\":2\\.44(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"QtrAvgSalesPrice\":3\\.12(?:0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeChainingAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(Amount mul Product/TaxRate as Tax)" + + "/compute(Amount add Tax as Total,Amount div 10 as Discount)" + + "/compute(Total sub Discount as SalesPrice)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(8, result.Count); + Assert.Matches("\\{\"SalesPrice\":1\\.04(?:0+)?,\"Discount\":0\\.1(?:0+)?,\"Total\":1\\.14(?:0+)?,\"Tax\":0\\.14(?:0+)?,\"Id\":1,\"Year\":2022,\"Quarter\":\"2022-1\",\"Amount\":1(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.92(?:0+)?,\"Discount\":0\\.2(?:0+)?,\"Total\":2\\.12(?:0+)?,\"Tax\":0\\.12(?:0+)?,\"Id\":2,\"Year\":2022,\"Quarter\":\"2022-2\",\"Amount\":2(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":3\\.84(?:0+)?,\"Discount\":0\\.4(?:0+)?,\"Total\":4\\.24(?:0+)?,\"Tax\":0\\.24(?:0+)?,\"Id\":3,\"Year\":2022,\"Quarter\":\"2022-3\",\"Amount\":4(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":7\\.68(?:0+)?,\"Discount\":0\\.8(?:0+)?,\"Total\":8\\.48(?:0+)?,\"Tax\":0\\.48(?:0+)?,\"Id\":4,\"Year\":2022,\"Quarter\":\"2022-1\",\"Amount\":8(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":4\\.16(?:0+)?,\"Discount\":0\\.4(?:0+)?,\"Total\":4\\.56(?:0+)?,\"Tax\":0\\.56(?:0+)?,\"Id\":5,\"Year\":2022,\"Quarter\":\"2022-4\",\"Amount\":4(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.92(?:0+)?,\"Discount\":0\\.2(?:0+)?,\"Total\":2\\.12(?:0+)?,\"Tax\":0\\.12(?:0+)?,\"Id\":6,\"Year\":2022,\"Quarter\":\"2022-2\",\"Amount\":2(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":1\\.04(?:0+)?,\"Discount\":0\\.1(?:0+)?,\"Total\":1\\.14(?:0+)?,\"Tax\":0\\.14(?:0+)?,\"Id\":7,\"Year\":2022,\"Quarter\":\"2022-3\",\"Amount\":1(?:\\.0+)?\\}", content); + Assert.Matches("\\{\"SalesPrice\":2\\.08(?:0+)?,\"Discount\":0\\.2(?:0+)?,\"Total\":2\\.28(?:0+)?,\"Tax\":0\\.28(?:0+)?,\"Id\":8,\"Year\":2022,\"Quarter\":\"2022-4\",\"Amount\":2(?:\\.0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeChainingThenAggregateAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(Amount mul Product/TaxRate as Tax)" + + "/compute(Amount add Tax as Total,Amount div 10 as Discount)" + + "/compute(Total sub Discount as SalesPrice)" + + "/aggregate(SalesPrice with average as AverageSalesPrice,Amount with average as AverageAmount)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Single(result); + Assert.Matches("\\{\"AverageAmount\":3(?:\\.0+)?,\"AverageSalesPrice\":2\\.96(?:0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeChainingThenGroupByAndAggregateAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(Amount mul Product/TaxRate as Tax)" + + "/compute(Amount add Tax as Total,Amount div 10 as Discount)" + + "/compute(Total sub Discount as SalesPrice)" + + "/groupby((Quarter),aggregate(SalesPrice with average as QtrAvgSalesPrice,Amount with average as QtrAvgAmount))"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Matches("\\{\"Quarter\":\"2022-1\",\"QtrAvgAmount\":4\\.5(?:0+)?,\"QtrAvgSalesPrice\":4\\.36(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-2\",\"QtrAvgAmount\":2(?:\\.0+)?,\"QtrAvgSalesPrice\":1\\.92(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-3\",\"QtrAvgAmount\":2\\.5(?:0+)?,\"QtrAvgSalesPrice\":2\\.44(?:0+)?\\}", content); + Assert.Matches("\\{\"Quarter\":\"2022-4\",\"QtrAvgAmount\":3(?:\\.0+)?,\"QtrAvgSalesPrice\":3\\.12(?:0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeThenAggregateThenComputeAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(Amount mul Product/TaxRate as Tax,Amount div 10 as Discount)" + + "/aggregate(Tax with average as AverageTax,Discount with average as AverageDiscount,Amount with average as AverageAmount)" + + "/compute(AverageAmount add AverageTax as AverageTotal,AverageAmount add AverageTax sub AverageDiscount as AverageSalesPrice)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Single(result); + Assert.Matches("\\{\"AverageSalesPrice\":2\\.96(?:0+)?,\"AverageTotal\":3\\.26(?:0+)?,\"AverageAmount\":3(?:\\.0+)?,\"AverageDiscount\":0\\.3(?:0+)?,\"AverageTax\":0\\.26(?:0+)?\\}", content); + } + + [Theory] + [InlineData("default")] + [InlineData("custom")] + [InlineData("nonflattening")] + [InlineData("defaultsql")] + [InlineData("customsql")] + [InlineData("nonflatteningsql")] + public async Task TestComputeThenGroupByAndAggregateThenComputeAsync(string routePrefix) + { + // Arrange + var queryUrl = $"{routePrefix}/Sales?$apply=compute(Amount mul Product/TaxRate as Tax,Amount div 10 as Discount)" + + "/groupby((Quarter),aggregate(Tax with average as QtrAvgTax,Discount with average as QtrAvgDiscount,Amount with average as QtrAvgAmount))" + + "/compute(QtrAvgAmount add QtrAvgTax as QtrAvgTotal,QtrAvgAmount add QtrAvgTax sub QtrAvgDiscount as QtrAvgSalesPrice)"; + + // Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var content = await response.Content.ReadAsStringAsync(); + var result = JObject.Parse(content).GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Matches("\\{\"QtrAvgSalesPrice\":4\\.36(?:0+)?,\"QtrAvgTotal\":4\\.81(?:0+)?,\"Quarter\":\"2022-1\",\"QtrAvgAmount\":4\\.5(?:0+)?,\"QtrAvgDiscount\":0\\.45(?:0+)?,\"QtrAvgTax\":0\\.31(?:0+)?\\}", content); + Assert.Matches("\\{\"QtrAvgSalesPrice\":1\\.92(?:0+)?,\"QtrAvgTotal\":2\\.12(?:0+)?,\"Quarter\":\"2022-2\",\"QtrAvgAmount\":2(?:\\.0+)?,\"QtrAvgDiscount\":0\\.2(?:0+)?,\"QtrAvgTax\":0\\.12(?:0+)?\\}", content); + Assert.Matches("\\{\"QtrAvgSalesPrice\":2\\.44(?:0+)?,\"QtrAvgTotal\":2\\.69(?:0+)?,\"Quarter\":\"2022-3\",\"QtrAvgAmount\":2\\.5(?:0+)?,\"QtrAvgDiscount\":0\\.25(?:0+)?,\"QtrAvgTax\":0\\.19(?:0+)?\\}", content); + Assert.Matches("\\{\"QtrAvgSalesPrice\":3\\.12(?:0+)?,\"QtrAvgTotal\":3\\.42(?:0+)?,\"Quarter\":\"2022-4\",\"QtrAvgAmount\":3(?:\\.0+)?,\"QtrAvgDiscount\":0\\.3(?:0+)?,\"QtrAvgTax\":0\\.42(?:0+)?\\}", content); + } + + [Fact] + public void TestQueryForFlatteningAggregationBinderAsync() + { + var applyClause = "groupby((Year,Quarter),aggregate(Product/TaxRate with average as AverageProductTaxRate,Product/TaxRate with sum as SumProductTaxRate))"; + var expected = ".Select($it => new TestFlatteningWrapper`1() {" + + "Source = $it, " + + "GroupByContainer = new TestAggregationPropertyContainer() {" + + "Name = \"Property1\", " + + "Value = Convert($it.Product.TaxRate, Object), " + + "Next = new LastInChain() {" + + "Name = \"Property0\", " + + "Value = Convert($it.Product.TaxRate, Object)}}})" + + ".GroupBy($it => new TestGroupByWrapper() {" + + "GroupByContainer = new TestAggregationPropertyContainer() {" + + "Name = \"Quarter\", " + + "Value = $it.Source.Quarter, " + + "Next = new LastInChain() {" + + "Name = \"Year\", " + + "Value = Convert($it.Source.Year, Object)}}})" + + ".Select($it => new TestGroupByWrapper() {" + + "GroupByContainer = $it.Key.GroupByContainer, " + + "Container = new TestAggregationPropertyContainer() {" + + "Name = \"SumProductTaxRate\", " + + "Value = Convert(Convert($it, IEnumerable`1).Sum($it => Convert($it.GroupByContainer.Next.Value, Decimal)), Object), " + + "Next = new LastInChain() {" + + "Name = \"AverageProductTaxRate\", " + + "Value = Convert(Convert($it, IEnumerable`1).Average($it => Convert($it.GroupByContainer.Value, Decimal)), Object)}}})"; + + SetupAndVerifyQueryExpression( + new TestAggregationBinder(), + "Sales", + applyClause, + (actual) => Assert.Equal(expected, actual)); + } + + [Fact] + public void TestQueryForNonFlatteningAggregationBinderAsync() + { + var applyClause = "groupby((Year,Quarter),aggregate(Product/TaxRate with average as AverageProductTaxRate,Product/TaxRate with sum as SumProductTaxRate))"; + var expected = ".GroupBy($it => new TestGroupByWrapper() {" + + "GroupByContainer = new TestAggregationPropertyContainer() {" + + "Name = \"Quarter\", " + + "Value = $it.Quarter, " + + "Next = new LastInChain() {" + + "Name = \"Year\", " + + "Value = Convert($it.Year, Object)}}})" + + ".Select($it => new TestGroupByWrapper() {" + + "GroupByContainer = $it.Key.GroupByContainer, " + + "Container = new TestAggregationPropertyContainer() {" + + "Name = \"SumProductTaxRate\", " + + "Value = Convert(Convert($it, IEnumerable`1).Sum($it => $it.Product.TaxRate), Object), " + + "Next = new LastInChain() {" + + "Name = \"AverageProductTaxRate\", " + + "Value = Convert(Convert($it, IEnumerable`1).Average($it => $it.Product.TaxRate), Object)}}})"; + + SetupAndVerifyQueryExpression( + new TestNonFlatteningAggregationBinder(), + "Sales", + applyClause, + (actual) => Assert.Equal(expected, actual)); + } + + private Task SetupAndFireRequestAsync(string queryUrl) + { + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=none")); + var client = CreateClient(); + + return client.SendAsync(request); + } + + private void SetupAndVerifyQueryExpression(IAggregationBinder binder, string entitySetName, string applyExpression, Action verifyAction) + { + var model = DollarApplyEdmModel.GetEdmModel(); + var entityType = model.FindDeclaredType(typeof(T).FullName) as IEdmEntityType; + var entitySet = model.EntityContainer.FindEntitySet(entitySetName); + + var queryOptionParser = new ODataQueryOptionParser(model, entityType, entitySet, new Dictionary { { "$apply", applyExpression } }); + var applyClause = queryOptionParser.ParseApply(); + + var binderContext = new QueryBinderContext(model, new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }, typeof(T)); + var querySource = Enumerable.Empty().AsQueryable(); + + var queryResult = binder.ApplyBind(querySource, applyClause.Transformations.First(), binderContext, out Type resultClrType); + + var queryExpression = queryResult.Expression; + var queryToString = queryExpression.ToString(); + + var normalized = queryToString.Replace("System.Linq.EmptyPartition`1[" + typeof(T).FullName + "]", string.Empty); + + verifyAction(normalized); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs new file mode 100644 index 000000000..01e8cd787 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs @@ -0,0 +1,111 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq.Expressions; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.Query.Container; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; + +internal class TestAggregationPropertyContainer : IAggregationPropertyContainer +{ + public string Name { get; set; } + + public object Value { get; set; } + + public TestGroupByWrapper NestedValue + { + get { return (TestGroupByWrapper)this.Value; } + set { Value = value; } + } + + public IAggregationPropertyContainer Next { get; set; } + + public void ToDictionaryCore( + Dictionary dictionary, + IPropertyMapper propertyMapper, + bool includeAutoSelected) + { + Contract.Assert(dictionary != null); + + if (Name != null && includeAutoSelected) + { + string mappedName = propertyMapper.MapProperty(Name); + if (mappedName != null) + { + if (String.IsNullOrEmpty(mappedName)) + { + throw Error.InvalidOperation(SRResources.InvalidPropertyMapping, Name); + } + + dictionary.Add(mappedName, Value); + } + } + + if (Next != null) + { + Next.ToDictionaryCore(dictionary, propertyMapper, includeAutoSelected); + } + } + + public static Expression CreateNextNamedPropertyContainer(IList namedProperties) + { + Expression container = null; + + // Build the linked list of properties + for (int i = 0; i < namedProperties.Count; i++) + { + var property = namedProperties[i]; + Type namedPropertyType = null; + if (container != null) + { + namedPropertyType = (property.Value.Type == typeof(TestGroupByWrapper)) ? typeof(NestedProperty) : typeof(TestAggregationPropertyContainer); + } + else + { + namedPropertyType = (property.Value.Type == typeof(TestGroupByWrapper)) ? typeof(NestedPropertyLastInChain) : typeof(LastInChain); + } + + var bindings = new List + { + Expression.Bind(namedPropertyType.GetProperty("Name"), property.Name) + }; + + if (property.Value.Type == typeof(TestGroupByWrapper)) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("NestedValue"), property.Value)); + } + else + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("Value"), property.Value)); + } + + if (container != null) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("Next"), container)); + } + + if (property.NullCheck != null) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("IsNull"), property.NullCheck)); + } + + container = Expression.MemberInit(Expression.New(namedPropertyType), bindings); + } + + return container; + } + + private class NestedProperty : TestAggregationPropertyContainer { } + + private class LastInChain : TestAggregationPropertyContainer { } + + private class NestedPropertyLastInChain : TestAggregationPropertyContainer { } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs new file mode 100644 index 000000000..1d90078be --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs @@ -0,0 +1,18 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Query.Container; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; + +internal class TestPropertyMapper : IPropertyMapper +{ + public string MapProperty(string propertyName) + { + return propertyName; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs new file mode 100644 index 000000000..06705563b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs @@ -0,0 +1,553 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.Edm; +using Microsoft.OData; +using Microsoft.OData.UriParser.Aggregation; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.UriParser; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Expressions; + +internal class TestAggregationBinder : QueryBinder, IAggregationBinder, IFlatteningBinder +{ + public virtual Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context) + { + Debug.Assert(transformationNode != null, "transformationNode != null"); + Debug.Assert(context != null, "context != null"); + + if (transformationNode is GroupByTransformationNode groupByTransformationNode && groupByTransformationNode.GroupingProperties?.Any() == true) + { + var groupingProperties = CreateGroupByMemberAssignments(groupByTransformationNode.GroupingProperties, context); + + var groupingPropertiesContainerProperty = typeof(TestGroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + var memberAssignments = new List(capacity: 1) + { + Expression.Bind( + typeof(TestGroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(groupingProperties)) + }; + + return Expression.Lambda( + Expression.MemberInit(Expression.New(typeof(TestGroupByWrapper)), memberAssignments), + context.CurrentParameter); + } + + return Expression.Lambda(Expression.New(typeof(TestGroupByWrapper)), context.CurrentParameter); + } + + public virtual Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context) + { + Debug.Assert(transformationNode != null, "transformationNode != null"); + Debug.Assert(context != null, "context != null"); + + var groupByClrType = typeof(TestGroupByWrapper); + var groupingType = typeof(IGrouping<,>).MakeGenericType(groupByClrType, context.TransformationElementType); + var resultClrType = typeof(TestGroupByWrapper); + + var groupingParam = Expression.Parameter(groupingType, "$it"); + var memberAssignments = new List(); + + if (transformationNode is GroupByTransformationNode groupByTransformationNode && groupByTransformationNode.GroupingProperties?.Any() == true) + { + memberAssignments.Add( + Expression.Bind( + resultClrType.GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty), + Expression.Property(Expression.Property(groupingParam, "Key"), QueryConstants.GroupByWrapperGroupByContainerProperty) + )); + } + + // If there are aggregate expressions + var aggregateExpressions = GetAggregateExpressions(transformationNode, context); + if (aggregateExpressions?.Any() == true) + { + var aggregationProperties = new List(); + foreach (var aggregateExpr in aggregateExpressions) + { + aggregationProperties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpr.Alias), + CreateAggregateExpression(groupingParam, aggregateExpr, context.TransformationElementType, context))); + } + + memberAssignments.Add( + Expression.Bind(resultClrType.GetProperty(QueryConstants.GroupByWrapperContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(aggregationProperties))); + } + + return Expression.Lambda( + Expression.MemberInit(Expression.New(resultClrType), memberAssignments), + groupingParam); + } + + public virtual AggregationFlatteningResult FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context) + { + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } + + if (query == null) + { + throw Error.ArgumentNull(nameof(query)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + IEnumerable groupingProperties = null; + if (transformationNode.Kind == TransformationNodeKind.GroupBy) + { + groupingProperties = (transformationNode as GroupByTransformationNode)?.GroupingProperties; + } + + // Aggregate expressions to flatten - excludes VirtualPropertyCount + List aggregateExpressions = GetAggregateExpressions(transformationNode, context)?.OfType() + .Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); + + if ((aggregateExpressions?.Count ?? 0) == 0 || !(groupingProperties?.Any() == true)) + { + return null; + } + + Type wrapperType = typeof(TestFlatteningWrapper<>).MakeGenericType(context.TransformationElementType); + PropertyInfo sourceProperty = wrapperType.GetProperty(QueryConstants.FlatteningWrapperSourceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(sourceProperty, context.CurrentParameter) + }; + + // Generated Select will be stack-like; meaning that first property in the list will be deepest one + // For example if we add $it.B.C, $it.B.D, Select will look like + // new { + // Value = $it.B.C + // Next = new { + // Value = $it.B.D + // } + // } + + // We are generating references (in currentContainerExpr) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) + // We have proper match we need insert properties in reverse order + // After this, + // properties = { $it.B.D, $it.B.C } + // PreFlattenedMap = { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + + int aliasIdx = aggregateExpressions.Count - 1; + NamedPropertyExpression[] properties = new NamedPropertyExpression[aggregateExpressions.Count]; + + AggregationFlatteningResult flatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedPropertiesMapping = new Dictionary(aggregateExpressions.Count) + }; + + MemberExpression containerExpr = Expression.Property(flatteningResult.RedefinedContextParameter, QueryConstants.GroupByWrapperGroupByContainerProperty); + + for (int i = 0; i < aggregateExpressions.Count; i++) + { + AggregateExpression aggregateExpression = aggregateExpressions[i]; + + string alias = string.Concat("Property", aliasIdx.ToString(CultureInfo.CurrentCulture)); // We just need unique alias, we aren't going to use it + + // Add Value = $it.B.C + Expression propertyAccessExpression = BindAccessExpression(aggregateExpression.Expression, context); + Type type = propertyAccessExpression.Type; + propertyAccessExpression = WrapConvert(propertyAccessExpression); + properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propertyAccessExpression); + + // Save $it.Container.Next.Value for future use + UnaryExpression flattenedAccessExpression = Expression.Convert( + Expression.Property(containerExpr, "Value"), + type); + containerExpr = Expression.Property(containerExpr, "Next"); + flatteningResult.FlattenedPropertiesMapping.Add(aggregateExpression.Expression, flattenedAccessExpression); + aliasIdx--; + } + + PropertyInfo wrapperProperty = typeof(TestGroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + + flatteningResult.FlattenedExpression = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); + + return flatteningResult; + } + + private IList CreateGroupByMemberAssignments(IEnumerable groupByPropertyNodes, QueryBinderContext context) + { + var namedProperties = new List(); + + foreach (var groupByPropertyNode in groupByPropertyNodes) + { + if (groupByPropertyNode.Expression != null) + { + namedProperties.Add(new NamedPropertyExpression( + Expression.Constant(groupByPropertyNode.Name), + WrapConvert(BindAccessExpression(groupByPropertyNode.Expression, context)))); + } + else + { + var memberAssignments = new List(capacity: 1) + { + Expression.Bind( + typeof(TestGroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer( + CreateGroupByMemberAssignments(groupByPropertyNode.ChildTransformations, context))) + }; + + namedProperties.Add(new NamedPropertyExpression( + Expression.Constant(groupByPropertyNode.Name), + Expression.MemberInit(Expression.New(typeof(TestGroupByWrapper)), memberAssignments))); + } + } + + return namedProperties; + } + + private Expression CreateAggregateExpression(ParameterExpression groupingParameter, AggregateExpressionBase aggregateExpr, Type baseType, QueryBinderContext context) + { + switch (aggregateExpr.AggregateKind) + { + case AggregateExpressionKind.PropertyAggregate: + return CreatePropertyAggregateExpression(groupingParameter, aggregateExpr as AggregateExpression, baseType, context); + case AggregateExpressionKind.EntitySetAggregate: + return CreateEntitySetAggregateExpression(groupingParameter, aggregateExpr as EntitySetAggregateExpression, baseType, context); + default: + throw new ODataException(Error.Format(SRResources.AggregateKindNotSupported, aggregateExpr.AggregateKind)); + } + } + + private Expression CreatePropertyAggregateExpression(ParameterExpression groupingParam, AggregateExpression aggregateExpr, Type baseType, QueryBinderContext context) + { + var queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); + var queryableExpr = Expression.Convert(groupingParam, queryableType); + + if (aggregateExpr.Method == AggregationMethod.VirtualPropertyCount) + { + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(baseType); + return WrapConvert(Expression.Call(null, countMethod, queryableExpr)); + } + + var lambdaParam = baseType == context.TransformationElementType ? context.CurrentParameter : Expression.Parameter(baseType, "$it"); + if (!(context.FlattenedExpressionMapping?.TryGetValue(aggregateExpr.Expression, out Expression body) == true)) + { + body = BindAccessExpression(aggregateExpr.Expression, context, lambdaParam); + } + + var propertyLambda = Expression.Lambda(body, lambdaParam); + + Expression propertyAggregateExpr; + + switch (aggregateExpr.Method) + { + case AggregationMethod.Min: + var minMethod = ExpressionHelperMethods.EnumerableMin.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, minMethod, queryableExpr, propertyLambda); + + break; + case AggregationMethod.Max: + var maxMethod = ExpressionHelperMethods.EnumerableMax.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, maxMethod, queryableExpr, propertyLambda); + + break; + case AggregationMethod.Sum: + { + // For dynamic properties, cast dynamic to decimal + var propertyDynamicCastExpr = WrapDynamicCastIfNeeded(body); + propertyLambda = Expression.Lambda(propertyDynamicCastExpr, lambdaParam); + + if (!ExpressionHelperMethods.EnumerableSumGenerics.TryGetValue(propertyDynamicCastExpr.Type, out MethodInfo sumGenericMethod)) + { + throw new NotSupportedException( + $"Aggregation '{aggregateExpr.Method}' not supported for property '{aggregateExpr.Expression}' of type '{propertyDynamicCastExpr.Type}'."); + } + + var sumMethod = sumGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpr = Expression.Call(null, sumMethod, queryableExpr, propertyLambda); + + // For dynamic properties, cast dynamic to back to object + if (propertyLambda.Type == typeof(object)) + { + propertyAggregateExpr = Expression.Convert(propertyAggregateExpr, typeof(object)); + } + } + + break; + case AggregationMethod.Average: + { + // For dynamic properties, cast dynamic to decimal + var propertyDynamicCastExpr = WrapDynamicCastIfNeeded(body); + propertyLambda = Expression.Lambda(propertyDynamicCastExpr, lambdaParam); + + if (!ExpressionHelperMethods.EnumerableAverageGenerics.TryGetValue(propertyDynamicCastExpr.Type, out MethodInfo averageGenericMethod)) + { + throw new NotSupportedException( + $"Aggregation '{aggregateExpr.Method}' not supported for property '{aggregateExpr.Expression}' of type '{propertyDynamicCastExpr.Type}'."); + } + + var averageMethod = averageGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpr = Expression.Call(null, averageMethod, queryableExpr, propertyLambda); + + // For dynamic properties, cast dynamic to back to object + if (propertyLambda.Type == typeof(object)) + { + propertyAggregateExpr = Expression.Convert(propertyAggregateExpr, typeof(object)); + } + } + + break; + case AggregationMethod.CountDistinct: + { + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + context.TransformationElementType, + propertyLambda.Body.Type); + + Expression queryableSelectExpr = Expression.Call(null, selectMethod, queryableExpr, propertyLambda); + + // Run distinct over the set of items + MethodInfo distinctMethod = ExpressionHelperMethods.EnumerableDistinct.MakeGenericMethod(propertyLambda.Body.Type); + Expression distinctExpr = Expression.Call(null, distinctMethod, queryableSelectExpr); + + // Count the distinct items as the aggregation expression + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, countMethod, distinctExpr); + } + + break; + case AggregationMethod.Custom: + { + var customMethod = GetCustomMethod(aggregateExpr, context); + var selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod(context.TransformationElementType, propertyLambda.Body.Type); + var queryableSelectExpr = Expression.Call(null, selectMethod, queryableExpr, propertyLambda); + propertyAggregateExpr = Expression.Call(null, customMethod, queryableSelectExpr); + } + + break; + default: + throw new NotSupportedException($"{aggregateExpr.Method} method not supported"); + } + + return WrapConvert(propertyAggregateExpr); + } + + private Expression CreateEntitySetAggregateExpression( + ParameterExpression groupingParameter, + EntitySetAggregateExpression entitySetAggregateExpression, + Type baseType, + QueryBinderContext context) + { + // Generates the expression: + // $it => $it.AsQueryable() + // .SelectMany($it => $it.SomeEntitySet) + // .GroupBy($gr => new Object()) + // .Select($p => new DynamicTypeWrapper() + // { + // Alias1 = $p.AsQueryable().AggregateMethod1($it => $it.SomePropertyOfSomeEntitySet), + // Alias2 = $p.AsQueryable().AggregateMethod2($it => $it.AnotherPropertyOfSomeEntitySet), + // ... + // AliasN = ... , // A nested expression of this same format. + // ... + // }) + + List wrapperTypeMemberAssignments = new List(); + MethodInfo asQueryableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); + Expression asQueryableExpression = Expression.Call(null, asQueryableMethod, groupingParameter); + + // Create lambda to access the entity set from expression + Expression source = BindAccessExpression(entitySetAggregateExpression.Expression.Source, context); + string propertyName = context.Model.GetClrPropertyName(entitySetAggregateExpression.Expression.NavigationProperty); + + MemberExpression property = Expression.Property(source, propertyName); + + Type baseElementType = source.Type; + Type selectedElementType = property.Type.GenericTypeArguments.Single(); + + // Create method to get property collections to aggregate + MethodInfo selectManyMethod = ExpressionHelperMethods.EnumerableSelectManyGeneric.MakeGenericMethod(baseElementType, selectedElementType); + + // Create the lambda that access the property in the SelectMany clause. + ParameterExpression selectManyParam = Expression.Parameter(baseElementType, "$it"); + MemberExpression propertyExpression = Expression.Property(selectManyParam, entitySetAggregateExpression.Expression.NavigationProperty.Name); + + // Collection selector body is IQueryable, we need to adjust the type to IEnumerable, to match the SelectMany signature + // therefore the delegate type is specified explicitly + Type collectionSelectorLambdaType = typeof(Func<,>).MakeGenericType( + source.Type, + typeof(IEnumerable<>).MakeGenericType(selectedElementType)); + LambdaExpression selectManyLambda = Expression.Lambda(collectionSelectorLambdaType, propertyExpression, selectManyParam); + + // Get expression to get collection of entities + MethodCallExpression entitySet = Expression.Call(null, selectManyMethod, asQueryableExpression, selectManyLambda); + + // Getting method and lambda expression of groupBy + Type groupKeyType = typeof(object); + MethodInfo groupByMethod = + ExpressionHelperMethods.EnumerableGroupByGeneric.MakeGenericMethod(selectedElementType, groupKeyType); + LambdaExpression groupByLambda = Expression.Lambda( + Expression.New(groupKeyType), + Expression.Parameter(selectedElementType, "$gr")); + + // Group entities in a single group to apply select + MethodCallExpression groupedEntitySet = Expression.Call(null, groupByMethod, entitySet, groupByLambda); + + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupKeyType, selectedElementType); + ParameterExpression innerGroupingParameter = Expression.Parameter(groupingType, "$p"); + + // Nested properties + // Create dynamicTypeWrapper to encapsulate the aggregate result + List properties = new List(); + foreach (AggregateExpressionBase aggregateExpression in entitySetAggregateExpression.Children) + { + properties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpression.Alias), + CreateAggregateExpression(innerGroupingParameter, aggregateExpression, selectedElementType, context))); + } + + Type nestedResultType = typeof(TestGroupByWrapper); + PropertyInfo wrapperProperty = nestedResultType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + + MemberInitExpression initializedMember = Expression.MemberInit(Expression.New(nestedResultType), wrapperTypeMemberAssignments); + LambdaExpression selectLambda = Expression.Lambda(initializedMember, innerGroupingParameter); + + // Get select method + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + groupingType, + selectLambda.Body.Type); + + return Expression.Call(null, selectMethod, groupedEntitySet, selectLambda); + } + + private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessExpr) + { + if (propertyAccessExpr.Type == typeof(object)) + { + return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessExpr); + } + + return propertyAccessExpr; + } + + /// + /// Gets a collection of from a . + /// + /// The query binder context. + /// The . + /// A collection of aggregate expressions. + private IEnumerable GetAggregateExpressions(TransformationNode transformationNode, QueryBinderContext context) + { + Contract.Assert(transformationNode != null); + Contract.Assert(context != null); + + IEnumerable aggregateExpressions = null; + + switch (transformationNode.Kind) + { + case TransformationNodeKind.Aggregate: + AggregateTransformationNode aggregateClause = transformationNode as AggregateTransformationNode; + return aggregateClause.AggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpr = exp as AggregateExpression; + + return aggregationExpr?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpr, context) : exp; + }); + + case TransformationNodeKind.GroupBy: + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + if (groupByClause.ChildTransformations != null) + { + if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) + { + AggregateTransformationNode aggregationNode = groupByClause.ChildTransformations as AggregateTransformationNode; + return aggregationNode.AggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpr = exp as AggregateExpression; + + return aggregationExpr?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpr, context) : exp; + }); + } + else + { + throw new NotSupportedException( + $"Transformation kind '{groupByClause.ChildTransformations.Kind}' is not supported as a child transformation of kind '{transformationNode.Kind}'"); + } + } + + break; + + default: + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedTransformationKind, + transformationNode.Kind)); + } + + return aggregateExpressions; + } + + /// + /// Fixes return type for custom aggregation method. + /// + /// The aggregation expression + /// The query binder context. + /// The + private AggregateExpression FixCustomMethodReturnType(AggregateExpression aggregationExpression, QueryBinderContext context) + { + Debug.Assert(aggregationExpression != null, $"{nameof(aggregationExpression)} != null"); + Debug.Assert(aggregationExpression.Method == AggregationMethod.Custom, $"{nameof(aggregationExpression)}.Method == {nameof(AggregationMethod.Custom)}"); + + MethodInfo customMethod = GetCustomMethod(aggregationExpression, context); + + IEdmPrimitiveTypeReference typeReference = context.Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); + + return new AggregateExpression(aggregationExpression.Expression, aggregationExpression.MethodDefinition, aggregationExpression.Alias, typeReference); + } + + /// + /// Gets a custom aggregation method for the aggregation expression. + /// + /// The aggregation expression. + /// The query binder context. + /// The custom method. + private MethodInfo GetCustomMethod(AggregateExpression aggregationExpression, QueryBinderContext context) + { + LambdaExpression propertyLambda = Expression.Lambda(BindAccessExpression(aggregationExpression.Expression, context), context.CurrentParameter); + Type inputType = propertyLambda.Body.Type; + + string methodToken = aggregationExpression.MethodDefinition.MethodLabel; + CustomAggregateMethodAnnotation customMethodAnnotations = context.Model.GetAnnotationValue(context.Model); + + MethodInfo customMethod; + if (!customMethodAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + { + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregationExpression.Method, + aggregationExpression.Expression, + inputType)); + } + + return customMethod; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestComputeBinder.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestComputeBinder.cs new file mode 100644 index 000000000..621a9a2b8 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestComputeBinder.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Expressions; + +internal class TestComputeBinder : QueryBinder, IComputeBinder +{ + public Expression BindCompute(ComputeTransformationNode computeTransformationNode, QueryBinderContext context) + { + Type wrapperType = typeof(TestComputeWrapper<>).MakeGenericType(context.TransformationElementType); + // Set Instance property + PropertyInfo wrapperInstanceProperty = wrapperType.GetProperty(QueryConstants.ComputeWrapperInstanceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(wrapperInstanceProperty, context.CurrentParameter) + }; + + List properties = new List(); + foreach (ComputeExpression computeExpression in computeTransformationNode.Expressions) + { + properties.Add( + new NamedPropertyExpression(Expression.Constant(computeExpression.Alias), + WrapConvert(BindAccessExpression(computeExpression.Expression, context)))); + } + + // Initialize property 'Model' on the wrapper class. + // source = new Wrapper { Model = parameterized(IEdmModel) } + // Always parameterize as EntityFramework does not let you inject non primitive constant values (like IEdmModel). + PropertyInfo wrapperModelProperty = wrapperType.GetProperty(QueryConstants.ComputeWrapperModelProperty); + Expression wrapperModelPropertyValueExpression = LinqParameterContainer.Parameterize(typeof(IEdmModel), context.Model); + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperModelProperty, wrapperModelPropertyValueExpression)); + + // Set new compute properties + PropertyInfo wrapperContainerProperty = wrapperType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); + wrapperTypeMemberAssignments.Add(Expression.Bind( + wrapperContainerProperty, + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + + return Expression.Lambda( + Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestNonFlatteningAggregationBinder.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestNonFlatteningAggregationBinder.cs new file mode 100644 index 000000000..d2c473527 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestNonFlatteningAggregationBinder.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq.Expressions; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Expressions; + +internal class TestNonFlatteningAggregationBinder : QueryBinder, IAggregationBinder +{ + private readonly IAggregationBinder aggregationBinder = new TestAggregationBinder(); + + public Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context) + { + return aggregationBinder.BindGroupBy(transformationNode, context); + } + + public Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context) + { + return aggregationBinder.BindSelect(transformationNode, context); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestComputeWrapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestComputeWrapper.cs new file mode 100644 index 000000000..78f050121 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestComputeWrapper.cs @@ -0,0 +1,73 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.AspNetCore.OData.Query.Wrapper; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; + +internal class TestComputeWrapper : TestGroupByWrapper, IComputeWrapper +{ + private bool merged; + public T Instance { get; set; } + public IEdmModel Model { get; set; } + + public override Dictionary Values + { + get + { + EnsureValues(); + return base.Values; + } + } + + private void EnsureValues() + { + if (!this.merged) + { + // Properties available via Instance can be structural properties or generated in previous transformations + if (this.Instance is DynamicTypeWrapper instanceContainer) + { + // Add properties generated in previous transformations to the dictionary + base.Values.MergeWithReplace(instanceContainer.Values); + } + else + { + // Add structural properties to the dictionary + // We need to use injected model to real property names + var structuredTypeReference = Model.GetEdmTypeReference(typeof(T)) as IEdmStructuredTypeReference; + + TypedEdmStructuredObject typedEdmStructuredObject; + if (structuredTypeReference is IEdmComplexTypeReference complexTypeReference) + { + typedEdmStructuredObject = new TypedEdmComplexObject(Instance, complexTypeReference, Model); + } + else + { + var entityTypeReference = structuredTypeReference as IEdmEntityTypeReference; + typedEdmStructuredObject = new TypedEdmEntityObject(Instance, entityTypeReference, Model); + } + + var primitiveProperties = structuredTypeReference.DeclaredStructuralProperties().Where(p => p.Type.IsPrimitive()).Select(p => p.Name); + foreach (var propertyName in primitiveProperties) + { + if (typedEdmStructuredObject.TryGetPropertyValue(propertyName, out object value)) + { + base.Values[propertyName] = value; + } + } + } + + this.merged = true; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs new file mode 100644 index 000000000..6e0b62964 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; + +internal class TestFlatteningWrapper : TestGroupByWrapper, IGroupByWrapper, IFlatteningWrapper +{ + public T Source { get; set; } +} + +internal class TestFlatteningWrapperConverter : JsonConverter> +{ + public override TestFlatteningWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(Error.Format(SRResources.JsonConverterDoesnotSupportRead, typeof(TestFlatteningWrapper<>).Name)); + } + + public override void Write(Utf8JsonWriter writer, TestFlatteningWrapper value, JsonSerializerOptions options) + { + if (value != null) + { + JsonSerializer.Serialize(writer, value.Values, options); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs new file mode 100644 index 000000000..2731276c9 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs @@ -0,0 +1,116 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; + +/// +/// Test wrapper for GroupBy and aggregation transformations. +/// +/// Overriding Equals and GetHashCode is especially important where input source is an in-memory collection of objects. +internal class TestGroupByWrapper : DynamicTypeWrapper, IGroupByWrapper +{ + private Dictionary values; + protected static readonly IPropertyMapper testPropertyMapper = new TestPropertyMapper(); + + /// + /// Gets or sets the property container that contains the grouping properties + /// + public TestAggregationPropertyContainer GroupByContainer { get; set; } + + /// + /// Gets or sets the property container that contains the aggregation properties + /// + public TestAggregationPropertyContainer Container { get; set; } + + public override Dictionary Values + { + get + { + EnsureValues(); + + return this.values; + } + } + + private void EnsureValues() + { + if (this.values == null) + { + if (this.GroupByContainer != null) + { + Dictionary dictionary = new Dictionary(); + + this.GroupByContainer.ToDictionaryCore(dictionary, testPropertyMapper, true); + this.values = dictionary; + } + else + { + this.values = new Dictionary(); + } + + if (this.Container != null) + { + Dictionary dictionary = new Dictionary(); + + this.Container.ToDictionaryCore(dictionary, testPropertyMapper, true); + this.values.MergeWithReplace(dictionary); + } + } + } + + /// + public override bool Equals(object obj) + { + var compareWith = obj as TestGroupByWrapper; + if (compareWith == null) + { + return false; + } + var dictionary1 = this.Values; + var dictionary2 = compareWith.Values; + return dictionary1.Count == dictionary2.Count && !dictionary1.Except(dictionary2).Any(); + } + + /// + public override int GetHashCode() + { + EnsureValues(); + long hash = 1870403278L; //Arbitrary number from Anonymous Type GetHashCode implementation + foreach (var v in this.Values.Values) + { + hash = (hash * -1521134295L) + (v == null ? 0 : v.GetHashCode()); + } + + return (int)hash; + } +} + +internal class TestGroupByWrapperConverter : JsonConverter +{ + public override TestGroupByWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, TestGroupByWrapper wrapper, JsonSerializerOptions options) + { + if (wrapper != null) + { + JsonSerializer.Serialize(writer, wrapper.Values, options); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeController.cs index 8e02a4072..2947ad124 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeController.cs @@ -5,6 +5,7 @@ // //------------------------------------------------------------------------------ +using System; using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Query; @@ -50,4 +51,16 @@ public IActionResult GetSales() { return Ok(); } + + [HttpGet("odata/Shoppers")] + public IQueryable Get(ODataQueryOptions queryOptions) + { + var queryable = DollarComputeDataSource.Shoppers.AsQueryable(); + + return queryOptions.ApplyTo(queryable, new ODataQuerySettings + { + PageSize = 3, + TimeZone = TimeZoneInfo.Utc + }); + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataModel.cs index 49f396732..009fe8d1e 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataModel.cs @@ -49,3 +49,12 @@ public class ComputeSale public IDictionary Dynamics { get; set; } = new Dictionary(); } + +public class ComputeShopper +{ + public int Id { get; set; } + + public string Name { get; set; } + + public int Age { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataSource.cs index aea5ab4e1..9bd2faadb 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataSource.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeDataSource.cs @@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarCompute; public class DollarComputeDataSource { private static IList _customers; + private static IList _shoppers; static DollarComputeDataSource() { @@ -21,6 +22,8 @@ static DollarComputeDataSource() public static IList Customers => _customers; + public static IList Shoppers => _shoppers; + private static void GenerateCustomers() { _customers = new List @@ -44,5 +47,11 @@ private static void GenerateCustomers() _customers[i - 1].Sales = Enumerable.Range(0, i + 3) .Select(idx => new ComputeSale { Id = 100 * i + idx, Amount = idx + i, Price = (3.1 + idx) * i, TaxRate = (0.1 + idx + i) / 10.0 }).ToList(); } + + _shoppers = new List(_customers.Count); + foreach (var c in _customers) + { + _shoppers.Add(new ComputeShopper { Id = c.Id, Name = c.Name, Age = c.Age }); + } } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeEdmModel.cs index 041b79147..01e36fcef 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeEdmModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeEdmModel.cs @@ -17,6 +17,8 @@ public static IEdmModel GetEdmModel() var builder = new ODataConventionModelBuilder(); builder.EntitySet("Customers"); builder.EntitySet("Sales"); + + builder.EntitySet("Shoppers"); IEdmModel model = builder.GetEdmModel(); return model; } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeTests.cs index 34571529b..4c74d64ee 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarCompute/DollarComputeTests.cs @@ -365,4 +365,57 @@ public async Task QuerySales_ThrowsNotAllowed_IncludesDollarCompute_WithAllowedQ "Query option 'Compute' is not allowed. To allow it, set the 'AllowedQueryOptions' property on EnableQueryAttribute or QueryValidationSettings", payload); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + + [Fact] + public async Task QueryShoppers_UsingQueryOptionsWithQuerySettings_IncludesDollarCompute_WorksWithDollarSelect() + { + // Arrange + string queryUrl = "odata/shoppers?$compute=age add 10 as agePlusTen&$select=id,name,agePlusTen"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + HttpResponseMessage response; + + // Act + response = await client.SendAsync(request); + + // Assert + string payload = await response.Content.ReadAsStringAsync(); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + // It returns 3 entities because there's a `pagesize=3` setting in the controller/action + Assert.Equal("[{\"Id\":1,\"Name\":\"Peter\",\"agePlusTen\":29}," + + "{\"Id\":2,\"Name\":\"Sam\",\"agePlusTen\":50}," + + "{\"Id\":3,\"Name\":\"John\",\"agePlusTen\":44}]", payload); + } + + [Fact] + public async Task QueryShoppers_UsingQueryOptionsWithQuerySettings_IncludesDollarCompute_WorksWithDollorOrderby() + { + // Arrange + string queryUrl = "odata/shoppers?$compute=age add 10 as agePlusTen&$orderby=agePlusTen desc&$select=id,name,agePlusTen&$filter=agePlusTen ge 21"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + HttpClient client = CreateClient(); + HttpResponseMessage response; + + // Act + response = await client.SendAsync(request); + + // Assert + string payload = await response.Content.ReadAsStringAsync(); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + // It returns 3 entities because there's a `pagesize=3` setting in the controller/action + // But the payload returns the ordered list. + Assert.Equal("[" + + "{\"Id\":2,\"Name\":\"Sam\",\"agePlusTen\":50}," + + "{\"Id\":3,\"Name\":\"John\",\"agePlusTen\":44}," + + "{\"Id\":4,\"Name\":\"Kerry\",\"agePlusTen\":39}]", + payload); + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchBinder.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchBinder.cs index e88e34627..03ea41c0f 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchBinder.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchBinder.cs @@ -37,6 +37,11 @@ public class DollarSearchBinder : QueryBinder, ISearchBinder "white", "red", "green", "blue", "brown" }; + private static ISet _tagNames = new HashSet + { + "telemetry", "privacy", "sdk", "deprecated" + }; + public Expression BindSearch(SearchClause searchClause, QueryBinderContext context) { Expression exp = BindSingleValueNode(searchClause.Expression, context); @@ -78,6 +83,19 @@ public Expression BindSearchTerm(SearchTermNode node, QueryBinderContext context Expression source = context.CurrentParameter; string text = node.Text.ToLowerInvariant(); + if (context.ElementClrType == typeof(SearchTag)) + { + if (_tagNames.Contains(text)) + { + // $it.name + Expression nameProperty = Expression.Property(source, "Name"); + + // string.Equals($it.Name, text, StringComparison.OrdinalIgnoreCase); + return Expression.Call(null, StringEqualsMethodInfo, + nameProperty, Expression.Constant(text, typeof(string)), Expression.Constant(StringComparison.OrdinalIgnoreCase, typeof(StringComparison))); + } + } + if (_categories.Contains(text)) { // $it.Category diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataModel.cs index 2d3eefa79..afc9bfa96 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataModel.cs @@ -5,6 +5,8 @@ // //------------------------------------------------------------------------------ +using System.Collections.Generic; + namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarSearch; public class SearchProduct @@ -20,6 +22,8 @@ public class SearchProduct public int Qty { get; set; } public SearchCategory Category { get; set; } + + public List Tags { get; set; } // List of tags } public class SearchCategory @@ -29,6 +33,15 @@ public class SearchCategory public string Name { get; set; } } +public class SearchTag +{ + public int Id { get; set; } + + public string Name { get; set; } // Tag name (e.g., "Telemetry", "Privacy", "Security") + + public string Description { get; set; } +} + public enum SearchColor { White, diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataSource.cs index e4c4ffbe4..b6e27262d 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataSource.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchDataSource.cs @@ -13,6 +13,7 @@ public class DollarSearchDataSource { private static IList _products; private static IList _categories; + private static IList _tags; static DollarSearchDataSource() { @@ -23,6 +24,8 @@ static DollarSearchDataSource() public static IList Category => _categories; + public static IList Tags => _tags; + private static void GenerateProducts() { _products = new List @@ -42,13 +45,29 @@ private static void GenerateProducts() new SearchCategory { Id = 3, Name = "Device" }, }; + _tags = [ + new SearchTag { Id = 1, Name = "Telemetry", Description ="Data collected from product usage" }, + new SearchTag { Id = 2, Name = "Privacy", Description ="Indicates privacy-sensitive content" }, + new SearchTag { Id = 3, Name = "SDK", Description ="Related to software development kits" }, + new SearchTag { Id = 4, Name = "Deprecated", Description ="No longer in use" }, + ]; + _products[0].Category = _categories[0]; + _products[0].Tags = [_tags[0], _tags[2], _tags[3]]; + _products[2].Category = _categories[0]; + _products[2].Tags = [_tags[1], _tags[2], _tags[3]]; _products[1].Category = _categories[1]; + _products[1].Tags = [_tags[1], _tags[2]]; + _products[4].Category = _categories[1]; + _products[4].Tags = [_tags[0], _tags[1]]; _products[3].Category = _categories[2]; + _products[3].Tags = [_tags[2], _tags[3]]; + _products[5].Category = _categories[2]; + _products[5].Tags = [_tags[0], _tags[2]]; } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchTests.cs index cb24c9e59..86e127434 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarSearch/DollarSearchTests.cs @@ -191,9 +191,88 @@ public async Task QueryForProducts_IncludesDollarSearch_WithOtherQueryOptions() "]}", payloadBody); } - private static int[] GetIds(JObject payload) + [Fact] + public async Task QueryForProductsForNestedNavigationProperty_WithoutDollarSearchBinder() + { + // Arrange + string queryUrl = $"odata/Products/1?$expand=Tags($select=Name)&$select=Id"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=none")); + HttpClient client = CreateClient(); + HttpResponseMessage response; + + // Act + response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + string payloadBody = await response.Content.ReadAsStringAsync(); + + Assert.Equal("{\"Id\":1,\"Tags\":[{\"Name\":\"Telemetry\"},{\"Name\":\"SDK\"},{\"Name\":\"Deprecated\"}]}", payloadBody); + } + + [Theory] + [InlineData("$expand=Tags($search=SDK)", new[] { 3 })] + [InlineData("$expand=Tags($search=NOT SDK)", new[] { 1, 4 })] + public async Task QueryForSingleProduct_IncludesDollarSearchOnNavigation_OnName(string query, int[] ids) + { + // Arrange + string queryUrl = $"odata/Products/1?{query}"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=none")); + HttpClient client = CreateClient(); + HttpResponseMessage response; + + // Act + response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + JObject payloadBody = await response.Content.ReadAsObject(); + + int[] actualIds = GetIds(payloadBody, "Tags"); + Assert.True(ids.SequenceEqual(actualIds)); + } + + [Fact] + public async Task QueryForProducts_IncludesDollarSearchOnNavigation_OnName() + { + // Arrange + string queryUrl = $"odata/Products?$expand=Tags($search=Deprecated;$select=Id,Name)&$select=Id"; + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=none")); + HttpClient client = CreateClient(); + HttpResponseMessage response; + + // Act + response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + string payloadBody = await response.Content.ReadAsStringAsync(); + + Assert.Equal("{\"value\":[" + + "{\"Id\":1,\"Tags\":[{\"Id\":4,\"Name\":\"Deprecated\"}]}," + + "{\"Id\":2,\"Tags\":[]}," + + "{\"Id\":3,\"Tags\":[{\"Id\":4,\"Name\":\"Deprecated\"}]}," + + "{\"Id\":4,\"Tags\":[{\"Id\":4,\"Name\":\"Deprecated\"}]}," + + "{\"Id\":5,\"Tags\":[]}," + + "{\"Id\":6,\"Tags\":[]}" + + "]}", payloadBody); + } + + private static int[] GetIds(JObject payload, string propertyName = "value") { - JArray value = payload["value"] as JArray; + JArray value = payload[propertyName] as JArray; Assert.NotNull(value); int[] ids = new int[value.Count()]; diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsController.cs index 6e2fbc43f..a4085ef15 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsController.cs @@ -157,6 +157,19 @@ public IActionResult PostToSkillSet(int key, [FromBody]Skill newSkill) return Updated(employee.SkillSet); } + [HttpPost("Employees({key})/AddAccessRight")] + public IActionResult AddAccessRight(int key, [FromODataBody] AccessLevel accessRight) + { + var employee = Employees.FirstOrDefault(e => e.ID == key); + if (employee == null) + { + return NotFound(); + } + + employee.AccessLevel = accessRight; + return Ok(employee.AccessLevel); + } + public IActionResult Put(int key, [FromBody]Employee employee) { employee.ID = key; diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsDataModel.cs index 08eea53c5..28ff6f343 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsDataModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsDataModel.cs @@ -31,11 +31,15 @@ public class Employee [Flags] public enum AccessLevel { + None = 0, + Read = 1, Write = 2, - Execute = 4 + Execute = 4, + + Admin = 7 // Read | Write | Execute } [Flags] diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsEdmModel.cs index 7486ce5c2..adffbdb07 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsEdmModel.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsEdmModel.cs @@ -34,9 +34,11 @@ public static IEdmModel GetExplicitModel() gender.Member(Gender.Male); var accessLevel = builder.EnumType(); + accessLevel.Member(AccessLevel.None); accessLevel.Member(AccessLevel.Execute); accessLevel.Member(AccessLevel.Read); accessLevel.Member(AccessLevel.Write); + accessLevel.Member(AccessLevel.Admin); var employeeType = builder.EnumType(); employeeType.Member(EmployeeType.FullTime); @@ -92,6 +94,10 @@ private static void AddBoundActionsAndFunctions(EntityTypeConfiguration(); + + var actionAddAccessRight = employee.Action("AddAccessRight"); + actionAddAccessRight.Parameter("accessRight"); + actionAddAccessRight.Returns(); } private static void AddUnboundActionsAndFunctions(ODataModelBuilder odataModelBuilder) diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsTest.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsTest.cs index 82e499a1c..2516f6943 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsTest.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Enums/EnumsTest.cs @@ -10,6 +10,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.OData.E2E.Tests.Commons; @@ -96,7 +97,7 @@ public async Task ModelBuilderTest(string modelMode) var accessLevel = employee.Properties().SingleOrDefault(p => p.Name == "AccessLevel") as IEdmStructuralProperty; edmEnumType = accessLevel.Type.Definition as IEdmEnumType; - Assert.Equal(3, edmEnumType.Members.Count()); + Assert.Equal(5, edmEnumType.Members.Count()); Assert.True(edmEnumType.IsFlags); var employeeType = employee.Properties().SingleOrDefault(p => p.Name == "EmployeeType") as IEdmStructuralProperty; @@ -848,9 +849,31 @@ public async Task EnumInActionOutput() Assert.Equal("Read, Write", value); } -#endregion -#region Enum with function + + [Theory] + [InlineData("/convention/Employees(2)/AddAccessRight")] + [InlineData("/explicit/Employees(3)/AddAccessRight")] + public async Task InvokeActionWithEnumParameterAndVerifyReturnValue(string requestUri) + { + // Arrange + await ResetDatasource(); + var client = CreateClient(); + var content = JObject.Parse(@"{""accessRight"":""Read,Execute""}"); + + // Act + var response = await client.PostAsJsonAsync(requestUri, content); + + // Assert + Assert.True(response.IsSuccessStatusCode); + + var json = await response.Content.ReadAsObject(); + Assert.Equal("Read, Execute", json.GetValue("value").ToString()); + } + + #endregion + + #region Enum with function [Fact] public async Task EnumInFunctionOutput() diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs index a69c1fe4d..e73e29c17 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableController.cs @@ -5,11 +5,13 @@ // //------------------------------------------------------------------------------ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Attributes; using Microsoft.AspNetCore.OData.Routing.Controllers; namespace Microsoft.AspNetCore.OData.E2E.Tests.IAsyncEnumerableTests; @@ -40,6 +42,7 @@ public IAsyncEnumerable CustomersData() [EnableQuery] [HttpGet("odata/Customers")] + [ODataRouteComponent] public IAsyncEnumerable Get() { return _context.Customers.AsAsyncEnumerable(); @@ -52,6 +55,23 @@ public ActionResult> CustomersDataNew() return Ok(_context.Customers.AsAsyncEnumerable()); } + [EnableQuery] + [HttpGet("v3/Customers")] + public IActionResult SearchCustomersForV3Route([FromQuery] Variant variant = Variant.None) + { + var asyncEnumerable = _context.Customers.AsAsyncEnumerable(); + if (variant == Variant.Generic) + { + asyncEnumerable = GenericAsyncEnumerableWithDelay(asyncEnumerable, TimeSpan.FromSeconds(1)); + } + else if (variant == Variant.Typed) + { + asyncEnumerable = TypedAsyncEnumerableWithDelay(asyncEnumerable, TimeSpan.FromSeconds(1)); + } + + return Ok(asyncEnumerable); + } + public async IAsyncEnumerable CreateCollectionAsync() { await Task.Delay(5); @@ -131,4 +151,29 @@ public void Generate() _context.SaveChanges(); } + + public enum Variant + { + None = 0, + Typed = 1, + Generic = 2, + } + + private async IAsyncEnumerable TypedAsyncEnumerableWithDelay(IAsyncEnumerable asyncEnumerable, TimeSpan delay) + { + await foreach (var entity in asyncEnumerable) + { + await Task.Delay(delay); + yield return entity; + } + } + + private async IAsyncEnumerable GenericAsyncEnumerableWithDelay(IAsyncEnumerable asyncEnumerable, TimeSpan delay) + { + await foreach (var entity in asyncEnumerable) + { + await Task.Delay(delay); + yield return entity; + } + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableTests.cs index 3afab2610..e1650f0c9 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/IAsyncEnumerableTests/IAsyncEnumerableTests.cs @@ -6,9 +6,10 @@ //------------------------------------------------------------------------------ using System.Collections.Generic; -using System.Net.Http.Headers; -using System.Net.Http; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.OData.TestCommon; using Microsoft.EntityFrameworkCore; @@ -38,6 +39,13 @@ public override void ConfigureServices(IServiceCollection services) services.AddControllers().AddOData(opt => opt.Count().Filter().Expand().Select().OrderBy().SetMaxTop(null) .AddRouteComponents("v2", edmModel)); + + services.AddControllers().AddOData(opt => opt.Count().Filter().Expand().Select().OrderBy().SetMaxTop(null) + .AddRouteComponents("v3", IAsyncEnumerableEdmModel.GetEdmModel())) + .AddJsonOptions(opt => + { + opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); } } @@ -109,4 +117,31 @@ public async Task UsingAsAsyncEnumerableWithActionResultWorks() Assert.Equal(3, customers.Count); Assert.Equal(2, customers[0].Orders.Count); } + + [Theory] + [InlineData("None")] + [InlineData("Generic")] + [InlineData("Typed")] + public async Task EnsureAsyncIteratorIsTreatedAsIAsyncEnumerable(string variant) + { + // Arrange + string queryUrl = $"v3/Customers?variant={variant}"; + var expectedResult = "{\"@odata.context\":\"http://localhost/v3/$metadata#Customers\",\"value\":[{\"Id\":1,\"Name\":\"Customer0\",\"Address\":{\"Name\":\"City1\",\"Street\":\"Street1\"}},{\"Id\":2,\"Name\":\"Customer1\",\"Address\":{\"Name\":\"City0\",\"Street\":\"Street0\"}},{\"Id\":3,\"Name\":\"Customer0\",\"Address\":{\"Name\":\"City1\",\"Street\":\"Street1\"}}]}"; + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + + // Act + HttpResponseMessage response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var resultObject = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedResult, resultObject); + + var json = await response.Content.ReadAsStringAsync(); + List customers = JToken.Parse(json)["value"].ToObject>(); + Assert.Equal(3, customers.Count); + } } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MetadataEndpointsTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MetadataEndpointsTests.cs new file mode 100644 index 000000000..f76022a95 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MetadataEndpointsTests.cs @@ -0,0 +1,209 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.OData.Edm; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public class MetadataEndpointsTests : IClassFixture> +{ + private readonly MinimalTestFixture _factory; + + public MetadataEndpointsTests(MinimalTestFixture factory) + { + _factory = factory; + } + + protected static void ConfigureAPIs(WebApplication app) + { + IEdmModel model = MinimalEdmModel.GetEdmModel(); + + app.MapODataServiceDocument("v1/$document", model); + + app.MapODataServiceDocument("v2/$document", model) + .WithODataBaseAddressFactory(h => new Uri("http://localhost/v2")); + + app.MapODataMetadata("v1/$metadata", model); + } + + [Fact] + public async Task ServiceDocumentEndpointV1_ShouldReturn_CorrectJsonResult() + { + // Arrange + HttpClient client = _factory.CreateClient(); + + // Act + var result = await client.GetAsync("/v1/$document"); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + var content = await result.Content.ReadAsStringAsync(); + + Assert.Equal("{\"@odata.context\":\"http://localhost/$metadata\"," + + "\"value\":[" + + "{\"name\":\"Todos\",\"kind\":\"EntitySet\",\"url\":\"Todos\"}" + + "]}", + content); + } + + [Fact] + public async Task ServiceDocumentEndpointV2_ShouldReturn_CorrectJsonResult() + { + // Arrange + HttpClient client = _factory.CreateClient(); + + // Act + var result = await client.GetAsync("/v2/$document"); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + var content = await result.Content.ReadAsStringAsync(); + + Assert.Equal("{\"@odata.context\":\"http://localhost/v2/$metadata\"," + + "\"value\":[" + + "{\"name\":\"Todos\",\"kind\":\"EntitySet\",\"url\":\"Todos\"}" + + "]}", + content); + } + + [Theory] + [InlineData(null)] + [InlineData("?$format=application/xml")] + public async Task MetadataEndpointForCsdlXml_ShouldReturn_CorrectXmlResult(string query) + { + // Arrange + HttpClient client = _factory.CreateClient(); + + // Act + var result = await client.GetAsync($"/v1/$metadata{query}"); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + var content = await result.Content.ReadAsStringAsync(); + + Assert.Equal("" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "", content); + } + + [Theory] + [InlineData(null)] + [InlineData("?$format=application/json")] + public async Task MetadataEndpointForCsdlJson_ShouldReturn_CorrectJsonResult(string query) + { + // Arrange + HttpClient client = _factory.CreateClient(); + + // Act + HttpResponseMessage response; + if (query == null) + { + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "/v1/$metadata"); + httpRequestMessage.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + response = await client.SendAsync(httpRequestMessage); + } + else + { + response = await client.GetAsync($"/v1/$metadata{query}"); + } + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Equal(@"{ + ""$Version"": ""4.0"", + ""$EntityContainer"": ""Default.Container"", + ""Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis"": { + ""MiniTodo"": { + ""$Kind"": ""EntityType"", + ""$Key"": [ + ""Id"" + ], + ""Id"": { + ""$Type"": ""Edm.Int32"" + }, + ""Owner"": { + ""$Nullable"": true + }, + ""Title"": { + ""$Nullable"": true + }, + ""IsDone"": { + ""$Type"": ""Edm.Boolean"" + }, + ""Tasks"": { + ""$Collection"": true, + ""$Type"": ""Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis.MiniTask"", + ""$Nullable"": true + } + }, + ""MiniTask"": { + ""$Kind"": ""ComplexType"", + ""Id"": { + ""$Type"": ""Edm.Int32"" + }, + ""Description"": { + ""$Nullable"": true + }, + ""Created"": { + ""$Type"": ""Edm.Date"" + }, + ""IsComplete"": { + ""$Type"": ""Edm.Boolean"" + }, + ""Priority"": { + ""$Type"": ""Edm.Int32"" + } + } + }, + ""Default"": { + ""Container"": { + ""$Kind"": ""EntityContainer"", + ""Todos"": { + ""$Collection"": true, + ""$Type"": ""Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis.MiniTodo"" + } + } + } +}", content); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITasksEndpointsTest.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITasksEndpointsTest.cs new file mode 100644 index 000000000..dc9aea6b2 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITasksEndpointsTest.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public class MinimalAPITasksEndpointsTest : IClassFixture> +{ + private readonly MinimalTestFixture _factory; + + public MinimalAPITasksEndpointsTest(MinimalTestFixture factory) + { + _factory = factory; + } + + protected static void ConfigureServices(IServiceCollection services) + { + services.AddOData(opt => opt.EnableAll());// global configuration. + services.AddSingleton(); + } + + protected static void ConfigureAPIs(WebApplication app) + { + var model = MinimalEdmModel.GetAllEntitySetEdmModel(); + + // Use Group to config + var group = app.MapGroup("odata") + .WithODataModel(model) + .WithODataResult() + .AddODataQueryEndpointFilter(querySetup: s => s.PageSize = 2); + + group.MapGet("tasks", (IMiniTodoTaskRepository db) => db.GetTasks()); + } + + [Fact] + public async Task QueryTasks_WithODataOnGroup_ReturnsODataPayload() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var result = await client.GetAsync("/odata/tasks?$orderby=Created&$select=Description"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@odata.context\":\"http://localhost/$metadata#Tasks(Description)\"," + + "\"value\":[" + + "{\"Description\":\"Boil Rice\"}," + + "{\"Description\":\"Cook Pizza\"}" + + "]," + + "\"@odata.nextLink\":\"http://localhost/odata/tasks?$orderby=Created&$select=Description&$skiptoken=Created-2021-04-22,Id-13\"}", + content); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs new file mode 100644 index 000000000..6156e91d8 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs @@ -0,0 +1,191 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public class MinimalAPITodoEndpointsTest : IClassFixture> +{ + private MinimalTestFixture _factory; + private HttpClient _client; + + public MinimalAPITodoEndpointsTest(MinimalTestFixture factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + protected static void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + } + + protected static void ConfigureAPIs(WebApplication app) + { + app.MapGet("v0/todos", (IMiniTodoTaskRepository db) => db.GetTodos()); + + app.MapGet("v1/todos", (IMiniTodoTaskRepository db) => db.GetTodos()) + .WithODataResult(); + + IEdmModel model = MinimalEdmModel.GetEdmModel(); + + app.MapGet("v0/todos/{id}", (IMiniTodoTaskRepository db, int id) => db.GetTodo(id)); + + app.MapGet("v1/todos/{id}", (IMiniTodoTaskRepository db, int id) => db.GetTodo(id)) + .WithODataResult() + .WithODataModel(model); + + // Use ODataQueryOptions + app.MapGet("v2/todos", (IMiniTodoTaskRepository db, ODataQueryOptions queryOptions) + => queryOptions.ApplyTo(db.GetTodos().AsQueryable(), new ODataQuerySettings())) + .WithODataResult() + .WithODataModel(model); + + // Use Filter + app.MapGet("v2/todos/{id}", (IMiniTodoTaskRepository db, int id) => db.GetTodo(id)) + .AddODataQueryEndpointFilter() + .WithODataResult() + .WithODataModel(model) + .WithODataVersion(Microsoft.OData.ODataVersion.V401) + .WithODataBaseAddressFactory(h => new Uri("http://localhost/v2")) + .WithODataOptions(opt => opt.EnableAll().SetCaseInsensitive(true)); + + // DeltaSet endpoint + app.MapPatch("v2/todos", (IMiniTodoTaskRepository db, DeltaSet changes) => $"Patch : '{changes.Count}' to todos") + .WithODataResult() + .WithODataModel(model) + .WithODataVersion(Microsoft.OData.ODataVersion.V401) + .WithODataBaseAddressFactory(h => new Uri("http://localhost/v2")) + .WithODataPathFactory( + (h, t) => + { + IEdmEntitySet todos = model.FindDeclaredEntitySet("Todos"); + return new ODataPath(new EntitySetSegment(todos)); + }); + } + + [Fact] + public async Task QueryTodos_ReturnsNormalJsonPayload() + { + // Arrange & Act + var result = await _client.GetAsync("/v0/todos"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("[{\"id\":1,\"owner\":\"Peter\",\"title\":\"Cooking\",\"isDone\":false,\"tasks\":[{\"id\":11,\"description\":\"Boil Rice\",\"created\":\"2021-04-22\",\"isComplete\":true,\"priority\":1},{\"id\":12,\"description\":\"Cook Potate\",\"created\":\"2022-04-22\",\"isComplete\":false,\"priority\":1},{\"id\":13,\"description\":\"Cook Pizza\",\"created\":\"2021-04-22\",\"isComplete\":false,\"priority\":2}]}," + + "{\"id\":2,\"owner\":\"Wu\",\"title\":\"English Practice\",\"isDone\":true,\"tasks\":[{\"id\":21,\"description\":\"Read English book\",\"created\":\"2024-02-11\",\"isComplete\":true,\"priority\":1},{\"id\":22,\"description\":\"Watch video\",\"created\":\"2022-03-04\",\"isComplete\":false,\"priority\":2}]}," + + "{\"id\":3,\"owner\":\"John\",\"title\":\"Shopping\",\"isDone\":true,\"tasks\":[{\"id\":31,\"description\":\"Buy bread\",\"created\":\"2022-02-11\",\"isComplete\":false,\"priority\":3},{\"id\":32,\"description\":\"Buy washing machine\",\"created\":\"2023-12-14\",\"isComplete\":true,\"priority\":2}]}," + + "{\"id\":4,\"owner\":\"Sam\",\"title\":\"Clean House\",\"isDone\":false,\"tasks\":[{\"id\":41,\"description\":\"Clean carpet\",\"created\":\"2025-02-11\",\"isComplete\":false,\"priority\":2},{\"id\":42,\"description\":\"Clean bathroom\",\"created\":\"2025-12-14\",\"isComplete\":true,\"priority\":1}]}]", content); + } + + [Fact] + public async Task QueryTodos_WithODataResult_ReturnsODataJsonPayload() + { + // Arrange & Act + var result = await _client.GetAsync("/v1/todos"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@odata.context\":\"http://localhost/$metadata#MiniTodo\"," + + "\"value\":[" + + "{\"Id\":1,\"Owner\":\"Peter\",\"Title\":\"Cooking\",\"IsDone\":false}," + + "{\"Id\":2,\"Owner\":\"Wu\",\"Title\":\"English Practice\",\"IsDone\":true}," + + "{\"Id\":3,\"Owner\":\"John\",\"Title\":\"Shopping\",\"IsDone\":true}," + + "{\"Id\":4,\"Owner\":\"Sam\",\"Title\":\"Clean House\",\"IsDone\":false}" + + "]}", content); + } + + [Fact] + public async Task QueryTodo_WithODataResult_WithModel_ReturnsODataJsonPayload() + { + // Arrange & Act + var result = await _client.GetAsync("/v1/todos/3"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@odata.context\":\"http://localhost/$metadata#Todos/$entity\"," + + "\"Id\":3," + + "\"Owner\":\"John\"," + + "\"Title\":\"Shopping\"," + + "\"IsDone\":true," + + "\"Tasks\":[" + + "{\"Id\":31,\"Description\":\"Buy bread\",\"Created\":\"2022-02-11\",\"IsComplete\":false,\"Priority\":3}," + + "{\"Id\":32,\"Description\":\"Buy washing machine\",\"Created\":\"2023-12-14\",\"IsComplete\":true,\"Priority\":2}]}", + content); + } + + [Fact] + public async Task QueryTodos_WithODataResult_WithModel_UsingODataQuery_ReturnsODataJsonPayload() + { + // Arrange & Act + var result = await _client.GetAsync("/v2/todos?$select=Owner,Tasks($select=Description)"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@odata.context\":\"http://localhost/$metadata#Todos(Owner,Tasks/Description)\"," + + "\"value\":[" + + "{\"Owner\":\"Peter\",\"Tasks\":[{\"Description\":\"Boil Rice\"},{\"Description\":\"Cook Potate\"},{\"Description\":\"Cook Pizza\"}]}," + + "{\"Owner\":\"Wu\",\"Tasks\":[{\"Description\":\"Read English book\"},{\"Description\":\"Watch video\"}]}," + + "{\"Owner\":\"John\",\"Tasks\":[{\"Description\":\"Buy bread\"},{\"Description\":\"Buy washing machine\"}]}," + + "{\"Owner\":\"Sam\",\"Tasks\":[{\"Description\":\"Clean carpet\"},{\"Description\":\"Clean bathroom\"}]}]}", + content); + } + + [Fact] + public async Task QueryTodos_WithODataResult_WithModel_UsingFilter_ReturnsODataJsonPayload() + { + // Arrange & Act + var result = await _client.GetAsync("/v2/todos/4?$select=title,Tasks($select=Description;$orderby=id desc)"); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@context\":\"http://localhost/v2/$metadata#Todos(Title,Tasks/Description)/$entity\"," + + "\"Title\":\"Clean House\"," + + "\"Tasks\":[{\"Description\":\"Clean bathroom\"},{\"Description\":\"Clean carpet\"}]}", + content); + } + + [Fact] + public async Task PatchChangesToTodos_WithODataResult_WithModel_WithPath_ReturnsODataJsonPayload() + { + // Arrange & Act + var payload = @"{ + '@context':'http://localhost/v2/$metadata#Todos/$delta', + 'value':[ + { '@odata.id': 'Todos(42)','Title':'No 42 Todo'}, + { '@odata.context': 'http://localhost/v2/$metadata#Todos/$deletedEntity', 'Id': 'Todos(12)', 'reason':'deleted'} + ]}"; + + StringContent stringContent = new StringContent(payload, Encoding.UTF8, "application/json"); + + var result = await _client.PatchAsync("/v2/todos", stringContent); + var content = await result.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal("{\"@context\":\"http://localhost/v2/$metadata#Edm.String\",\"value\":\"Patch : '2' to todos\"}", content); + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataModel.cs new file mode 100644 index 000000000..d650d47d0 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataModel.cs @@ -0,0 +1,38 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public class MiniTodo +{ + public int Id { get; set; } + + public string Owner { get; set; } + + public string Title { get; set; } = string.Empty; + + public bool IsDone { get; set; } + + public IList Tasks { get; set; } +} + +public class MiniTask +{ + public int Id { get; set; } + + public string Description { get; set; } + + public DateOnly Created { get; set; } + + public bool IsComplete { get; set; } + + public int Priority { get; set; } +} + diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataSource.cs new file mode 100644 index 000000000..68ed5b003 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalDataSource.cs @@ -0,0 +1,91 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public interface IMiniTodoTaskRepository +{ + IEnumerable GetTodos(); + + IEnumerable GetTasks(); + + MiniTodo GetTodo(int id); + + MiniTask GetTask(int id); +} + +public class MiniTodoTaskInMemoryRepository : IMiniTodoTaskRepository +{ + private static IList _todos; + private static IList _tasks; + + public IEnumerable GetTodos() => _todos; + + public IEnumerable GetTasks() => _tasks; + + public MiniTodo GetTodo(int id) => _todos.FirstOrDefault(t => t.Id == id); + + public MiniTask GetTask(int id) => _tasks.FirstOrDefault(t => t.Id == id); + + #region TodoTasks + static MiniTodoTaskInMemoryRepository() + { + _todos = new List + { + new MiniTodo + { + Id = 1, Owner = "Peter", Title = "Cooking", IsDone = false, + Tasks = + [ + new MiniTask { Id = 11, Created = new DateOnly(2021, 4, 22), Description = "Boil Rice", IsComplete = true, Priority = 1 }, + new MiniTask { Id = 12, Created = new DateOnly(2022, 4, 22), Description = "Cook Potate", IsComplete = false, Priority = 1 }, + new MiniTask { Id = 13, Created = new DateOnly(2021, 4, 22), Description = "Cook Pizza", IsComplete = false, Priority = 2 }, + ] + }, + new MiniTodo + { + Id = 2, Owner = "Wu", Title = "English Practice", IsDone = true, + Tasks = + [ + new MiniTask { Id = 21, Created = new DateOnly(2024, 2, 11), Description = "Read English book", IsComplete = true, Priority = 1 }, + new MiniTask { Id = 22, Created = new DateOnly(2022, 3, 4), Description = "Watch video", IsComplete = false, Priority = 2 }, + ] + }, + new MiniTodo + { + Id = 3, Owner = "John", Title = "Shopping", IsDone = true, + Tasks = + [ + new MiniTask { Id = 31, Created = new DateOnly(2022, 2, 11), Description = "Buy bread", IsComplete = false, Priority = 3 }, + new MiniTask { Id = 32, Created = new DateOnly(2023, 12, 14), Description = "Buy washing machine", IsComplete = true, Priority = 2 }, + ] + }, + new MiniTodo + { + Id = 4, Owner = "Sam", Title = "Clean House", IsDone = false, + Tasks = + [ + new MiniTask { Id = 41, Created = new DateOnly(2025, 2, 11), Description = "Clean carpet", IsComplete = false, Priority = 2 }, + new MiniTask { Id = 42, Created = new DateOnly(2025, 12, 14), Description = "Clean bathroom", IsComplete = true, Priority = 1 }, + ] + } + }; + + List tasks = new List(); + foreach (var todo in _todos) + { + tasks.AddRange(todo.Tasks); + } + + _tasks = tasks; + } + #endregion +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalEdmModel.cs new file mode 100644 index 000000000..36bf424ab --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalEdmModel.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.MinimalApis; + +public class MinimalEdmModel +{ + private static IEdmModel _model; + + public static IEdmModel GetEdmModel() + { + if (_model == null) + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("Todos"); + builder.ComplexType(); // by default to make it as complex type + _model = builder.GetEdmModel(); + } + + return _model; + } + + private static IEdmModel _entitySetModel; + public static IEdmModel GetAllEntitySetEdmModel() + { + if (_entitySetModel == null) + { + var builder = new ODataConventionModelBuilder(); + builder.EntitySet("Todos"); + builder.EntitySet("Tasks"); + _entitySetModel = builder.GetEdmModel(); + } + + return _entitySetModel; + } +} + + diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Regressions/LocationHeaderTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Regressions/LocationHeaderTests.cs index 0f18c6d8a..8e6802e40 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Regressions/LocationHeaderTests.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Regressions/LocationHeaderTests.cs @@ -58,7 +58,7 @@ public async Task CreateCustomerWithSingleKey_ReturnsCorrectLocationHeaderEscape string locationHeader = response.Headers.GetValues("Location").Single(); - Assert.Equal("http://localhost/location/Customers('abc%2F$+%2F-8')", locationHeader); + Assert.Equal("http://localhost/location/Customers('abc%2F%24%2B%2F-8%27%27%20%26%2C%3F%22')", locationHeader); } [Fact] @@ -101,7 +101,7 @@ public class HandleController : ODataController [HttpPost("location/customers")] public IActionResult CreateCustomer([FromBody]LocCustomer customer) { - customer.Id = $"{customer.Name}/$+/-8"; // insert slash middle + customer.Id = $"{customer.Name}/$+/-8' &,?\""; // insert slash middle and other unsafe url chars return Created(customer); } diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs new file mode 100644 index 000000000..1e966f4d5 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessController.cs @@ -0,0 +1,77 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +public class TypelessDeltaController : ODataController +{ + [HttpGet] + public EdmChangedObjectCollection GetChanges() + { + return TypelessDataSource.TypelessChangeSets; + } +} + +public class TypedDeltaController : ODataController +{ + [HttpGet] + public DeltaSet GetChanges() + { + return TypelessDataSource.TypedChangeSets; + } +} + +public class TypelessOrdersController : ODataController +{ + [HttpGet("Orders")] + public EdmEntityObjectCollection Get() + { + Request.Process(); + + return TypelessDataSource.TypelessUnchangedOrders; + } + + [HttpGet("Orders({key})")] + public IEdmEntityObject Get(int key) + { + Request.Process(); + + var orderEntityObject = TypelessDataSource.TypelessUnchangedOrders.FirstOrDefault(d => + { + if (d.TryGetPropertyValue("Id", out object value) && value.Equals(key)) + { + return true; + } + + return false; + }); + + return orderEntityObject; + } + + [HttpGet("Orders/GetChanged()")] + public ActionResult GetChanged() + { + Request.Process(); + + return TypelessDataSource.TypelessChangedOrders; + } + + [HttpGet("Orders/GetUnchanged()")] + public ActionResult GetUnchanged() + { + Request.Process(); + + return TypelessDataSource.TypelessUnchangedOrders; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs new file mode 100644 index 000000000..2084bf46d --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataModel.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +// Types included for comparison with typeless scenario +public class ChangeSet +{ + public int Id { get; set; } + public object Changed { get; set; } +} + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal CreditLimit { get; set; } + public List Orders { get; set; } +} + +public class Order +{ + public int Id { get; set; } + public decimal Amount { get; set; } + public DateTimeOffset OrderDate { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs new file mode 100644 index 000000000..b3c0fa2f0 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessDataSource.cs @@ -0,0 +1,212 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter.Value; +using Microsoft.OData.Edm; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +internal static class TypelessDataSource +{ + private readonly static EdmChangedObjectCollection typelessChangeSets; + private readonly static DeltaSet typedChangeSets; + private readonly static EdmEntityObjectCollection typelessUnchangedOrders; + private readonly static EdmChangedObjectCollection typelessChangedOrders; + + static TypelessDataSource() + { + typelessChangeSets = CreateTypelessChangeSets(); + typedChangeSets = CreateTypedChangedSets(); + typelessUnchangedOrders = CreateUnchangedOrders(); + typelessChangedOrders = CreateChangedOrders(); + } + + private static EdmChangedObjectCollection CreateTypelessChangeSets() + { + var changeSets = new EdmChangedObjectCollection(TypelessEdmModel.ChangeSetEntityType); + + var changeSetObject1 = new EdmEntityObject(TypelessEdmModel.ChangeSetEntityType); + changeSetObject1.TrySetPropertyValue("Id", 1); + changeSetObject1.TrySetPropertyValue("Changed", CreateTypelessChangedCustomer()); + changeSets.Add(changeSetObject1); + + var changeSetObject2 = new EdmEntityObject(TypelessEdmModel.ChangeSetEntityType); + changeSetObject2.TrySetPropertyValue("Id", 2); + changeSetObject2.TrySetPropertyValue("Changed", CreateTypelessChangedOrder()); + changeSets.Add(changeSetObject2); + + return changeSets; + } + + private static DeltaSet CreateTypedChangedSets() + { + var changeSetDeltaSet = new DeltaSet(); + + var changeSetDeltaItem1 = new Delta(); + changeSetDeltaItem1.TrySetPropertyValue("Id", 1); + changeSetDeltaItem1.TrySetPropertyValue("Changed", CreateTypedChangedCustomer()); + changeSetDeltaSet.Add(changeSetDeltaItem1); + + var changeSetDeltaItem2 = new Delta(); + changeSetDeltaItem2.TrySetPropertyValue("Id", 2); + changeSetDeltaItem2.TrySetPropertyValue("Changed", CreateTypedChangedOrder()); + changeSetDeltaSet.Add(changeSetDeltaItem2); + + return changeSetDeltaSet; + } + + private static EdmDeltaResourceObject CreateTypelessChangedCustomer() + { + var changedOrders = new EdmChangedObjectCollection( + EdmCoreModel.Instance.GetEntityType()); + + var changeOrder1Object = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + changeOrder1Object.TrySetPropertyValue("Id", 1); + changedOrders.Add(changeOrder1Object); + + var changedOrder2Object = new EdmDeltaDeletedResourceObject(TypelessEdmModel.OrderEntityType); + changedOrder2Object.Id = new Uri("http://tempuri.org/Orders(2)"); + changedOrder2Object.TrySetPropertyValue("Id", 2); + changedOrders.Add(changedOrder2Object); + + var changedCustomerObject = new EdmDeltaResourceObject(TypelessEdmModel.CustomerEntityType); + changedCustomerObject.TrySetPropertyValue("Id", 1); + changedCustomerObject.TrySetPropertyValue("Orders", changedOrders); + + return changedCustomerObject; + } + + private static EdmDeltaResourceObject CreateTypelessChangedOrder() + { + var changedOrderObject = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + changedOrderObject.TrySetPropertyValue("Id", 1); + changedOrderObject.TrySetPropertyValue("Amount", 310m); + + return changedOrderObject; + } + + private static Delta CreateTypedChangedCustomer() + { + var ordersDeltaSet = new DeltaSet(); + + var order1DeltaObject = new Delta(); + order1DeltaObject.TrySetPropertyValue("Id", 1); + ordersDeltaSet.Add(order1DeltaObject); + + var order2DeltaObject = new DeltaDeletedResource(); + order2DeltaObject.Id = new Uri("http://tempuri.org/Orders(2)"); + order2DeltaObject.TrySetPropertyValue("Id", 2); + ordersDeltaSet.Add(order2DeltaObject); + + var customerDeltaObject = new Delta(); + customerDeltaObject.TrySetPropertyValue("Id", 1); + customerDeltaObject.TrySetPropertyValue("Orders", ordersDeltaSet); + + return customerDeltaObject; + } + + private static Delta CreateTypedChangedOrder() + { + var orderDeltaObject = new Delta(); + orderDeltaObject.TrySetPropertyValue("Id", 1); + orderDeltaObject.TrySetPropertyValue("Amount", 310m); + + return orderDeltaObject; + } + + private static EdmEntityObjectCollection CreateUnchangedOrders() + { + var address1Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address1Object.TrySetPropertyValue("City", "Redmond"); + address1Object.TrySetPropertyValue("State", "Washington"); + + var address2Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address2Object.TrySetPropertyValue("City", "Dallas"); + address2Object.TrySetPropertyValue("State", "Texas"); + + var customer1Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer1Object.TrySetPropertyValue("Id", 1); + customer1Object.TrySetPropertyValue("Name", "Sue"); + customer1Object.TrySetPropertyValue("CreditLimit", 1300m); + + var customer2Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer2Object.TrySetPropertyValue("Id", 2); + customer2Object.TrySetPropertyValue("Name", "Joe"); + customer2Object.TrySetPropertyValue("CreditLimit", 1700m); + + var order1Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order1Object.TrySetPropertyValue("Id", 1); + order1Object.TrySetPropertyValue("Amount", 310m); + order1Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 7, 11, 59, 59, TimeSpan.Zero)); + order1Object.TrySetPropertyValue("ShippingAddress", address1Object); + order1Object.TrySetPropertyValue("Customer", customer1Object); + + var order2Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order2Object.TrySetPropertyValue("Id", 2); + order2Object.TrySetPropertyValue("Amount", 290m); + order2Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 14, 11, 59, 59, TimeSpan.Zero)); + order2Object.TrySetPropertyValue("ShippingAddress", address2Object); + order2Object.TrySetPropertyValue("Customer", customer2Object); + + return new EdmEntityObjectCollection( + new EdmCollectionTypeReference( + new EdmCollectionType( + new EdmEntityTypeReference(TypelessEdmModel.OrderEntityType, false)))) + { + order1Object, + order2Object + }; + } + + private static EdmChangedObjectCollection CreateChangedOrders() + { + // EdmComplexObject used in place of EdmDeltaComplexObject + var address1Object = new EdmComplexObject(TypelessEdmModel.AddressComplexType); + address1Object.TrySetPropertyValue("City", "Redmond"); + + var address2Object = new EdmDeltaComplexObject(TypelessEdmModel.AddressComplexType); + address2Object.TrySetPropertyValue("State", "Texas"); + + // EdmEntityObject used in place of EdmDeltaResourceObject + var customer1Object = new EdmEntityObject(TypelessEdmModel.CustomerEntityType); + customer1Object.TrySetPropertyValue("Id", 1); + customer1Object.TrySetPropertyValue("CreditLimit", 3100m); + + var customer2Object = new EdmDeltaResourceObject(TypelessEdmModel.CustomerEntityType); + customer2Object.TrySetPropertyValue("Id", 2); + customer2Object.TrySetPropertyValue("Name", "Luc"); + + var order1Object = new EdmDeltaResourceObject(TypelessEdmModel.OrderEntityType); + order1Object.TrySetPropertyValue("Id", 1); + order1Object.TrySetPropertyValue("OrderDate", new DateTimeOffset(2025, 02, 7, 11, 59, 59, TimeSpan.Zero)); + order1Object.TrySetPropertyValue("ShippingAddress", address1Object); + order1Object.TrySetPropertyValue("Customer", customer1Object); + + // EdmEntityObject used in place of EdmDeltaResourceObject + var order2Object = new EdmEntityObject(TypelessEdmModel.OrderEntityType); + order2Object.TrySetPropertyValue("Id", 2); + order2Object.TrySetPropertyValue("Amount", 590m); + order2Object.TrySetPropertyValue("ShippingAddress", address2Object); + order2Object.TrySetPropertyValue("Customer", customer2Object); + + return new EdmChangedObjectCollection(TypelessEdmModel.OrderEntityType) + { + order1Object, + order2Object + }; + } + + public static EdmChangedObjectCollection TypelessChangeSets => typelessChangeSets; + + public static DeltaSet TypedChangeSets => typedChangeSets; + + public static EdmEntityObjectCollection TypelessUnchangedOrders => typelessUnchangedOrders; + + public static EdmChangedObjectCollection TypelessChangedOrders => typelessChangedOrders; +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs new file mode 100644 index 000000000..e622247ef --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessEdmModel.cs @@ -0,0 +1,133 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +internal static class TypelessEdmModel +{ + const string typelessNamespace = "Microsoft.AspNetCore.OData.E2E.Tests.Typeless"; + const string defaultNamespace = "Default"; + + private static readonly EdmModel model; + private static readonly EdmEntityType deltaEntityType; + private static readonly EdmEntityType changeSetEntityType; + private static readonly EdmEntityType customerEntityType; + private static readonly EdmEntityType orderEntityType; + private static readonly EdmComplexType addressComplexType; + + static TypelessEdmModel() + { + model = new EdmModel(); + + deltaEntityType = model.AddEntityType(typelessNamespace, "Delta"); + var deltaChangeIdProperty = deltaEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + deltaEntityType.AddKeys(deltaChangeIdProperty); + + changeSetEntityType = model.AddEntityType(typelessNamespace, "ChangeSet"); + var changeSetIdProperty = changeSetEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + changeSetEntityType.AddKeys(changeSetIdProperty); + + changeSetEntityType.AddUnidirectionalNavigation(new EdmNavigationPropertyInfo + { + Name = "Changed", + Target = EdmCoreModel.Instance.GetEntityType(), + TargetMultiplicity = EdmMultiplicity.One + }); + + addressComplexType = model.AddComplexType(typelessNamespace, "Address"); + addressComplexType.AddStructuralProperty("City", EdmPrimitiveTypeKind.String); + addressComplexType.AddStructuralProperty("State", EdmPrimitiveTypeKind.String); + + orderEntityType = model.AddEntityType(typelessNamespace, "Order"); + var orderIdProperty = orderEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + orderEntityType.AddKeys(orderIdProperty); + orderEntityType.AddStructuralProperty("Amount", EdmPrimitiveTypeKind.Decimal); + orderEntityType.AddStructuralProperty("OrderDate", EdmPrimitiveTypeKind.DateTimeOffset); + orderEntityType.AddStructuralProperty("ShippingAddress", new EdmComplexTypeReference(addressComplexType, false)); + + customerEntityType = model.AddEntityType(typelessNamespace, "Customer"); + var customerIdProperty = customerEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + customerEntityType.AddKeys(customerIdProperty); + customerEntityType.AddStructuralProperty("Name", EdmPrimitiveTypeKind.String); + customerEntityType.AddStructuralProperty("CreditLimit", EdmPrimitiveTypeKind.Decimal); + + var ordersNavigationProperty = customerEntityType.AddUnidirectionalNavigation(new EdmNavigationPropertyInfo + { + Name = "Orders", + Target = orderEntityType, + TargetMultiplicity = EdmMultiplicity.Many + }); + + var customerNavigationProperty = orderEntityType.AddUnidirectionalNavigation( + new EdmNavigationPropertyInfo + { + Name = "Customer", + TargetMultiplicity = EdmMultiplicity.One, + Target = customerEntityType + }); + + var getChangesFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetChanges", + returnType: new EdmCollectionTypeReference(new EdmCollectionType(new EdmEntityTypeReference(changeSetEntityType, false))), + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getChangesFunction.AddParameter("bindingParameter", new EdmEntityTypeReference(deltaEntityType, false)); + model.AddElement(getChangesFunction); + + var orderEntityCollectionTypeReference = new EdmCollectionTypeReference(new EdmCollectionType(new EdmEntityTypeReference(orderEntityType, false))); + + var getChangedFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetChanged", + returnType: orderEntityCollectionTypeReference, + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getChangedFunction.AddParameter("bindingParameter", orderEntityCollectionTypeReference); + model.AddElement(getChangedFunction); + + var getUnchangedFunction = new EdmFunction( + namespaceName: defaultNamespace, + name: "GetUnchanged", + returnType: orderEntityCollectionTypeReference, + isBound: true, + entitySetPathExpression: null, + isComposable: true); + getUnchangedFunction.AddParameter("bindingParameter", orderEntityCollectionTypeReference); + model.AddElement(getUnchangedFunction); + + var defaultEntityContainer = model.AddEntityContainer("Default", "Container"); + var customersEntitySet = defaultEntityContainer.AddEntitySet("Customers", customerEntityType); + var ordersEntitySet = defaultEntityContainer.AddEntitySet("Orders", orderEntityType); + + customersEntitySet.AddNavigationTarget(ordersNavigationProperty, ordersEntitySet); + ordersEntitySet.AddNavigationTarget(customerNavigationProperty, customersEntitySet); + + defaultEntityContainer.AddSingleton("TypelessDelta", deltaEntityType); + defaultEntityContainer.AddSingleton("TypedDelta", deltaEntityType); + defaultEntityContainer.AddEntitySet("ChangeSets", changeSetEntityType); + + model.SetAnnotationValue(getChangesFunction, new ReturnedEntitySetAnnotation("ChangeSets")); + } + + public static EdmEntityType DeltaEntityType => deltaEntityType; + + public static EdmEntityType ChangeSetEntityType => changeSetEntityType; + + public static EdmEntityType CustomerEntityType => customerEntityType; + + public static EdmEntityType OrderEntityType => orderEntityType; + + public static EdmComplexType AddressComplexType => addressComplexType; + + public static EdmModel GetModel() => model; +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs new file mode 100644 index 000000000..6dd2cf5ba --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessExtensions.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +internal static class TypelessExtensions +{ + public static void Process(this HttpRequest request) + { + var path = request.ODataFeature().Path; + var elementType = path.EdmType().Definition.AsElementType(); + var model = request.ODataFeature().Model; + var queryContext = new ODataQueryContext(model, elementType, path); + var queryOptions = new ODataQueryOptions(queryContext, request); + + request.ODataFeature().SelectExpandClause = queryOptions.SelectExpand?.SelectExpandClause; + request.ODataFeature().ApplyClause = queryOptions.Apply?.ApplyClause; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs new file mode 100644 index 000000000..a8def1192 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Typeless/TypelessTests.cs @@ -0,0 +1,207 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Typeless; + +public class TypelessTests : WebApiTestBase +{ + public TypelessTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = TypelessEdmModel.GetModel(); + + services.ConfigureControllers(typeof(TypelessOrdersController), typeof(TypelessDeltaController), typeof(TypedDeltaController)); + services.AddControllers().AddOData( + options => options.EnableQueryFeatures().AddRouteComponents(TypelessEdmModel.GetModel())); + } + + [Theory] + [InlineData("TypelessDelta/GetChanges()")] + [InlineData("TypedDelta/GetChanges()")] // Typed scenario included for comparison + public async Task TestPropertiesNotSetInDeltaAreNotIncludedInPayloadAsync(string requestUri) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith("/$metadata#ChangeSets/$delta\",\"value\":[" + + "{\"Id\":1," + + "\"Changed\":{" + + "\"@odata.type\":\"#Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Customer\"," + + "\"Id\":1," + + "\"Orders@delta\":[{\"Id\":1},{\"@odata.removed\":{\"reason\":\"deleted\"},\"@odata.id\":\"http://tempuri.org/Orders(2)\",\"Id\":2}]}}," + + "{\"Id\":2," + + "\"Changed\":{" + + "\"@odata.type\":\"#Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order\"," + + "\"Id\":1," + + "\"Amount\":310}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyNotAutoExpandedAsync(string requestUri, string metadataFragment) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Customer())")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyExpandedAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$expand=Customer"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}," + + "\"Customer\":{\"Id\":1,\"Name\":\"Sue\",\"CreditLimit\":1300}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}," + + "\"Customer\":{\"Id\":2,\"Name\":\"Joe\",\"CreditLimit\":1700}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Customer(Name))")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestNavigationPropertyExpandedWithNestedSelectAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$expand=Customer($select=Name)"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1," + + "\"Amount\":310," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\",\"State\":\"Washington\"}," + + "\"Customer\":{\"Name\":\"Sue\"}}," + + "{\"Id\":2," + + "\"Amount\":290," + + "\"OrderDate\":\"2025-02-14T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Dallas\",\"State\":\"Texas\"}," + + "\"Customer\":{\"Name\":\"Joe\"}}]}", + content); + } + + [Theory] + [InlineData("Orders", "Orders(Id,Amount,Customer(Id,CreditLimit))")] + [InlineData("Orders/GetUnchanged()", "Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)")] + public async Task TestSelectAndNavigationPropertyExpandedWithNestedSelectAsync(string resourcePath, string metadataFragment) + { + // Arrange + var requestUri = $"{resourcePath}?$select=Id,Amount&$expand=Customer($select=Id,CreditLimit)"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith($"/$metadata#{metadataFragment}\",\"value\":[" + + "{\"Id\":1,\"Amount\":310,\"Customer\":{\"Id\":1,\"CreditLimit\":1300}}," + + "{\"Id\":2,\"Amount\":290,\"Customer\":{\"Id\":2,\"CreditLimit\":1700}}]}", + content); + } + + [Fact] + public async Task TestSingleResultSelectAndNavigationPropertyExpandedWithNestedSelectAsync() + { + // Arrange + var requestUri = "Orders(1)?$select=Id,Amount&$expand=Customer($select=Id,CreditLimit)"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith("/$metadata#Orders(Id,Amount,Customer(Id,CreditLimit))/$entity\"," + + "\"Id\":1,\"Amount\":310,\"Customer\":{\"Id\":1,\"CreditLimit\":1300}}", + content); + } + + [Fact] + public async Task TestEdmEntityObjectAndEdmComplexObjectInChangedObjectCollection() + { + // Arrange + var requestUri = "Orders/GetChanged()"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.EndsWith("/$metadata#Collection(Microsoft.AspNetCore.OData.E2E.Tests.Typeless.Order)/$delta\",\"value\":[" + + "{\"Id\":1," + + "\"OrderDate\":\"2025-02-07T11:59:59Z\"," + + "\"ShippingAddress\":{\"City\":\"Redmond\"}," + + "\"Customer\":{\"Id\":1,\"CreditLimit\":3100}}," + + "{\"Id\":2," + + "\"Amount\":590," + + "\"ShippingAddress\":{\"State\":\"Texas\"}," + + "\"Customer\":{\"Id\":2,\"Name\":\"Luc\"}}]}", + content); + } +} diff --git a/test/Microsoft.AspNetCore.OData.TestCommon/Utils/MinimalTestFixtureOfT.cs b/test/Microsoft.AspNetCore.OData.TestCommon/Utils/MinimalTestFixtureOfT.cs new file mode 100644 index 000000000..18f4dcf8d --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.TestCommon/Utils/MinimalTestFixtureOfT.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.TestCommon; + +public class MinimalTestFixture : IDisposable where T : class +{ + /// + /// The test server. + /// + private TestServer _server; + + /// + /// Initializes a new instance of the class + /// + public MinimalTestFixture() + { + Initialize(); + } + + /// + /// Create the . + /// + /// The created HttpClient. + public HttpClient CreateClient() + { + return _server.CreateClient(); + } + + /// + /// Cleanup the server. + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + + /// + /// Cleanup the server. + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (_server != null) + { + _server.Dispose(); + _server = null; + } + + GC.SuppressFinalize(this); + } + } + + /// + /// Initialize the fixture. + /// + private async void Initialize() + { + Type testType = typeof(T); + + // Be noted: + // We use the convention as follows + // 1) if you want to configure the service, add "protected static void ConfigureServices(IServiceCollection)" method into your test class. + MethodInfo configureServicesMethod = testType.GetMethod("ConfigureServices", BindingFlags.NonPublic | BindingFlags.Static); + + // 2) if you want to configure the routing, add "protected static void ConfigureAPIs(WebApplication)" method into your test class. + MethodInfo configureMethod = testType.GetMethod("ConfigureAPIs", BindingFlags.NonPublic | BindingFlags.Static); + // Owing that this is used in Test only, I assume every developer can following the convention. + // So I skip the method parameter checking. + + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.UseTestServer(); + + configureServicesMethod?.Invoke(null, [builder.Services]); + + var app = builder.Build(); + + _server = (TestServer)app.Services.GetRequiredService(); + + configureMethod?.Invoke(null, [app]); + + await app.RunAsync(); + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Commons/TypeHelperTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Commons/TypeHelperTest.cs index ad6859416..3a228da60 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Commons/TypeHelperTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Commons/TypeHelperTest.cs @@ -232,6 +232,92 @@ public void GetLoadedTypes_ReturnsAsExpected() Assert.DoesNotContain(typeof(TypeHelperTest), foundTypes); } + [Theory] + [InlineData(typeof(IAsyncEnumerable))] + [InlineData(typeof(IAsyncEnumerable>))] + [InlineData(typeof(CustomGenericAsyncEnumerable))] + [InlineData(typeof(CustomGenericAsyncEnumerable))] + [InlineData(typeof(CustomAsyncEnumerable))] + public void IsAsyncEnumerableType_ReturnsTrue_ForIAsyncEnumerable(Type type) + { + // Arrange & Act + var result = TypeHelper.IsAsyncEnumerableType(type); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(typeof(List))] + [InlineData(typeof(IList))] + [InlineData(typeof(ICollection))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(IQueryable))] + [InlineData(typeof(IAsyncEnumerator))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(IEnumerable>))] + [InlineData(typeof(IEnumerable>))] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(CustomBoolCollection))] + [InlineData(typeof(CustomIntCollection))] + [InlineData(typeof(CustomAbstractClass))] + [InlineData(typeof(CustomConcreteClass))] + [InlineData(typeof(Task))] + [InlineData(typeof(Task))] + [InlineData(typeof(Task>))] + [InlineData(typeof(Task>))] + [InlineData(typeof(Task>>))] + [InlineData(typeof(Task>>))] + [InlineData(typeof(Task>))] + [InlineData(typeof(Task>))] + [InlineData(typeof(Task>))] + [InlineData(typeof(CustomInternalClass[]))] + public void IsAsyncEnumerableType_ReturnsFalse_ForNonAsyncEnumerableType(Type type) + { + // Arrange & Act + var result = TypeHelper.IsAsyncEnumerableType(type); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsAsyncEnumerableType_ReturnsTrue_ForGenericAsyncEnumerable() + { + // Arrange + var asyncNumbers = ToAsyncEnumerable(Enumerable.Range(1, 10)); + var asyncEnumerable = GenericAsyncEnumerableWithDelay(asyncNumbers, TimeSpan.FromSeconds(1)); + + // Act + var result = TypeHelper.IsAsyncEnumerableType(asyncEnumerable.GetType()); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsAsyncEnumerableType_ReturnsTrue_ForTypedAsyncEnumerable() + { + // Arrange + var asyncNumbers = ToAsyncEnumerable(Enumerable.Range(1, 5).Select(i => new CustomInternalClass())); + var asyncEnumerable = TypedAsyncEnumerableWithDelay(asyncNumbers, TimeSpan.FromSeconds(1)); + + // Act + var result = TypeHelper.IsAsyncEnumerableType(asyncEnumerable.GetType()); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsAsyncEnumerableType_ThrowsArgumentNullException_ForNullType() + { + // Act & Assert + Assert.Throws(() => TypeHelper.IsAsyncEnumerableType(null)); + } + /// /// Custom internal class /// @@ -268,4 +354,53 @@ private class CustomConcreteClass : CustomAbstractClass { public override int Area() { return 42; } } + + public IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + private async IAsyncEnumerable GenericAsyncEnumerableWithDelay(IAsyncEnumerable asyncEnumerable, TimeSpan delay) + { + await foreach (var entity in asyncEnumerable) + { + await Task.Delay(delay); + yield return entity; + } + } + + private async IAsyncEnumerable TypedAsyncEnumerableWithDelay(IAsyncEnumerable asyncEnumerable, TimeSpan delay) + { + await foreach (var entity in asyncEnumerable) + { + await Task.Delay(delay); + yield return entity; + } + } + + private class CustomGenericAsyncEnumerable : IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private class CustomAsyncEnumerable : IAsyncEnumerable> + { + public IAsyncEnumerator> GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + await Task.Yield(); + } + } + } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Deltas/DeltaTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Deltas/DeltaTests.cs index 61e24f414..44f89f5d4 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Deltas/DeltaTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Deltas/DeltaTests.cs @@ -5,6 +5,13 @@ // //------------------------------------------------------------------------------ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.AspNetCore.OData.Tests.Commons; +using Microsoft.AspNetCore.OData.Tests.Models; +using Microsoft.OData.Edm; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -12,11 +19,7 @@ using System.Linq; using System.Reflection; using System.Runtime.Serialization; -using Microsoft.AspNetCore.OData.Deltas; -using Microsoft.AspNetCore.OData.TestCommon; -using Microsoft.AspNetCore.OData.Tests.Commons; -using Microsoft.AspNetCore.OData.Tests.Models; -using Microsoft.OData.Edm; +using System.Threading.Tasks; using Xunit; namespace Microsoft.AspNetCore.OData.Tests.Deltas; @@ -60,6 +63,19 @@ public static IEnumerable DeltaModelPropertyNamesData } } + [Fact] + public async ValueTask BindAsync_ThrowsArgumentNull_ForInputs() + { + // Arrange & Act & Assert + ArgumentNullException exception = await ExceptionAssert.ThrowsAsync(async () => await Delta.BindAsync(null, null), "httpContext", false, true); + Assert.Equal("The parameter cannot be null. (Parameter 'httpContext')", exception.Message); + + // Arrange & Act & Assert + HttpContext httpContext = new DefaultHttpContext(); + await ExceptionAssert.ThrowsAsync(async () => await Delta.BindAsync(httpContext, null)); + Assert.Equal("The parameter cannot be null. (Parameter 'parameter')", exception.Message); + } + [Fact] public void TryGetPropertyValue_ThrowsArgumentNull_original() { diff --git a/test/Microsoft.AspNetCore.OData.Tests/Extensions/HttpContextExtensionsTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Extensions/HttpContextExtensionsTests.cs index 2b47e40ec..3b1ff21f1 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Extensions/HttpContextExtensionsTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Extensions/HttpContextExtensionsTests.cs @@ -5,7 +5,9 @@ // //------------------------------------------------------------------------------ +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Abstracts; using Microsoft.AspNetCore.OData.Extensions; using Microsoft.AspNetCore.OData.Tests.Commons; using Xunit; @@ -29,4 +31,84 @@ public void ODataBatchFeature_ThrowsArgumentNull_HttpContext() HttpContext httpContext = null; ExceptionAssert.ThrowsArgumentNull(() => httpContext.ODataBatchFeature(), "httpContext"); } + + [Fact] + public void ODataOptions_ThrowsArgumentNull_HttpContext() + { + // Arrange & Act & Assert + HttpContext httpContext = null; + ExceptionAssert.ThrowsArgumentNull(() => httpContext.ODataOptions(), "httpContext"); + } + + [Fact] + public void ODataFeature_ReturnsODataFeature() + { + // Arrange + HttpContext httpContext = new DefaultHttpContext(); + IODataFeature odataFeature = new ODataFeature(); + httpContext.Features.Set(odataFeature); + + // Act + IODataFeature result = httpContext.ODataFeature(); + + // Assert + Assert.Same(odataFeature, result); + } + + [Fact] + public void IsMinimalEndpoint_ThrowsArgumentNull_HttpContext() + { + // Arrange & Act & Assert + HttpContext httpContext = null; + ExceptionAssert.ThrowsArgumentNull(() => httpContext.IsMinimalEndpoint(), "httpContext"); + } + + [Fact] + public void IsMinimalEndpoint_ReturnsFalse_WhenEndpointIsNull() + { + // Arrange + HttpContext httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(null); + + // Act + bool result = httpContext.IsMinimalEndpoint(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMinimalEndpoint_ReturnsFalse_WhenEndpointIsNotNull_WithoutMetadata() + { + // Arrange + HttpContext httpContext = new DefaultHttpContext(); + Endpoint endpoint = new Endpoint((context) => Task.CompletedTask, EndpointMetadataCollection.Empty, "TestEndpoint"); + httpContext.SetEndpoint(endpoint); + + // Act + bool result = httpContext.IsMinimalEndpoint(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMinimalEndpoint_ReturnsTrue_WhenEndpointHasMetadata() + { + // Arrange + HttpContext httpContext = new DefaultHttpContext(); + Endpoint endpoint = new Endpoint( + (context) => Task.CompletedTask, + new EndpointMetadataCollection([new ODataMiniMetadata()]), + "TestEndpoint"); + + httpContext.SetEndpoint(endpoint); + + // Act + bool result = httpContext.IsMinimalEndpoint(); + + // Assert + Assert.True(result); + } + } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataActionParametersTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataActionParametersTests.cs new file mode 100644 index 000000000..5f324b3c7 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataActionParametersTests.cs @@ -0,0 +1,76 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Deserialization; +using Microsoft.AspNetCore.OData.Tests.Commons; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Formatter; + +public class ODataActionParametersTests +{ + [Fact] + public async ValueTask BindAsync_ThrowsArgumentNull_ForInputs() + { + // Arrange & Act & Assert + ArgumentNullException exception = await ExceptionAssert.ThrowsAsync(async () => await ODataActionParameters.BindAsync(null, null), "httpContext", false, true); + Assert.Equal("The parameter cannot be null. (Parameter 'httpContext')", exception.Message); + + // Arrange & Act & Assert + HttpContext httpContext = new DefaultHttpContext(); + await ExceptionAssert.ThrowsAsync(async () => await ODataActionParameters.BindAsync(httpContext, null)); + Assert.Equal("The parameter cannot be null. (Parameter 'parameter')", exception.Message); + } + + [Fact] + public async ValueTask BindAsync_Returns_ValidODataActionParameter() + { + // Arrange + Mock deserializerProviderMock = new Mock(); + Mock mock = new Mock(deserializerProviderMock.Object); + + ODataActionParameters expectedParameters = new ODataActionParameters(); + + mock.Setup(m => m.ReadAsync(It.IsAny(), typeof(ODataActionParameters), It.IsAny())) + .ReturnsAsync(expectedParameters); + + HttpContext httpContext = new DefaultHttpContext(); + + ODataMiniMetadata metadata = new ODataMiniMetadata(); + metadata.Model = EdmCoreModel.Instance; + metadata.PathFactory = (c, t) => new ODataPath(); + metadata.BaseAddressFactory = c => new Uri("http://localhost/odata/"); + metadata.Services = services => services.AddSingleton(s => mock.Object); + + Endpoint endpoint = new Endpoint( + (context) => Task.CompletedTask, + new EndpointMetadataCollection([metadata]), + "TestEndpoint"); + + httpContext.SetEndpoint(endpoint); + ParameterInfo parameter = typeof(ODataActionParametersTests).GetMethod("TestMethod", BindingFlags.NonPublic | BindingFlags.Static).GetParameters().First(); + + ODataActionParameters actualParameter = await ODataActionParameters.BindAsync(httpContext, parameter); + + // Act & Assert + Assert.Same(expectedParameters, actualParameter); + } + + // This empty method is used to provide a parameter for the BindAsync test. + private static void TestMethod(ODataActionParameters parameters) { } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataUntypedActionParametersTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataUntypedActionParametersTests.cs index baa102c7e..fa6228461 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataUntypedActionParametersTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/ODataUntypedActionParametersTests.cs @@ -5,8 +5,19 @@ // //------------------------------------------------------------------------------ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OData.Formatter; +using Microsoft.AspNetCore.OData.Formatter.Deserialization; using Microsoft.AspNetCore.OData.Tests.Commons; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Moq; using Xunit; namespace Microsoft.AspNetCore.OData.Tests.Formatter; @@ -19,4 +30,54 @@ public void Ctor_ThrowsArgumentNull_Action() // Arrange & Act & Assert ExceptionAssert.ThrowsArgumentNull(() => new ODataUntypedActionParameters(null), "action"); } + + [Fact] + public async ValueTask BindAsync_ThrowsArgumentNull_ForInputs() + { + // Arrange & Act & Assert + ArgumentNullException exception = await ExceptionAssert.ThrowsAsync(async () => await ODataUntypedActionParameters.BindAsync(null, null), "httpContext", false, true); + Assert.Equal("The parameter cannot be null. (Parameter 'httpContext')", exception.Message); + + // Arrange & Act & Assert + HttpContext httpContext = new DefaultHttpContext(); + await ExceptionAssert.ThrowsAsync(async () => await ODataUntypedActionParameters.BindAsync(httpContext, null)); + Assert.Equal("The parameter cannot be null. (Parameter 'parameter')", exception.Message); + } + + [Fact] + public async ValueTask BindAsync_Returns_ValidODataUntypedActionParameter() + { + // Arrange + Mock deserializerProviderMock = new Mock(); + Mock mock = new Mock(deserializerProviderMock.Object); + + ODataUntypedActionParameters expectedParameters = new ODataUntypedActionParameters(new Mock().Object); + + mock.Setup(m => m.ReadAsync(It.IsAny(), typeof(ODataUntypedActionParameters), It.IsAny())) + .ReturnsAsync(expectedParameters); + + HttpContext httpContext = new DefaultHttpContext(); + + ODataMiniMetadata metadata = new ODataMiniMetadata(); + metadata.Model = EdmCoreModel.Instance; + metadata.PathFactory = (c, t) => new ODataPath(); + metadata.BaseAddressFactory = c => new Uri("http://localhost/odata/"); + metadata.Services = services => services.AddSingleton(s => mock.Object); + + Endpoint endpoint = new Endpoint( + (context) => Task.CompletedTask, + new EndpointMetadataCollection([metadata]), + "TestEndpoint"); + + httpContext.SetEndpoint(endpoint); + ParameterInfo parameter = typeof(ODataActionParametersTests).GetMethod("TestMethod", BindingFlags.NonPublic | BindingFlags.Static).GetParameters().First(); + + ODataUntypedActionParameters actualParameter = await ODataUntypedActionParameters.BindAsync(httpContext, parameter); + + // Act & Assert + Assert.Same(expectedParameters, actualParameter); + } + + // This empty method is used to provide a parameter for the BindAsync test. + private static void TestMethod(ODataUntypedActionParameters parameters) { } } diff --git a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs index 7b3160426..729716069 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Formatter/Serialization/ODataSerializerContextTest.cs @@ -40,7 +40,7 @@ public void Ctor_ForNestedContext_ThrowsArgumentNull_Resource() // Act & Assert ExceptionAssert.ThrowsArgumentNull( - () => new ODataSerializerContext(resource: null, selectExpandClause: selectExpand, edmProperty: navProp), "resource"); + () => new ODataSerializerContext(resource: null, selectExpandClause: selectExpand, edmProperty: navProp), "resourceContext"); } [Fact] diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl index ee7febe86..65a1e9bc2 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl @@ -23,6 +23,86 @@ public sealed class Microsoft.AspNetCore.OData.ODataApplicationBuilderExtensions public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseODataRouteDebug (Microsoft.AspNetCore.Builder.IApplicationBuilder app, string routePattern) } +[ +ExtensionAttribute(), +] +public sealed class Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions { + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.RouteHandlerBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Routing.RouteGroupBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Routing.RouteGroupBuilder builder) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.RouteHandlerBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Routing.RouteGroupBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Routing.RouteGroupBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.RouteHandlerBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, params System.Action`1[[Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings]] validationSetup, params System.Action`1[[Microsoft.AspNetCore.OData.Query.ODataQuerySettings]] querySetup) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Routing.RouteGroupBuilder AddODataQueryEndpointFilter (Microsoft.AspNetCore.Routing.RouteGroupBuilder builder, params System.Action`1[[Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings]] validationSetup, params System.Action`1[[Microsoft.AspNetCore.OData.Query.ODataQuerySettings]] querySetup) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapODataMetadata (Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, Microsoft.OData.Edm.IEdmModel model) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapODataServiceDocument (Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, string pattern, Microsoft.OData.Edm.IEdmModel model) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataBaseAddressFactory (TBuilder builder, System.Func`2[[Microsoft.AspNetCore.Http.HttpContext],[System.Uri]] baseAddressFactory) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataModel (TBuilder builder, Microsoft.OData.Edm.IEdmModel model) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataOptions (TBuilder builder, System.Action`1[[Microsoft.AspNetCore.OData.ODataMiniOptions]] setupAction) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataPathFactory (TBuilder builder, System.Func`3[[Microsoft.AspNetCore.Http.HttpContext],[System.Type],[Microsoft.OData.UriParser.ODataPath]] pathFactory) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataResult (TBuilder builder) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataServices (TBuilder builder, System.Action`1[[Microsoft.Extensions.DependencyInjection.IServiceCollection]] services) + + [ + ExtensionAttribute(), + ] + public static TBuilder WithODataVersion (TBuilder builder, Microsoft.OData.ODataVersion version) +} + [ ExtensionAttribute(), ] @@ -67,6 +147,16 @@ public sealed class Microsoft.AspNetCore.OData.ODataMvcCoreBuilderExtensions { ExtensionAttribute(), ] public sealed class Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions { + [ + ExtensionAttribute(), + ] + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddOData (Microsoft.Extensions.DependencyInjection.IServiceCollection services) + + [ + ExtensionAttribute(), + ] + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddOData (Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action`1[[Microsoft.AspNetCore.OData.ODataMiniOptions]] setupAction) + [ ExtensionAttribute(), ] @@ -89,6 +179,42 @@ public class Microsoft.AspNetCore.OData.ODataJsonOptionsSetup : IConfigureOption public virtual void Configure (Microsoft.AspNetCore.Mvc.JsonOptions options) } +public class Microsoft.AspNetCore.OData.ODataMiniMetadata { + public ODataMiniMetadata () + + System.Func`2[[Microsoft.AspNetCore.Http.HttpContext],[System.Uri]] BaseAddressFactory { public get; public set; } + bool IsODataFormat { public get; public set; } + Microsoft.OData.Edm.IEdmModel Model { public get; public set; } + Microsoft.AspNetCore.OData.ODataMiniOptions Options { public get; } + System.Func`3[[Microsoft.AspNetCore.Http.HttpContext],[System.Type],[Microsoft.OData.UriParser.ODataPath]] PathFactory { public get; public set; } + System.IServiceProvider ServiceProvider { public get; public set; } + System.Action`1[[Microsoft.Extensions.DependencyInjection.IServiceCollection]] Services { public get; public set; } + Microsoft.OData.ODataVersion Version { public get; public set; } +} + +public class Microsoft.AspNetCore.OData.ODataMiniOptions { + public ODataMiniOptions () + + bool EnableCaseInsensitive { public get; } + bool EnableNoDollarQueryOptions { public get; } + Microsoft.AspNetCore.OData.Query.DefaultQueryConfigurations QueryConfigurations { public get; } + System.TimeZoneInfo TimeZone { public get; } + Microsoft.OData.ODataVersion Version { public get; } + + public Microsoft.AspNetCore.OData.ODataMiniOptions Count () + public Microsoft.AspNetCore.OData.ODataMiniOptions EnableAll (params System.Nullable`1[[System.Int32]] maxTopValue) + public Microsoft.AspNetCore.OData.ODataMiniOptions Expand () + public Microsoft.AspNetCore.OData.ODataMiniOptions Filter () + public Microsoft.AspNetCore.OData.ODataMiniOptions OrderBy () + public Microsoft.AspNetCore.OData.ODataMiniOptions Select () + public Microsoft.AspNetCore.OData.ODataMiniOptions SetCaseInsensitive (bool enableCaseInsensitive) + public Microsoft.AspNetCore.OData.ODataMiniOptions SetMaxTop (System.Nullable`1[[System.Int32]] maxTopValue) + public Microsoft.AspNetCore.OData.ODataMiniOptions SetNoDollarQueryOptions (bool enableNoDollarQueryOptions) + public Microsoft.AspNetCore.OData.ODataMiniOptions SetTimeZoneInfo (System.TimeZoneInfo tzi) + public Microsoft.AspNetCore.OData.ODataMiniOptions SetVersion (Microsoft.OData.ODataVersion version) + public Microsoft.AspNetCore.OData.ODataMiniOptions SkipToken () +} + public class Microsoft.AspNetCore.OData.ODataMvcOptionsSetup : IConfigureOptions`1 { public ODataMvcOptionsSetup () @@ -517,6 +643,11 @@ public class Microsoft.AspNetCore.OData.Deltas.Delta`1 : Microsoft.AspNetCore.OD System.Type StructuredType { public virtual get; } System.Collections.Generic.IList`1[[System.String]] UpdatableProperties { public get; } + [ + AsyncStateMachineAttribute(), + ] + public static ValueTask`1 BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) + public virtual void Clear () public void CopyChangedValues (T original) public void CopyUnchangedValues (T original) @@ -551,6 +682,15 @@ public class Microsoft.AspNetCore.OData.Deltas.DeltaSet`1 : System.Collections.O System.Type ExpectedClrType { public virtual get; } System.Type StructuredType { public virtual get; } + + [ + AsyncStateMachineAttribute(), + ] + public static ValueTask`1 BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) +} + +public interface Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration { + Microsoft.OData.ModelBuilder.ODataModelBuilder Apply (Microsoft.AspNetCore.Http.HttpContext context, Microsoft.OData.ModelBuilder.ODataModelBuilder builder, System.Type clrType) } public interface Microsoft.AspNetCore.OData.Edm.IODataTypeMapper { @@ -958,6 +1098,14 @@ public sealed class Microsoft.AspNetCore.OData.Extensions.SerializableErrorKeys public static readonly string StackTraceKey = "stacktrace" } +public class Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext { + public ODataQueryFilterInvocationContext () + + Microsoft.AspNetCore.Http.HttpContext HttpContext { public get; } + Microsoft.AspNetCore.Http.EndpointFilterInvocationContext InvocationContext { public get; public set; } + System.Reflection.MethodInfo MethodInfo { public get; public set; } +} + public enum Microsoft.AspNetCore.OData.Formatter.ODataMetadataLevel : int { Full = 1 Minimal = 0 @@ -1016,6 +1164,11 @@ NonValidatingParameterBindingAttribute(), ] public class Microsoft.AspNetCore.OData.Formatter.ODataActionParameters : System.Collections.Generic.Dictionary`2[[System.String],[System.Object]], ICollection, IDictionary, IEnumerable, IDeserializationCallback, ISerializable, IDictionary`2, IReadOnlyDictionary`2, ICollection`1, IEnumerable`1, IReadOnlyCollection`1 { public ODataActionParameters () + + [ + AsyncStateMachineAttribute(), + ] + public static System.Threading.Tasks.ValueTask`1[[Microsoft.AspNetCore.OData.Formatter.ODataActionParameters]] BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) } public class Microsoft.AspNetCore.OData.Formatter.ODataInputFormatter : Microsoft.AspNetCore.Mvc.Formatters.TextInputFormatter, IApiRequestFormatMetadataProvider, IInputFormatter { @@ -1065,6 +1218,11 @@ public class Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters : public ODataUntypedActionParameters (Microsoft.OData.Edm.IEdmAction action) Microsoft.OData.Edm.IEdmAction Action { public get; } + + [ + AsyncStateMachineAttribute(), + ] + public static System.Threading.Tasks.ValueTask`1[[Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters]] BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) } public class Microsoft.AspNetCore.OData.Formatter.ResourceContext { @@ -1208,6 +1366,11 @@ public interface Microsoft.AspNetCore.OData.Query.ICountOptionCollection : IEnum System.Nullable`1[[System.Int64]] TotalCount { public abstract get; } } +public interface Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter : IEndpointFilter { + System.Threading.Tasks.ValueTask`1[[System.Object]] OnFilterExecutedAsync (object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + System.Threading.Tasks.ValueTask OnFilterExecutingAsync (Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) +} + public interface Microsoft.AspNetCore.OData.Query.IODataQueryRequestParser { bool CanParse (Microsoft.AspNetCore.Http.HttpRequest request) System.Threading.Tasks.Task`1[[System.String]] ParseAsync (Microsoft.AspNetCore.Http.HttpRequest request) @@ -1387,6 +1550,36 @@ public class Microsoft.AspNetCore.OData.Query.ODataQueryContext { System.IServiceProvider RequestContainer { public get; } } +public class Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter : IEndpointFilter, IODataQueryEndpointFilter { + public ODataQueryEndpointFilter () + + Microsoft.AspNetCore.OData.Query.ODataQuerySettings QuerySettings { public get; } + Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings ValidationSettings { public get; } + + public virtual System.Linq.IQueryable ApplyQuery (System.Linq.IQueryable queryable, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) + public virtual object ApplyQuery (object entity, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) + protected virtual Microsoft.AspNetCore.OData.Query.ODataQueryOptions CreateAndValidateQueryOptions (Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.OData.Query.ODataQueryContext queryContext) + protected virtual Microsoft.AspNetCore.OData.Query.ODataQueryOptions CreateQueryOptionsOnExecuting (Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + protected virtual object ExecuteQuery (object responseValue, System.Linq.IQueryable singleResultCollection, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + protected virtual Microsoft.OData.Edm.IEdmModel GetModel (System.Type elementClrType, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + [ + AsyncStateMachineAttribute(), + ] + public virtual System.Threading.Tasks.ValueTask`1[[System.Object]] InvokeAsync (Microsoft.AspNetCore.Http.EndpointFilterInvocationContext invocationContext, Microsoft.AspNetCore.Http.EndpointFilterDelegate next) + + [ + AsyncStateMachineAttribute(), + ] + public virtual System.Threading.Tasks.ValueTask`1[[System.Object]] OnFilterExecutedAsync (object responseValue, Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + + [ + AsyncStateMachineAttribute(), + ] + public virtual System.Threading.Tasks.ValueTask OnFilterExecutingAsync (Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext context) + + protected virtual void ValidateQuery (Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions) +} + [ NonValidatingParameterBindingAttribute(), ODataQueryParameterBindingAttribute(), @@ -1430,7 +1623,7 @@ public class Microsoft.AspNetCore.OData.Query.ODataQueryOptions { [ ODataQueryParameterBindingAttribute(), ] -public class Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1 : Microsoft.AspNetCore.OData.Query.ODataQueryOptions { +public class Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1 : Microsoft.AspNetCore.OData.Query.ODataQueryOptions, IEndpointParameterMetadataProvider { public ODataQueryOptions`1 (Microsoft.AspNetCore.OData.Query.ODataQueryContext context, Microsoft.AspNetCore.Http.HttpRequest request) ETag`1 IfMatch { public get; } @@ -1438,7 +1631,13 @@ public class Microsoft.AspNetCore.OData.Query.ODataQueryOptions`1 : Microsoft.As public virtual System.Linq.IQueryable ApplyTo (System.Linq.IQueryable query) public virtual System.Linq.IQueryable ApplyTo (System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) + [ + AsyncStateMachineAttribute(), + ] + public static ValueTask`1 BindAsync (Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) + internal virtual Microsoft.AspNetCore.OData.Query.ETag GetETag (Microsoft.Net.Http.Headers.EntityTagHeaderValue etagHeaderValue) + public static void PopulateMetadata (System.Reflection.ParameterInfo parameter, Microsoft.AspNetCore.Builder.EndpointBuilder builder) } public class Microsoft.AspNetCore.OData.Query.ODataQueryRequestMiddleware { @@ -1552,6 +1751,7 @@ public class Microsoft.AspNetCore.OData.Query.SearchQueryOption { Microsoft.OData.UriParser.SearchClause SearchClause { public get; } public System.Linq.IQueryable ApplyTo (System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) + public void Validate (Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } public class Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption { @@ -1620,6 +1820,11 @@ public interface Microsoft.AspNetCore.OData.Results.IODataErrorResult { Microsoft.OData.ODataError Error { public abstract get; } } +public interface Microsoft.AspNetCore.OData.Results.IODataResult { + System.Type ExpectedType { public abstract get; } + object Value { public abstract get; } +} + [ DataContractAttribute(), ] @@ -1708,6 +1913,7 @@ public class Microsoft.AspNetCore.OData.Results.ODataErrorResult : Microsoft.Asp [ DataContractAttribute(), +JsonConverterAttribute(), ] public class Microsoft.AspNetCore.OData.Results.PageResult`1 : Microsoft.AspNetCore.OData.Results.PageResult, IEnumerable`1, IEnumerable { public PageResult`1 (IEnumerable`1 items, System.Uri nextPageLink, System.Nullable`1[[System.Int64]] count) @@ -1757,6 +1963,9 @@ public class Microsoft.AspNetCore.OData.Results.UpdatedODataResult`1 : Microsoft public virtual System.Threading.Tasks.Task ExecuteResultAsync (Microsoft.AspNetCore.Mvc.ActionContext context) } +[ +JsonConverterAttribute(), +] public sealed class Microsoft.AspNetCore.OData.Results.SingleResult`1 : Microsoft.AspNetCore.OData.Results.SingleResult { public SingleResult`1 (IQueryable`1 queryable) @@ -2529,6 +2738,7 @@ public class Microsoft.AspNetCore.OData.Formatter.Value.EdmComplexObjectCollecti } [ +ObsoleteAttribute(), NonValidatingParameterBindingAttribute(), ] public class Microsoft.AspNetCore.OData.Formatter.Value.EdmDeltaComplexObject : Microsoft.AspNetCore.OData.Formatter.Value.EdmComplexObject, IDynamicMetaObjectProvider, IDelta, IDeltaSetItem, IEdmChangedObject, IEdmComplexObject, IEdmObject, IEdmStructuredObject { @@ -2574,6 +2784,7 @@ public class Microsoft.AspNetCore.OData.Formatter.Value.EdmDeltaLink : Microsoft } [ +ObsoleteAttribute(), NonValidatingParameterBindingAttribute(), ] public class Microsoft.AspNetCore.OData.Formatter.Value.EdmDeltaResourceObject : Microsoft.AspNetCore.OData.Formatter.Value.EdmEntityObject, IDynamicMetaObjectProvider, IDelta, IDeltaSetItem, IEdmChangedObject, IEdmEntityObject, IEdmObject, IEdmStructuredObject { @@ -2736,6 +2947,15 @@ public sealed class Microsoft.AspNetCore.OData.Formatter.Wrapper.ODataResourceWr Microsoft.OData.ODataResourceBase Resource { public get; } } +public interface Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer`2 { + string Name { public abstract get; public abstract set; } + TWrapper NestedValue { public abstract get; public abstract set; } + Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer`2 Next { public abstract get; public abstract set; } + object Value { public abstract get; public abstract set; } + + void ToDictionaryCore (System.Collections.Generic.Dictionary`2[[System.String],[System.Object]] dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) +} + public interface Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper { string MapProperty (string propertyName) } @@ -2777,10 +2997,23 @@ public class Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection`1 : System.Nullable`1[[System.Int64]] TotalCount { public virtual get; } } +public interface Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder { + System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) +} + +public interface Microsoft.AspNetCore.OData.Query.Expressions.IComputeBinder { + System.Linq.Expressions.Expression BindCompute (Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) +} + public interface Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder { System.Linq.Expressions.Expression BindFilter (Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) } +public interface Microsoft.AspNetCore.OData.Query.Expressions.IFlatteningBinder { + Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) +} + public interface Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder { Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinderResult BindOrderBy (Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) } @@ -2793,6 +3026,9 @@ public interface Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinde System.Linq.Expressions.Expression BindSelectExpand (Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) } +[ +ObsoleteAttribute(), +] public abstract class Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase { System.Linq.Expressions.ParameterExpression Parameter { protected abstract get; } @@ -2811,6 +3047,7 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected static System.Linq.Expressions.Expression ApplyNullPropagationForFilterBody (System.Linq.Expressions.Expression body, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression Bind (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindAccessExpression (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, params System.Linq.Expressions.Expression baseElement) public virtual System.Linq.Expressions.Expression BindAllNode (Microsoft.OData.UriParser.AllNode allNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindAnyNode (Microsoft.OData.UriParser.AnyNode anyNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression[] BindArguments (System.Collections.Generic.IEnumerable`1[[Microsoft.OData.UriParser.QueryNode]] nodes, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) @@ -2859,9 +3096,11 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected virtual System.Linq.Expressions.Expression BindToUpper (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected virtual System.Linq.Expressions.Expression BindTrim (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindUnaryOperatorNode (Microsoft.OData.UriParser.UnaryOperatorNode unaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression CreateOpenPropertyAccessExpression (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.CollectionOpenPropertyAccessNode openCollectionNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression GetFlattenedPropertyExpression (string propertyPath, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + protected static System.Linq.Expressions.Expression WrapConvert (System.Linq.Expressions.Expression expression) } [ @@ -2883,6 +3122,11 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension ] public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + [ + ExtensionAttribute(), + ] + public static System.Linq.Expressions.Expression ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder binder, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.SearchClause searchClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + [ ExtensionAttribute(), ] @@ -2898,6 +3142,16 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension ] public static object ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinder binder, object source, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + [ + ExtensionAttribute(), + ] + public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type& resultClrType) + + [ + ExtensionAttribute(), + ] + public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IComputeBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type& resultClrType) + [ ExtensionAttribute(), ] @@ -2909,6 +3163,28 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, bool alreadyOrdered) } +public class Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IAggregationBinder, IFlatteningBinder { + public AggregationBinder () + + public virtual System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) +} + +public class Microsoft.AspNetCore.OData.Query.Expressions.AggregationFlatteningResult { + public AggregationFlatteningResult () + + System.Linq.Expressions.Expression FlattenedExpression { public get; public set; } + System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode],[System.Linq.Expressions.Expression]] FlattenedPropertiesMapping { public get; public set; } + System.Linq.Expressions.ParameterExpression RedefinedContextParameter { public get; public set; } +} + +public class Microsoft.AspNetCore.OData.Query.Expressions.ComputeBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IComputeBinder { + public ComputeBinder () + + public virtual System.Linq.Expressions.Expression BindCompute (Microsoft.OData.UriParser.Aggregation.ComputeTransformationNode computeTransformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) +} + public class Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IFilterBinder { public FilterBinder () @@ -2938,10 +3214,13 @@ public class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext { System.Linq.Expressions.ParameterExpression CurrentParameter { public get; } System.Type ElementClrType { public get; } Microsoft.OData.Edm.IEdmType ElementType { public get; } + System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode],[System.Linq.Expressions.Expression]] FlattenedExpressionMapping { public get; } Microsoft.OData.Edm.IEdmModel Model { public get; } Microsoft.OData.Edm.IEdmNavigationSource NavigationSource { public get; public set; } Microsoft.AspNetCore.OData.Query.ODataQuerySettings QuerySettings { public get; } + Microsoft.AspNetCore.OData.Query.Expressions.ISearchBinder SearchBinder { public get; public set; } System.Linq.Expressions.Expression Source { public get; public set; } + System.Type TransformationElementType { public get; } public System.Linq.Expressions.ParameterExpression GetParameter (string name) public void RemoveParameter (string name) @@ -2958,7 +3237,7 @@ public class Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder : M public virtual System.Linq.Expressions.Expression BindSelectExpand (Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual void BuildDynamicProperty (Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType structuredType, System.Collections.Generic.IList`1[[Microsoft.AspNetCore.OData.Query.Container.NamedPropertyExpression]] includedProperties) public virtual System.Linq.Expressions.Expression CreatePropertyNameExpression (Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source) - public virtual System.Linq.Expressions.Expression CreatePropertyValueExpression (Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, params Microsoft.OData.UriParser.ComputeClause computeClause) + public virtual System.Linq.Expressions.Expression CreatePropertyValueExpression (Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmProperty edmProperty, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, params Microsoft.OData.UriParser.ComputeClause computeClause, params Microsoft.OData.UriParser.SearchClause search) public virtual System.Linq.Expressions.Expression CreateTotalCountExpression (Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, System.Nullable`1[[System.Boolean]] countOption) public virtual System.Linq.Expressions.Expression CreateTypeNameExpression (System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType elementType, Microsoft.OData.Edm.IEdmModel model) } @@ -2983,6 +3262,10 @@ public interface Microsoft.AspNetCore.OData.Query.Validator.IOrderByQueryValidat void Validate (Microsoft.AspNetCore.OData.Query.OrderByQueryOption orderByOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } +public interface Microsoft.AspNetCore.OData.Query.Validator.ISearchQueryValidator { + void Validate (Microsoft.AspNetCore.OData.Query.SearchQueryOption searchQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) +} + public interface Microsoft.AspNetCore.OData.Query.Validator.ISelectExpandQueryValidator { void Validate (Microsoft.AspNetCore.OData.Query.SelectExpandQueryOption selectExpandQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } @@ -3174,6 +3457,20 @@ public class Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator : ITop public virtual void Validate (Microsoft.AspNetCore.OData.Query.TopQueryOption topQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper`1 { + T Instance { public abstract get; public abstract set; } + Microsoft.OData.Edm.IEdmModel Model { public abstract get; public abstract set; } +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper`1 { + T Source { public abstract get; public abstract set; } +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper`2 { + TContainer Container { public abstract get; public abstract set; } + TContainer GroupByContainer { public abstract get; public abstract set; } +} + public interface Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper { System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary () System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary (System.Func`3[[Microsoft.OData.Edm.IEdmModel],[Microsoft.OData.Edm.IEdmStructuredType],[Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper]] propertyMapperProvider) @@ -3187,6 +3484,15 @@ public abstract class Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrappe public virtual bool TryGetPropertyValue (string propertyName, out System.Object& value) } +public class Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter : System.Text.Json.Serialization.JsonConverterFactory { + public static readonly System.Func`3[[Microsoft.OData.Edm.IEdmModel],[Microsoft.OData.Edm.IEdmStructuredType],[Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper]] MapperProvider = System.Func`3[Microsoft.OData.Edm.IEdmModel,Microsoft.OData.Edm.IEdmStructuredType,Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper] + + public SelectExpandWrapperConverter () + + public virtual bool CanConvert (System.Type typeToConvert) + public virtual System.Text.Json.Serialization.JsonConverter CreateConverter (System.Type type, System.Text.Json.JsonSerializerOptions options) +} + [ AttributeUsageAttribute(), ] diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs index 95aeb621c..c1a80427c 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs @@ -172,7 +172,7 @@ public void GroupByAndAggregate() "groupby((ProductName), aggregate(SupplierID with sum as SupplierID))", ".Select($it => new FlatteningWrapper`1() {Source = $it, GroupByContainer = new LastInChain() {Name = Property0, Value = Convert($it.SupplierID), }, })" + ".GroupBy($it => new GroupByWrapper() {GroupByContainer = new LastInChain() {Name = ProductName, Value = $it.Source.ProductName, }, })" - + ".Select($it => new AggregationWrapper() {GroupByContainer = $it.Key.GroupByContainer, Container = new LastInChain() {Name = SupplierID, Value = Convert(Convert($it).Sum($it => Convert($it.GroupByContainer.Value))), }, })"); + + ".Select($it => new AggregationWrapper() {GroupByContainer = $it.Key.GroupByContainer, Container = new LastInChain() {Name = SupplierID, Value = Convert(Convert($it).Sum($it => Convert(Convert($it.GroupByContainer).Value))), }, })"); } [Fact] @@ -182,17 +182,7 @@ public void GroupByAndMultipleAggregations() "groupby((ProductName), aggregate(SupplierID with sum as SupplierID, CategoryID with sum as CategoryID))", ".Select($it => new FlatteningWrapper`1() {Source = $it, GroupByContainer = new AggregationPropertyContainer() {Name = Property1, Value = Convert($it.SupplierID), Next = new LastInChain() {Name = Property0, Value = Convert($it.CategoryID), }, }, })" + ".GroupBy($it => new GroupByWrapper() {GroupByContainer = new LastInChain() {Name = ProductName, Value = $it.Source.ProductName, }, })" - + ".Select($it => new AggregationWrapper() {GroupByContainer = $it.Key.GroupByContainer, Container = new AggregationPropertyContainer() {Name = CategoryID, Value = Convert(Convert($it).Sum($it => Convert($it.GroupByContainer.Next.Value))), Next = new LastInChain() {Name = SupplierID, Value = Convert(Convert($it).Sum($it => Convert($it.GroupByContainer.Value))), }, }, })"); - } - - [Fact] - public void ClassicEFQueryShape() - { - var filters = VerifyQueryDeserialization( - "aggregate(SupplierID with sum as SupplierID)", - ".GroupBy($it => new NoGroupByWrapper())" - + ".Select($it => new NoGroupByAggregationWrapper() {Container = new LastInChain() {Name = SupplierID, Value = $it.AsQueryable().Sum($it => $it.SupplierID), }, })", - classicEF: true); + + ".Select($it => new AggregationWrapper() {GroupByContainer = $it.Key.GroupByContainer, Container = new AggregationPropertyContainer() {Name = CategoryID, Value = Convert(Convert($it).Sum($it => Convert(Convert($it.GroupByContainer.Next).Value))), Next = new LastInChain() {Name = SupplierID, Value = Convert(Convert($it).Sum($it => Convert(Convert($it.GroupByContainer).Value))), }, }, })"); } [Fact] @@ -235,12 +225,12 @@ public void CastToNonDerivedType_Throws() "Encountered invalid type cast. 'Microsoft.AspNetCore.OData.Tests.Models.DerivedCategory' is not assignable from 'Microsoft.AspNetCore.OData.Tests.Models.Product'."); } - private Expression VerifyQueryDeserialization(string filter, string expectedResult = null, Action settingsCustomizer = null, bool classicEF = false) + private Expression VerifyQueryDeserialization(string filter, string expectedResult = null, Action settingsCustomizer = null) { - return VerifyQueryDeserialization(filter, expectedResult, settingsCustomizer, classicEF); + return VerifyQueryDeserialization(filter, expectedResult, settingsCustomizer); } - private Expression VerifyQueryDeserialization(string clauseString, string expectedResult = null, Action settingsCustomizer = null, bool classicEF = false) where T : class + private Expression VerifyQueryDeserialization(string clauseString, string expectedResult = null, Action settingsCustomizer = null) where T : class { IEdmModel model = GetModel(); ApplyClause clause = CreateApplyNode(clauseString, model, typeof(T)); @@ -256,23 +246,16 @@ private Expression VerifyQueryDeserialization(string clauseString, string exp return settings; }; - var binder = classicEF - ? new AggregationBinderEFFake( - customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), - assembliesResolver, - typeof(T), - model, - clause.Transformations.First()) - : new AggregationBinder( - customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), - assembliesResolver, - typeof(T), - model, - clause.Transformations.First()); + QueryBinderContext queryBinderContext = new QueryBinderContext( + model, + customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), + typeof(T)); + + var binder = new AggregationBinder(); var query = Enumerable.Empty().AsQueryable(); - var queryResult = binder.Bind(query); + var queryResult = binder.ApplyBind(query, clause.Transformations.First(), queryBinderContext, out Type resultClrType); var applyExpr = queryResult.Expression; @@ -327,17 +310,4 @@ private IEdmModel GetModel() where T : class } return value; } - - private class AggregationBinderEFFake : AggregationBinder - { - internal AggregationBinderEFFake(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, IEdmModel model, TransformationNode transformation) - : base(settings, assembliesResolver, elementType, model, transformation) - { - } - - internal override bool IsClassicEF(IQueryable query) - { - return true; - } - } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/BinderExtensionsTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/BinderExtensionsTests.cs index 552e01c97..2f301cbc2 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/BinderExtensionsTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/BinderExtensionsTests.cs @@ -200,6 +200,27 @@ public void ApplyBind_OnISelectExpandBinder_WithObject_ThrowsArgumentNull_ForInp ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(source, selectExpandClause, null), "context"); } + [Fact] + public void ApplyBind_OnISearchBinder_WithExpression_ThrowsArgumentNull_ForInputs() + { + // Arrange & Act & Assert + ISearchBinder binder = null; + Expression source = null; + ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(source, null, null), "binder"); + + // Arrange & Act & Assert + binder = new Mock().Object; + ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(source, null, null), "source"); + + // Arrange & Act & Assert + source = new Mock().Object; + ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(source, null, null), "searchClause"); + + // Arrange & Act & Assert + SearchClause searchClause = new SearchClause(new Mock().Object); + ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(source, searchClause, null), "context"); + } + [Fact] public void ApplyBind_OnISearchBinder_WithQueryable_ThrowsArgumentNull_ForInputs() { @@ -221,6 +242,30 @@ public void ApplyBind_OnISearchBinder_WithQueryable_ThrowsArgumentNull_ForInputs ExceptionAssert.ThrowsArgumentNull(() => binder.ApplyBind(query, searchClause, null), "context"); } + [Fact] + public void SearchBinder_ApplyBind_WorksForQueryable() + { + // Arrange + IQueryable products = new List().AsQueryable(); + QueryBinderContext context = new QueryBinderContext(_model, _defaultSettings, typeof(Product)); + + // Act & Assert + Mock binder = new Mock(); + SearchClause searchClause = new SearchClause(new Mock().Object); + Expression body = Expression.Constant(true); + ParameterExpression searchParameter = context.CurrentParameter; + LambdaExpression searchExpr = Expression.Lambda(body, searchParameter); + + binder.Setup(b => b.BindSearch(searchClause, context)).Returns(searchExpr); + + // Act + Expression result = binder.Object.ApplyBind(Expression.Constant(products), searchClause, context); + + // Assert + Assert.NotNull(result); + Assert.Equal("System.Collections.Generic.List`1[Microsoft.AspNetCore.OData.Tests.Models.Product].Where($it => True)", result.ToString()); + } + [Fact] public void FilterBinder_ApplyBind_WorksForQueryable() { @@ -231,7 +276,7 @@ public void FilterBinder_ApplyBind_WorksForQueryable() // Act & Assert Mock binder = new Mock(); FilterClause filterClause = new FilterClause(new Mock().Object, new Mock().Object); - Expression body = Expression.Constant(true);; + Expression body = Expression.Constant(true); ParameterExpression filterParameter = context.CurrentParameter; LambdaExpression filterExpr = Expression.Lambda(body, filterParameter); diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs index e0e2464e7..e0524842e 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs @@ -428,22 +428,6 @@ public void GetDynamicCollectionValuedPropertyContainer_ThrowsNotSupported_ForNo "The type 'Ns.NonOpenEdmType' must be an open type. The dynamic properties container property is only expected on open types."); } - [Theory] - [InlineData(true, "$it.Values.Item[\"Values\"]")] - [InlineData(false, "$it.Values")] - public void GetPropertyExpression_Works_ForAggregateOrNonAggregate(bool isAggregated, string expected) - { - // Arrange - ParameterExpression source = Expression.Parameter(typeof(GroupByWrapper), "$it"); - - // Act - var expression = QueryBinder.GetPropertyExpression(source, "Values", isAggregated); - - // Assert - Assert.NotNull(expression); - Assert.Equal(expected, expression.ToString()); - } - [Fact] public void GetFullPropertyPath_WithSingleComplexNode() { diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/SelectExpandBinderTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/SelectExpandBinderTest.cs index f90875ade..73134570e 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/SelectExpandBinderTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/SelectExpandBinderTest.cs @@ -1758,6 +1758,85 @@ public void CreatePropertyValueExpression_Collection_Works_HandleNullPropagation Assert.Equal(1, order.Id); } + [Theory] + [InlineData(HandleNullPropagationOption.True)] + [InlineData(HandleNullPropagationOption.False)] + public void CreatePropertyValueExpression_Collection_WorksWithSearchBinder_HandleNullPropagationOption(HandleNullPropagationOption nullOption) + { + // Arrange + _settings.HandleNullPropagation = nullOption; + var source = Expression.Constant(new QueryCustomer + { + Orders = new[] + { + new QueryOrder { Id = 12, Title = "Compute" }, + new QueryOrder { Id = 45, Title = "Food" } + } + }); + + SelectExpandBinder binder = GetBinder(_model); + + var ordersProperty = _customer.NavigationProperties().Single(p => p.Name == "Orders"); + + SelectExpandClause selectExpand = ParseSelectExpand(null, "Orders($search=food)", _model, _customer, _customers); + ExpandedNavigationSelectItem expandItem = Assert.Single(selectExpand.SelectedItems) as ExpandedNavigationSelectItem; + Assert.NotNull(expandItem); + Assert.NotNull(expandItem.SearchOption); + _queryBinderContext.SearchBinder = new MySearchBinder(); + + // Act + var searchInExpand = binder.CreatePropertyValueExpression(_queryBinderContext, _customer, ordersProperty, source, expandItem.FilterOption, expandItem.ComputeOption, expandItem.SearchOption); + + // Assert + if (nullOption == HandleNullPropagationOption.True) + { + Assert.Equal( + string.Format( + "IIF((value({0}) == null), null, IIF((value({0}).Orders == null), null, " + + "value({0}).Orders.Where($it => Equals($it.Title, \"food\", OrdinalIgnoreCase))))", + source.Type), + searchInExpand.ToString()); + } + else + { + Assert.Equal( + string.Format( + "value({0}).Orders.Where($it => Equals($it.Title, \"food\", OrdinalIgnoreCase))", + source.Type), + searchInExpand.ToString()); + } + + var orders = Expression.Lambda(searchInExpand).Compile().DynamicInvoke() as IEnumerable; + QueryOrder order = Assert.Single(orders); + Assert.Equal(45, order.Id); + } + + private class MySearchBinder : ISearchBinder + { + internal static readonly MethodInfo StringEqualsMethodInfo = typeof(string).GetMethod("Equals", + [ + typeof(string), + typeof(string), + typeof(StringComparison) + ]); + + public Expression BindSearch(SearchClause searchClause, QueryBinderContext context) + { + SearchTermNode searchTerm = searchClause.Expression as SearchTermNode; + + Expression source = context.CurrentParameter; + + Expression titleProperty = Expression.Property(source, "Title"); + + Expression exp = Expression.Call(null, StringEqualsMethodInfo, + titleProperty, + Expression.Constant(searchTerm.Text, typeof(string)), + Expression.Constant(StringComparison.OrdinalIgnoreCase, typeof(StringComparison))); + + return Expression.Lambda(exp, context.CurrentParameter); + } + } + // OData.ModelBuilder 1.0.4 make the ClrTypeAnnotation ctor throws argument null exception // 1.0.5 will allow null. So, please enable this when update to 1.0.5 model builder. /* diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs index 3e6e11d7a..74e74c4e8 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Query/ApplyQueryOptionTest.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.AspNetCore.OData.Routing.Controllers; @@ -167,7 +168,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary { { "Name", "Lowest"} } } }, - /* TODO: Sam XU enable this test case when we refactor aggregationBinder with FilterBinder { "groupby((Name), aggregate(Id with sum as Total))/filter(Total eq 3)", new List> @@ -182,7 +182,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary { { "Name", "Lowest"} } } }, - */ { "groupby((Address/City))", new List> @@ -332,7 +331,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary {{ "Company/CEO/EmployeeName", "john"} } } }, - /* TODO: Sam XU enable this test case when we refactor aggregationBinder with FilterBinder { "groupby((Company/CEO/EmployeeName))/filter(Company/CEO/EmployeeName eq 'john')", new List> @@ -340,7 +338,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary {{ "Company/CEO/EmployeeName", "john"} } } }, - */ { "groupby((Name, Company/CEO/EmployeeName))", new List> @@ -521,7 +518,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary { { "Count", 5L}, { "DoubleCount", 10L } } } }, - /* TODO: Sam XU enable this test case when we refactor aggregationBinder with FilterBinder { "groupby((Name), aggregate(Id with sum as Total))/compute(Total add Total as DoubleTotal, length(Name) as NameLen)", new List> @@ -531,7 +527,6 @@ public static TheoryDataSet>> CustomerTe new Dictionary { { "Name", "Middle"}, { "Total", 3 }, { "DoubleTotal", 6 }, { "NameLen", 6 }, } } }, - */ { "compute(length(Name) as NameLen)", new List> @@ -1744,6 +1739,42 @@ public void ApplyTo_Returns_Correct_Queryable_ForTypeCast(string apply, List { { "$apply", filter } }); + var applyOption = new ApplyQueryOption(filter, context, queryOptionParser); + IEnumerable customers = CustomerApplyTestData; + + // Act + IQueryable queryable = applyOption.ApplyTo(customers.AsQueryable(), new OData.Query.ODataQuerySettings()); + new DynamicTypeWrapperExpressionVisitor().Visit(queryable.Expression); + } + + private class DynamicTypeWrapperExpressionVisitor : ExpressionVisitor + { + protected override Expression VisitMemberInit(MemberInitExpression node) + { + if (typeof(DynamicTypeWrapper).IsAssignableFrom(node.Type)) + { + foreach (var binding in node.Bindings) + { + Assert.Equal(node.Type, binding.Member.ReflectedType); + } + } + + return base.VisitMemberInit(node); + } + } + private object GetValue(DynamicTypeWrapper wrapper, string path) { var parts = path.Split('/'); diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTests.cs similarity index 99% rename from test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTest.cs rename to test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTests.cs index b072991b0..7e5c36bb2 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTest.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/OrderByQueryValidatorTests.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------------- -// +// // Copyright (c) .NET Foundation and Contributors. All rights reserved. // See License.txt in the project root for license information. // @@ -19,12 +19,12 @@ namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; -public class OrderByQueryValidatorTest +public class OrderByQueryValidatorTests { private OrderByQueryValidator _validator; private ODataQueryContext _context; - public OrderByQueryValidatorTest() + public OrderByQueryValidatorTests() { _context = ValidationTestHelper.CreateCustomerContext(); _validator = new OrderByQueryValidator(); diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/QueryBinderValidatorTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/QueryBinderValidatorTests.cs new file mode 100644 index 000000000..48626d27a --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/QueryBinderValidatorTests.cs @@ -0,0 +1,447 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.AspNetCore.OData.Query.Wrapper; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; + +public class QueryBinderValidatorTests +{ + [Fact] + public void ValidateGroupByExpressionType_DefaultGroupByWrapper_DoesNotThrow() + { + // Arrange + Type defaultGroupByWrapper = typeof(GroupByWrapper); + + // Act & Assert + QueryBinderValidator.ValidateGroupByExpressionType(defaultGroupByWrapper); + } + + [Fact] + public void ValidateGroupByExpressionType_ValidGroupByWrapper_DoesNotThrow() + { + // Arrange + Type validGroupByWrapper = typeof(ValidGroupByWrapper); + + // Act & Assert + QueryBinderValidator.ValidateGroupByExpressionType(validGroupByWrapper); + } + + [Fact] + public void ValidateGroupByExpressionType_InvalidGroupByWrapperWithoutInheritance_ThrowsInvalidOperationException() + { + // Arrange + Type invalidGroupByWrapper = typeof(InvalidGroupByWrapperWithoutInheritance); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateGroupByExpressionType(invalidGroupByWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidGroupByWrapperWithoutInheritance).FullName}' does not inherit from 'Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper'.", + exception.Message); + } + + [Fact] + public void ValidateGroupByExpressionType_InvalidGroupByWrapperWithoutInterface_ThrowsInvalidOperationException() + { + // Arrange + Type invalidGroupByWrapper = typeof(InvalidGroupByWrapperWithoutIGroupByWrapperOfTInterface); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateGroupByExpressionType(invalidGroupByWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidGroupByWrapperWithoutIGroupByWrapperOfTInterface).FullName}' does not implement 'Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper{{TContainer,TWrapper}}' interface.", + exception.Message); + } + + [Fact] + public void ValidateFlattenedExpressionType_DefaultFlatteningWrapper_DoesNotThrow() + { + // Arrange + Type defaultFlatteningWrapper = typeof(FlatteningWrapper); + + // Act & Assert + QueryBinderValidator.ValidateFlattenedExpressionType(defaultFlatteningWrapper); + } + + [Fact] + public void ValidateFlattenedExpressionType_ValidFlatteningWrapper_DoesNotThrow() + { + // Arrange + Type validFlatteningWrapper = typeof(ValidFlatteningWrapper); + + // Act & Assert + QueryBinderValidator.ValidateFlattenedExpressionType(validFlatteningWrapper); + } + + [Fact] + public void ValidateFlattenedExpressionType_InvalidFlatteningWrapperWithoutInheritance_ThrowsInvalidOperationException() + { + // Arrange + Type invalidFlatteningWrapper = typeof(InvalidFlatteningWrapperWithoutInheritance); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlattenedExpressionType(invalidFlatteningWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidFlatteningWrapperWithoutInheritance).FullName}' does not inherit from 'Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper'.", + exception.Message); + } + + [Fact] + public void ValidateFlattenedExpressionType_InvalidFlatteningWrapperWithoutIGroupByWrapperOfTInterface_ThrowsInvalidOperationException() + { + // Arrange + Type invalidFlatteningWrapper = typeof(InvalidFlatteningWrapperWithoutIGroupByWrapperOfTInterface); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlattenedExpressionType(invalidFlatteningWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidFlatteningWrapperWithoutIGroupByWrapperOfTInterface).FullName}' does not implement 'Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper{{TContainer,TWrapper}}' interface.", + exception.Message); + } + + [Fact] + public void ValidateFlattenedExpressionType_InvalidFlatteningWrapperWithoutIFlatteningWrapperOfTInterface_ThrowsInvalidOperationException() + { + // Arrange + Type invalidFlatteningWrapper = typeof(InvalidFlatteningWrapperWithoutIFlatteningWrapperOfTInterface); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlattenedExpressionType(invalidFlatteningWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidFlatteningWrapperWithoutIFlatteningWrapperOfTInterface).FullName}' does not implement 'Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper{{T}}' interface.", + exception.Message); + } + + [Fact] + public void ValidateAggregationFlatteningResult_NullResult_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => QueryBinderValidator.ValidateFlatteningResult(null)); + } + + [Fact] + public void ValidateAggregationFlatteningResult_ValidResult_DoesNotThrow() + { + // Arrange + var wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(typeof(TestSale)); + var validFlatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedExpression = Expression.Constant(1), + FlattenedPropertiesMapping = new Dictionary + { + { new ConstantNode(1), Expression.Constant(1) } + } + }; + + // Act & Assert + QueryBinderValidator.ValidateFlatteningResult(validFlatteningResult); + } + + [Fact] + public void ValidateAggregationFlatteningResult_NullFlattenedExpression_ThrowsInvalidOperationException() + { + // Arrange + var wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(typeof(TestSale)); + var invalidFlatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedExpression = null, + FlattenedPropertiesMapping = new Dictionary + { + { new ConstantNode(1), Expression.Constant(1) } + } + }; + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlatteningResult(invalidFlatteningResult)); + Assert.Equal("The 'FlattenedExpression' property must be set. (Parameter 'flatteningResult')", exception.Message); + } + + [Fact] + public void ValidateAggregationFlatteningResult_NullRedefinedContextParameter_ThrowsInvalidOperationException() + { + // Arrange + var invalidFlatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = null, + FlattenedExpression = Expression.Constant(1), + FlattenedPropertiesMapping = new Dictionary + { + { new ConstantNode(1), Expression.Constant(1) } + } + }; + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlatteningResult(invalidFlatteningResult)); + Assert.Equal( + "The 'RedefinedContextParameter' property must be set when the 'FlattenedExpression' property is set. (Parameter 'flatteningResult')", + exception.Message); + } + + [Fact] + public void ValidateAggregationFlatteningResult_NullFlattenedPropertiesMapping_ThrowsInvalidOperationException() + { + // Arrange + var wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(typeof(TestSale)); + var invalidFlatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedExpression = Expression.Constant(1), + FlattenedPropertiesMapping = null + }; + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlatteningResult(invalidFlatteningResult)); + Assert.Equal( + "The 'FlattenedPropertiesMapping' property must be set when the 'FlattenedExpression' property is set. (Parameter 'flatteningResult')", + exception.Message); + } + + [Fact] + public void ValidateAggregationFlatteningResult_EmptyFlattenedPropertiesMapping_ThrowsInvalidOperationException() + { + // Arrange + Type wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(typeof(TestSale)); + var invalidFlatteningResult = new AggregationFlatteningResult + { + RedefinedContextParameter = Expression.Parameter(wrapperType, "$it"), + FlattenedExpression = Expression.Constant(1), + FlattenedPropertiesMapping = new Dictionary() + }; + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateFlatteningResult(invalidFlatteningResult)); + Assert.Equal( + "The 'FlattenedPropertiesMapping' property must be set when the 'FlattenedExpression' property is set. (Parameter 'flatteningResult')", + exception.Message); + } + + [Fact] + public void ValidateComputeExpressionType_DefaultComputeWrapper_DoesNotThrow() + { + // Arrange + Type defaultComputeWrapper = typeof(ComputeWrapper); + + // Act & Assert + QueryBinderValidator.ValidateComputeExpressionType(defaultComputeWrapper); + } + + [Fact] + public void ValidateComputeExpressionType_ValidComputeWrapper_DoesNotThrow() + { + // Arrange + Type validComputeWrapper = typeof(ValidComputeWrapper); + + // Act & Assert + QueryBinderValidator.ValidateComputeExpressionType(validComputeWrapper); + } + + [Fact] + public void ValidateComputeExpressionType_InvalidComputeWrapperWithoutInheritance_ThrowsInvalidOperationException() + { + // Arrange + Type invalidComputeWrapper = typeof(InvalidComputeWrapperWithoutInheritance); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateComputeExpressionType(invalidComputeWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidComputeWrapperWithoutInheritance).FullName}' does not inherit from 'Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper'.", + exception.Message); + } + + [Fact] + public void ValidateComputeExpressionType_InvalidComputeWrapperWithoutIGroupByWrapperOfTInterface_ThrowsInvalidOperationException() + { + // Arrange + Type invalidComputeWrapper = typeof(InvalidComputeWrapperWithoutIGroupByWrapperOfTInterface); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateComputeExpressionType(invalidComputeWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidComputeWrapperWithoutIGroupByWrapperOfTInterface).FullName}' does not implement 'Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper{{TContainer,TWrapper}}' interface.", + exception.Message); + } + + [Fact] + public void ValidateComputeExpressionType_InvalidComputeWrapperWithoutIComputeWrapperOfTInterface_ThrowsInvalidOperationException() + { + // Arrange + Type invalidComputeWrapper = typeof(InvalidComputeWrapperWithoutIComputeWrapperOfTInterface); + + // Act & Assert + var exception = Assert.Throws(() => QueryBinderValidator.ValidateComputeExpressionType(invalidComputeWrapper)); + Assert.Equal( + $"The type '{typeof(InvalidComputeWrapperWithoutIComputeWrapperOfTInterface).FullName}' does not implement 'Microsoft.AspNetCore.OData.Query.Wrapper.IComputeWrapper{{T}}' interface.", + exception.Message); + } +} + +// Inherits from DynamicTypeWrapper and implements IGroupByWrapper +internal class ValidGroupByWrapper : DynamicTypeWrapper, IGroupByWrapper +{ + public AggregationPropertyContainerForValidGroupWrapper GroupByContainer + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public AggregationPropertyContainerForValidGroupWrapper Container + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public override Dictionary Values => throw new NotImplementedException(); +} + +// Does not inherit from DynamicTypeWrapper +internal class InvalidGroupByWrapperWithoutInheritance + : IGroupByWrapper +{ + public AggregationPropertyContainerForInvalidGroupByWrapper GroupByContainer + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public AggregationPropertyContainerForInvalidGroupByWrapper Container + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } +} + +// Does not implement IGroupByWrapper +internal class InvalidGroupByWrapperWithoutIGroupByWrapperOfTInterface : DynamicTypeWrapper +{ + public override Dictionary Values => throw new NotImplementedException(); +} + +// Inherits from DynamicTypeWrapper, implements IGroupByWrapper, and implements IFlatteningWrapper +internal class ValidFlatteningWrapper + : ValidGroupByWrapper, IGroupByWrapper, IFlatteningWrapper +{ + public TestSale Source { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not inherit from DynamicTypeWrapper +internal class InvalidFlatteningWrapperWithoutInheritance + : IGroupByWrapper, IFlatteningWrapper +{ + public AggregationPropertyContainerForValidGroupWrapper GroupByContainer + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public AggregationPropertyContainerForValidGroupWrapper Container + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public TestSale Source { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not implement IGroupByWrapper +internal class InvalidFlatteningWrapperWithoutIGroupByWrapperOfTInterface : DynamicTypeWrapper, IFlatteningWrapper +{ + public override Dictionary Values => throw new NotImplementedException(); + public TestSale Source { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not implement IFlatteningWrapper +internal class InvalidFlatteningWrapperWithoutIFlatteningWrapperOfTInterface + : ValidGroupByWrapper, IGroupByWrapper +{ +} + +// Inherits from DynamicTypeWrapper, implements IGroupByWrapper, and implements IComputeWrapper +internal class ValidComputeWrapper + : ValidGroupByWrapper, IGroupByWrapper, IComputeWrapper +{ + public TestSale Instance { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public IEdmModel Model { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not inherit from DynamicTypeWrapper +internal class InvalidComputeWrapperWithoutInheritance + : IGroupByWrapper, IComputeWrapper +{ + public AggregationPropertyContainerForValidGroupWrapper GroupByContainer + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public AggregationPropertyContainerForValidGroupWrapper Container + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public TestSale Instance { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public IEdmModel Model { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not implement IGroupByWrapper +internal class InvalidComputeWrapperWithoutIGroupByWrapperOfTInterface : DynamicTypeWrapper, IComputeWrapper +{ + public override Dictionary Values => throw new NotImplementedException(); + public TestSale Instance { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public IEdmModel Model { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } +} + +// Does not implement IComputeWrapper +internal class InvalidComputeWrapperWithoutIComputeWrapperOfTInterface + : ValidGroupByWrapper, IGroupByWrapper +{ +} + +// Test aggregation property container for valid groupby wrapper +internal class AggregationPropertyContainerForValidGroupWrapper + : IAggregationPropertyContainer +{ + public string Name { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public object Value { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public ValidGroupByWrapper NestedValue { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public IAggregationPropertyContainer Next + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected) + { + throw new NotImplementedException(); + } +} + +// Test aggregation property container for invalid groupby wrapper +internal class AggregationPropertyContainerForInvalidGroupByWrapper + : IAggregationPropertyContainer +{ + public string Name { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public object Value { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public InvalidGroupByWrapperWithoutInheritance NestedValue { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public IAggregationPropertyContainer Next + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + public void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected) + { + throw new NotImplementedException(); + } +} + +// Test model +internal class TestSale +{ + public int Id { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/SearchQueryValidatorTest.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/SearchQueryValidatorTest.cs new file mode 100644 index 000000000..1cd15060c --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/SearchQueryValidatorTest.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Validator; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData.Edm; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; + +public class SearchQueryValidatorTest +{ + [Fact] + public void ValidateSearchQuery_CallsTheRegisteredValidator() + { + // Arrange + int count = 0; + ODataQueryContext context = ValidationTestHelper.CreateCustomerContext(s => s.AddSingleton(new MySearchValidator(() => count++))); + ODataValidationSettings settings = new ODataValidationSettings(); + + // Act & Assert + Assert.Equal(0, count); + SearchQueryOption search = new SearchQueryOption("any", context); + + search.Validate(settings); + Assert.Equal(1, count); + + search.Validate(settings); + Assert.Equal(2, count); + } + + [Fact] + public void GetSearchQueryValidator_Returns_Validator() + { + // Arrange & Act & Assert + ODataQueryContext context = null; + Assert.Null(context.GetSearchQueryValidator()); + + // Arrange & Act & Assert + context = new ODataQueryContext(EdmCoreModel.Instance, typeof(int)); + Assert.Null(context.GetSearchQueryValidator()); + + // Arrange & Act & Assert + IServiceProvider services = new ServiceCollection() + .AddSingleton(new MySearchValidator()).BuildServiceProvider(); + context.RequestContainer = services; + Assert.NotNull(context.GetSearchQueryValidator()); + } + + private class MySearchValidator : ISearchQueryValidator + { + public MySearchValidator(Action verify = null) + { + Verify = verify; + } + + public Action Verify { get; } + + public void Validate(SearchQueryOption searchQueryOption, ODataValidationSettings validationSettings) + { + Verify?.Invoke(); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ValidationTestHelper.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ValidationTestHelper.cs index d87bf7eda..20b46f5c4 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ValidationTestHelper.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Validator/ValidationTestHelper.cs @@ -9,8 +9,10 @@ using Microsoft.AspNetCore.OData.TestCommon; using Microsoft.AspNetCore.OData.Tests.Models; using Microsoft.AspNetCore.OData.Tests.Query.Models; +using Microsoft.Extensions.DependencyInjection; using Microsoft.OData.Edm; using Microsoft.OData.ModelBuilder; +using System; namespace Microsoft.AspNetCore.OData.Tests.Query.Validator; @@ -34,6 +36,15 @@ internal static ODataQueryContext CreateCustomerContext(bool setRequestContainer return context; } + internal static ODataQueryContext CreateCustomerContext(Action setupAction, Action config = null) + { + ODataQueryContext context = new ODataQueryContext(GetCustomersModel(), typeof(QueryCompositionCustomer), null); + context.RequestContainer = new MockServiceProvider(setupAction); + + config?.Invoke(context.DefaultQueryConfigurations); + return context; + } + internal static ODataQueryContext CreateProductContext() { return new ODataQueryContext(GetProductsModel(), typeof(Product)); diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Wrapper/AggregationWrapperTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Wrapper/AggregationWrapperTests.cs new file mode 100644 index 000000000..ac61aa232 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Wrapper/AggregationWrapperTests.cs @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.Query.Wrapper; +using Microsoft.AspNetCore.OData.Tests.Query.Validator; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Query.Wrapper; + +public class AggregationWrapperTests +{ + [Fact] + public void TestDefaultGroupByWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(GroupByWrapper).IsGroupByWrapper()); + } + + [Fact] + public void TestValidGroupByWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(ValidGroupByWrapper).IsGroupByWrapper()); + } + + [Fact] + public void TestInvalidGroupByWrapperWithNoIGroupByWrapperOfTInterfaceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidGroupByWrapperWithoutIGroupByWrapperOfTInterface).IsGroupByWrapper()); + } + + [Fact] + public void TestInvalidGroupByWrapperWithNoDynamicTypeWrapperInheritanceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidGroupByWrapperWithoutInheritance).IsGroupByWrapper()); + } + + [Fact] + public void TestDefaultFlatteningWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(FlatteningWrapper).IsFlatteningWrapper()); + } + + [Fact] + public void TestValidFlatteningWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(ValidFlatteningWrapper).IsFlatteningWrapper()); + } + + [Fact] + public void TestInvalidFlatteningWrapperWithNoDynamicTypeWrapperInheritanceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidFlatteningWrapperWithoutInheritance).IsFlatteningWrapper()); + } + + [Fact] + public void TestInvalidFlatteningWrapperWithNoIGroupByWrapperOfTInterfaceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidFlatteningWrapperWithoutIGroupByWrapperOfTInterface).IsFlatteningWrapper()); + } + + [Fact] + public void TestInvalidFlatteningWrapperWithNoIFlatteningWrapperOfTInterfaceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidFlatteningWrapperWithoutIFlatteningWrapperOfTInterface).IsFlatteningWrapper()); + } + + [Fact] + public void TestDefaultComputeWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(ComputeWrapper).IsComputeWrapper(out _)); + } + + [Fact] + public void TestValidComputeWrapperReturnsTrue() + { + // Arrange & Act & Assert + Assert.True(typeof(ValidComputeWrapper).IsComputeWrapper(out _)); + } + + [Fact] + public void TestInvalidComputeWrapperWithNoDynamicTypeWrapperInheritanceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidComputeWrapperWithoutInheritance).IsComputeWrapper(out _)); + } + + [Fact] + public void TestInvalidComputeWrapperWithNoIGroupByWrapperOfTInterfaceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidComputeWrapperWithoutIGroupByWrapperOfTInterface).IsComputeWrapper(out _)); + } + + [Fact] + public void TestInvalidComputeWrapperWithNoIComputeWrapperOfTInterfaceReturnsFalse() + { + // Arrange & Act & Assert + Assert.False(typeof(InvalidComputeWrapperWithoutIComputeWrapperOfTInterface).IsComputeWrapper(out _)); + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Resources/SRResourcesTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Resources/SRResourcesTests.cs new file mode 100644 index 000000000..9af61d278 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.Tests/Resources/SRResourcesTests.cs @@ -0,0 +1,251 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace Microsoft.AspNetCore.OData.Tests.Resources; + +public class SRResourcesTests +{ + [Theory] + [InlineData("ActionContextMustHaveDescriptor", new object[] { })] + [InlineData("ActionDescriptorNotControllerActionDescriptor", new object[] { })] + [InlineData("ActionExecutedContextMustHaveRequest", new object[] { })] + [InlineData("ActionNotBoundToCollectionOfEntity", new object[] { "arg1" })] + [InlineData("ActionNotBoundToEntity", new object[] { "arg1" })] + [InlineData("AggregateKindNotSupported", new object[] { "arg1" })] + [InlineData("AggregationMethodNotSupported", new object[] { "arg1" })] + [InlineData("AggregationNotSupportedForType", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("AmbiguousNavigationSourceNameFound", new object[] { "arg1" })] + [InlineData("AmbiguousPropertyNameFound", new object[] { "arg1" })] + [InlineData("AmbiguousTypeNameFound", new object[] { "arg1" })] + [InlineData("ApplyQueryOptionNotSupportedForLinq2SQL", new object[] { })] + [InlineData("ApplyToOnUntypedQueryOption", new object[] { "arg1" })] + [InlineData("ArgumentMustBeGreaterThanOrEqualTo", new object[] { "arg1" })] + [InlineData("ArgumentMustBeLessThanOrEqualTo", new object[] { "arg1" })] + [InlineData("ArgumentMustBeOfType", new object[] { "arg1" })] + [InlineData("ArgumentNullOrEmpty", new object[] { "arg1" })] + [InlineData("BatchRequestInvalidMediaType", new object[] { "arg1", "arg2" })] + [InlineData("BatchRequestMissingBody", new object[] { })] + [InlineData("BatchRequestMissingBoundary", new object[] { })] + [InlineData("BatchRequestMissingContentType", new object[] { })] + [InlineData("BinaryOperatorNotSupported", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("CannotAddToNullCollection", new object[] { "arg1", "arg2" })] + [InlineData("CannotApplyETagOfT", new object[] { "arg1", "arg2", "arg3", "arg4" })] + [InlineData("CannotApplyODataQueryOptionsOfT", new object[] { "arg1", "arg2", "arg3", "arg4" })] + [InlineData("CannotCastFilter", new object[] { "arg1", "arg2" })] + [InlineData("CannotDeserializeUnknownProperty", new object[] { "arg1", "arg2" })] + [InlineData("CannotFindKeyInEntityType", new object[] { "arg1", "arg2" })] + [InlineData("CannotFindParameterInOperation", new object[] { "arg1", "arg2" })] + [InlineData("CannotGetEnumClrMember", new object[] { "arg1" })] + [InlineData("CannotInstantiateAbstractResourceType", new object[] { "arg1" })] + [InlineData("CannotParseQueryRequestPayload", new object[] { })] + [InlineData("CannotPatchNavigationProperties", new object[] { "arg1", "arg2" })] + [InlineData("CannotProcessPrefixTemplate", new object[] { "arg1" })] + [InlineData("CannotSerializerNull", new object[] { "arg1" })] + [InlineData("CannotSetDynamicPropertyDictionary", new object[] { "arg1", "arg2" })] + [InlineData("CannotWriteType", new object[] { "arg1", "arg2" })] + [InlineData("ClrTypeNotInModel", new object[] { "arg1" })] + [InlineData("CollectionParameterShouldHaveAddMethod", new object[] { "arg1", "arg2" })] + [InlineData("CollectionShouldHaveAddMethod", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("CollectionShouldHaveClearMethod", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("ConvertToEnumFailed", new object[] { "arg1", "arg2" })] + [InlineData("CreateODataValueNotSupported", new object[] { "arg1" })] + [InlineData("CustomQueryOptionNotSupportedWithDollarSign", new object[] { "arg1" })] + [InlineData("DeltaEntityTypeNotAssignable", new object[] { "arg1", "arg2" })] + [InlineData("DeltaNestedResourceNameNotFound", new object[] { "arg1", "arg2" })] + [InlineData("DeltaTypeMismatch", new object[] { "arg1", "arg2" })] + [InlineData("DeserializerDoesNotSupportRead", new object[] { "arg1" })] + [InlineData("DoesNotSupportReadInLine", new object[] { "arg1" })] + [InlineData("DuplicateDynamicPropertyNameFound", new object[] { "arg1", "arg2" })] + [InlineData("DynamicPropertyCannotBeSerialized", new object[] { "arg1", "arg2" })] + [InlineData("DynamicPropertyNameAlreadyUsedAsDeclaredPropertyName", new object[] { "arg1", "arg2" })] + [InlineData("DynamicResourceSetTypeNameIsRequired", new object[] { "arg1" })] + [InlineData("EditLinkNullForLocationHeader", new object[] { "arg1" })] + [InlineData("EdmComplexObjectNullRef", new object[] { "arg1", "arg2" })] + [InlineData("EdmObjectNull", new object[] { "arg1" })] + [InlineData("EdmTypeCannotBeNull", new object[] { "arg1", "arg2" })] + [InlineData("EdmTypeNotSupported", new object[] { "arg1" })] + [InlineData("ElementClrTypeNull", new object[] { "arg1" })] + [InlineData("EmptyKeyTemplate", new object[] { "arg1", "arg2" })] + [InlineData("EmptyParameterAlias", new object[] { "arg1", "arg2" })] + [InlineData("EmptyPathTemplate", new object[] { "arg1" })] + [InlineData("EntityReferenceMustHasKeySegment", new object[] { "arg1" })] + [InlineData("EntityTypeMismatch", new object[] { "arg1", "arg2" })] + [InlineData("ErrorTypeMustBeODataErrorOrHttpError", new object[] { "arg1" })] + [InlineData("ETagNotWellFormed", new object[] { })] + [InlineData("ExpandFilterExpressionNotLambdaExpression", new object[] { "arg1", "arg2" })] + [InlineData("ExpressionLexer_UnbalancedBracketExpression", new object[] { "arg1", "arg2" })] + [InlineData("ExpressionLexerSyntaxError", new object[] { "arg1", "arg2" })] + [InlineData("ExpressionLexerUnterminatedStringLiteral", new object[] { "arg1", "arg2" })] + [InlineData("FailedToBuildEdmModelBecauseReturnTypeIsNull", new object[] { "arg1", "arg2" })] + [InlineData("FailedToRetrieveTypeToBuildEdmModel", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("FormatterReadIsNotSupportedForType", new object[] { "arg1", "arg2" })] + [InlineData("FunctionNotBoundToCollectionOfEntity", new object[] { "arg1" })] + [InlineData("FunctionNotBoundToEntity", new object[] { "arg1" })] + [InlineData("FunctionNotSupportedOnEnum", new object[] { "arg1" })] + [InlineData("GetOnlyCollectionCannotBeArray", new object[] { "arg1", "arg2" })] + [InlineData("IdLinkNullForEntityIdHeader", new object[] { "arg1" })] + [InlineData("InfiniteParameterAlias", new object[] { "arg1" })] + [InlineData("InputCastTypeKindNotMatch", new object[] { "arg1", "arg2" })] + [InlineData("InputKeyNotMatchEntityTypeKey", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("InvalidAttributeRoutingTemplateSegment", new object[] { "arg1" })] + [InlineData("InvalidBatchReaderState", new object[] { "arg1", "arg2" })] + [InlineData("InvalidExpansionDepthValue", new object[] { "arg1", "arg2" })] + [InlineData("InvalidKeyInUriFound", new object[] { "arg1", "arg2" })] + [InlineData("InvalidLastSegmentInSelectExpandPath", new object[] { "arg1" })] + [InlineData("InvalidODataRouteOnAction", new object[] { "arg1", "arg2", "arg3", "arg4" })] + [InlineData("InvalidODataUntypedValue", new object[] { "arg1" })] + [InlineData("InvalidParameterValueInUriFound", new object[] { "arg1", "arg2" })] + [InlineData("InvalidPropertyMapper", new object[] { "arg1", "arg2" })] + [InlineData("InvalidPropertyMapping", new object[] { "arg1" })] + [InlineData("InvalidSegmentInSelectExpandPath", new object[] { "arg1" })] + [InlineData("InvalidTemplateLiteral", new object[] { "arg1", "arg2" })] + [InlineData("JsonConverterDoesnotSupportRead", new object[] { "arg1" })] + [InlineData("KeyTemplateMustBeInCurlyBraces", new object[] { "arg1", "arg2" })] + [InlineData("KeyValueCannotBeNull", new object[] { "arg1", "arg2" })] + [InlineData("MappingDoesNotContainResourceType", new object[] { "arg1" })] + [InlineData("MaxAnyAllExpressionLimitExceeded", new object[] { "arg1", "arg2" })] + [InlineData("MaxExpandDepthExceeded", new object[] { "arg1", "arg2" })] + [InlineData("MaxNodeLimitExceeded", new object[] { "arg1", "arg2" })] + [InlineData("MissingNonODataContainer", new object[] { })] + [InlineData("MissingNonStandardTypeSupportFor", new object[] { "arg1" })] + [InlineData("MissingODataContainer", new object[] { "arg1" })] + [InlineData("MissingODataServices", new object[] { "arg1" })] + [InlineData("MissingParameterAlias", new object[] { "arg1" })] + [InlineData("MissingRequiredParameterInOperation", new object[] { "arg1", "arg2" })] + [InlineData("ModelBinderUtil_ModelMetadataCannotBeNull", new object[] { })] + [InlineData("ModelBinderUtil_ValueCannotBeEnum", new object[] { "arg1", "arg2" })] + [InlineData("ModelBindingContextMustHaveRequest", new object[] { })] + [InlineData("ModelMissingFromReadContext", new object[] { })] + [InlineData("ModelPrefixAlreadyUsed", new object[] { "arg1" })] + [InlineData("MultipleActionImportFound", new object[] { "arg1" })] + [InlineData("MultipleMatchingClrTypesForEdmType", new object[] { "arg1", "arg2" })] + [InlineData("MultipleSingleLiteralNotAllowed", new object[] { "arg1" })] + [InlineData("NavigationSourceMissingDuringDeserialization", new object[] { })] + [InlineData("NavigationSourceMissingDuringSerialization", new object[] { })] + [InlineData("NestedCollectionsNotSupported", new object[] { "arg1" })] + [InlineData("NestedPropertyNotfound", new object[] { "arg1", "arg2" })] + [InlineData("NonSelectExpandOnSingleEntity", new object[] { })] + [InlineData("NotAllowedArithmeticOperator", new object[] { "arg1", "arg2" })] + [InlineData("NotAllowedFunction", new object[] { "arg1", "arg2" })] + [InlineData("NotAllowedLogicalOperator", new object[] { "arg1", "arg2" })] + [InlineData("NotAllowedOrderByProperty", new object[] { "arg1", "arg2" })] + [InlineData("NotAllowedQueryOption", new object[] { "arg1", "arg2" })] + [InlineData("NotCountableEntitySetUsedForCount", new object[] { "arg1" })] + [InlineData("NotCountablePropertyUsedForCount", new object[] { "arg1" })] + [InlineData("NotExpandablePropertyUsedInExpand", new object[] { "arg1" })] + [InlineData("NotFilterablePropertyUsedInFilter", new object[] { "arg1" })] + [InlineData("NotNavigablePropertyUsedInNavigation", new object[] { "arg1" })] + [InlineData("NotSelectablePropertyUsedInSelect", new object[] { "arg1" })] + [InlineData("NotSortablePropertyUsedInOrderBy", new object[] { "arg1" })] + [InlineData("NotSupportedChildTransformationKind", new object[] { "arg1", "arg2" })] + [InlineData("NotSupportedTransformationKind", new object[] { "arg1" })] + [InlineData("NullContainer", new object[] { })] + [InlineData("NullContainerBuilder", new object[] { })] + [InlineData("NullElementInCollection", new object[] { })] + [InlineData("ODataFunctionNotSupported", new object[] { "arg1" })] + [InlineData("ODataPathMissing", new object[] { })] + [InlineData("OnlySingleValueNodeSupported", new object[] { })] + [InlineData("OperationIsNotBound", new object[] { "arg1", "arg2" })] + [InlineData("OperationMustBeUniqueInEntitySetContainer", new object[] { "arg1" })] + [InlineData("OrderByClauseInvalid", new object[] { "arg1", "arg2" })] + [InlineData("OrderByClauseNotSupported", new object[] { })] + [InlineData("OrderByDuplicateIt", new object[] { })] + [InlineData("OrderByDuplicateProperty", new object[] { "arg1" })] + [InlineData("OrderByNodeCountExceeded", new object[] { "arg1" })] + [InlineData("ParameterTemplateMustBeInCurlyBraces", new object[] { "arg1", "arg2" })] + [InlineData("ParameterTypeIsNotCollection", new object[] { "arg1", "arg2" })] + [InlineData("PropertyCannotBeConverted", new object[] { "arg1" })] + [InlineData("PropertyIsNotCollection", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("PropertyMustBeBoolean", new object[] { })] + [InlineData("PropertyMustBeDateTimeOffsetOrDate", new object[] { })] + [InlineData("PropertyMustBeEnum", new object[] { "arg1", "arg2" })] + [InlineData("PropertyMustBeSet", new object[] { "arg1" })] + [InlineData("PropertyMustBeSetWhenAnotherPropertyIsSet", new object[] { "arg1", "arg2" })] + [InlineData("PropertyMustBeString", new object[] { })] + [InlineData("PropertyMustBeStringLengthOne", new object[] { })] + [InlineData("PropertyMustBeStringMaxLengthOne", new object[] { })] + [InlineData("PropertyMustBeTimeOfDay", new object[] { })] + [InlineData("PropertyMustHavePublicGetterAndSetter", new object[] { })] + [InlineData("PropertyNotFound", new object[] { "arg1", "arg2" })] + [InlineData("PropertyNotFoundOnPathExpression", new object[] { "arg1", "arg2" })] + [InlineData("PropertyOrPathWasRemovedFromContext", new object[] { "arg1" })] + [InlineData("PropertyTypeOverflow", new object[] { "arg1" })] + [InlineData("PropertyUnrecognizedFormat", new object[] { "arg1" })] + [InlineData("QueryCannotBeEmpty", new object[] { "arg1" })] + [InlineData("QueryGetModelMustNotReturnNull", new object[] { })] + [InlineData("QueryNodeBindingNotSupported", new object[] { "arg1", "arg2" })] + [InlineData("QueryNodeValidationNotSupported", new object[] { "arg1", "arg2" })] + [InlineData("QueryParameterNotSupported", new object[] { "arg1" })] + [InlineData("ReadFromStreamAsyncMustHaveRequest", new object[] { })] + [InlineData("ReferenceNavigationPropertyExpandFilterVisitorUnexpectedParameter", new object[] { "arg1" })] + [InlineData("RequestMustHaveModel", new object[] { })] + [InlineData("RequestNotActionInvocation", new object[] { "arg1" })] + [InlineData("RequiredParametersNotSubsetOfFunctionParameters", new object[] { "arg1", "arg2" })] + [InlineData("ResourceTypeNotInModel", new object[] { "arg1" })] + [InlineData("RootElementNameMissing", new object[] { "arg1" })] + [InlineData("RouteOptionDisabledKeySegment", new object[] { })] + [InlineData("RouteOptionDisabledOperationSegment", new object[] { })] + [InlineData("RouteServicesAlreadyExist", new object[] { })] + [InlineData("SegmentShouldBeKind", new object[] { "arg1", "arg2" })] + [InlineData("SelectExpandEmptyOrNull", new object[] { })] + [InlineData("SelectExpandEmptyOrWhitespace", new object[] { })] + [InlineData("SelectionTypeNotSupported", new object[] { "arg1" })] + [InlineData("SelectNonStructured", new object[] { "arg1" })] + [InlineData("ShouldHaveNavigationPropertyInNavigationExpandPath", new object[] { })] + [InlineData("SingleResultHasMoreThanOneEntity", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("SkipTokenNotSupportedOrderByExpression", new object[] { "arg1" })] + [InlineData("SkipTokenParseError", new object[] { "arg1" })] + [InlineData("SkipTokenProcessingError", new object[] { })] + [InlineData("SkipTopLimitExceeded", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("TargetKindNotImplemented", new object[] { "arg1", "arg2" })] + [InlineData("TypeCannotBeDeserialized", new object[] { "arg1" })] + [InlineData("TypeCannotBeSerialized", new object[] { "arg1" })] + [InlineData("TypeMustBeEntity", new object[] { "arg1" })] + [InlineData("TypeMustBeEnumOrNullableEnum", new object[] { "arg1" })] + [InlineData("TypeMustBeOpenType", new object[] { "arg1" })] + [InlineData("TypeMustBeRelated", new object[] { "arg1", "arg2" })] + [InlineData("TypeMustBeResourceSet", new object[] { "arg1" })] + [InlineData("TypeMustImplementInterface", new object[] { "arg1", "arg2" })] + [InlineData("TypeMustInheritFromType", new object[] { "arg1", "arg2" })] + [InlineData("TypeOfDynamicPropertyNotSupported", new object[] { "arg1", "arg2" })] + [InlineData("UnableToDetermineBaseUrl", new object[] { })] + [InlineData("UnableToDetermineMetadataUrl", new object[] { })] + [InlineData("UnableToIdentifyUniqueProperty", new object[] { "arg1" })] + [InlineData("UnaryNodeValidationNotSupported", new object[] { "arg1", "arg2" })] + [InlineData("UnexpectedElementType", new object[] { "arg1", "arg2", "arg3" })] + [InlineData("UnsupportedEdmType", new object[] { "arg1", "arg2" })] + [InlineData("UnsupportedEdmTypeKind", new object[] { "arg1" })] + [InlineData("UriFunctionClrBinderAlreadyBound", new object[] { "arg1" })] + [InlineData("UriQueryStringInvalid", new object[] { "arg1" })] + [InlineData("ValueIsInvalid", new object[] { "arg1", "arg2" })] + [InlineData("WriteObjectInlineNotSupported", new object[] { "arg1" })] + [InlineData("WriteObjectNotSupported", new object[] { "arg1" })] + [InlineData("WriteToResponseAsyncMustHaveRequest", new object[] { })] + public void ResourceKey_ShouldFormatWithoutException(string resourceKey, object[] formatArgs) + { + // Arrange + var resourceValue = SRResources.ResourceManager.GetString(resourceKey, SRResources.Culture); + Assert.NotNull(resourceValue); // Ensure the resource exists + + // Act & Assert + var exception = Record.Exception(() => Error.Format(resourceValue, formatArgs)); + Assert.Null(exception); // Ensure no Exception is thrown + + // Match numbers of arguments required in the resource string and validate against formatArgs + var matches = Regex.Matches(resourceValue, @"\{\d+\}").Select(m => m.Value).Distinct(); + Assert.Equal(matches.Count(), formatArgs.Length); + + var formattedValue = Error.Format(resourceValue, formatArgs); + Assert.NotNull(formattedValue); + + Assert.Equal(formattedValue, string.Format(resourceValue, formatArgs)); + } +} diff --git a/test/Microsoft.AspNetCore.OData.Tests/Results/PageResultValueConverterTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Results/PageResultValueConverterTests.cs index cec5a7d54..1570bd122 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Results/PageResultValueConverterTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Results/PageResultValueConverterTests.cs @@ -10,6 +10,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.OData.Results; +using Microsoft.AspNetCore.OData.TestCommon; using Microsoft.AspNetCore.OData.Tests.Extensions; using Xunit; @@ -48,8 +49,23 @@ public void CreateConverter_WorksForPageResultValueConverter() Assert.Null(typeConverter); } - [Fact] - public void PageResultValueConverter_CanSerializePageResultOfT() + public static TheoryDataSet PageResultValueConverterData + { + get + { + return new TheoryDataSet + { + { "{\"items\":[{\"Id\":1,\"Name\":\"abc\"},{\"Id\":2,\"Name\":\"efg\"}],\"nextpagelink\":\"http://any\",\"count\":2}", null }, + { "{\"items\":[{\"id\":1,\"name\":\"abc\"},{\"id\":2,\"name\":\"efg\"}],\"nextpagelink\":\"http://any\",\"count\":2}", JsonNamingPolicy.CamelCase }, + { "{\"ITEMS\":[{\"ID\":1,\"NAME\":\"abc\"},{\"ID\":2,\"NAME\":\"efg\"}],\"NEXTPAGELINK\":\"http://any\",\"COUNT\":2}", JsonNamingPolicy.SnakeCaseUpper }, + { "{\"values\":[{\"Id\":1,\"Name\":\"abc\"},{\"Id\":2,\"Name\":\"efg\"}],\"next_page_link\":\"http://any\",\"count\":2}", new MyNamingPolicy() } + }; + } + } + + [Theory] + [MemberData(nameof(PageResultValueConverterData))] + public void PageResultValueConverter_CanSerializePageResultOfT_WithNamingPolicy(string expected, JsonNamingPolicy policy) { // Arrange & Act & Assert IEnumerable customers = new Customer[] @@ -61,7 +77,11 @@ public void PageResultValueConverter_CanSerializePageResultOfT() long? count = 2; PageResult result = new PageResult(customers, nextPageLink, count); - JsonSerializerOptions options = new JsonSerializerOptions(); + JsonSerializerOptions options = new JsonSerializerOptions + { + PropertyNamingPolicy = policy + }; + PageResultValueConverter converterFactory = new PageResultValueConverter(); Type type = typeof(PageResult); PageResultConverter typeConverter = converterFactory.CreateConverter(type, options) as PageResultConverter; @@ -70,7 +90,25 @@ public void PageResultValueConverter_CanSerializePageResultOfT() string json = SerializeUtils.SerializeAsJson(jsonWriter => typeConverter.Write(jsonWriter, result, options)); // Assert - Assert.Equal("{\"items\":[{\"Id\":1,\"Name\":\"abc\"},{\"Id\":2,\"Name\":\"efg\"}],\"nextpagelink\":\"http://any\",\"count\":2}", json); + Assert.Equal(expected, json); + } + + private class MyNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + if (name == "nextpagelink") + { + return "next_page_link"; + } + + if (name == "items") + { + return "values"; + } + + return name; + } } private class Customer diff --git a/test/Microsoft.AspNetCore.OData.Tests/Routing/ODataPathSegmentHandlerTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Routing/ODataPathSegmentHandlerTests.cs index 76417dd2c..3ec572135 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Routing/ODataPathSegmentHandlerTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Routing/ODataPathSegmentHandlerTests.cs @@ -402,14 +402,60 @@ public void ConvertKeysToString_ConvertKeysValues_ShouldEscapeUriString() IEnumerable> keys = new KeyValuePair[] { KeyValuePair.Create("Id", (object)4), - KeyValuePair.Create("Name", (object)"2425/&Foo") + KeyValuePair.Create("Name", (object)"'24 25/&Foo,?\"") }; // Act string actual = ODataPathSegmentHandler.ConvertKeysToString(keys, entityType); // Assert - Assert.Equal("Id=4,Name='2425%2F&Foo'", actual); + Assert.Equal("Id=4,Name='%27%2724%2025%2F%26Foo%2C%3F%22'", actual); + } + + [Fact] + public void ConvertKeysToString_ConvertKeysValues_ProducesSameEncodingAsODL() + { + // Arrange + EdmEntityType entityType = new EdmEntityType("NS", "Entity"); + IEdmStructuralProperty key1 = entityType.AddStructuralProperty("Id", EdmCoreModel.Instance.GetInt32(false)); + IEdmStructuralProperty key2 = entityType.AddStructuralProperty("Name", EdmCoreModel.Instance.GetString(false)); + + entityType.AddKeys(key1, key2); + IEnumerable> keys = new KeyValuePair[] + { + KeyValuePair.Create("Id", (object)4), + KeyValuePair.Create("Name", (object)"'24 25/&Foo,?\"") + }; + + // Act + string actual = '(' + ODataPathSegmentHandler.ConvertKeysToString(keys, entityType) + ')'; + + ODataPath path = new ODataPath(new KeySegment(keys, entityType, null)); + + // Assert + Assert.Equal(path.ToResourcePathString(ODataUrlKeyDelimiter.Parentheses), actual); + } + + [Fact] + public void ConvertKeysToString_ConvertKeysValues_ShouldNotQuoteNull() + { + // Arrange + EdmEntityType entityType = new EdmEntityType("NS", "Entity"); + IEdmStructuralProperty key1 = entityType.AddStructuralProperty("Id", EdmCoreModel.Instance.GetInt32(false)); + IEdmStructuralProperty key2 = entityType.AddStructuralProperty("Name", EdmCoreModel.Instance.GetString(true)); + + entityType.AddKeys(key1, key2); + IEnumerable> keys = new KeyValuePair[] + { + KeyValuePair.Create("Id", (object)4), + KeyValuePair.Create("Name", (object)null) + }; + + // Act + string actual = ODataPathSegmentHandler.ConvertKeysToString(keys, entityType); + + // Assert + Assert.Equal("Id=4,Name=null", actual); } [Fact] diff --git a/tool/builder.versions.settings.targets b/tool/builder.versions.settings.targets index c88081814..28282f2b5 100644 --- a/tool/builder.versions.settings.targets +++ b/tool/builder.versions.settings.targets @@ -2,9 +2,9 @@ 9 - 2 + 4 0 - + preview