From abb62333053541f0c5895976ecadc7e11f641b49 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:00:08 +0200 Subject: [PATCH 01/14] PostgreSQL connection strings: remove default port, add error details --- src/Examples/DatabasePerTenantExample/appsettings.json | 6 +++--- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 2 +- src/Examples/NoEntityFrameworkExample/appsettings.json | 2 +- .../Transactions/AtomicTransactionConsistencyTests.cs | 4 +++- test/TestBuildingBlocks/IntegrationTestContext.cs | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index c065f66c64..fafa64906b 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { "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=###" + "DefaultConnection": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", + "AdventureWorksConnection": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", + "ContosoConnection": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index ec2ea30102..4425e833fb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" + "DefaultConnection": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index cea6a7a623..e8ab902908 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,6 +1,6 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=NoEntityFrameworkExample;User ID=postgres;Password=###" + "DefaultConnection": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { 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/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 7856ba67f9..161005befc 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -65,8 +65,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(); From 93f6532c708b98a05cc3712db2ee849caea41dda Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:09:57 +0200 Subject: [PATCH 02/14] Use default ASP.NET location for connection strings --- .../DatabasePerTenantExample/Data/AppDbContext.cs | 2 +- src/Examples/DatabasePerTenantExample/appsettings.json | 8 ++++---- src/Examples/JsonApiDotNetCoreExample/Program.cs | 4 ++-- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 4 ++-- src/Examples/NoEntityFrameworkExample/Program.cs | 2 +- .../NoEntityFrameworkExample/Services/WorkItemService.cs | 2 +- src/Examples/NoEntityFrameworkExample/appsettings.json | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index ba73b8bf3a..d6200f59d7 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -36,7 +36,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 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/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index fafa64906b..d615577636 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", - "AdventureWorksConnection": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", - "ContosoConnection": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" + "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": { diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index e2fbc66ffd..4c5ad211ef 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -48,8 +48,8 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddDbContext(options => { string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); + #if DEBUG options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); @@ -76,7 +76,7 @@ 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); } static void ConfigurePipeline(WebApplication webApplication) diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 4425e833fb..7c757dc4cb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" + "ConnectionStrings": { + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 363b58b19e..cb2d3505f3 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -29,7 +29,7 @@ static string? GetConnectionString(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 34a40755cb..a46a8a0cb5 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -16,7 +16,7 @@ public sealed class WorkItemService : IResourceService public WorkItemService(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + _connectionString = configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } public async Task> GetAsync(CancellationToken cancellationToken) diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index e8ab902908..ae9a16c7f0 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,6 +1,6 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" + "ConnectionStrings": { + "Default": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { From a4d0836f6b173dc6c5601342de3f6022b10efc96 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:54:43 +0200 Subject: [PATCH 03/14] Drop /v1 from API namespace, because it suggests API versioning should be done this way --- docs/internals/queries.md | 2 +- docs/usage/options.md | 4 ++-- docs/usage/routing.md | 8 ++++---- src/Examples/JsonApiDotNetCoreExample/Program.cs | 2 +- .../Properties/launchSettings.json | 4 ++-- src/Examples/NoEntityFrameworkExample/Program.cs | 2 +- .../Properties/launchSettings.json | 4 ++-- src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs | 6 +++--- .../Creating/AtomicCreateResourceTests.cs | 2 +- .../Deleting/AtomicDeleteResourceTests.cs | 2 +- .../Relationships/AtomicAddToToManyRelationshipTests.cs | 2 +- .../AtomicRemoveFromToManyRelationshipTests.cs | 2 +- .../Relationships/AtomicReplaceToManyRelationshipTests.cs | 2 +- .../Relationships/AtomicUpdateToOneRelationshipTests.cs | 2 +- .../Updating/Resources/AtomicUpdateResourceTests.cs | 2 +- test/NoEntityFrameworkTests/WorkItemTests.cs | 8 ++++---- 16 files changed, 27 insertions(+), 27 deletions(-) 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/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/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 4c5ad211ef..905e27355e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -60,7 +60,7 @@ static void ConfigureServices(WebApplicationBuilder builder) { builder.Services.AddJsonApi(options => { - options.Namespace = "api/v1"; + options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; options.SerializerOptions.WriteIndented = true; diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 6a5108a8ad..9448a05959 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchUrl": "api/todoItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchUrl": "api/todoItems", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index cb2d3505f3..df5de241c0 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -10,7 +10,7 @@ string? connectionString = GetConnectionString(builder.Configuration); builder.Services.AddNpgsql(connectionString); -builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +builder.Services.AddJsonApi(options => options.Namespace = "api", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); builder.Services.AddResourceService(); diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 82c88ace03..beecefbb9f 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/workItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/workItems", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" 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/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/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/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index 7bf09d35aa..25b132e006 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -45,7 +45,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/v1/workItems"; + const string route = "/api/workItems"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -71,7 +71,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/v1/workItems/{workItem.StringId}"; + string route = $"/api/workItems/{workItem.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -110,7 +110,7 @@ public async Task Can_create_WorkItem() } }; - const string route = "/api/v1/workItems/"; + const string route = "/api/workItems/"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); @@ -140,7 +140,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/v1/workItems/{workItem.StringId}"; + string route = $"/api/workItems/{workItem.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await ExecuteDeleteAsync(route); From 67f3ef410f4207f7e191220890a3cc0c2709eb8c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:16:31 +0200 Subject: [PATCH 04/14] Seed core example with sample data, open browser on run, use long IDs --- .../Data/AppDbContext.cs | 32 +++++++++-- .../Data/RotatingList.cs | 35 ++++++++++++ .../JsonApiDotNetCoreExample/Data/Seeder.cs | 56 +++++++++++++++++++ .../Definitions/TodoItemDefinition.cs | 4 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 10 +++- .../JsonApiDotNetCoreExample/Models/Tag.cs | 2 +- .../Models/TodoItem.cs | 9 ++- .../Models/TodoItemPriority.cs | 6 +- .../JsonApiDotNetCoreExample/Program.cs | 4 ++ .../Properties/launchSettings.json | 8 +-- 10 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs 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 905e27355e..ad09426d20 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -65,6 +65,7 @@ static void ConfigureServices(WebApplicationBuilder builder) options.IncludeTotalResourceCount = true; options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; @@ -98,5 +99,8 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureDeletedAsync(); 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 9448a05959..b3ca85be5a 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/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From f202021f7e34d7b5e93ac9f0a7670b3532af4967 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 14:49:23 +0200 Subject: [PATCH 05/14] Enable detailed logging --- .../Data/AppDbContext.cs | 7 ++-- .../DatabasePerTenantExample/Program.cs | 39 +++++++++++++------ .../DatabasePerTenantExample/appsettings.json | 2 + src/Examples/GettingStarted/Program.cs | 22 ++++++++++- .../Properties/launchSettings.json | 4 +- src/Examples/GettingStarted/appsettings.json | 2 + .../JsonApiDotNetCoreExample/Program.cs | 24 ++++++++---- .../Properties/launchSettings.json | 4 +- .../JsonApiDotNetCoreExample/appsettings.json | 9 +++-- src/Examples/MultiDbContextExample/Program.cs | 31 ++++++++++++++- .../Properties/launchSettings.json | 4 +- .../MultiDbContextExample/appsettings.json | 6 ++- .../NoEntityFrameworkExample/Program.cs | 27 +++++++++++-- .../NoEntityFrameworkExample/appsettings.json | 6 ++- src/Examples/ReportsExample/Program.cs | 12 +++++- src/Examples/ReportsExample/appsettings.json | 3 +- test/MultiDbContextTests/ResourceTests.cs | 4 +- .../IntegrationTestContext.cs | 16 +++++--- test/TestBuildingBlocks/TestableDbContext.cs | 7 +++- test/TestBuildingBlocks/appsettings.json | 5 +-- 20 files changed, 179 insertions(+), 55 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index d6200f59d7..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,10 +28,10 @@ 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() 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 d615577636..01687be022 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -7,7 +7,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/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/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index ad09426d20..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] @@ -50,10 +52,7 @@ static void ConfigureServices(WebApplicationBuilder builder) string? connectionString = GetConnectionString(builder.Configuration); options.UseNpgsql(connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + SetDbContextDebugOptions(options); }); using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) @@ -63,12 +62,12 @@ static void ConfigureServices(WebApplicationBuilder builder) 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()); } @@ -80,6 +79,14 @@ static void ConfigureServices(WebApplicationBuilder builder) 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) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); @@ -99,8 +106,9 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - await Seeder.CreateSampleDataAsync(dbContext); + 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 b3ca85be5a..54646922e1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", + "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/todoItems?include=tags&filter=equals(priority,'High')", + "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 7c757dc4cb..058685ecb1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -5,10 +5,11 @@ "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/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index df5de241c0..1f024adc16 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,4 +1,6 @@ using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; using NoEntityFrameworkExample.Services; @@ -7,10 +9,29 @@ // Add services to the container. -string? connectionString = GetConnectionString(builder.Configuration); -builder.Services.AddNpgsql(connectionString); +builder.Services.AddDbContext(options => +{ + string? connectionString = GetConnectionString(builder.Configuration); + options.UseNpgsql(connectionString); + +#if DEBUG + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warningsBuilder => warningsBuilder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +#endif +}); -builder.Services.AddJsonApi(options => options.Namespace = "api", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, resources: resourceGraphBuilder => resourceGraphBuilder.Add()); builder.Services.AddResourceService(); diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index ae9a16c7f0..7fe15f3562 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -5,8 +5,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/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/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 3452b6aea9..fed2e4cbfc 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -33,7 +33,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 +49,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/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 161005befc..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; @@ -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/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..4ce03e86b3 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -3,9 +3,8 @@ "LogLevel": { "Default": "Warning", "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information" + "Microsoft.EntityFrameworkCore": "Warning", + "JsonApiDotNetCore": "Warning" } } } From c3f5baa7ebe29541d95ffd9d334eb6a6d364ee97 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 15:03:34 +0200 Subject: [PATCH 06/14] Feed includes from query string to the serializer when custom resource service is used --- .../OperationsSerializationBenchmarks.cs | 3 +- .../ResourceSerializationBenchmarks.cs | 3 +- .../Queries/Internal/EvaluatedIncludeCache.cs | 29 +++++++++++++++++++ .../Response/IncompleteResourceGraphTests.cs | 2 +- .../Response/ResponseModelAdapterTests.cs | 2 +- 5 files changed, 35 insertions(+), 4 deletions(-) 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..a63a0d9cc4 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; @@ -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/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/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(); From 7b63f584dbe74a2a8378e734e37b87fc53d6e138 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:38:14 +0200 Subject: [PATCH 07/14] Updates NoEntityFrameworkExample to use a hardcoded in-memory dataset, demonstrating how to implement a custom read-only resource service and resource repository, which compiles the produced LINQ query and executes it against the dataset. --- .../Data/AppDbContext.cs | 16 - .../NoEntityFrameworkExample/Data/Database.cs | 131 +++ .../Data/InMemoryModel.cs | 25 + .../InMemoryInverseNavigationResolver.cs | 41 + .../NoEntityFrameworkExample/Models/Person.cs | 27 + .../NoEntityFrameworkExample/Models/Tag.cs | 18 + .../Models/TodoItem.cs | 31 + .../Models/TodoItemPriority.cs | 11 + .../Models/WorkItem.cs | 22 - .../NoEntityFrameworkExample.csproj | 2 - .../NullSafeExpressionRewriter.cs | 316 +++++++ .../NoEntityFrameworkExample/Program.cs | 39 +- .../Properties/launchSettings.json | 4 +- .../QueryLayerIncludeConverter.cs | 81 ++ .../QueryLayerToLinqConverter.cs | 44 + .../InMemoryResourceRepository.cs | 60 ++ .../Repositories/PersonRepository.cs | 21 + .../Repositories/TagRepository.cs | 21 + .../Repositories/TodoItemRepository.cs | 21 + .../Services/InMemoryResourceService.cs | 209 +++++ .../Services/TodoItemService.cs | 38 + .../Services/WorkItemService.cs | 109 --- .../NoEntityFrameworkExample/appsettings.json | 7 +- .../ReadWrite/Fetching/FetchResourceTests.cs | 2 +- .../NullSafeExpressionRewriterTests.cs | 881 ++++++++++++++++++ test/NoEntityFrameworkTests/PersonTests.cs | 226 +++++ test/NoEntityFrameworkTests/TodoItemTests.cs | 350 +++++++ test/NoEntityFrameworkTests/WorkItemTests.cs | 166 ---- 28 files changed, 2561 insertions(+), 358 deletions(-) delete mode 100644 src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Data/Database.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs create mode 100644 src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/Person.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/Tag.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs delete mode 100644 src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs create mode 100644 src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs delete mode 100644 src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs create mode 100644 test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs create mode 100644 test/NoEntityFrameworkTests/PersonTests.cs create mode 100644 test/NoEntityFrameworkTests/TodoItemTests.cs delete mode 100644 test/NoEntityFrameworkTests/WorkItemTests.cs 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 1f024adc16..8b299e2c24 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,39 +1,24 @@ using JsonApiDotNetCore.Configuration; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; +using NoEntityFrameworkExample; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddDbContext(options => -{ - string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); - -#if DEBUG - options.EnableDetailedErrors(); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warningsBuilder => warningsBuilder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); -#endif -}); - 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 -}, resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +}, discovery => discovery.AddCurrentAssembly()); -builder.Services.AddResourceService(); +builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -43,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.GetConnectionString("Default")?.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 beecefbb9f..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/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/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 a46a8a0cb5..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.GetConnectionString("Default")?.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 7fe15f3562..603d1f4f9f 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,14 +1,11 @@ { - "ConnectionStrings": { - "Default": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" - }, "Logging": { "LogLevel": { "Default": "Warning", - // Include server startup, incoming requests and SQL commands. + // Include server startup, incoming requests and sample logging. "Microsoft.Hosting.Lifetime": "Information", "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "NoEntityFrameworkExample": "Information" } }, "AllowedHosts": "*" 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/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..cd80320f45 --- /dev/null +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -0,0 +1,226 @@ +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.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class PersonTests : IntegrationTest, IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(WebApplicationFactory 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..f26470f72d --- /dev/null +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -0,0 +1,350 @@ +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.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class TodoItemTests : IntegrationTest, IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(WebApplicationFactory 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 25b132e006..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/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/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/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/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); - } -} From 2a57fe1aa0672e6cfa86be1abb36725d570939cc Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:49:25 +0200 Subject: [PATCH 08/14] Update documentation --- docs/getting-started/faq.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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! From d037c75b52a86b8b6da0d32a4300fac448848ae9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 19 Apr 2023 12:56:41 +0200 Subject: [PATCH 09/14] Reduce logging from tests during cibuild --- test/MultiDbContextTests/ResourceTests.cs | 7 +++--- test/NoEntityFrameworkTests/PersonTests.cs | 7 +++--- test/NoEntityFrameworkTests/TodoItemTests.cs | 7 +++--- .../NoLoggingWebApplicationFactory.cs | 24 +++++++++++++++++++ test/TestBuildingBlocks/appsettings.json | 7 +++++- 5 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index fed2e4cbfc..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; } diff --git a/test/NoEntityFrameworkTests/PersonTests.cs b/test/NoEntityFrameworkTests/PersonTests.cs index cd80320f45..965cac9a3e 100644 --- a/test/NoEntityFrameworkTests/PersonTests.cs +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NoEntityFrameworkExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace NoEntityFrameworkTests; -public sealed class PersonTests : IntegrationTest, IClassFixture> +public sealed class PersonTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public PersonTests(WebApplicationFactory factory) + public PersonTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } diff --git a/test/NoEntityFrameworkTests/TodoItemTests.cs b/test/NoEntityFrameworkTests/TodoItemTests.cs index f26470f72d..3fdd5683c5 100644 --- a/test/NoEntityFrameworkTests/TodoItemTests.cs +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NoEntityFrameworkExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace NoEntityFrameworkTests; -public sealed class TodoItemTests : IntegrationTest, IClassFixture> +public sealed class TodoItemTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public TodoItemTests(WebApplicationFactory factory) + public TodoItemTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } 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/appsettings.json b/test/TestBuildingBlocks/appsettings.json index 4ce03e86b3..c60110712a 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -2,9 +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", - "JsonApiDotNetCore": "Warning" + "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", + "Microsoft.EntityFrameworkCore.Update": "Critical", + "Microsoft.EntityFrameworkCore.Database.Command": "Critical", + "JsonApiDotNetCore": "Critical" } } } From 858228b8466f7a609596e84606a16744d4042f1f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 22 Apr 2023 18:56:31 +0200 Subject: [PATCH 10/14] Remove unused dependencies on Moq --- test/MultiDbContextTests/MultiDbContextTests.csproj | 1 - test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj | 1 - test/SourceGeneratorTests/SourceGeneratorTests.csproj | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) 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/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/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 @@ - From b96cc4ed917fa17550fbc4a6dd3c67428c776510 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:09:10 +0200 Subject: [PATCH 11/14] Revert workaround --- appveyor.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ca29435445..7d20248b68 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: From 012cf4ecd90065d7f3b752b2c1e58eaafb2f4d32 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:24:27 +0200 Subject: [PATCH 12/14] Update scripts for breaking change in PowerShell 7.3 --- appveyor.yml | 3 +++ docs/build-dev.ps1 | 2 +- docs/generate-examples.ps1 | 2 +- docs/request-examples/001_GET_Books.ps1 | 2 ++ .../request-examples/002_GET_Person-by-ID.ps1 | 2 ++ .../003_GET_Books-including-Author.ps1 | 2 ++ .../004_GET_Books-PublishYear.ps1 | 2 ++ .../005_GET_People-Filter_Partial.ps1 | 2 ++ ...Books-sorted-by-PublishYear-descending.ps1 | 2 ++ .../007_GET_Books-paginated.ps1 | 2 ++ docs/request-examples/010_CREATE_Person.ps1 | 10 +++++---- .../011_CREATE_Book-with-Author.ps1 | 22 ++++++++++--------- docs/request-examples/012_PATCH_Book.ps1 | 12 +++++----- docs/request-examples/013_DELETE_Book.ps1 | 2 ++ 14 files changed, 46 insertions(+), 21 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 7d20248b68..0139480fec 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -98,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/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/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 From 6678d87c8304fae88f16f358f8cae2419f1625d2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 28 Apr 2023 11:10:27 +0200 Subject: [PATCH 13/14] Fixed: incoming string representation should not be part of the semantic expression value; fix potential NullReferenceException; use culture-insensitive conversion when no string specified --- .../Expressions/LiteralConstantExpression.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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(); } } From e0464f5bdf1e187f94e3b580a697b7c29daf1121 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 3 May 2023 09:42:03 +0200 Subject: [PATCH 14/14] Correct forgotten part in rename --- .../Serialization/ResourceSerializationBenchmarks.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index a63a0d9cc4..3f9efcc11d 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -122,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 {