diff --git a/appveyor.yml b/appveyor.yml index ca29435445..0139480fec 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,8 +1,6 @@ image: - Ubuntu2004 - # Downgrade to workaround error NETSDK1194 during 'dotnet pack': The "--output" option isn't supported when building a solution. - # https://stackoverflow.com/questions/75453953/how-to-fix-github-actions-dotnet-publish-workflow-error-the-output-option-i - - Previous Visual Studio 2022 + - Visual Studio 2022 version: '{build}' @@ -34,7 +32,7 @@ for: - matrix: only: - - image: Previous Visual Studio 2022 + - image: Visual Studio 2022 services: - postgresql15 install: @@ -100,6 +98,9 @@ build_script: Write-Output ".NET version:" dotnet --version + Write-Output "PowerShell version:" + pwsh --version + Write-Output "PostgreSQL version:" if ($IsWindows) { . "${env:ProgramFiles}\PostgreSQL\15\bin\psql" --version diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 471c9604c7..5bb97a3156 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -2,6 +2,7 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -130,6 +131,6 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - return new EvaluatedIncludeCache(); + return new EvaluatedIncludeCache(Array.Empty()); } } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index a985bd5936..3f9efcc11d 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; @@ -121,12 +122,12 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - ResourceType resourceAType = resourceGraph.GetResourceType(); + ResourceType resourceType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + RelationshipAttribute single2 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); var include = new IncludeExpression(new HashSet { @@ -142,7 +143,7 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG }.ToImmutableHashSet()) }.ToImmutableHashSet()); - var cache = new EvaluatedIncludeCache(); + var cache = new EvaluatedIncludeCache(Array.Empty()); cache.Set(include); return cache; } diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index 5212429b7d..bdd13d16b8 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development. diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index 468b8447ac..cbe7d13e9d 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script generates response documents for ./request-examples diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index d47cf8618e..e1caa85797 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -133,24 +133,24 @@ Here are some injectable request-scoped types to be aware of: - `IJsonApiRequest`: This contains routing information, such as whether a primary, secondary, or relationship endpoint is being accessed. - `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates). - `IEnumerable`: Provides access to the parsed query string parameters. -- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render, which you need to populate. -- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the attributes and relationship objects. You need to populate this as well. +- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render. +- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. -You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources and relationships). +You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships). So, back to the topic of where to intercept. It helps to familiarize yourself with the [execution pipeline](~/internals/queries.md). Replacing at the service level is the simplest. But it means you'll need to read the parsed query string parameters and invoke all resource definition callbacks yourself. And you won't get change detection (HTTP 203 Not Modified). Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs) to see what you're missing out on. -You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options, analyze query strings or populate caches for the serializer. +You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings. And most resource definition callbacks are handled. That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`. Now the hard part for you becomes reading that data structure and producing data access calls from that. If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs), which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/). -Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. -We use this for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. There's an example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs). +We use a similar approach for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). > [!TIP] > [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees! diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 5c2b238a18..76f062c233 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -29,7 +29,7 @@ Processing a request involves the following steps: To get a sense of what this all looks like, let's look at an example query string: ``` -/api/v1/blogs? +/api/blogs? include=owner,posts.comments.author& filter=has(posts)& sort=count(posts)& diff --git a/docs/request-examples/001_GET_Books.ps1 b/docs/request-examples/001_GET_Books.ps1 index 559bbfb4d5..f89f9cdd4f 100644 --- a/docs/request-examples/001_GET_Books.ps1 +++ b/docs/request-examples/001_GET_Books.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books diff --git a/docs/request-examples/002_GET_Person-by-ID.ps1 b/docs/request-examples/002_GET_Person-by-ID.ps1 index d565c7cf53..77851b3116 100644 --- a/docs/request-examples/002_GET_Person-by-ID.ps1 +++ b/docs/request-examples/002_GET_Person-by-ID.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people/1 diff --git a/docs/request-examples/003_GET_Books-including-Author.ps1 b/docs/request-examples/003_GET_Books-including-Author.ps1 index 33f5dcd487..6dd71f1f4e 100644 --- a/docs/request-examples/003_GET_Books-including-Author.ps1 +++ b/docs/request-examples/003_GET_Books-including-Author.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?include=author diff --git a/docs/request-examples/004_GET_Books-PublishYear.ps1 b/docs/request-examples/004_GET_Books-PublishYear.ps1 index a08cb7e6a0..d07cc7bc1a 100644 --- a/docs/request-examples/004_GET_Books-PublishYear.ps1 +++ b/docs/request-examples/004_GET_Books-PublishYear.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear diff --git a/docs/request-examples/005_GET_People-Filter_Partial.ps1 b/docs/request-examples/005_GET_People-Filter_Partial.ps1 index 2e2339f76c..092a54ef1e 100644 --- a/docs/request-examples/005_GET_People-Filter_Partial.ps1 +++ b/docs/request-examples/005_GET_People-Filter_Partial.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/people?filter=contains(name,'Shell')" diff --git a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 index 309ad6dcc6..fd96cb3c86 100644 --- a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 +++ b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?sort=-publishYear diff --git a/docs/request-examples/007_GET_Books-paginated.ps1 b/docs/request-examples/007_GET_Books-paginated.ps1 index c088163a52..a744886801 100644 --- a/docs/request-examples/007_GET_Books-paginated.ps1 +++ b/docs/request-examples/007_GET_Books-paginated.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/books?page%5Bsize%5D=1&page%5Bnumber%5D=2" diff --git a/docs/request-examples/010_CREATE_Person.ps1 b/docs/request-examples/010_CREATE_Person.ps1 index 1a76f0cad1..e8f95020cd 100644 --- a/docs/request-examples/010_CREATE_Person.ps1 +++ b/docs/request-examples/010_CREATE_Person.ps1 @@ -1,10 +1,12 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"people\", - \"attributes\": { - \"name\": \"Alice\" + "data": { + "type": "people", + "attributes": { + "name": "Alice" } } }' diff --git a/docs/request-examples/011_CREATE_Book-with-Author.ps1 b/docs/request-examples/011_CREATE_Book-with-Author.ps1 index bf839f5a85..0737689408 100644 --- a/docs/request-examples/011_CREATE_Book-with-Author.ps1 +++ b/docs/request-examples/011_CREATE_Book-with-Author.ps1 @@ -1,17 +1,19 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"books\", - \"attributes\": { - \"title\": \"Valperga\", - \"publishYear\": 1823 + "data": { + "type": "books", + "attributes": { + "title": "Valperga", + "publishYear": 1823 }, - \"relationships\": { - \"author\": { - \"data\": { - \"type\": \"people\", - \"id\": \"1\" + "relationships": { + "author": { + "data": { + "type": "people", + "id": "1" } } } diff --git a/docs/request-examples/012_PATCH_Book.ps1 b/docs/request-examples/012_PATCH_Book.ps1 index d704c8c8c8..61ea6bee76 100644 --- a/docs/request-examples/012_PATCH_Book.ps1 +++ b/docs/request-examples/012_PATCH_Book.ps1 @@ -1,12 +1,14 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -H "Content-Type: application/vnd.api+json" ` -X PATCH ` -d '{ - \"data\": { - \"type\": \"books\", - \"id\": \"1\", - \"attributes\": { - \"publishYear\": 1820 + "data": { + "type": "books", + "id": "1", + "attributes": { + "publishYear": 1820 } } }' diff --git a/docs/request-examples/013_DELETE_Book.ps1 b/docs/request-examples/013_DELETE_Book.ps1 index d5fdd8e103..bbd7ba7445 100644 --- a/docs/request-examples/013_DELETE_Book.ps1 +++ b/docs/request-examples/013_DELETE_Book.ps1 @@ -1,2 +1,4 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -X DELETE diff --git a/docs/usage/options.md b/docs/usage/options.md index 6c896b9698..549bfc454c 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -55,8 +55,8 @@ options.UseRelativeLinks = true; "relationships": { "author": { "links": { - "self": "/api/v1/articles/4309/relationships/author", - "related": "/api/v1/articles/4309/author" + "self": "/articles/4309/relationships/author", + "related": "/articles/4309/author" } } } diff --git a/docs/usage/routing.md b/docs/usage/routing.md index a264622931..e3e021ec23 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -11,10 +11,10 @@ You can add a namespace to all URLs by specifying it at startup. ```c# // Program.cs -builder.Services.AddJsonApi(options => options.Namespace = "api/v1"); +builder.Services.AddJsonApi(options => options.Namespace = "api/shopping"); ``` -Which results in URLs like: https://yourdomain.com/api/v1/people +Which results in URLs like: https://yourdomain.com/api/shopping/articles ## Default routing convention @@ -66,14 +66,14 @@ It is possible to override the default routing convention for an auto-generated ```c# // Auto-generated [DisableRoutingConvention] -[Route("v1/custom/route/summaries-for-orders")] +[Route("custom/route/summaries-for-orders")] partial class OrderSummariesController { } // Hand-written [DisableRoutingConvention] -[Route("v1/custom/route/lines-in-order")] +[Route("custom/route/lines-in-order")] public class OrderLineController : JsonApiController { public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index ba73b8bf3a..cfc82ab27a 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -16,7 +16,8 @@ public sealed class AppDbContext : DbContext public DbSet Employees => Set(); - public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + public AppDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : base(options) { _httpContextAccessor = httpContextAccessor; _configuration = configuration; @@ -27,16 +28,16 @@ public void SetTenantName(string tenantName) _forcedTenantName = tenantName; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + protected override void OnConfiguring(DbContextOptionsBuilder builder) { string connectionString = GetConnectionString(); - optionsBuilder.UseNpgsql(connectionString); + builder.UseNpgsql(connectionString); } private string GetConnectionString() { string? tenantName = GetTenantName(); - string? connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + string? connectionString = _configuration.GetConnectionString(tenantName ?? "Default"); if (connectionString == null) { diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs index b6f960831d..1414e28424 100644 --- a/src/Examples/DatabasePerTenantExample/Program.cs +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -1,20 +1,29 @@ +using System.Diagnostics; using DatabasePerTenantExample.Data; using DatabasePerTenantExample.Models; using JsonApiDotNetCore.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddSingleton(); -builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddDbContext(options => SetDbContextDebugOptions(options)); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -31,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); @@ -41,18 +58,18 @@ static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider servi dbContext.SetTenantName(tenantName); } - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - - if (tenantName != null) + if (await dbContext.Database.EnsureCreatedAsync()) { - dbContext.Employees.Add(new Employee + if (tenantName != null) { - FirstName = "John", - LastName = "Doe", - CompanyName = tenantName - }); + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); + } } } diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index c065f66c64..01687be022 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,13 +1,15 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", - "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", - "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index cf19380c6c..9ce6beda08 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,19 +1,31 @@ +using System.Diagnostics; using GettingStarted.Data; using GettingStarted.Models; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddSqlite("Data Source=sample.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDb.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -28,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index bcf154605c..d806502bcd 100644 --- a/src/Examples/GettingStarted/Properties/launchSettings.json +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -19,7 +19,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "applicationUrl": "http://localhost:14141", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/GettingStarted/appsettings.json b/src/Examples/GettingStarted/appsettings.json index 270cabc088..590851ee61 100644 --- a/src/Examples/GettingStarted/appsettings.json +++ b/src/Examples/GettingStarted/appsettings.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 24378e3182..dd30287500 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; // @formatter:wrap_chained_method_calls chop_always @@ -18,14 +19,33 @@ public AppDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - // When deleting a person, un-assign him/her from existing todo items. + // When deleting a person, un-assign him/her from existing todo-items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee!); + .WithOne(todoItem => todoItem.Assignee); - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany(); + // When deleting a person, the todo-items he/she owns are deleted too. + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + AdjustDeleteBehaviorForJsonApi(builder); + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs new file mode 100644 index 0000000000..59247532b9 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs @@ -0,0 +1,35 @@ +namespace JsonApiDotNetCoreExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = new(); + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList +{ + private int _index = -1; + + public IList Elements { get; } + + public RotatingList(IList elements) + { + Elements = elements; + } + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs new file mode 100644 index 0000000000..3bc2e4bacf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExample.Models; + +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int tagCount = 25; + + RotatingList people = RotatingList.Create(personCount, index => new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }); + + RotatingList tags = RotatingList.Create(tagCount, index => new Tag + { + Name = $"TagName{index + 1:D2}" + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index ee7b874fc4..c533143855 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TodoItemDefinition : JsonApiResourceDefinition +public sealed class TodoItemDefinition : JsonApiResourceDefinition { private readonly ISystemClock _systemClock; @@ -29,7 +29,7 @@ private SortExpression GetDefaultSortOrder() { return CreateSortExpressionFromLambda(new PropertySortOrder { - (todoItem => todoItem.Priority, ListSortDirection.Descending), + (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) }); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 5415d37bb3..d11fbffff6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Person : Identifiable +public sealed class Person : Identifiable { [Attr] public string? FirstName { get; set; } @@ -14,6 +15,13 @@ public sealed class Person : Identifiable [Attr] public string LastName { get; set; } = null!; + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + [HasMany] public ISet AssignedTodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 9095b0af80..8904ec01a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Tag : Identifiable +public sealed class Tag : Identifiable { [Attr] [MinLength(1)] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 5fe508f7f2..68df7cef27 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class TodoItem : Identifiable +public sealed class TodoItem : Identifiable { [Attr] public string Description { get; set; } = null!; @@ -16,6 +16,9 @@ public sealed class TodoItem : Identifiable [Required] public TodoItemPriority? Priority { get; set; } + [Attr] + public long? DurationInHours { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } @@ -25,9 +28,9 @@ public sealed class TodoItem : Identifiable [HasOne] public Person Owner { get; set; } = null!; - [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowSet)] + [HasOne] public Person? Assignee { get; set; } - [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter)] + [HasMany] public ISet Tags { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 9ef85348f1..84e3567b31 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] public enum TodoItemPriority { - Low, - Medium, - High + High = 1, + Medium = 2, + Low = 3 } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index e2fbc66ffd..2884e7750c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using JsonApiDotNetCore.Configuration; @@ -5,6 +6,7 @@ using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; [assembly: ExcludeFromCodeCoverage] @@ -48,26 +50,24 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddDbContext(options => { string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + + SetDbContextDebugOptions(options); }); using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) { builder.Services.AddJsonApi(options => { - options.Namespace = "api/v1"; + options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; #endif }, discovery => discovery.AddCurrentAssembly()); } @@ -76,7 +76,15 @@ static void ConfigureServices(WebApplicationBuilder builder) static string? GetConnectionString(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); +} + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } static void ConfigurePipeline(WebApplication webApplication) @@ -98,5 +106,9 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 6a5108a8ad..54646922e1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index ec2ea30102..058685ecb1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,14 +1,15 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", + // Include server startup, JsonApiDotNetCore measurements, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "JsonApiDotNetCore": "Information", "JsonApiDotNetCoreExample": "Information" } }, diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index f8a99654de..a8acd7ae83 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using MultiDbContextExample.Data; using MultiDbContextExample.Models; using MultiDbContextExample.Repositories; @@ -7,13 +10,29 @@ // Add services to the container. -builder.Services.AddSqlite("Data Source=A.db;Pooling=False"); -builder.Services.AddSqlite("Data Source=B.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbA.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); + +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbB.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif }, dbContextTypes: new[] { typeof(DbContextA), @@ -35,6 +54,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index a77f78562b..9d3467265f 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/MultiDbContextExample/appsettings.json b/src/Examples/MultiDbContextExample/appsettings.json index d0229a3016..590851ee61 100644 --- a/src/Examples/MultiDbContextExample/appsettings.json +++ b/src/Examples/MultiDbContextExample/appsettings.json @@ -2,8 +2,10 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs deleted file mode 100644 index c10cda8e6c..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using NoEntityFrameworkExample.Models; - -namespace NoEntityFrameworkExample.Data; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext -{ - public DbSet WorkItems => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/Database.cs b/src/Examples/NoEntityFrameworkExample/Data/Database.cs new file mode 100644 index 0000000000..eee64653ee --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/Database.cs @@ -0,0 +1,131 @@ +using JetBrains.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Database +{ + public static List TodoItems { get; } + public static List Tags { get; } + public static List People { get; } + + static Database() + { + int personIndex = 0; + int tagIndex = 0; + int todoItemIndex = 0; + + var john = new Person + { + Id = ++personIndex, + FirstName = "John", + LastName = "Doe" + }; + + var jane = new Person + { + Id = ++personIndex, + FirstName = "Jane", + LastName = "Doe" + }; + + var personalTag = new Tag + { + Id = ++tagIndex, + Name = "Personal" + }; + + var familyTag = new Tag + { + Id = ++tagIndex, + Name = "Family" + }; + + var businessTag = new Tag + { + Id = ++tagIndex, + Name = "Business" + }; + + TodoItems = new List + { + new() + { + Id = ++todoItemIndex, + Description = "Make homework", + DurationInHours = 3, + Priority = TodoItemPriority.High, + Owner = john, + Assignee = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Book vacation", + DurationInHours = 2, + Priority = TodoItemPriority.Low, + Owner = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Cook dinner", + DurationInHours = 1, + Priority = TodoItemPriority.Medium, + Owner = jane, + Assignee = john, + Tags = + { + familyTag, + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Check emails", + DurationInHours = 1, + Priority = TodoItemPriority.Low, + Owner = john, + Assignee = john, + Tags = + { + businessTag + } + } + }; + + Tags = new List + { + personalTag, + familyTag, + businessTag + }; + + People = new List + { + john, + jane + }; + + foreach (Tag tag in Tags) + { + tag.TodoItems = TodoItems.Where(todoItem => todoItem.Tags.Any(tagInTodoItem => tagInTodoItem.Id == tag.Id)).ToHashSet(); + } + + foreach (Person person in People) + { + person.OwnedTodoItems = TodoItems.Where(todoItem => todoItem.Owner == person).ToHashSet(); + person.AssignedTodoItems = TodoItems.Where(todoItem => todoItem.Assignee == person).ToHashSet(); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs new file mode 100644 index 0000000000..c81aa07b8f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample.Data; + +internal sealed class InMemoryModel : RuntimeModel +{ + public InMemoryModel(IResourceGraph resourceGraph) + { + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + RuntimeEntityType entityType = AddEntityType(resourceType.ClrType.FullName!, resourceType.ClrType); + SetEntityProperties(entityType, resourceType); + } + } + + private static void SetEntityProperties(RuntimeEntityType entityType, ResourceType resourceType) + { + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + entityType.AddProperty(property.Name, property.PropertyType, property); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs new file mode 100644 index 0000000000..179eafd516 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample; + +internal sealed class InMemoryInverseNavigationResolver : IInverseNavigationResolver +{ + private readonly IResourceGraph _resourceGraph; + + public InMemoryInverseNavigationResolver(IResourceGraph resourceGraph) + { + _resourceGraph = resourceGraph; + } + + /// + public void Resolve() + { + ResourceType todoItemType = _resourceGraph.GetResourceType(); + RelationshipAttribute todoItemOwnerRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Owner)); + RelationshipAttribute todoItemAssigneeRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Assignee)); + RelationshipAttribute todoItemTagsRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Tags)); + + ResourceType personType = _resourceGraph.GetResourceType(); + RelationshipAttribute personOwnedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.OwnedTodoItems)); + RelationshipAttribute personAssignedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.AssignedTodoItems)); + + ResourceType tagType = _resourceGraph.GetResourceType(); + RelationshipAttribute tagTodoItemsRelationship = tagType.GetRelationshipByPropertyName(nameof(Tag.TodoItems)); + + // Inverse navigations are required for pagination on non-primary endpoints. + todoItemOwnerRelationship.InverseNavigationProperty = personOwnedTodoItemsRelationship.Property; + todoItemAssigneeRelationship.InverseNavigationProperty = personAssignedTodoItemsRelationship.Property; + todoItemTagsRelationship.InverseNavigationProperty = tagTodoItemsRelationship.Property; + + personOwnedTodoItemsRelationship.InverseNavigationProperty = todoItemOwnerRelationship.Property; + personAssignedTodoItemsRelationship.InverseNavigationProperty = todoItemAssigneeRelationship.Property; + + tagTodoItemsRelationship.InverseNavigationProperty = todoItemTagsRelationship.Property; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Person.cs b/src/Examples/NoEntityFrameworkExample/Models/Person.cs new file mode 100644 index 0000000000..47a7f4da9a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Person.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs new file mode 100644 index 0000000000..425fe0923f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs new file mode 100644 index 0000000000..75d948ca7c --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..7dfd01f570 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs deleted file mode 100644 index 43bf1f422e..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace NoEntityFrameworkExample.Models; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] -public sealed class WorkItem : Identifiable -{ - [Attr] - public bool IsBlocked { get; set; } - - [Attr] - public string Title { get; set; } = null!; - - [Attr] - public long DurationInHours { get; set; } - - [Attr] - public Guid ProjectId { get; set; } = Guid.NewGuid(); -} diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index a244287c13..9f0037b058 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -10,8 +10,6 @@ - - diff --git a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs new file mode 100644 index 0000000000..35b2a29e9e --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs @@ -0,0 +1,316 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace NoEntityFrameworkExample; + +/// +/// Inserts a null check on member dereference and extension method invocation, to prevent a from being thrown when +/// the expression is compiled and executed. +/// +/// For example, +/// todoItem.Assignee.Id == todoItem.Owner.Id) +/// ]]> +/// would throw if the database contains a +/// TodoItem that doesn't have an assignee. +/// +public sealed class NullSafeExpressionRewriter : ExpressionVisitor +{ + private const string MinValueName = nameof(long.MinValue); + private static readonly ConstantExpression Int32MinValueConstant = Expression.Constant(int.MinValue, typeof(int)); + + private static readonly ExpressionType[] ComparisonExpressionTypes = + { + ExpressionType.LessThan, + ExpressionType.LessThanOrEqual, + ExpressionType.GreaterThan, + ExpressionType.GreaterThanOrEqual, + ExpressionType.Equal + // ExpressionType.NotEqual is excluded because WhereClauseBuilder never produces that. + }; + + private readonly Stack _callStack = new(); + + public TExpression Rewrite(TExpression expression) + where TExpression : Expression + { + _callStack.Clear(); + + return (TExpression)Visit(expression); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.Name == "Where") + { + _callStack.Push(MethodType.Where); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (node.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending") + { + // Ordering can be improved by expanding into multiple OrderBy/ThenBy() calls, as described at + // https://stackoverflow.com/questions/26186527/linq-order-by-descending-with-null-values-on-bottom/26186585#26186585. + // For example: + // .OrderBy(element => element.First.Second.CharValue) + // Could be translated to: + // .OrderBy(element => element.First != null) + // .ThenBy(element => element.First == null ? false : element.First.Second != null) + // .ThenBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // Which correctly orders 'element.First == null' before 'element.First.Second == null'. + // The current implementation translates to: + // .OrderBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // in which the order of these two rows is undeterministic. + + _callStack.Push(MethodType.Ordering); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (_callStack.Count > 0) + { + MethodType outerMethodType = _callStack.Peek(); + + if (outerMethodType == MethodType.Ordering && node.Method.Name == "Count") + { + return ToNullSafeCountInvocationInOrderBy(node); + } + + if (outerMethodType == MethodType.Where && node.Method.Name == "Any") + { + return ToNullSafeAnyInvocationInWhere(node); + } + } + + return base.VisitMethodCall(node); + } + + private static Expression ToNullSafeCountInvocationInOrderBy(MethodCallExpression countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // OrderClauseBuilder never produces nested Count() calls. + + // SRC: some.Other.Children.Count() + // DST: some.Other == null ? int.MinValue : some.Other.Children == null ? int.MinValue : some.Other.Children.Count() + return ToConditionalMemberAccessInOrderBy(countMethodCall, memberArgument, Int32MinValueConstant); + } + + return countMethodCall; + } + + private static Expression ToConditionalMemberAccessInOrderBy(Expression outer, MemberExpression innerMember, ConstantExpression defaultValue) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.StringValue + // DST: first.Second == null ? null : first.Second.StringValue + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNull = Expression.Equal(currentMember, nullConstant); + result = Expression.Condition(isNull, defaultValue, result); + } + + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + private static bool IsStaticMemberAccess(MemberExpression member) + { + if (member.Member is FieldInfo field) + { + return field.IsStatic; + } + + if (member.Member is PropertyInfo property) + { + MethodInfo? getter = property.GetGetMethod(); + return getter != null && getter.IsStatic; + } + + return false; + } + + private Expression ToNullSafeAnyInvocationInWhere(MethodCallExpression anyMethodCall) + { + Expression thisArgument = anyMethodCall.Arguments.First(); + + if (thisArgument is MemberExpression memberArgument) + { + MethodCallExpression newAnyMethodCall = anyMethodCall; + + if (anyMethodCall.Arguments.Count > 1) + { + // SRC: .Any(first => first.Second.Value == 1) + // DST: .Any(first => first != null && first.Second != null && first.Second.Value == 1) + List newArguments = anyMethodCall.Arguments.Skip(1).Select(Visit).Cast().ToList(); + newArguments.Insert(0, thisArgument); + + newAnyMethodCall = anyMethodCall.Update(anyMethodCall.Object, newArguments); + } + + // SRC: some.Other.Any() + // DST: some != null && some.Other != null && some.Other.Any() + return ToConditionalMemberAccessInBooleanExpression(newAnyMethodCall, memberArgument, false); + } + + return anyMethodCall; + } + + private static Expression ToConditionalMemberAccessInBooleanExpression(Expression outer, MemberExpression innerMember, bool skipNullCheckOnLastAccess) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Null-check the last member access in the chain on extension method invocation. For example: a.b.c.Count() requires a null-check on 'c'. + // This is unneeded for boolean comparisons. For example: a.b.c == d does not require a null-check on 'c'. + if (!skipNullCheckOnLastAccess || currentMember != innerMember) + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.Value == 1 + // DST: first.Second != null && first.Second.Value == 1 + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNotNull = Expression.NotEqual(currentMember, nullConstant); + result = Expression.AndAlso(isNotNull, result); + } + } + + // Do not null-check the first member access in the chain, because that's the lambda parameter itself. + // For example, in: item => item.First.Second, 'item' does not require a null-check. + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Where) + { + if (ComparisonExpressionTypes.Contains(node.NodeType)) + { + Expression result = node; + + result = ToNullSafeTermInBinary(node.Right, result); + result = ToNullSafeTermInBinary(node.Left, result); + + return result; + } + } + + return base.VisitBinary(node); + } + + private static Expression ToNullSafeTermInBinary(Expression binaryTerm, Expression result) + { + if (binaryTerm is MemberExpression rightMember) + { + // SRC: some.Other.Value == 1 + // DST: some != null && some.Other != null && some.Other.Value == 1 + return ToConditionalMemberAccessInBooleanExpression(result, rightMember, true); + } + + if (binaryTerm is MethodCallExpression { Method.Name: "Count" } countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // SRC: some.Other.Count() == 1 + // DST: some != null && some.Other != null && some.Other.Count() == 1 + return ToConditionalMemberAccessInBooleanExpression(result, memberArgument, false); + } + } + + return result; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Ordering) + { + if (node.Expression is MemberExpression innerMember) + { + ConstantExpression defaultValue = CreateConstantForMemberIsNull(node.Type); + return ToConditionalMemberAccessInOrderBy(node, innerMember, defaultValue); + } + + return node; + } + + return base.VisitMember(node); + } + + private static ConstantExpression CreateConstantForMemberIsNull(Type type) + { + bool canContainNull = !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + if (canContainNull) + { + return Expression.Constant(null, type); + } + + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + ConstantExpression? constant = TryCreateConstantForStaticMinValue(innerType); + + if (constant != null) + { + return constant; + } + + object? defaultValue = Activator.CreateInstance(type); + return Expression.Constant(defaultValue, type); + } + + private static ConstantExpression? TryCreateConstantForStaticMinValue(Type type) + { + // Int32.MinValue is a field, while Int128.MinValue is a property. + + FieldInfo? field = type.GetField(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (field != null) + { + object? value = field.GetValue(null); + return Expression.Constant(value, type); + } + + PropertyInfo? property = type.GetProperty(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (property != null) + { + MethodInfo? getter = property.GetGetMethod(); + + if (getter != null) + { + object? value = getter.Invoke(null, Array.Empty()); + return Expression.Constant(value, type); + } + } + + return null; + } + + private enum MethodType + { + Where, + Ordering + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 363b58b19e..8b299e2c24 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,18 +1,24 @@ using JsonApiDotNetCore.Configuration; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; +using NoEntityFrameworkExample; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -string? connectionString = GetConnectionString(builder.Configuration); -builder.Services.AddNpgsql(connectionString); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; -builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); -builder.Services.AddResourceService(); +builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -22,20 +28,4 @@ app.UseJsonApi(); app.MapControllers(); -await CreateDatabaseAsync(app.Services); - app.Run(); - -static string? GetConnectionString(IConfiguration configuration) -{ - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); -} - -static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) -{ - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); -} diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 82c88ace03..d1e2e0ca67 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs new file mode 100644 index 0000000000..c1db07b0fb --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs @@ -0,0 +1,81 @@ +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample; + +/// +/// Replaces all s with s. +/// +internal sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +{ + private readonly QueryLayer _queryLayer; + + public QueryLayerIncludeConverter(QueryLayer queryLayer) + { + _queryLayer = queryLayer; + } + + public void ConvertIncludesToSelections() + { + if (_queryLayer.Include != null) + { + Visit(_queryLayer.Include, _queryLayer); + _queryLayer.Include = null; + } + + EnsureNonEmptySelection(_queryLayer); + } + + public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) + { + foreach (IncludeElementExpression element in expression.Elements) + { + _ = Visit(element, queryLayer); + } + + return null; + } + + public override object? VisitIncludeElement(IncludeElementExpression expression, QueryLayer queryLayer) + { + QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); + + foreach (IncludeElementExpression nextIncludeElement in expression.Children) + { + Visit(nextIncludeElement, subLayer); + } + + return null; + } + + private static QueryLayer EnsureRelationshipInSelection(QueryLayer queryLayer, RelationshipAttribute relationship) + { + queryLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + if (!selectors.ContainsField(relationship)) + { + selectors.IncludeRelationship(relationship, new QueryLayer(relationship.RightType)); + } + + QueryLayer subLayer = selectors[relationship]!; + EnsureNonEmptySelection(subLayer); + + return subLayer; + } + + private static void EnsureNonEmptySelection(QueryLayer queryLayer) + { + if (queryLayer.Selection == null) + { + queryLayer.Selection = new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + foreach (AttrAttribute attribute in queryLayer.ResourceType.Attributes) + { + selectors.IncludeAttribute(attribute); + } + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs new file mode 100644 index 0000000000..fb65a46015 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample; + +internal sealed class QueryLayerToLinqConverter +{ + private readonly IResourceFactory _resourceFactory; + private readonly IModel _model; + + public QueryLayerToLinqConverter(IResourceFactory resourceFactory, IModel model) + { + _resourceFactory = resourceFactory; + _model = model; + } + + public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) + where TResource : class, IIdentifiable + { + // The Include() extension method from Entity Framework Core is unavailable, so rewrite into selectors. + var converter = new QueryLayerIncludeConverter(queryLayer); + converter.ConvertIncludesToSelections(); + + // Convert QueryLayer into LINQ expression. + Expression source = ((IEnumerable)resources).AsQueryable().Expression; + var nameFactory = new LambdaParameterNameFactory(); + var queryableBuilder = new QueryableBuilder(source, queryLayer.ResourceType.ClrType, typeof(Enumerable), nameFactory, _resourceFactory, _model); + Expression expression = queryableBuilder.ApplyQuery(queryLayer); + + // Insert null checks to prevent a NullReferenceException during execution of expressions such as: + // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. + NullSafeExpressionRewriter rewriter = new(); + expression = rewriter.Rewrite(expression); + + // Compile and execute LINQ expression against the in-memory database. + Delegate function = Expression.Lambda(expression).Compile(); + object result = function.DynamicInvoke()!; + return (IEnumerable)result; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs new file mode 100644 index 0000000000..0b88ee3222 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -0,0 +1,60 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Repositories; + +/// +/// Demonstrates how to replace the built-in . This read-only repository uses the built-in +/// to convert the incoming into a LINQ expression, then compiles and executes it against the +/// in-memory database. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceRepository : IResourceReadRepository + where TResource : class, IIdentifiable +{ + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult>(resources.ToList()); + } + + /// + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = filter + }; + + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult(resources.Count()); + } + + protected abstract IEnumerable GetDataSource(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs new file mode 100644 index 0000000000..d710cff0de --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PersonRepository : InMemoryResourceRepository +{ + public PersonRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.People; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs new file mode 100644 index 0000000000..da38005bb3 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TagRepository : InMemoryResourceRepository +{ + public TagRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.Tags; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs new file mode 100644 index 0000000000..38cd656e0a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemRepository : InMemoryResourceRepository +{ + public TodoItemRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.TodoItems; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs new file mode 100644 index 0000000000..de9450298f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -0,0 +1,209 @@ +using System.Collections; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Services; + +/// +/// Demonstrates how to replace the built-in . This read-only resource service uses the built-in +/// to convert the incoming query string parameters into a , then uses the built-in +/// to convert the into a LINQ expression, then compiles and executes it against the in-memory +/// database. +/// +/// +/// +/// This resource service is a simplified version of the built-in resource service. Instead of implementing a resource service, consider implementing a +/// resource repository, which only needs to provide data access. +/// +/// The incoming filter from query string is logged, just to show how you can access it directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceService : IResourceQueryService + where TResource : class, IIdentifiable +{ + private readonly IJsonApiOptions _options; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IEnumerable _constraintProviders; + private readonly ILogger> _logger; + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, + IResourceFactory resourceFactory, IPaginationContext paginationContext, IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + { + _options = options; + _queryLayerComposer = queryLayerComposer; + _paginationContext = paginationContext; + _constraintProviders = constraintProviders; + + _logger = loggerFactory.CreateLogger>(); + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(CancellationToken cancellationToken) + { + LogFiltersInTopScope(); + + if (SetPrimaryTotalCountIsZero()) + { + return Task.FromResult>(Array.Empty()); + } + + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_resourceType); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + List resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource).ToList(); + + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult>(resources); + } + + private void LogFiltersInTopScope() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + FilterExpression[] filtersInTopScope = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filtersInTopScope); + + if (filter != null) + { + _logger.LogInformation($"Incoming top-level filter from query string: {filter}"); + } + } + + private bool SetPrimaryTotalCountIsZero() + { + if (_options.IncludeTotalResourceCount) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_resourceType) + }; + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + + if (_paginationContext.TotalResourceCount == 0) + { + return true; + } + } + + return false; + } + + /// + public Task GetAsync(TId id, CancellationToken cancellationToken) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetById(id, _resourceType, TopFieldSelection.PreserveExisting); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + TResource? resource = resources.SingleOrDefault(); + + if (resource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + return Task.FromResult(resource); + } + + /// + public Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + RelationshipAttribute? relationship = _resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _resourceType.PublicName); + } + + SetNonPrimaryTotalCount(id, relationship); + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(relationship.RightType); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _resourceType, id, relationship); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable primaryResources = _queryLayerToLinqConverter.ApplyQueryLayer(primaryLayer, dataSource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + + if (primaryResource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + object? rightValue = relationship.GetValue(primaryResource); + + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult(rightValue); + } + + private void SetNonPrimaryTotalCount(TId id, RelationshipAttribute relationship) + { + if (_options.IncludeTotalResourceCount && relationship is HasManyAttribute hasManyRelationship) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, hasManyRelationship); + + if (secondaryFilter == null) + { + return; + } + + var queryLayer = new QueryLayer(hasManyRelationship.RightType) + { + Filter = secondaryFilter + }; + + IEnumerable dataSource = GetDataSource(hasManyRelationship.RightType); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + } + } + + /// + public Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + return GetSecondaryAsync(id, relationshipName, cancellationToken); + } + + protected abstract IEnumerable GetDataSource(ResourceType resourceType); +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs new file mode 100644 index 0000000000..11a4ad0b4a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemService : InMemoryResourceService +{ + public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IResourceFactory resourceFactory, + IPaginationContext paginationContext, IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(options, resourceGraph, queryLayerComposer, resourceFactory, paginationContext, constraintProviders, loggerFactory) + { + } + + protected override IEnumerable GetDataSource(ResourceType resourceType) + { + if (resourceType.ClrType == typeof(TodoItem)) + { + return Database.TodoItems; + } + + if (resourceType.ClrType == typeof(Person)) + { + return Database.People; + } + + if (resourceType.ClrType == typeof(Tag)) + { + return Database.Tags; + } + + throw new InvalidOperationException($"Unknown data source '{resourceType.ClrType}'."); + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs deleted file mode 100644 index 34a40755cb..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Dapper; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using NoEntityFrameworkExample.Models; -using Npgsql; - -namespace NoEntityFrameworkExample.Services; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class WorkItemService : IResourceService -{ - private readonly string? _connectionString; - - public WorkItemService(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); - } - - public async Task> GetAsync(CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"""; - var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); - - return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - } - - public async Task GetAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; - - var commandDefinition = new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) - { - const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - - var commandDefinition = new CommandDefinition(commandText, new - { - title = resource.Title, - isBlocked = resource.IsBlocked, - durationInHours = resource.DurationInHours, - projectId = resource.ProjectId - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; - - await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken))); - } - - public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); - dbConnection.Open(); - - IEnumerable resources = await query(dbConnection); - return resources.ToList(); - } -} diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index cea6a7a623..603d1f4f9f 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,12 +1,11 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=NoEntityFrameworkExample;User ID=postgres;Password=###" - }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and sample logging. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "NoEntityFrameworkExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 16abac381f..04920d0068 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -4,7 +4,17 @@ // Add services to the container. -builder.Services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); WebApplication app = builder.Build(); diff --git a/src/Examples/ReportsExample/appsettings.json b/src/Examples/ReportsExample/appsettings.json index 270cabc088..1e325ebe92 100644 --- a/src/Examples/ReportsExample/appsettings.json +++ b/src/Examples/ReportsExample/appsettings.json @@ -2,8 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup and incoming requests. "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information" } }, "AllowedHosts": "*" diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index fcec2af464..bd068b5496 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -15,7 +15,7 @@ public interface IJsonApiOptions /// /// /// /// string? Namespace { get; } @@ -64,8 +64,8 @@ public interface IJsonApiOptions /// "relationships": { /// "author": { /// "links": { - /// "self": "/api/v1/articles/4309/relationships/author", - /// "related": "/api/v1/articles/4309/author" + /// "self": "/api/shopping/articles/4309/relationships/author", + /// "related": "/api/shopping/articles/4309/author" /// } /// } /// } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index e8041f0cf2..e5ca0c0318 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,3 +1,4 @@ +using System.Globalization; using JetBrains.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; @@ -8,12 +9,13 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { + // Only used to show the original input, in case expression parse failed. Not part of the semantic expression value. private readonly string _stringValue; public object TypedValue { get; } public LiteralConstantExpression(object typedValue) - : this(typedValue, typedValue.ToString()!) + : this(typedValue, GetStringValue(typedValue)!) { } @@ -26,6 +28,13 @@ public LiteralConstantExpression(object typedValue, string stringValue) _stringValue = stringValue; } + private static string? GetStringValue(object typedValue) + { + ArgumentGuard.NotNull(typedValue); + + return typedValue is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, CultureInfo.InvariantCulture) : typedValue.ToString(); + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitLiteralConstant(this, argument); @@ -56,11 +65,11 @@ public override bool Equals(object? obj) var other = (LiteralConstantExpression)obj; - return Equals(TypedValue, other.TypedValue) && _stringValue == other._stringValue; + return TypedValue.Equals(other.TypedValue); } public override int GetHashCode() { - return HashCode.Combine(TypedValue, _stringValue); + return TypedValue.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index bbd383fa28..6f3e0caf4e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -5,7 +5,16 @@ namespace JsonApiDotNetCore.Queries.Internal; /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { + private readonly IEnumerable _constraintProviders; private IncludeExpression? _include; + private bool _isAssigned; + + public EvaluatedIncludeCache(IEnumerable constraintProviders) + { + ArgumentGuard.NotNull(constraintProviders); + + _constraintProviders = constraintProviders; + } /// public void Set(IncludeExpression include) @@ -13,11 +22,31 @@ public void Set(IncludeExpression include) ArgumentGuard.NotNull(include); _include = include; + _isAssigned = true; } /// public IncludeExpression? Get() { + if (!_isAssigned) + { + // In case someone has replaced the built-in JsonApiResourceService with their own that "forgets" to populate the cache, + // then as a fallback, we feed the requested includes from query string to the response serializer. + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + _include = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + _isAssigned = true; + } + return _include; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index dcd38763c9..5aff75498d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -501,7 +501,7 @@ public async Task Cannot_create_resource_for_href_element() new { op = "add", - href = "/api/v1/musicTracks" + href = "/api/musicTracks" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 4baf6c7816..c2dc97b612 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -334,7 +334,7 @@ public async Task Cannot_delete_resource_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 46ef0c4784..d5e74fa4c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -28,7 +28,9 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 92f1bc638b..909467ad18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -244,7 +244,7 @@ public async Task Cannot_add_for_href_element() new { op = "add", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index bed9b62d99..71b4a1bf09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -243,7 +243,7 @@ public async Task Cannot_remove_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index be0da69ad9..f36144ce70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -278,7 +278,7 @@ public async Task Cannot_replace_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 31b62a7c8e..026b4b0e1e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -531,7 +531,7 @@ public async Task Cannot_create_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/ownedBy" + href = "/api/musicTracks/1/relationships/ownedBy" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 2371ab092a..4b0a10fd82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -620,7 +620,7 @@ public async Task Cannot_update_resource_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index ad3be1d66f..487c6d72d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -349,7 +349,7 @@ public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() } [Fact] - public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + public async Task Cannot_get_secondary_resource_for_unknown_relationship() { // Arrange WorkItem workItem = _fakers.WorkItem.Generate(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs index 4384d6aaa7..247da8d6a3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs @@ -34,7 +34,7 @@ public void Fails_when_derived_type_is_missing_in_resource_graph() var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty(), resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var responseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetCache, requestQueryStringAccessor); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs index 39279181dd..8bd13a941a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -569,7 +569,7 @@ private ResponseModelAdapter CreateAdapter(IJsonApiOptions options, string? prim PrimaryId = primaryId }; - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var linkBuilder = new FakeLinkBuilder(); var metaBuilder = new FakeMetaBuilder(); var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index b08c25805f..5ec0b1400c 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 3452b6aea9..ed25d0f274 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using MultiDbContextExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace MultiDbContextTests; -public sealed class ResourceTests : IntegrationTest, IClassFixture> +public sealed class ResourceTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public ResourceTests(WebApplicationFactory factory) + public ResourceTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } @@ -33,7 +32,7 @@ public ResourceTests(WebApplicationFactory factory) public async Task Can_get_ResourceAs() { // Arrange - const string route = "/resourceAs"; + const string route = "/api/resourceAs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -49,7 +48,7 @@ public async Task Can_get_ResourceAs() public async Task Can_get_ResourceBs() { // Arrange - const string route = "/resourceBs"; + const string route = "/api/resourceBs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index d9d8b80ad8..b0c4838b1a 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs new file mode 100644 index 0000000000..57da032819 --- /dev/null +++ b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs @@ -0,0 +1,881 @@ +using System.Linq.Expressions; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class NullSafeExpressionRewriterTests +{ + [Fact] + public void Can_rewrite_where_clause_with_constant_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => source.Where(resource => resource.Parent!.Id == 3); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => ((resource.Parent != null) AndAlso (resource.Parent.Id == 3)))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_member_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + lastInDataSource.FirstChild!.Parent!.Id = lastInDataSource.Parent!.Id; + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Id == resource.FirstChild!.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.FirstChild != null) AndAlso ((resource.FirstChild.Parent != null) AndAlso (resource.Parent.Id == resource.FirstChild.Parent.Id)))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_not_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => !(resource.Parent!.Id == 3)); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => Not(((resource.Parent != null) AndAlso (resource.Parent.Id == 3))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Parent!.Children.Any()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.Parent.Parent != null) AndAlso ((resource.Parent.Parent.Children != null) AndAlso resource.Parent.Parent.Children.Any()))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Parent!.Id == 3 || resource.FirstChild!.Children.Any(child => !(child.Parent!.Id == 1))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => (((resource.Parent != null) AndAlso (resource.Parent.Id == 3)) OrElse " + + "((resource.FirstChild != null) AndAlso ((resource.FirstChild.Children != null) AndAlso resource.FirstChild.Children.Any(child => Not(((child.Parent != null) AndAlso (child.Parent.Id == 1))))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(2); + resources[0].Id.Should().Be(dataSource[1].Id); + resources[1].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_nested_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext(), + Children = null! + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Jack" + } + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Children.Any(child => child.Children.Any(childOfChild => childOfChild.Parent!.Name == "Jack"))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => " + + "((resource.Children != null) AndAlso resource.Children.Any(child => ((child.Children != null) AndAlso child.Children.Any(childOfChild => ((childOfChild.Parent != null) AndAlso (childOfChild.Parent.Name == \"Jack\")))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_count_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + }, + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Children.Count() > resource.Parent!.Children.Count()); + // ReSharper restore UseCollectionCountProperty + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Children != null) AndAlso ((resource.Parent != null) AndAlso ((resource.Parent.Children != null) AndAlso (resource.Children.Count() > resource.Parent.Children.Count())))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_long() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Id))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_IntPtr() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Pointer = (IntPtr)1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Pointer); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should() + .Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Pointer))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_nullable_int() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Number = -1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_enum() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Enum = TestEnum.Two + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Enum); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), Zero, resource.Parent.Enum))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_string() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "X" + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Name); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Name))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_count() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + // ReSharper disable once UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.OrderBy(resource => resource.Parent!.Children.Count()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderBy(resource => IIF((resource.Parent == null), -2147483648, IIF((resource.Parent.Children == null), -2147483648, resource.Parent.Children.Count())))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(4); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[3].Id); + } + + [Fact] + public void Can_rewrite_nested_descending_order_by_clauses() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 10 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 10 + } + } + }; + + Expression, IEnumerable>> expression = source => + source.OrderByDescending(resource => resource.Parent!.Name).ThenByDescending(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Name)).ThenByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(5); + resources[0].Id.Should().Be(dataSource[4].Id); + resources[1].Id.Should().Be(dataSource[3].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[1].Id); + resources[4].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Does_not_rewrite_in_select() + { + // Arrange + Expression, IEnumerable>> expression = source => source.Select(resource => new TestResource + { + Id = resource.Id, + Name = resource.Name, + Parent = resource.Parent, + Children = resource.Children + }); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be(expression.ToString()); + } + + private static TestResource[] DynamicInvoke(Expression, IEnumerable>> expression, + IEnumerable dataSource) + { + Delegate function = expression.Compile(); + object enumerable = function.DynamicInvoke(dataSource)!; + return ((IEnumerable)enumerable).ToArray(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class TestResource : Identifiable + { + [Attr] + public string? Name { get; set; } + + [Attr] + public int? Number { get; set; } + + [Attr] + public IntPtr Pointer { get; set; } + + [Attr] + public TestEnum Enum { get; set; } + + [HasOne] + public TestResource? Parent { get; set; } + + [HasOne] + public TestResource? FirstChild => Children.FirstOrDefault(); + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private enum TestEnum + { + Zero, + One, + Two + } + + private sealed class IdGenerator + { + private long _lastId; + + public long GetNext() + { + return ++_lastId; + } + } +} diff --git a/test/NoEntityFrameworkTests/PersonTests.cs b/test/NoEntityFrameworkTests/PersonTests.cs new file mode 100644 index 0000000000..965cac9a3e --- /dev/null +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class PersonTests : IntegrationTest, IClassFixture> +{ + private readonly NoLoggingWebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(NoLoggingWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/people?filter=equals(firstName,'Jane')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/people?filter=has(assignedTodoItems,equals(description,'Check emails'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("John")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + responseDocument.Data.ManyValue[1].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-count(assignedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + responseDocument.Data.ManyValue[1].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/people?page[size]=1&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/people?fields[people]=lastName,displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("lastName", "displayName")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/people?include=ownedTodoItems.assignee,assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/people/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/people/1/ownedTodoItems?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Make homework")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/people/2/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/TodoItemTests.cs b/test/NoEntityFrameworkTests/TodoItemTests.cs new file mode 100644 index 0000000000..3fdd5683c5 --- /dev/null +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -0,0 +1,349 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class TodoItemTests : IntegrationTest, IClassFixture> +{ + private readonly NoLoggingWebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(NoLoggingWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?filter=equals(priority,'High')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(TodoItemPriority.High)); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/todoItems?filter=not(equals(assignee.firstName,'Jane'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("4"); + responseDocument.Data.ManyValue[1].Id.Should().Be("3"); + responseDocument.Data.ManyValue[2].Id.Should().Be("2"); + responseDocument.Data.ManyValue[3].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=count(assignee.ownedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?page[size]=3&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?fields[todoItems]=description,priority"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("description", "priority")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/todoItems/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + const string route = "/api/todoItems/999999"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/todoItems/3/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Personal")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Family")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + const string route = "/api/todoItems/2/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + { + // Arrange + const string route = "/api/todoItems/999999/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resource_for_unknown_secondary_ID() + { + // Arrange + const string route = "/api/todoItems/2/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_relationship() + { + // Arrange + const string route = "/api/todoItems/2/unknown"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested relationship does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'unknown'."); + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be("2"); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/todoItems/4/relationships/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("3"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs deleted file mode 100644 index 7bf09d35aa..0000000000 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using TestBuildingBlocks; -using Xunit; - -namespace NoEntityFrameworkTests; - -public sealed class WorkItemTests : IntegrationTest, IClassFixture> -{ - private readonly WebApplicationFactory _factory; - - protected override JsonSerializerOptions SerializerOptions - { - get - { - var options = _factory.Services.GetRequiredService(); - return options.SerializerOptions; - } - } - - public WorkItemTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task Can_get_WorkItems() - { - var workItem = new WorkItem - { - Title = "Write some code." - }; - - // Arrange - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/api/v1/workItems"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldNotBeEmpty(); - } - - [Fact] - public async Task Can_get_WorkItem_by_ID() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(workItem.StringId); - } - - [Fact] - public async Task Can_create_WorkItem() - { - // Arrange - var newWorkItem = new WorkItem - { - IsBlocked = true, - Title = "Some", - DurationInHours = 2, - ProjectId = Guid.NewGuid() - }; - - var requestBody = new - { - data = new - { - type = "workItems", - attributes = new - { - isBlocked = newWorkItem.IsBlocked, - title = newWorkItem.Title, - durationInHours = newWorkItem.DurationInHours, - projectId = newWorkItem.ProjectId - } - } - }; - - const string route = "/api/v1/workItems/"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isBlocked").With(value => value.Should().Be(newWorkItem.IsBlocked)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newWorkItem.Title)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newWorkItem.DurationInHours)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("projectId").With(value => value.Should().Be(newWorkItem.ProjectId)); - } - - [Fact] - public async Task Can_delete_WorkItem() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await ExecuteDeleteAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - protected override HttpClient CreateClient() - { - return _factory.CreateClient(); - } - - private async Task RunOnDatabaseAsync(Func asyncAction) - { - await using AsyncServiceScope scope = _factory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await asyncAction(dbContext); - } -} diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index fd0fb92262..f9af731411 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworkName) @@ -13,6 +13,5 @@ - diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 7856ba67f9..05679aad07 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -65,8 +67,8 @@ private WebApplicationFactory CreateFactory() { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;" + - $"Password={postgresPassword};Include Error Detail=true"; + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; var factory = new IntegrationTestWebApplicationFactory(); @@ -81,11 +83,7 @@ private WebApplicationFactory CreateFactory() services.AddDbContext(options => { options.UseNpgsql(dbConnectionString, builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); - -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + SetDbContextDebugOptions(options); }); }); @@ -103,6 +101,14 @@ private WebApplicationFactory CreateFactory() return factoryWithConfiguredContentRoot; } + [Conditional("DEBUG")] + private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); + } + public void ConfigureLogging(Action loggingConfiguration) { _loggingConfiguration = loggingConfiguration; diff --git a/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs new file mode 100644 index 0000000000..94e21f2db4 --- /dev/null +++ b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging; + +namespace TestBuildingBlocks; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class NoLoggingWebApplicationFactory : WebApplicationFactory + where TEntryPoint : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + DisableLogging(builder); + } + + [Conditional("RELEASE")] + private static void DisableLogging(IWebHostBuilder builder) + { + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + } +} diff --git a/test/TestBuildingBlocks/TestableDbContext.cs b/test/TestBuildingBlocks/TestableDbContext.cs index 18ef090baa..b92f6be261 100644 --- a/test/TestBuildingBlocks/TestableDbContext.cs +++ b/test/TestBuildingBlocks/TestableDbContext.cs @@ -15,7 +15,12 @@ protected TestableDbContext(DbContextOptions options) protected override void OnConfiguring(DbContextOptionsBuilder builder) { - // Writes SQL statements to the Output Window when debugging. + WriteSqlStatementsToOutputWindow(builder); + } + + [Conditional("DEBUG")] + private static void WriteSqlStatementsToOutputWindow(DbContextOptionsBuilder builder) + { builder.LogTo(message => Debug.WriteLine(message), DbLoggerCategory.Database.Name.AsArray(), LogLevel.Information); } diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json index 160ba78e0f..c60110712a 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -2,10 +2,14 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + "Microsoft.AspNetCore.Hosting.Diagnostics": "None", "Microsoft.Hosting.Lifetime": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", "Microsoft.EntityFrameworkCore.Update": "Critical", "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information" + "JsonApiDotNetCore": "Critical" } } }