diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e0d4746bdb..6dc2459ae5 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2023.2.2", + "version": "2023.2.3", "commands": [ "jb" ] @@ -21,7 +21,7 @@ ] }, "docfx": { - "version": "2.71.1", + "version": "2.72.1", "commands": [ "docfx" ] diff --git a/Directory.Build.props b/Directory.Build.props index dce118d9db..543cf3bbb7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,12 +30,13 @@ 3.8.* 4.7.* 6.0.* + 2.1.* 2.1.* 7.0.* 6.12.* 2.3.* 1.3.* - 2023.2.* + 2023.3.* 7.0.* 13.20.* 13.0.* @@ -43,7 +44,7 @@ 1.1.* 6.5.* 7.0.* - 17.7.* + 17.8.* 2.5.* diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 92bc100b4b..fe428b22f0 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -56,6 +56,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnnotationTests", "test\AnnotationTests\AnnotationTests.csproj", "{24B0C12F-38CD-4245-8785-87BEFAD55B00}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperExample", "src\Examples\DapperExample\DapperExample.csproj", "{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperTests", "test\DapperTests\DapperTests.csproj", "{80E322F5-5F5D-4670-A30F-02D33C2C7900}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.OpenApi", "src\JsonApiDotNetCore.OpenApi\JsonApiDotNetCore.OpenApi.csproj", "{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiTests", "test\OpenApiTests\OpenApiTests.csproj", "{B693DE14-BB28-496F-AB39-B4E674ABCA80}" @@ -66,6 +70,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreExampleCli EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiClientTests", "test\OpenApiClientTests\OpenApiClientTests.csproj", "{77F98215-3085-422E-B99D-4C404C2114CF}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiEndToEndTests", "test\OpenApiEndToEndTests\OpenApiEndToEndTests.csproj", "{3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -292,6 +298,30 @@ Global {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x64.Build.0 = Release|Any CPU {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.ActiveCfg = Release|Any CPU {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.Build.0 = Release|Any CPU {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.Build.0 = Debug|Any CPU {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -352,6 +382,18 @@ Global {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x64.Build.0 = Release|Any CPU {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.ActiveCfg = Release|Any CPU {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x64.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x86.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|Any CPU.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x64.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x64.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x86.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -375,11 +417,14 @@ Global {83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {24B0C12F-38CD-4245-8785-87BEFAD55B00} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {80E322F5-5F5D-4670-A30F-02D33C2C7900} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {71287D6F-6C3B-44B4-9FCA-E78FE3F02289} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {B693DE14-BB28-496F-AB39-B4E674ABCA80} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {7FC5DFA3-6F66-4FD8-820D-81E93856F252} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {77F98215-3085-422E-B99D-4C404C2114CF} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index f05478062e..7dbf969ff8 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -662,8 +662,12 @@ $left$ = $right$; True True True + True True + True True + True + True True True True diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 574ebaf92c..57f1258c24 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -145,12 +145,18 @@ Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonA 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`. +That's because the built-in resource service translates all JSON:API query 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), +If your data store provides a LINQ provider, you can probably reuse [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/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. 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). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll need to +[prevent that from happening](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs). + +The example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs) compiles and executes +the LINQ query against an in-memory list of resources. +For [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/master/src/JsonApiDotNetCore.MongoDb/Repositories/MongoRepository.cs), we use the MongoDB LINQ provider. +If there's no LINQ provider available, the example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/DapperExample/Repositories/DapperRepository.cs) may be of help, +which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access. > [!TIP] > [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees! diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs new file mode 100644 index 0000000000..c442861bc4 --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs @@ -0,0 +1,61 @@ +using System.Data.Common; +using JsonApiDotNetCore; +using JsonApiDotNetCore.AtomicOperations; + +namespace DapperExample.AtomicOperations; + +/// +/// Represents an ADO.NET transaction in a JSON:API atomic:operations request. +/// +internal sealed class AmbientTransaction : IOperationsTransaction +{ + private readonly AmbientTransactionFactory _owner; + + public DbTransaction Current { get; } + + /// + public string TransactionId { get; } + + public AmbientTransaction(AmbientTransactionFactory owner, DbTransaction current, Guid transactionId) + { + ArgumentGuard.NotNull(owner); + ArgumentGuard.NotNull(current); + + _owner = owner; + Current = current; + TransactionId = transactionId.ToString(); + } + + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return Current.CommitAsync(cancellationToken); + } + + /// + public async ValueTask DisposeAsync() + { + DbConnection? connection = Current.Connection; + + await Current.DisposeAsync(); + + if (connection != null) + { + await connection.DisposeAsync(); + } + + _owner.Detach(this); + } +} diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs new file mode 100644 index 0000000000..d10959b79a --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs @@ -0,0 +1,78 @@ +using System.Data.Common; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; + +namespace DapperExample.AtomicOperations; + +/// +/// Provides transaction support for JSON:API atomic:operation requests using ADO.NET. +/// +public sealed class AmbientTransactionFactory : IOperationsTransactionFactory +{ + private readonly IJsonApiOptions _options; + private readonly IDataModelService _dataModelService; + + internal AmbientTransaction? AmbientTransaction { get; private set; } + + public AmbientTransactionFactory(IJsonApiOptions options, IDataModelService dataModelService) + { + ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(dataModelService); + + _options = options; + _dataModelService = dataModelService; + } + + internal async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + var instance = (IOperationsTransactionFactory)this; + + IOperationsTransaction transaction = await instance.BeginTransactionAsync(cancellationToken); + return (AmbientTransaction)transaction; + } + + async Task IOperationsTransactionFactory.BeginTransactionAsync(CancellationToken cancellationToken) + { + if (AmbientTransaction != null) + { + throw new InvalidOperationException("Cannot start transaction because another transaction is already active."); + } + + DbConnection dbConnection = _dataModelService.CreateConnection(); + + try + { + await dbConnection.OpenAsync(cancellationToken); + + DbTransaction transaction = _options.TransactionIsolationLevel != null + ? await dbConnection.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) + : await dbConnection.BeginTransactionAsync(cancellationToken); + + var transactionId = Guid.NewGuid(); + AmbientTransaction = new AmbientTransaction(this, transaction, transactionId); + + return AmbientTransaction; + } + catch (DbException) + { + await dbConnection.DisposeAsync(); + throw; + } + } + + internal void Detach(AmbientTransaction ambientTransaction) + { + ArgumentGuard.NotNull(ambientTransaction); + + if (AmbientTransaction != null && AmbientTransaction == ambientTransaction) + { + AmbientTransaction = null; + } + else + { + throw new InvalidOperationException("Failed to detach ambient transaction."); + } + } +} diff --git a/src/Examples/DapperExample/Controllers/OperationsController.cs b/src/Examples/DapperExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..979e6c9cd7 --- /dev/null +++ b/src/Examples/DapperExample/Controllers/OperationsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.Controllers; + +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } +} diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj new file mode 100644 index 0000000000..4445af8c1e --- /dev/null +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -0,0 +1,19 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + + + + diff --git a/src/Examples/DapperExample/Data/AppDbContext.cs b/src/Examples/DapperExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..ee18bab08e --- /dev/null +++ b/src/Examples/DapperExample/Data/AppDbContext.cs @@ -0,0 +1,81 @@ +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +// @formatter:wrap_chained_method_calls chop_always + +namespace DapperExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext +{ + private readonly IConfiguration _configuration; + + public DbSet TodoItems => Set(); + public DbSet People => Set(); + public DbSet LoginAccounts => Set(); + public DbSet AccountRecoveries => Set(); + public DbSet Tags => Set(); + public DbSet RgbColors => Set(); + + public AppDbContext(DbContextOptions options, IConfiguration configuration) + : base(options) + { + ArgumentGuard.NotNull(configuration); + + _configuration = configuration; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(person => person.AssignedTodoItems) + .WithOne(todoItem => todoItem.Assignee); + + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + builder.Entity() + .HasOne(person => person.Account) + .WithOne(loginAccount => loginAccount.Person) + .HasForeignKey("AccountId"); + + builder.Entity() + .HasOne(loginAccount => loginAccount.Recovery) + .WithOne(accountRecovery => accountRecovery.Account) + .HasForeignKey("RecoveryId"); + + builder.Entity() + .HasOne(tag => tag.Color) + .WithOne(rgbColor => rgbColor.Tag) + .HasForeignKey("TagId"); + + var databaseProvider = _configuration.GetValue("DatabaseProvider"); + + if (databaseProvider != DatabaseProvider.SqlServer) + { + // In this example project, all cascades happen in the database, but SQL Server doesn't support that very well. + 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/DapperExample/Data/RotatingList.cs b/src/Examples/DapperExample/Data/RotatingList.cs new file mode 100644 index 0000000000..3fa04762a3 --- /dev/null +++ b/src/Examples/DapperExample/Data/RotatingList.cs @@ -0,0 +1,35 @@ +namespace DapperExample.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/DapperExample/Data/Seeder.cs b/src/Examples/DapperExample/Data/Seeder.cs new file mode 100644 index 0000000000..eb86eca7e8 --- /dev/null +++ b/src/Examples/DapperExample/Data/Seeder.cs @@ -0,0 +1,94 @@ +using DapperExample.Models; +using JetBrains.Annotations; + +namespace DapperExample.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 accountRecoveryCount = 50; + const int loginAccountCount = 50; + const int tagCount = 25; + const int colorCount = 25; + + RotatingList accountRecoveries = RotatingList.Create(accountRecoveryCount, index => new AccountRecovery + { + PhoneNumber = $"PhoneNumber{index + 1:D2}", + EmailAddress = $"EmailAddress{index + 1:D2}" + }); + + RotatingList loginAccounts = RotatingList.Create(loginAccountCount, index => new LoginAccount + { + UserName = $"UserName{index + 1:D2}", + Recovery = accountRecoveries.GetNext() + }); + + RotatingList people = RotatingList.Create(personCount, index => + { + var person = new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + person.Account = loginAccounts.GetNext(); + } + + return person; + }); + + RotatingList colors = + RotatingList.Create(colorCount, index => RgbColor.Create((byte)(index % 255), (byte)(index % 255), (byte)(index % 255))); + + RotatingList tags = RotatingList.Create(tagCount, index => + { + var tag = new Tag + { + Name = $"TagName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + tag.Color = colors.GetNext(); + } + + return tag; + }); + + 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/DapperExample/DatabaseProvider.cs b/src/Examples/DapperExample/DatabaseProvider.cs new file mode 100644 index 0000000000..ea9c293c11 --- /dev/null +++ b/src/Examples/DapperExample/DatabaseProvider.cs @@ -0,0 +1,11 @@ +namespace DapperExample; + +/// +/// Lists the supported databases. +/// +public enum DatabaseProvider +{ + PostgreSql, + MySql, + SqlServer +} diff --git a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000000..77bf6f9548 --- /dev/null +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,53 @@ +using System.ComponentModel; +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; + +namespace DapperExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition : JsonApiResourceDefinition +{ + private readonly ISystemClock _systemClock; + + public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + ArgumentGuard.NotNull(systemClock); + + _systemClock = systemClock; + } + + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (todoItem => todoItem.Priority, ListSortDirection.Ascending), + (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) + }); + } + + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + resource.CreatedAt = _systemClock.UtcNow; + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _systemClock.UtcNow; + } + + return Task.CompletedTask; + } +} diff --git a/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs new file mode 100644 index 0000000000..8ab88473c1 --- /dev/null +++ b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs @@ -0,0 +1,46 @@ +using DapperExample.Data; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace DapperExample; + +/// +/// Resolves inverse navigations and initializes from an Entity Framework Core . +/// +internal sealed class FromEntitiesNavigationResolver : IInverseNavigationResolver +{ + private readonly InverseNavigationResolver _defaultResolver; + private readonly FromEntitiesDataModelService _dataModelService; + private readonly DbContext _appDbContext; + + public FromEntitiesNavigationResolver(IResourceGraph resourceGraph, FromEntitiesDataModelService dataModelService, AppDbContext appDbContext) + { + ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(appDbContext); + + _defaultResolver = new InverseNavigationResolver(resourceGraph, new[] + { + new DbContextResolver(appDbContext) + }); + + _dataModelService = dataModelService; + _appDbContext = appDbContext; + } + + public void Resolve() + { + // In order to produce SQL, some knowledge of the underlying database model is required. + // Because the database in this example project is created using Entity Framework Core, we derive that information from its model. + // Some alternative approaches to consider: + // - Query the database to obtain model information at startup. + // - Create a custom attribute that is put on [HasOne/HasMany] resource properties and scan for them at startup. + // - Hard-code the required information in the application. + + _defaultResolver.Resolve(); + _dataModelService.Initialize(_appDbContext); + } +} diff --git a/src/Examples/DapperExample/Models/AccountRecovery.cs b/src/Examples/DapperExample/Models/AccountRecovery.cs new file mode 100644 index 0000000000..38410c203c --- /dev/null +++ b/src/Examples/DapperExample/Models/AccountRecovery.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class AccountRecovery : Identifiable +{ + [Attr] + public string? PhoneNumber { get; set; } + + [Attr] + public string? EmailAddress { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/LoginAccount.cs b/src/Examples/DapperExample/Models/LoginAccount.cs new file mode 100644 index 0000000000..149fc6c7f8 --- /dev/null +++ b/src/Examples/DapperExample/Models/LoginAccount.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class LoginAccount : Identifiable +{ + [Attr] + public string UserName { get; set; } = null!; + + public DateTimeOffset? LastUsedAt { get; set; } + + [HasOne] + public AccountRecovery Recovery { get; set; } = null!; + + [HasOne] + public Person Person { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/Person.cs b/src/Examples/DapperExample/Models/Person.cs new file mode 100644 index 0000000000..1eb4ecadee --- /dev/null +++ b/src/Examples/DapperExample/Models/Person.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + // Mistakenly includes AllowFilter, so we can test for the error produced. + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasOne] + public LoginAccount? Account { get; set; } + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/DapperExample/Models/RgbColor.cs b/src/Examples/DapperExample/Models/RgbColor.cs new file mode 100644 index 0000000000..c29e1b1ba1 --- /dev/null +++ b/src/Examples/DapperExample/Models/RgbColor.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Drawing; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)] +public sealed class RgbColor : Identifiable +{ + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public override int? Id + { + get => base.Id; + set => base.Id = value; + } + + [HasOne] + public Tag Tag { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Red => Id == null ? null : (byte)((Id & 0xFF_0000) >> 16); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Green => Id == null ? null : (byte)((Id & 0x00_FF00) >> 8); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Blue => Id == null ? null : (byte)(Id & 0x00_00FF); + + public static RgbColor Create(byte red, byte green, byte blue) + { + Color color = Color.FromArgb(0xFF, red, green, blue); + + return new RgbColor + { + Id = color.ToArgb() & 0x00FF_FFFF + }; + } + + protected override string? GetStringId(int? value) + { + return value?.ToString("X6"); + } + + protected override int? GetTypedId(string? value) + { + return value == null ? null : Convert.ToInt32(value, 16) & 0xFF_FFFF; + } +} diff --git a/src/Examples/DapperExample/Models/Tag.cs b/src/Examples/DapperExample/Models/Tag.cs new file mode 100644 index 0000000000..cb49ff42fb --- /dev/null +++ b/src/Examples/DapperExample/Models/Tag.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasOne] + public RgbColor? Color { get; set; } + + [HasOne] + public TodoItem? TodoItem { get; set; } +} diff --git a/src/Examples/DapperExample/Models/TodoItem.cs b/src/Examples/DapperExample/Models/TodoItem.cs new file mode 100644 index 0000000000..d2f3916268 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItem.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +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; } + + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } + + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { 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/DapperExample/Models/TodoItemPriority.cs b/src/Examples/DapperExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..ba10336ec3 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs new file mode 100644 index 0000000000..e19e45478f --- /dev/null +++ b/src/Examples/DapperExample/Program.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; +using DapperExample; +using DapperExample.AtomicOperations; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection.Extensions; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.TryAddSingleton(); + +DatabaseProvider databaseProvider = GetDatabaseProvider(builder.Configuration); +string? connectionString = builder.Configuration.GetConnectionString($"DapperExample{databaseProvider}"); + +switch (databaseProvider) +{ + case DatabaseProvider.PostgreSql: + { + builder.Services.AddNpgsql(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } + case DatabaseProvider.MySql: + { + builder.Services.AddMySql(connectionString, ServerVersion.AutoDetect(connectionString), + optionsAction: options => SetDbContextDebugOptions(options)); + + break; + } + case DatabaseProvider.SqlServer: + { + builder.Services.AddSqlServer(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } +} + +builder.Services.AddScoped(typeof(IResourceRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceReadRepository<,>), typeof(DapperRepository<,>)); + +builder.Services.AddJsonApi(options => +{ + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.DefaultPageSize = null; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly(), resourceGraphBuilder => +{ + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); +}); + +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static DatabaseProvider GetDatabaseProvider(IConfiguration configuration) +{ + return configuration.GetValue("DatabaseProvider"); +} + +[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(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } +} diff --git a/src/Examples/DapperExample/Properties/AssemblyInfo.cs b/src/Examples/DapperExample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..acbcc24f88 --- /dev/null +++ b/src/Examples/DapperExample/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DapperTests")] diff --git a/src/Examples/DapperExample/Properties/launchSettings.json b/src/Examples/DapperExample/Properties/launchSettings.json new file mode 100644 index 0000000000..137620d860 --- /dev/null +++ b/src/Examples/DapperExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14146", + "sslPort": 44346 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "applicationUrl": "https://localhost:44346;http://localhost:14146", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs new file mode 100644 index 0000000000..294e314eba --- /dev/null +++ b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs @@ -0,0 +1,22 @@ +using System.Data.Common; +using Dapper; +using DapperExample.AtomicOperations; + +namespace DapperExample.Repositories; + +internal static class CommandDefinitionExtensions +{ + // SQL Server and MySQL require any active DbTransaction to be explicitly associated to the DbConnection. + + public static CommandDefinition Associate(this CommandDefinition command, DbTransaction transaction) + { + return new CommandDefinition(command.CommandText, command.Parameters, transaction, cancellationToken: command.CancellationToken); + } + + public static CommandDefinition Associate(this CommandDefinition command, AmbientTransaction? transaction) + { + return transaction != null + ? new CommandDefinition(command.CommandText, command.Parameters, transaction.Current, cancellationToken: command.CancellationToken) + : command; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperFacade.cs b/src/Examples/DapperExample/Repositories/DapperFacade.cs new file mode 100644 index 0000000000..d3247967f9 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperFacade.cs @@ -0,0 +1,192 @@ +using Dapper; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Constructs Dapper s from SQL trees and handles order of updates. +/// +internal sealed class DapperFacade +{ + private readonly IDataModelService _dataModelService; + + public DapperFacade(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + public CommandDefinition GetSqlCommand(SqlTreeNode node, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(node); + + var queryBuilder = new SqlQueryBuilder(_dataModelService.DatabaseProvider); + string statement = queryBuilder.GetCommand(node); + IDictionary parameters = queryBuilder.Parameters; + + return new CommandDefinition(statement, parameters, cancellationToken: cancellationToken); + } + + public IReadOnlyCollection BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(ResourceChangeDetector changeDetector, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + List sqlCommands = new(); + + foreach ((HasOneAttribute relationship, (object? currentRightId, object newRightId)) in changeDetector.GetOneToOneRelationshipsChangedToNotNull()) + { + // To prevent a unique constraint violation on the foreign key, first detach/delete the other row pointing to us, if any. + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + + ResourceType resourceType = foreignKey.IsAtLeftSide ? relationship.LeftType : relationship.RightType; + string whereColumnName = foreignKey.IsAtLeftSide ? foreignKey.ColumnName : TableSourceNode.IdColumnName; + object? whereValue = foreignKey.IsAtLeftSide ? newRightId : currentRightId; + + if (whereValue == null) + { + // Creating new resource, so there can't be any existing FKs in other resources that are already pointing to us. + continue; + } + + if (foreignKey.IsNullable) + { + var updateBuilder = new UpdateClearOneToOneStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(resourceType, foreignKey.ColumnName, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + else + { + var deleteBuilder = new DeleteOneToOneStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(resourceType, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(deleteNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands; + } + + public IReadOnlyCollection BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(ResourceChangeDetector changeDetector, + TId leftId, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + List sqlCommands = new(); + + foreach ((HasOneAttribute hasOneRelationship, (object? currentRightId, object? newRightId)) in changeDetector + .GetChangedToOneRelationshipsWithForeignKeyAtRightSide()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = newRightId == null ? null : leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(hasOneRelationship.RightType, columnsToUpdate, (newRightId ?? currentRightId)!); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + foreach ((HasManyAttribute hasManyRelationship, (ISet currentRightIds, ISet newRightIds)) in changeDetector + .GetChangedToManyRelationships()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasManyRelationship); + + object[] rightIdsToRemove = currentRightIds.Except(newRightIds).ToArray(); + object[] rightIdsToAdd = newRightIds.Except(currentRightIds).ToArray(); + + if (rightIdsToRemove.Any()) + { + CommandDefinition sqlCommand = BuildSqlCommandForRemoveFromToMany(foreignKey, rightIdsToRemove, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + if (rightIdsToAdd.Any()) + { + CommandDefinition sqlCommand = BuildSqlCommandForAddToToMany(foreignKey, leftId!, rightIdsToAdd, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands; + } + + public CommandDefinition BuildSqlCommandForRemoveFromToMany(RelationshipForeignKey foreignKey, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(foreignKey); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + if (!foreignKey.IsNullable) + { + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(foreignKey.Relationship.RightType, rightResourceIdValues); + return GetSqlCommand(deleteNode, cancellationToken); + } + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = null + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForAddToToMany(RelationshipForeignKey foreignKey, object leftId, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(foreignKey); + ArgumentGuard.NotNull(leftId); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForCreate(ResourceChangeDetector changeDetector, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + IReadOnlyDictionary columnsToSet = changeDetector.GetChangedColumnValues(); + + var insertBuilder = new InsertStatementBuilder(_dataModelService); + InsertNode insertNode = insertBuilder.Build(changeDetector.ResourceType, columnsToSet); + return GetSqlCommand(insertNode, cancellationToken); + } + + public CommandDefinition? BuildSqlCommandForUpdate(ResourceChangeDetector changeDetector, TId leftId, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(changeDetector); + + IReadOnlyDictionary columnsToUpdate = changeDetector.GetChangedColumnValues(); + + if (columnsToUpdate.Any()) + { + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(changeDetector.ResourceType, columnsToUpdate, leftId!); + return GetSqlCommand(updateNode, cancellationToken); + } + + return null; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs new file mode 100644 index 0000000000..bbbeda2ea3 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -0,0 +1,582 @@ +using System.Data.Common; +using Dapper; +using DapperExample.AtomicOperations; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A JsonApiDotNetCore resource repository that converts into SQL and uses +/// to execute the SQL and materialize result sets into JSON:API resources. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +/// +/// This implementation has the following limitations: +/// +/// +/// +/// No pagination. Surprisingly, this is insanely complicated and requires non-standard, vendor-specific SQL. +/// +/// +/// +/// +/// No many-to-many relationships. It requires additional information about the database model but should be possible to implement. +/// +/// +/// +/// +/// No resource inheritance. Requires additional information about the database and is complex to implement. +/// +/// +/// +/// +/// No composite primary/foreign keys. It could be implemented, but it's a corner case that few people use. +/// +/// +/// +/// +/// Only parameterless constructors in resource classes. This is because materialization is performed by Dapper, which doesn't support constructors with +/// parameters. +/// +/// +/// +/// +/// Simple change detection in write operations. It includes scalar properties, but relationships go only one level deep. This is sufficient for +/// JSON:API. +/// +/// +/// +/// +/// The database table/column/key name mapping is based on hardcoded conventions. This could be generalized but wasn't done to keep it simple. +/// +/// +/// +/// +/// Cascading deletes are assumed to occur inside the database, which SQL Server does not support very well. This is a lot of work to implement. +/// +/// +/// +/// +/// No [EagerLoad] support. It could be done, but it's rarely used. +/// +/// +/// +/// +/// Untested with self-referencing resources and relationship cycles. +/// +/// +/// +/// +/// No support for . Because no +/// is used, it doesn't apply. +/// +/// +/// +/// +public sealed class DapperRepository : IResourceRepository, IRepositorySupportsTransaction + where TResource : class, IIdentifiable +{ + private readonly ITargetedFields _targetedFields; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly AmbientTransactionFactory _transactionFactory; + private readonly IDataModelService _dataModelService; + private readonly SqlCaptureStore _captureStore; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger> _logger; + private readonly CollectionConverter _collectionConverter = new(); + private readonly ParameterFormatter _parameterFormatter = new(); + private readonly DapperFacade _dapperFacade; + + private ResourceType ResourceType => _resourceGraph.GetResourceType(); + + public string? TransactionId => _transactionFactory.AmbientTransaction?.TransactionId; + + public DapperRepository(ITargetedFields targetedFields, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor, AmbientTransactionFactory transactionFactory, IDataModelService dataModelService, + SqlCaptureStore captureStore, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(targetedFields); + ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(resourceFactory); + ArgumentGuard.NotNull(resourceDefinitionAccessor); + ArgumentGuard.NotNull(transactionFactory); + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(captureStore); + ArgumentGuard.NotNull(loggerFactory); + + _targetedFields = targetedFields; + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _transactionFactory = transactionFactory; + _dataModelService = dataModelService; + _captureStore = captureStore; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger>(); + _dapperFacade = new DapperFacade(dataModelService); + } + + /// + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(queryLayer); + + var mapper = new ResultSetMapper(queryLayer.Include); + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Columns); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + IReadOnlyCollection resources = await ExecuteQueryAsync(async connection => + { + // Reads must occur within the active transaction, when in an atomic:operations request. + sqlCommand = sqlCommand.Associate(_transactionFactory.AmbientTransaction); + + // Unfortunately, there's no CancellationToken support. See https://github.com/DapperLib/Dapper/issues/1181. + _ = await connection.QueryAsync(sqlCommand.CommandText, mapper.ResourceClrTypes, mapper.Map, sqlCommand.Parameters, sqlCommand.Transaction); + + return mapper.GetResources(); + }, cancellationToken); + + return resources; + } + + /// + public async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(ResourceType) + { + Filter = filter + }; + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Count); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + return await ExecuteQueryAsync(async connection => await connection.ExecuteScalarAsync(sqlCommand), cancellationToken); + } + + /// + public Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceClrType); + + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); + resource.Id = id; + + return Task.FromResult(resource); + } + + /// + public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceFromRequest); + ArgumentGuard.NotNull(resourceForDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceForDatabase); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition insertCommand = _dapperFacade.BuildSqlCommandForCreate(changeDetector, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + LogSqlCommand(insertCommand); + resourceForDatabase.Id = (await transaction.Connection!.ExecuteScalarAsync(insertCommand.Associate(transaction)))!; + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceForDatabase.Id, cancellationToken); + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + } + + private async Task ApplyTargetedFieldsAsync(TResource resourceFromRequest, TResource resourceInDatabase, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceInDatabase, relationship, rightValue, writeOperation, cancellationToken); + + relationship.SetValue(resourceInDatabase, rightValueEvaluated); + } + + foreach (AttrAttribute attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceInDatabase, attribute.GetValue(resourceFromRequest)); + } + } + + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, + cancellationToken); + } + + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, + cancellationToken); + + return _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + } + + return rightValue; + } + + /// + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(queryLayer); + + IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } + + /// + public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceFromRequest); + ArgumentGuard.NotNull(resourceFromDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(resourceFromDatabase); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceFromDatabase); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, resourceFromDatabase.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceFromDatabase.Id, cancellationToken); + + if (preSqlCommands.Any() || updateCommand != null || postSqlCommands.Any()) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + } + } + + /// + public async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) + { + TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); + placeholderResource.Id = id; + + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(ResourceType, placeholderResource.Id!); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(deleteNode, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + } + + /// + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(leftResource); + + RelationshipAttribute relationship = _targetedFields.Relationships.Single(); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(leftResource); + + object? rightValueEvaluated = + await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); + + relationship.SetValue(leftResource, rightValueEvaluated); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + + changeDetector.CaptureNewValues(leftResource); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, leftResource.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, leftResource.Id, cancellationToken); + + if (preSqlCommands.Any() || updateCommand != null || postSqlCommands.Any()) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new Exception("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new Exception("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + } + } + + /// + public async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; + + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); + relationship.SetValue(leftPlaceholderResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + + if (rightResourceIds.Any()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + + CommandDefinition sqlCommand = + _dapperFacade.BuildSqlCommandForAddToToMany(foreignKey, leftPlaceholderResource.Id!, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + } + } + + /// + public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(leftResource); + ArgumentGuard.NotNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken); + relationship.SetValue(leftResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + + if (rightResourceIds.Any()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + CommandDefinition sqlCommand = _dapperFacade.BuildSqlCommandForRemoveFromToMany(foreignKey, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new Exception("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + } + } + + private void LogSqlCommand(CommandDefinition command) + { + var parameters = (IDictionary?)command.Parameters; + + _captureStore.Add(command.CommandText, parameters); + + string message = GetLogText(command.CommandText, parameters); + _logger.LogInformation(message); + } + + private string GetLogText(string statement, IDictionary? parameters) + { + if (parameters?.Any() == true) + { + string parametersText = string.Join(", ", parameters.Select(parameter => _parameterFormatter.Format(parameter.Key, parameter.Value))); + return $"Executing SQL with parameters: {parametersText}{Environment.NewLine}{statement}"; + } + + return $"Executing SQL: {Environment.NewLine}{statement}"; + } + + private async Task ExecuteQueryAsync(Func> asyncAction, CancellationToken cancellationToken) + { + if (_transactionFactory.AmbientTransaction != null) + { + DbConnection connection = _transactionFactory.AmbientTransaction.Current.Connection!; + return await asyncAction(connection); + } + + await using DbConnection dbConnection = _dataModelService.CreateConnection(); + await dbConnection.OpenAsync(cancellationToken); + + return await asyncAction(dbConnection); + } + + private async Task ExecuteInTransactionAsync(Func asyncAction, CancellationToken cancellationToken) + { + try + { + if (_transactionFactory.AmbientTransaction != null) + { + await asyncAction(_transactionFactory.AmbientTransaction.Current); + } + else + { + await using AmbientTransaction transaction = await _transactionFactory.BeginTransactionAsync(cancellationToken); + await asyncAction(transaction.Current); + + await transaction.CommitAsync(cancellationToken); + } + } + catch (DbException exception) + { + throw new DataStoreUpdateException(exception); + } + } +} diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs new file mode 100644 index 0000000000..22da724ae2 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -0,0 +1,219 @@ +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A simplistic change detector. Detects changes in scalar properties, but relationship changes only one level deep. +/// +internal sealed class ResourceChangeDetector +{ + private readonly CollectionConverter _collectionConverter = new(); + private readonly IDataModelService _dataModelService; + + private Dictionary _currentColumnValues = new(); + private Dictionary _newColumnValues = new(); + + private Dictionary> _currentRightResourcesByRelationship = new(); + private Dictionary> _newRightResourcesByRelationship = new(); + + public ResourceType ResourceType { get; } + + public ResourceChangeDetector(ResourceType resourceType, IDataModelService dataModelService) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(dataModelService); + + ResourceType = resourceType; + _dataModelService = dataModelService; + } + + public void CaptureCurrentValues(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource); + AssertSameType(ResourceType, resource); + + _currentColumnValues = CaptureColumnValues(resource); + _currentRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + public void CaptureNewValues(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource); + AssertSameType(ResourceType, resource); + + _newColumnValues = CaptureColumnValues(resource); + _newRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + private Dictionary CaptureColumnValues(IIdentifiable resource) + { + Dictionary columnValues = new(); + + foreach ((string columnName, ResourceFieldAttribute? _) in _dataModelService.GetColumnMappings(ResourceType)) + { + columnValues[columnName] = _dataModelService.GetColumnValue(ResourceType, resource, columnName); + } + + return columnValues; + } + + private Dictionary> CaptureRightResourcesByRelationship(IIdentifiable resource) + { + Dictionary> relationshipValues = new(); + + foreach (RelationshipAttribute relationship in ResourceType.Relationships) + { + object? rightValue = relationship.GetValue(resource); + HashSet rightResources = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + relationshipValues[relationship] = rightResources; + } + + return relationshipValues; + } + + public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName) + { + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (!foreignKey.IsNullable) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + bool hasChanged = !Equals(currentRightId, newRightId); + + if (hasChanged && newRightId == null) + { + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); + } + } + } + } + } + + public IReadOnlyDictionary GetOneToOneRelationshipsChangedToNotNull() + { + Dictionary changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute { IsOneToOne: true } hasOneRelationship) + { + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (newRightId != null) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + } + + return changes; + } + + public IReadOnlyDictionary GetChangedColumnValues() + { + Dictionary changes = new(); + + foreach ((string columnName, object? newColumnValue) in _newColumnValues) + { + bool currentFound = _currentColumnValues.TryGetValue(columnName, out object? currentColumnValue); + + if (!currentFound || !Equals(currentColumnValue, newColumnValue)) + { + changes[columnName] = newColumnValue; + } + } + + return changes; + } + + public IReadOnlyDictionary GetChangedToOneRelationshipsWithForeignKeyAtRightSide() + { + Dictionary changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsAtLeftSide) + { + continue; + } + + object? currentRightId = _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out ISet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + + return changes; + } + + public IReadOnlyDictionary currentRightIds, ISet newRightIds)> GetChangedToManyRelationships() + { + Dictionary currentRightIds, ISet newRightIds)> changes = new(); + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet newRightIds = newRightResources.Select(resource => resource.GetTypedId()).ToHashSet(); + + HashSet currentRightIds = + _currentRightResourcesByRelationship.TryGetValue(hasManyRelationship, out ISet? currentRightResources) + ? currentRightResources.Select(resource => resource.GetTypedId()).ToHashSet() + : new HashSet(); + + if (!currentRightIds.SetEquals(newRightIds)) + { + changes[hasManyRelationship] = (currentRightIds, newRightIds); + } + } + } + + return changes; + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } +} diff --git a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs new file mode 100644 index 0000000000..e0a6efddd0 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Maps the result set from a SQL query that includes primary and related resources. +/// +internal sealed class ResultSetMapper + where TResource : class, IIdentifiable +{ + private readonly List _joinObjectTypes = new(); + + // For each object type, we keep a map of ID/instance pairs. + // Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances. + private readonly Dictionary> _resourceByTypeCache = new(); + + // Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row. + private readonly Dictionary _defaultValueByTypeCache = new(); + + // Used to determine where in the tree of included relationships a join object belongs to. + private readonly Dictionary _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance); + + // The return value of the mapping process. + private readonly List _primaryResourcesInOrder = new(); + + // The included relationships for which an INNER/LEFT JOIN statement was produced, which we're mapping. + private readonly IncludeExpression _include; + + public Type[] ResourceClrTypes => _joinObjectTypes.ToArray(); + + public ResultSetMapper(IncludeExpression? include) + { + _include = include ?? IncludeExpression.Empty; + _joinObjectTypes.Add(typeof(TResource)); + _resourceByTypeCache[typeof(TResource)] = new Dictionary(); + + var walker = new IncludeElementWalker(_include); + int index = 1; + + foreach (IncludeElementExpression includeElement in walker.BreadthFirstEnumerate()) + { + _joinObjectTypes.Add(includeElement.Relationship.RightType.ClrType); + _resourceByTypeCache[includeElement.Relationship.RightType.ClrType] = new Dictionary(); + _includeElementToJoinObjectArrayIndexLookup[includeElement] = index; + + index++; + } + } + + public object? Map(object[] joinObjects) + { + // This method executes for each row in the SQL result set. + + if (joinObjects.Length != _includeElementToJoinObjectArrayIndexLookup.Count + 1) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + object?[] objectsCached = joinObjects.Select(GetCached).ToArray(); + var leftResource = (TResource?)objectsCached[0]; + + if (leftResource == null) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + RecursiveSetRelationships(leftResource, _include.Elements, objectsCached); + + _primaryResourcesInOrder.Add(leftResource); + return null; + } + + private object? GetCached(object? resource) + { + if (resource == null) + { + return null; + } + + object? resourceId = GetResourceId(resource); + + if (resourceId == null || HasDefaultValue(resourceId)) + { + // When Id is not set, the entire object is empty (due to LEFT JOIN usage). + return null; + } + + Dictionary resourceByIdCache = _resourceByTypeCache[resource.GetType()]; + + if (resourceByIdCache.TryGetValue(resourceId, out object? cachedValue)) + { + return cachedValue; + } + + resourceByIdCache[resourceId] = resource; + return resource; + } + + private static object? GetResourceId(object resource) + { + PropertyInfo? property = resource.GetType().GetProperty(TableSourceNode.IdColumnName); + + if (property == null) + { + throw new InvalidOperationException($"{TableSourceNode.IdColumnName} property not found on object of type '{resource.GetType().Name}'."); + } + + return property.GetValue(resource); + } + + private bool HasDefaultValue(object value) + { + object? defaultValue = GetDefaultValueCached(value.GetType()); + return Equals(defaultValue, value); + } + + private object? GetDefaultValueCached(Type type) + { + if (_defaultValueByTypeCache.TryGetValue(type, out object? defaultValue)) + { + return defaultValue; + } + + defaultValue = RuntimeTypeConverter.GetDefaultValue(type); + _defaultValueByTypeCache[type] = defaultValue; + return defaultValue; + } + + private void RecursiveSetRelationships(object leftResource, IEnumerable includeElements, object?[] joinObjects) + { + foreach (IncludeElementExpression includeElement in includeElements) + { + int rightIndex = _includeElementToJoinObjectArrayIndexLookup[includeElement]; + object? rightResource = joinObjects[rightIndex]; + + SetRelationship(leftResource, includeElement.Relationship, rightResource); + + if (rightResource != null && includeElement.Children.Any()) + { + RecursiveSetRelationships(rightResource, includeElement.Children, joinObjects); + } + } + } + + private void SetRelationship(object leftResource, RelationshipAttribute relationship, object? rightResource) + { + if (rightResource != null) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + hasManyRelationship.AddValue(leftResource, (IIdentifiable)rightResource); + } + else + { + relationship.SetValue(leftResource, rightResource); + } + } + } + + public IReadOnlyCollection GetResources() + { + return _primaryResourcesInOrder.DistinctBy(resource => resource.Id).ToList(); + } + + private sealed class IncludeElementWalker + { + private readonly IncludeExpression _include; + + public IncludeElementWalker(IncludeExpression include) + { + _include = include; + } + + public IEnumerable BreadthFirstEnumerate() + { + foreach (IncludeElementExpression next in _include.Elements.OrderBy(element => element.Relationship.PublicName) + .SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + + private IEnumerable RecursiveEnumerateElement(IncludeElementExpression element) + { + yield return element; + + foreach (IncludeElementExpression next in element.Children.OrderBy(child => child.Relationship.PublicName).SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs new file mode 100644 index 0000000000..272dedbd37 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs @@ -0,0 +1,26 @@ +using DapperExample.TranslationToSql; +using JetBrains.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Captures the emitted SQL statements, which enables integration tests to assert on them. +/// +[PublicAPI] +public sealed class SqlCaptureStore +{ + private readonly List _sqlCommands = new(); + + public IReadOnlyList SqlCommands => _sqlCommands; + + public void Clear() + { + _sqlCommands.Clear(); + } + + internal void Add(string statement, IDictionary? parameters) + { + var sqlCommand = new SqlCommand(statement, parameters ?? new Dictionary()); + _sqlCommands.Add(sqlCommand); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..5a1293d41b --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs @@ -0,0 +1,37 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteOneToOneStatementBuilder : StatementBuilder +{ + public DeleteOneToOneStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public DeleteNode Build(ResourceType resourceType, string whereColumnName, object? whereValue) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode column = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(column, whereValue); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode parameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, parameter); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs new file mode 100644 index 0000000000..41794e8883 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs @@ -0,0 +1,37 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteResourceStatementBuilder : StatementBuilder +{ + public DeleteResourceStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public DeleteNode Build(ResourceType resourceType, params object[] idValues) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + List parameters = idValues.Select(idValue => ParameterGenerator.Create(idValue)).ToList(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs new file mode 100644 index 0000000000..b362a0e7b4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs @@ -0,0 +1,55 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class InsertStatementBuilder : StatementBuilder +{ + public InsertStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public InsertNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToSet) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(columnsToSet); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + List assignments = GetColumnAssignments(columnsToSet, table); + + return new InsertNode(table, assignments); + } + + private List GetColumnAssignments(IReadOnlyDictionary columnsToSet, TableNode table) + { + List assignments = new(); + ColumnNode idColumn = table.GetIdColumn(table.Alias); + + foreach ((string columnName, object? columnValue) in columnsToSet) + { + if (columnName == idColumn.Name) + { + object? defaultIdValue = columnValue == null ? null : RuntimeTypeConverter.GetDefaultValue(columnValue.GetType()); + + if (Equals(columnValue, defaultIdValue)) + { + continue; + } + } + + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs new file mode 100644 index 0000000000..d4fdd09b69 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Indicates what to select in a SELECT statement. +/// +internal enum SelectShape +{ + /// + /// Select a set of columns. + /// + Columns, + + /// + /// Select the number of rows: COUNT(*). + /// + Count, + + /// + /// Select only the first, unnamed column: SELECT 1. + /// + One +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs new file mode 100644 index 0000000000..4e12b735c7 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -0,0 +1,786 @@ +using System.Net; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Builds a SELECT statement from a . +/// +internal sealed class SelectStatementBuilder : QueryExpressionVisitor +{ + // State that is shared between sub-queries. + private readonly QueryState _queryState; + + // The FROM/JOIN/sub-SELECT tables, along with their selectors (which usually are column references). + private readonly Dictionary> _selectorsPerTable; + + // Used to assign unique names when adding selectors, in case tables are joined that would result in duplicate column names. + private readonly HashSet _selectorNamesUsed; + + // Filter constraints. + private readonly List _whereFilters; + + // Sorting on columns, or COUNT(*) in a sub-query. + private readonly List _orderByTerms; + + // Indicates whether to select a set of columns, the number of rows, or only the first (unnamed) column. + private SelectShape _selectShape; + + public SelectStatementBuilder(IDataModelService dataModelService, ILoggerFactory loggerFactory) + : this(new QueryState(dataModelService, new TableAliasGenerator(), new ParameterGenerator(), loggerFactory)) + { + } + + private SelectStatementBuilder(QueryState queryState) + { + _queryState = queryState; + _selectorsPerTable = new Dictionary>(); + _selectorNamesUsed = new HashSet(); + _whereFilters = new List(); + _orderByTerms = new List(); + } + + public SelectNode Build(QueryLayer queryLayer, SelectShape selectShape) + { + ArgumentGuard.NotNull(queryLayer); + + // Convert queryLayer.Include into multiple levels of queryLayer.Selection. + var includeConverter = new QueryLayerIncludeConverter(queryLayer); + includeConverter.ConvertIncludesToSelections(); + + ResetState(selectShape); + + TableAccessorNode primaryTableAccessor = CreatePrimaryTable(queryLayer.ResourceType); + ConvertQueryLayer(queryLayer, primaryTableAccessor); + + SelectNode select = ToSelect(false, false); + + if (_selectShape == SelectShape.Columns) + { + var staleRewriter = new StaleColumnReferenceRewriter(_queryState.OldToNewTableAliasMap, _queryState.LoggerFactory); + select = staleRewriter.PullColumnsIntoScope(select); + + var selectorsRewriter = new UnusedSelectorsRewriter(_queryState.LoggerFactory); + select = selectorsRewriter.RemoveUnusedSelectorsInSubQueries(select); + } + + return select; + } + + private void ResetState(SelectShape selectShape) + { + _queryState.Reset(); + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + _whereFilters.Clear(); + _orderByTerms.Clear(); + _selectShape = selectShape; + } + + private TableAccessorNode CreatePrimaryTable(ResourceType resourceType) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(resourceType); + var table = new TableNode(resourceType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + var from = new FromNode(table); + + TrackPrimaryTable(from); + return from; + } + + private void TrackPrimaryTable(TableAccessorNode tableAccessor) + { + if (_selectorsPerTable.Count > 0) + { + throw new InvalidOperationException("A primary table already exists."); + } + + _queryState.RelatedTables.Add(tableAccessor, new Dictionary()); + + _selectorsPerTable[tableAccessor] = _selectShape switch + { + SelectShape.Columns => Array.Empty(), + SelectShape.Count => new CountSelectorNode(null).AsArray(), + _ => new OneSelectorNode(null).AsArray() + }; + } + + private void ConvertQueryLayer(QueryLayer queryLayer, TableAccessorNode tableAccessor) + { + if (queryLayer.Filter != null) + { + var filter = (FilterNode)Visit(queryLayer.Filter, tableAccessor); + _whereFilters.Add(filter); + } + + if (queryLayer.Sort != null) + { + var orderBy = (OrderByNode)Visit(queryLayer.Sort, tableAccessor); + _orderByTerms.AddRange(orderBy.Terms); + } + + if (queryLayer.Pagination is { PageSize: not null }) + { + throw new NotSupportedException("Pagination is not supported."); + } + + if (queryLayer.Selection != null) + { + foreach (ResourceType resourceType in queryLayer.Selection.GetResourceTypes()) + { + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(resourceType); + ConvertFieldSelectors(selectors, tableAccessor); + } + } + } + + private void ConvertFieldSelectors(FieldSelectors selectors, TableAccessorNode tableAccessor) + { + HashSet selectedColumns = new(); + Dictionary nextLayers = new(); + + if (selectors.IsEmpty || selectors.ContainsReadOnlyAttribute || selectors.ContainsOnlyRelationships) + { + // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. + // And only selecting relationships implicitly means to fetch all scalar properties as well. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. + + selectedColumns = tableAccessor.Source.Columns.Where(column => column.Type == ColumnType.Scalar).ToHashSet(); + } + + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in selectors.OrderBy(selector => selector.Key.PublicName)) + { + if (field is AttrAttribute attribute) + { + // Returns null when the set contains an unmapped column, which is silently ignored. + ColumnNode? column = tableAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, tableAccessor.Source.Alias); + + if (column != null) + { + selectedColumns.Add(column); + } + } + + if (field is RelationshipAttribute relationship && nextLayer != null) + { + nextLayers.Add(relationship, nextLayer); + } + } + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, selectedColumns); + } + + foreach ((RelationshipAttribute relationship, QueryLayer nextLayer) in nextLayers) + { + ConvertNestedQueryLayer(tableAccessor, relationship, nextLayer); + } + } + + private void SetColumnSelectors(TableAccessorNode tableAccessor, IEnumerable columns) + { + if (!_selectorsPerTable.ContainsKey(tableAccessor)) + { + throw new InvalidOperationException($"Table {tableAccessor.Source.Alias} not found in selected tables."); + } + + // When selecting from a table, use a deterministic order to simplify test assertions. + // When selecting from a sub-query (typically spanning multiple tables and renamed columns), existing order must be preserved. + _selectorsPerTable[tableAccessor] = tableAccessor.Source is SelectNode + ? PreserveColumnOrderEnsuringUniqueNames(columns) + : OrderColumnsWithIdAtFrontEnsuringUniqueNames(columns); + } + + private List PreserveColumnOrderEnsuringUniqueNames(IEnumerable columns) + { + List selectors = new(); + + foreach (ColumnNode column in columns) + { + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + selectors.Add(columnSelector); + } + + return selectors; + } + + private List OrderColumnsWithIdAtFrontEnsuringUniqueNames(IEnumerable columns) + { + Dictionary> selectorsPerTable = new(); + + foreach (ColumnNode column in columns.OrderBy(column => column.GetTableAliasIndex()).ThenBy(column => column.Name)) + { + string tableAlias = column.TableAlias ?? "!"; + selectorsPerTable.TryAdd(tableAlias, new List()); + + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + + if (column.Name == TableSourceNode.IdColumnName) + { + selectorsPerTable[tableAlias].Insert(0, columnSelector); + } + else + { + selectorsPerTable[tableAlias].Add(columnSelector); + } + } + + return selectorsPerTable.SelectMany(selector => selector.Value).ToList(); + } + + private string GetUniqueSelectorName(string columnName) + { + string uniqueName = columnName; + + while (_selectorNamesUsed.Contains(uniqueName)) + { + uniqueName += "0"; + } + + _selectorNamesUsed.Add(uniqueName); + return uniqueName; + } + + private void ConvertNestedQueryLayer(TableAccessorNode tableAccessor, RelationshipAttribute relationship, QueryLayer nextLayer) + { + bool requireSubQuery = nextLayer.Filter != null; + + if (requireSubQuery) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState); + + TableAccessorNode primaryTableAccessor = subSelectBuilder.CreatePrimaryTable(relationship.RightType); + subSelectBuilder.ConvertQueryLayer(nextLayer, primaryTableAccessor); + + string[] innerTableAliases = subSelectBuilder._selectorsPerTable.Keys.Select(accessor => accessor.Source.Alias).Cast().ToArray(); + + // In the sub-query, select all columns, to enable referencing them from other locations in the query. + // This usually produces unused selectors, which will be removed in a post-processing step. + var selectorsToKeep = new Dictionary>(subSelectBuilder._selectorsPerTable); + subSelectBuilder.SelectAllColumnsInAllTables(selectorsToKeep.Keys); + + // Since there's no pagination support, it's pointless to preserve orderings in the sub-query. + List orderingsToKeep = subSelectBuilder._orderByTerms.ToList(); + subSelectBuilder._orderByTerms.Clear(); + + SelectNode aliasedSubQuery = subSelectBuilder.ToSelect(true, true); + + // Store inner-to-outer table aliases, to enable rewriting stale column references in a post-processing step. + // This is required for orderings that contain sub-selects, resulting from order-by-count. + MapOldTableAliasesToSubQuery(innerTableAliases, aliasedSubQuery.Alias!); + + TableAccessorNode outerTableAccessor = CreateRelatedTable(tableAccessor, relationship, aliasedSubQuery); + + // In the outer query, select only what was originally selected. + _selectorsPerTable[outerTableAccessor] = MapSelectorsFromSubQuery(selectorsToKeep.SelectMany(selector => selector.Value), aliasedSubQuery); + + // To achieve total ordering, all orderings from sub-query must always appear in the root query. + IReadOnlyList outerOrderingsToAdd = MapOrderingsFromSubQuery(orderingsToKeep, aliasedSubQuery); + _orderByTerms.AddRange(outerOrderingsToAdd); + } + else + { + TableAccessorNode relatedTableAccessor = GetOrCreateRelatedTable(tableAccessor, relationship); + ConvertQueryLayer(nextLayer, relatedTableAccessor); + } + } + + private void SelectAllColumnsInAllTables(IEnumerable tableAccessors) + { + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + + foreach (TableAccessorNode tableAccessor in tableAccessors) + { + _selectorsPerTable.Add(tableAccessor, Array.Empty()); + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, tableAccessor.Source.Columns); + } + } + } + + private void MapOldTableAliasesToSubQuery(IEnumerable oldTableAliases, string newTableAlias) + { + foreach (string oldTableAlias in oldTableAliases) + { + _queryState.OldToNewTableAliasMap[oldTableAlias] = newTableAlias; + } + } + + private TableAccessorNode CreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableSourceNode rightTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + JoinType joinType = foreignKey is { IsAtLeftSide: true, IsNullable: false } ? JoinType.InnerJoin : JoinType.LeftJoin; + + ComparisonNode joinCondition = CreateJoinCondition(leftTableAccessor.Source, relationship, rightTableSource); + + TableAccessorNode relatedTableAccessor = new JoinNode(joinType, rightTableSource, (ColumnNode)joinCondition.Left, (ColumnNode)joinCondition.Right); + + TrackRelatedTable(leftTableAccessor, relationship, relatedTableAccessor); + return relatedTableAccessor; + } + + private ComparisonNode CreateJoinCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship, TableSourceNode innerTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + + ColumnNode innerColumn = foreignKey.IsAtLeftSide + ? innerTableSource.GetIdColumn(innerTableSource.Alias) + : innerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, innerTableSource.Alias); + + ColumnNode outerColumn = foreignKey.IsAtLeftSide + ? outerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, outerTableSource.Alias) + : outerTableSource.GetIdColumn(outerTableSource.Alias); + + return new ComparisonNode(ComparisonOperator.Equals, outerColumn, innerColumn); + } + + private void TrackRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableAccessorNode rightTableAccessor) + { + _queryState.RelatedTables.Add(rightTableAccessor, new Dictionary()); + _selectorsPerTable[rightTableAccessor] = Array.Empty(); + + _queryState.RelatedTables[leftTableAccessor].Add(relationship, rightTableAccessor); + } + + private IReadOnlyList MapSelectorsFromSubQuery(IEnumerable innerSelectorsToKeep, SelectNode select) + { + List outerColumnsToKeep = new(); + + foreach (SelectorNode innerSelector in innerSelectorsToKeep) + { + if (innerSelector is ColumnSelectorNode innerColumnSelector) + { + // t2."Id" AS Id0 => t3.Id0 + ColumnNode innerColumn = innerColumnSelector.Column; + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == innerColumn); + outerColumnsToKeep.Add(outerColumn); + } + else + { + // If there's an alias, we should use it. Otherwise we could fallback to ordinal selector. + throw new NotImplementedException("Mapping non-column selectors is not implemented."); + } + } + + return PreserveColumnOrderEnsuringUniqueNames(outerColumnsToKeep); + } + + private IReadOnlyList MapOrderingsFromSubQuery(IEnumerable innerOrderingsToKeep, SelectNode select) + { + List orderingsToKeep = new(); + + foreach (OrderByTermNode innerTerm in innerOrderingsToKeep) + { + if (innerTerm is OrderByColumnNode orderByColumn) + { + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == orderByColumn.Column); + var outerTerm = new OrderByColumnNode(outerColumn, innerTerm.IsAscending); + orderingsToKeep.Add(outerTerm); + } + else + { + // Rewriting stale column references from order-by-count is non-trivial, so let the post-processor handle them. + orderingsToKeep.Add(innerTerm); + } + } + + return orderingsToKeep; + } + + private TableAccessorNode GetOrCreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + TableAccessorNode? relatedTableAccessor = _selectorsPerTable.Count == 0 + // Joining against something in an outer query. + ? CreatePrimaryTableWithIdentityCondition(leftTableAccessor.Source, relationship) + : FindRelatedTable(leftTableAccessor, relationship); + + if (relatedTableAccessor == null) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(relationship.RightType); + var rightTable = new TableNode(relationship.RightType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + + return CreateRelatedTable(leftTableAccessor, relationship, rightTable); + } + + return relatedTableAccessor; + } + + private TableAccessorNode CreatePrimaryTableWithIdentityCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship) + { + TableAccessorNode innerTableAccessor = CreatePrimaryTable(relationship.RightType); + + ComparisonNode joinCondition = CreateJoinCondition(outerTableSource, relationship, innerTableAccessor.Source); + _whereFilters.Add(joinCondition); + + return innerTableAccessor; + } + + private TableAccessorNode? FindRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + Dictionary rightTableAccessors = _queryState.RelatedTables[leftTableAccessor]; + return rightTableAccessors.TryGetValue(relationship, out TableAccessorNode? rightTableAccessor) ? rightTableAccessor : null; + } + + private SelectNode ToSelect(bool isSubQuery, bool createAlias) + { + WhereNode? where = GetWhere(); + OrderByNode? orderBy = !_orderByTerms.Any() ? null : new OrderByNode(_orderByTerms); + + // Materialization using Dapper requires selectors to match property names, so adjust selector names accordingly. + Dictionary> selectorsPerTable = + isSubQuery ? _selectorsPerTable : AliasSelectorsToTableColumnNames(_selectorsPerTable); + + string? alias = createAlias ? _queryState.TableAliasGenerator.GetNext() : null; + return new SelectNode(selectorsPerTable, where, orderBy, alias); + } + + private WhereNode? GetWhere() + { + if (_whereFilters.Count == 0) + { + return null; + } + + var combinator = new LogicalCombinator(); + + FilterNode filter = _whereFilters.Count == 1 ? _whereFilters[0] : new LogicalNode(LogicalOperator.And, _whereFilters); + FilterNode collapsed = combinator.Collapse(filter); + + return new WhereNode(collapsed); + } + + private static Dictionary> AliasSelectorsToTableColumnNames( + Dictionary> selectorsPerTable) + { + Dictionary> aliasedSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectorsPerTable) + { + aliasedSelectors[tableAccessor] = tableSelectors.Select(AliasToTableColumnName).ToList(); + } + + return aliasedSelectors; + } + + private static SelectorNode AliasToTableColumnName(SelectorNode selector) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (columnSelector.Column is ColumnInSelectNode columnInSelect) + { + string persistedColumnName = columnInSelect.GetPersistedColumnName(); + + if (columnInSelect.Name != persistedColumnName) + { + // t1.Id0 => t1.Id0 AS Id + return new ColumnSelectorNode(columnInSelect, persistedColumnName); + } + } + + if (columnSelector.Alias != null) + { + // t1."Id" AS Id0 => t1."Id" + return new ColumnSelectorNode(columnSelector.Column, null); + } + } + + return selector; + } + + public override SqlTreeNode DefaultVisit(QueryExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException($"Expressions of type '{expression.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitComparison(ComparisonExpression expression, TableAccessorNode tableAccessor) + { + SqlValueNode left = VisitComparisonTerm(expression.Left, tableAccessor); + SqlValueNode right = VisitComparisonTerm(expression.Right, tableAccessor); + + return new ComparisonNode(expression.Operator, left, right); + } + + private SqlValueNode VisitComparisonTerm(QueryExpression comparisonTerm, TableAccessorNode tableAccessor) + { + if (comparisonTerm is NullConstantExpression) + { + return NullConstantNode.Instance; + } + + SqlTreeNode treeNode = Visit(comparisonTerm, tableAccessor); + + if (treeNode is JoinNode join) + { + return join.InnerColumn; + } + + return (SqlValueNode)treeNode; + } + + public override SqlTreeNode VisitResourceFieldChain(ResourceFieldChainExpression expression, TableAccessorNode tableAccessor) + { + TableAccessorNode currentAccessor = tableAccessor; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is RelationshipAttribute relationship) + { + currentAccessor = GetOrCreateRelatedTable(currentAccessor, relationship); + } + else if (field is AttrAttribute attribute) + { + ColumnNode? column = currentAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, currentAccessor.Source.Alias); + + if (column == null) + { + // Unmapped columns cannot be translated to SQL. + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Sorting or filtering on the requested attribute is unavailable.", + Detail = $"Sorting or filtering on attribute '{attribute.PublicName}' is unavailable because it is unmapped." + }); + } + + return column; + } + } + + return currentAccessor; + } + + public override SqlTreeNode VisitLiteralConstant(LiteralConstantExpression expression, TableAccessorNode tableAccessor) + { + return _queryState.ParameterGenerator.Create(expression.TypedValue); + } + + public override SqlTreeNode VisitLogical(LogicalExpression expression, TableAccessorNode tableAccessor) + { + FilterNode[] terms = VisitSequence(expression.Terms, tableAccessor).ToArray(); + return new LogicalNode(expression.Operator, terms); + } + + private IEnumerable VisitSequence(IEnumerable source, TableAccessorNode tableAccessor) + where TIn : QueryExpression + where TOut : SqlTreeNode + { + return source.Select(expression => (TOut)Visit(expression, tableAccessor)).ToList(); + } + + public override SqlTreeNode VisitNot(NotExpression expression, TableAccessorNode tableAccessor) + { + var child = (FilterNode)Visit(expression.Child, tableAccessor); + FilterNode filter = child is NotNode notChild ? notChild.Child : new NotNode(child); + + var finder = new NullableAttributeFinder(_queryState.DataModelService); + finder.Visit(expression, null); + + if (finder.AttributesToNullCheck.Any()) + { + var orTerms = new List + { + filter + }; + + foreach (ResourceFieldChainExpression fieldChain in finder.AttributesToNullCheck) + { + var column = (ColumnInTableNode)Visit(fieldChain, tableAccessor); + var isNullCheck = new ComparisonNode(ComparisonOperator.Equals, column, NullConstantNode.Instance); + orTerms.Add(isNullCheck); + } + + return new LogicalNode(LogicalOperator.Or, orTerms); + } + + return filter; + } + + public override SqlTreeNode VisitHas(HasExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.One + }; + + return subSelectBuilder.GetExistsClause(expression, tableAccessor); + } + + private ExistsNode GetExistsClause(HasExpression expression, TableAccessorNode outerTableAccessor) + { + var rightTableAccessor = (TableAccessorNode)Visit(expression.TargetCollection, outerTableAccessor); + + if (expression.Filter != null) + { + var filter = (FilterNode)Visit(expression.Filter, rightTableAccessor); + _whereFilters.Add(filter); + } + + SelectNode select = ToSelect(true, false); + return new ExistsNode(select); + } + + public override SqlTreeNode VisitIsType(IsTypeExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException("Resource inheritance is not supported."); + } + + public override SqlTreeNode VisitSortElement(SortElementExpression expression, TableAccessorNode tableAccessor) + { + if (expression.Target is CountExpression count) + { + var newCount = (CountNode)Visit(count, tableAccessor); + return new OrderByCountNode(newCount, expression.IsAscending); + } + + if (expression.Target is ResourceFieldChainExpression fieldChain) + { + var column = (ColumnNode)Visit(fieldChain, tableAccessor); + return new OrderByColumnNode(column, expression.IsAscending); + } + + throw new NotSupportedException($"Unsupported sort type '{expression.Target.GetType().Name}' with value '{expression.Target}'."); + } + + public override SqlTreeNode VisitSort(SortExpression expression, TableAccessorNode tableAccessor) + { + OrderByTermNode[] terms = VisitSequence(expression.Elements, tableAccessor).ToArray(); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitCount(CountExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.Count + }; + + return subSelectBuilder.GetCountClause(expression, tableAccessor); + } + + private CountNode GetCountClause(CountExpression expression, TableAccessorNode outerTableAccessor) + { + _ = Visit(expression.TargetCollection, outerTableAccessor); + + SelectNode select = ToSelect(true, false); + return new CountNode(select); + } + + public override SqlTreeNode VisitMatchText(MatchTextExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + return new LikeNode(column, expression.MatchKind, (string)expression.TextValue.TypedValue); + } + + public override SqlTreeNode VisitAny(AnyExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + + ParameterNode[] parameters = + VisitSequence(expression.Constants.OrderBy(constant => constant.TypedValue), tableAccessor).ToArray(); + + return parameters.Length == 1 ? new ComparisonNode(ComparisonOperator.Equals, column, parameters[0]) : new InNode(column, parameters); + } + + private sealed class NullableAttributeFinder : QueryExpressionRewriter + { + private readonly IDataModelService _dataModelService; + + public IList AttributesToNullCheck { get; } = new List(); + + public NullableAttributeFinder(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + bool seenOptionalToOneRelationship = false; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsNullable) + { + seenOptionalToOneRelationship = true; + } + } + else if (field is AttrAttribute attribute) + { + if (seenOptionalToOneRelationship || _dataModelService.IsColumnNullable(attribute)) + { + AttributesToNullCheck.Add(expression); + } + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + } + + private sealed class QueryState + { + // Provides access to the underlying data model (tables, columns and foreign keys). + public IDataModelService DataModelService { get; } + + // Used to generate unique aliases for tables. + public TableAliasGenerator TableAliasGenerator { get; } + + // Used to generate unique parameters for constants (to improve query plan caching and guard against SQL injection). + public ParameterGenerator ParameterGenerator { get; } + + public ILoggerFactory LoggerFactory { get; } + + // Prevents importing a table multiple times and enables to reference a table imported by an inner/outer query. + // In case of sub-queries, this may include temporary tables that won't survive in the final query. + public Dictionary> RelatedTables { get; } = new(); + + // In case of sub-queries, we track old/new table aliases, so we can rewrite stale references afterwards. + // This cannot be done in the moment itself, because references to tables are on method call stacks. + public Dictionary OldToNewTableAliasMap { get; } = new(); + + public QueryState(IDataModelService dataModelService, TableAliasGenerator tableAliasGenerator, ParameterGenerator parameterGenerator, + ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(dataModelService); + ArgumentGuard.NotNull(tableAliasGenerator); + ArgumentGuard.NotNull(parameterGenerator); + ArgumentGuard.NotNull(loggerFactory); + + DataModelService = dataModelService; + TableAliasGenerator = tableAliasGenerator; + ParameterGenerator = parameterGenerator; + LoggerFactory = loggerFactory; + } + + public void Reset() + { + TableAliasGenerator.Reset(); + ParameterGenerator.Reset(); + + RelatedTables.Clear(); + OldToNewTableAliasMap.Clear(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs new file mode 100644 index 0000000000..f8a3412b36 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs @@ -0,0 +1,505 @@ +using System.Text; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Converts s into SQL text. +/// +internal sealed class SqlQueryBuilder : SqlTreeNodeVisitor +{ + private static readonly char[] SpecialCharactersInLikeDefault = + { + '\\', + '%', + '_' + }; + + private static readonly char[] SpecialCharactersInLikeSqlServer = + { + '\\', + '%', + '_', + '[', + ']' + }; + + private readonly DatabaseProvider _databaseProvider; + private readonly Dictionary _parametersByName = new(); + private int _indentDepth; + + private char[] SpecialCharactersInLike => + _databaseProvider == DatabaseProvider.SqlServer ? SpecialCharactersInLikeSqlServer : SpecialCharactersInLikeDefault; + + public IDictionary Parameters => _parametersByName.Values.ToDictionary(parameter => parameter.Name, parameter => parameter.Value); + + public SqlQueryBuilder(DatabaseProvider databaseProvider) + { + _databaseProvider = databaseProvider; + } + + public string GetCommand(SqlTreeNode node) + { + ArgumentGuard.NotNull(node); + + ResetState(); + + var builder = new StringBuilder(); + Visit(node, builder); + return builder.ToString(); + } + + private void ResetState() + { + _parametersByName.Clear(); + _indentDepth = 0; + } + + public override object? VisitSelect(SelectNode node, StringBuilder builder) + { + if (builder.Length > 0) + { + using (Indent()) + { + builder.Append('('); + WriteSelect(node, builder); + } + + AppendOnNewLine(")", builder); + } + else + { + WriteSelect(node, builder); + } + + WriteDeclareAlias(node.Alias, builder); + return null; + } + + private void WriteSelect(SelectNode node, StringBuilder builder) + { + AppendOnNewLine("SELECT ", builder); + + IEnumerable selectors = node.Selectors.SelectMany(selector => selector.Value); + VisitSequence(selectors, builder); + + foreach (TableAccessorNode tableAccessor in node.Selectors.Keys) + { + Visit(tableAccessor, builder); + } + + if (node.Where != null) + { + Visit(node.Where, builder); + } + + if (node.OrderBy != null) + { + Visit(node.OrderBy, builder); + } + } + + public override object? VisitInsert(InsertNode node, StringBuilder builder) + { + AppendOnNewLine("INSERT INTO ", builder); + Visit(node.Table, builder); + builder.Append(" ("); + VisitSequence(node.Assignments.Select(assignment => assignment.Column), builder); + builder.Append(')'); + + ColumnNode idColumn = node.Table.GetIdColumn(node.Table.Alias); + + if (_databaseProvider == DatabaseProvider.SqlServer) + { + AppendOnNewLine("OUTPUT INSERTED.", builder); + Visit(idColumn, builder); + } + + AppendOnNewLine("VALUES (", builder); + VisitSequence(node.Assignments.Select(assignment => assignment.Value), builder); + builder.Append(')'); + + if (_databaseProvider == DatabaseProvider.PostgreSql) + { + AppendOnNewLine("RETURNING ", builder); + Visit(idColumn, builder); + } + else if (_databaseProvider == DatabaseProvider.MySql) + { + builder.Append(';'); + ColumnAssignmentNode? idAssignment = node.Assignments.FirstOrDefault(assignment => assignment.Column == idColumn); + + if (idAssignment != null) + { + AppendOnNewLine("SELECT ", builder); + Visit(idAssignment.Value, builder); + } + else + { + AppendOnNewLine("SELECT LAST_INSERT_ID()", builder); + } + } + + return null; + } + + public override object? VisitUpdate(UpdateNode node, StringBuilder builder) + { + AppendOnNewLine("UPDATE ", builder); + Visit(node.Table, builder); + + AppendOnNewLine("SET ", builder); + VisitSequence(node.Assignments, builder); + + Visit(node.Where, builder); + return null; + } + + public override object? VisitDelete(DeleteNode node, StringBuilder builder) + { + AppendOnNewLine("DELETE FROM ", builder); + Visit(node.Table, builder); + Visit(node.Where, builder); + return null; + } + + public override object? VisitTable(TableNode node, StringBuilder builder) + { + string tableName = FormatIdentifier(node.Name); + builder.Append(tableName); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitFrom(FromNode node, StringBuilder builder) + { + AppendOnNewLine("FROM ", builder); + Visit(node.Source, builder); + return null; + } + + public override object? VisitJoin(JoinNode node, StringBuilder builder) + { + string joinTypeText = node.JoinType switch + { + JoinType.InnerJoin => "INNER JOIN ", + JoinType.LeftJoin => "LEFT JOIN ", + _ => throw new NotSupportedException($"Unknown join type '{node.JoinType}'.") + }; + + AppendOnNewLine(joinTypeText, builder); + Visit(node.Source, builder); + builder.Append(" ON "); + Visit(node.OuterColumn, builder); + builder.Append(" = "); + Visit(node.InnerColumn, builder); + return null; + } + + public override object? VisitColumnInTable(ColumnInTableNode node, StringBuilder builder) + { + WriteColumn(node, false, builder); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, StringBuilder builder) + { + WriteColumn(node, node.IsVirtual, builder); + return null; + } + + private void WriteColumn(ColumnNode column, bool isVirtualColumn, StringBuilder builder) + { + WriteReferenceAlias(column.TableAlias, builder); + + string name = isVirtualColumn ? column.Name : FormatIdentifier(column.Name); + builder.Append(name); + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, StringBuilder builder) + { + Visit(node.Column, builder); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitOneSelector(OneSelectorNode node, StringBuilder builder) + { + builder.Append('1'); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitCountSelector(CountSelectorNode node, StringBuilder builder) + { + builder.Append("COUNT(*)"); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitWhere(WhereNode node, StringBuilder builder) + { + AppendOnNewLine("WHERE ", builder); + Visit(node.Filter, builder); + return null; + } + + public override object? VisitNot(NotNode node, StringBuilder builder) + { + builder.Append("NOT ("); + Visit(node.Child, builder); + builder.Append(')'); + return null; + } + + public override object? VisitLogical(LogicalNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + LogicalOperator.And => "AND", + LogicalOperator.Or => "OR", + _ => throw new NotSupportedException($"Unknown logical operator '{node.Operator}'.") + }; + + builder.Append('('); + Visit(node.Terms[0], builder); + builder.Append(')'); + + foreach (FilterNode nextTerm in node.Terms.Skip(1)) + { + builder.Append($" {operatorText} ("); + Visit(nextTerm, builder); + builder.Append(')'); + } + + return null; + } + + public override object? VisitComparison(ComparisonNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + ComparisonOperator.Equals => node.Left is NullConstantNode || node.Right is NullConstantNode ? "IS" : "=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.LessThan => "<", + ComparisonOperator.LessOrEqual => "<=", + _ => throw new NotSupportedException($"Unknown comparison operator '{node.Operator}'.") + }; + + Visit(node.Left, builder); + builder.Append($" {operatorText} "); + Visit(node.Right, builder); + return null; + } + + public override object? VisitLike(LikeNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" LIKE '"); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.EndsWith) + { + builder.Append('%'); + } + + string safeValue = node.Text.Replace("'", "''"); + bool requireEscapeClause = node.Text.IndexOfAny(SpecialCharactersInLike) != -1; + + if (requireEscapeClause) + { + foreach (char specialCharacter in SpecialCharactersInLike) + { + safeValue = safeValue.Replace(specialCharacter.ToString(), @"\" + specialCharacter); + } + } + + if (requireEscapeClause && _databaseProvider == DatabaseProvider.MySql) + { + safeValue = safeValue.Replace(@"\\", @"\\\\"); + } + + builder.Append(safeValue); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.StartsWith) + { + builder.Append('%'); + } + + builder.Append('\''); + + if (requireEscapeClause) + { + builder.Append(_databaseProvider == DatabaseProvider.MySql ? @" ESCAPE '\\'" : @" ESCAPE '\'"); + } + + return null; + } + + public override object? VisitIn(InNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" IN ("); + VisitSequence(node.Values, builder); + builder.Append(')'); + return null; + } + + public override object? VisitExists(ExistsNode node, StringBuilder builder) + { + builder.Append("EXISTS "); + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitCount(CountNode node, StringBuilder builder) + { + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, StringBuilder builder) + { + AppendOnNewLine("ORDER BY ", builder); + VisitSequence(node.Terms, builder); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, StringBuilder builder) + { + Visit(node.Column, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, StringBuilder builder) + { + Visit(node.Count, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitColumnAssignment(ColumnAssignmentNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" = "); + Visit(node.Value, builder); + return null; + } + + public override object? VisitParameter(ParameterNode node, StringBuilder builder) + { + _parametersByName[node.Name] = node; + + builder.Append(node.Name); + return null; + } + + public override object? VisitNullConstant(NullConstantNode node, StringBuilder builder) + { + builder.Append("NULL"); + return null; + } + + private static void WriteDeclareAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($" AS {alias}"); + } + } + + private static void WriteReferenceAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($"{alias}."); + } + } + + private void VisitSequence(IEnumerable elements, StringBuilder builder) + where T : SqlTreeNode + { + bool isFirstElement = true; + + foreach (T element in elements) + { + if (isFirstElement) + { + isFirstElement = false; + } + else + { + builder.Append(", "); + } + + Visit(element, builder); + } + } + + private void AppendOnNewLine(string? value, StringBuilder builder) + { + if (!string.IsNullOrEmpty(value)) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(new string(' ', _indentDepth * 4)); + builder.Append(value); + } + } + + private string FormatIdentifier(string value) + { + return FormatIdentifier(value, _databaseProvider); + } + + internal static string FormatIdentifier(string value, DatabaseProvider databaseProvider) + { + return databaseProvider switch + { + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html + DatabaseProvider.PostgreSql => $"\"{value.Replace("\"", "\"\"")}\"", + // https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + DatabaseProvider.MySql => $"`{value.Replace("`", "``")}`", + // https://learn.microsoft.com/en-us/sql/t-sql/functions/quotename-transact-sql?view=sql-server-ver16 + DatabaseProvider.SqlServer => $"[{value.Replace("]", "]]")}]", + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + private IDisposable Indent() + { + _indentDepth++; + return new RevertIndentOnDispose(this); + } + + private sealed class RevertIndentOnDispose : IDisposable + { + private readonly SqlQueryBuilder _owner; + + public RevertIndentOnDispose(SqlQueryBuilder owner) + { + _owner = owner; + } + + public void Dispose() + { + _owner._indentDepth--; + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs new file mode 100644 index 0000000000..06ebc1867f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs @@ -0,0 +1,33 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.Builders; + +internal abstract class StatementBuilder +{ + private readonly IDataModelService _dataModelService; + + protected ParameterGenerator ParameterGenerator { get; } = new(); + + protected StatementBuilder(IDataModelService dataModelService) + { + ArgumentGuard.NotNull(dataModelService); + + _dataModelService = dataModelService; + } + + protected void ResetState() + { + ParameterGenerator.Reset(); + } + + protected TableNode GetTable(ResourceType resourceType, string? alias) + { + IReadOnlyDictionary columnMappings = _dataModelService.GetColumnMappings(resourceType); + return new TableNode(resourceType, columnMappings, alias); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..152e14991c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs @@ -0,0 +1,47 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateClearOneToOneStatementBuilder : StatementBuilder +{ + public UpdateClearOneToOneStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public UpdateNode Build(ResourceType resourceType, string setColumnName, string whereColumnName, object? whereValue) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(setColumnName); + ArgumentGuard.NotNull(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode setColumn = table.GetColumn(setColumnName, null, table.Alias); + ColumnAssignmentNode columnAssignment = GetColumnAssignment(setColumn); + + ColumnNode whereColumn = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(whereColumn, whereValue); + + return new UpdateNode(table, columnAssignment.AsList(), where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode whereParameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, whereParameter); + return new WhereNode(filter); + } + + private ColumnAssignmentNode GetColumnAssignment(ColumnNode setColumn) + { + ParameterNode parameter = ParameterGenerator.Create(null); + return new ColumnAssignmentNode(setColumn, parameter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs new file mode 100644 index 0000000000..ad4514dca6 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs @@ -0,0 +1,55 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateResourceStatementBuilder : StatementBuilder +{ + public UpdateResourceStatementBuilder(IDataModelService dataModelService) + : base(dataModelService) + { + } + + public UpdateNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToUpdate, params object[] idValues) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNullNorEmpty(columnsToUpdate); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + List assignments = GetColumnAssignments(columnsToUpdate, table); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new UpdateNode(table, assignments, where); + } + + private List GetColumnAssignments(IReadOnlyDictionary columnsToUpdate, TableNode table) + { + List assignments = new(); + + foreach ((string columnName, object? columnValue) in columnsToUpdate) + { + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments; + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + List parameters = idValues.Select(idValue => ParameterGenerator.Create(idValue)).ToList(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs new file mode 100644 index 0000000000..607d8dc080 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -0,0 +1,175 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Data.Common; +using System.Reflection; +using Dapper; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Database-agnostic base type that infers additional information, based on foreign keys (provided by derived type) and the JSON:API resource graph. +/// +public abstract class BaseDataModelService : IDataModelService +{ + private readonly Dictionary> _columnMappingsByType = new(); + + protected IResourceGraph ResourceGraph { get; } + + public abstract DatabaseProvider DatabaseProvider { get; } + + protected BaseDataModelService(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph); + + ResourceGraph = resourceGraph; + } + + public abstract DbConnection CreateConnection(); + + public abstract RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + protected void Initialize() + { + ScanColumnMappings(); + + if (DatabaseProvider == DatabaseProvider.MySql) + { + // https://stackoverflow.com/questions/12510299/get-datetime-as-utc-with-dapper + SqlMapper.AddTypeHandler(new DapperDateTimeOffsetHandlerForMySql()); + } + } + + private void ScanColumnMappings() + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + _columnMappingsByType[resourceType] = ScanColumnMappings(resourceType); + } + } + + private IReadOnlyDictionary ScanColumnMappings(ResourceType resourceType) + { + Dictionary mappings = new(); + + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + if (!IsMapped(property)) + { + continue; + } + + string columnName = property.Name; + ResourceFieldAttribute? field = null; + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(property.Name); + + if (relationship != null) + { + RelationshipForeignKey foreignKey = GetForeignKey(relationship); + + if (!foreignKey.IsAtLeftSide) + { + continue; + } + + field = relationship; + columnName = foreignKey.ColumnName; + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(property.Name); + + if (attribute != null) + { + field = attribute; + } + } + + mappings[columnName] = field; + } + + return mappings; + } + + private static bool IsMapped(PropertyInfo property) + { + return property.GetCustomAttribute() == null; + } + + public IReadOnlyDictionary GetColumnMappings(ResourceType resourceType) + { + if (_columnMappingsByType.TryGetValue(resourceType, out IReadOnlyDictionary? columnMappings)) + { + return columnMappings; + } + + throw new InvalidOperationException($"Column mappings for resource type '{resourceType.ClrType.Name}' are unavailable."); + } + + public object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(resource); + AssertSameType(resourceType, resource); + ArgumentGuard.NotNullNorEmpty(columnName); + + IReadOnlyDictionary columnMappings = GetColumnMappings(resourceType); + + if (!columnMappings.TryGetValue(columnName, out ResourceFieldAttribute? field)) + { + throw new InvalidOperationException($"Column '{columnName}' not found on resource type '{resourceType}'."); + } + + if (field is AttrAttribute attribute) + { + return attribute.GetValue(resource); + } + + if (field is RelationshipAttribute relationship) + { + var rightResource = (IIdentifiable?)relationship.GetValue(resource); + + if (rightResource == null) + { + return null; + } + + PropertyInfo rightKeyProperty = rightResource.GetType().GetProperty(TableSourceNode.IdColumnName)!; + return rightKeyProperty.GetValue(rightResource); + } + + PropertyInfo property = resourceType.ClrType.GetProperty(columnName)!; + return property.GetValue(resource); + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } + + public abstract bool IsColumnNullable(AttrAttribute attribute); + + private sealed class DapperDateTimeOffsetHandlerForMySql : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) + { + parameter.Value = value; + } + + public override DateTimeOffset Parse(object value) + { + return DateTime.SpecifyKind((DateTime)value, DateTimeKind.Utc); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs new file mode 100644 index 0000000000..81f2778a14 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs @@ -0,0 +1,145 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using MySqlConnector; +using Npgsql; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Derives foreign keys and connection strings from an existing Entity Framework Core model. +/// +public sealed class FromEntitiesDataModelService : BaseDataModelService +{ + private readonly Dictionary _foreignKeysByRelationship = new(); + private readonly Dictionary _columnNullabilityPerAttribute = new(); + private string? _connectionString; + private DatabaseProvider? _databaseProvider; + + public override DatabaseProvider DatabaseProvider => AssertHasDatabaseProvider(); + + public FromEntitiesDataModelService(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public void Initialize(DbContext dbContext) + { + _connectionString = dbContext.Database.GetConnectionString(); + + _databaseProvider = dbContext.Database.ProviderName switch + { + "Npgsql.EntityFrameworkCore.PostgreSQL" => DatabaseProvider.PostgreSql, + "Pomelo.EntityFrameworkCore.MySql" => DatabaseProvider.MySql, + "Microsoft.EntityFrameworkCore.SqlServer" => DatabaseProvider.SqlServer, + _ => throw new NotSupportedException($"Unknown database provider '{dbContext.Database.ProviderName}'.") + }; + + ScanForeignKeys(dbContext.Model); + ScanColumnNullability(dbContext.Model); + Initialize(); + } + + private void ScanForeignKeys(IModel entityModel) + { + foreach (RelationshipAttribute relationship in ResourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) + { + IEntityType? leftEntityType = entityModel.FindEntityType(relationship.LeftType.ClrType); + INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); + + if (navigation != null) + { + bool isAtLeftSide = navigation.ForeignKey.DeclaringEntityType.ClrType == relationship.LeftType.ClrType; + string columnName = navigation.ForeignKey.Properties.Single().Name; + bool isNullable = !navigation.ForeignKey.IsRequired; + + var foreignKey = new RelationshipForeignKey(DatabaseProvider, relationship, isAtLeftSide, columnName, isNullable); + _foreignKeysByRelationship[relationship] = foreignKey; + } + } + } + + private void ScanColumnNullability(IModel entityModel) + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + ScanColumnNullability(resourceType, entityModel); + } + } + + private void ScanColumnNullability(ResourceType resourceType, IModel entityModel) + { + IEntityType? entityType = entityModel.FindEntityType(resourceType.ClrType); + + if (entityType != null) + { + foreach (AttrAttribute attribute in resourceType.Attributes) + { + IProperty? property = entityType.FindProperty(attribute.Property.Name); + + if (property != null) + { + _columnNullabilityPerAttribute[attribute] = property.IsNullable; + } + } + } + } + + public override DbConnection CreateConnection() + { + string connectionString = AssertHasConnectionString(); + DatabaseProvider databaseProvider = AssertHasDatabaseProvider(); + + return databaseProvider switch + { + DatabaseProvider.PostgreSql => new NpgsqlConnection(connectionString), + DatabaseProvider.MySql => new MySqlConnection(connectionString), + DatabaseProvider.SqlServer => new SqlConnection(connectionString), + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + public override RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship) + { + if (_foreignKeysByRelationship.TryGetValue(relationship, out RelationshipForeignKey? foreignKey)) + { + return foreignKey; + } + + throw new InvalidOperationException( + $"Foreign key mapping for relationship '{relationship.LeftType.ClrType.Name}.{relationship.Property.Name}' is unavailable."); + } + + public override bool IsColumnNullable(AttrAttribute attribute) + { + if (_columnNullabilityPerAttribute.TryGetValue(attribute, out bool isNullable)) + { + return isNullable; + } + + throw new InvalidOperationException($"Attribute '{attribute}' is unavailable."); + } + + private DatabaseProvider AssertHasDatabaseProvider() + { + if (_databaseProvider == null) + { + throw new InvalidOperationException($"Database provider is unavailable. Call {nameof(Initialize)} first."); + } + + return _databaseProvider.Value; + } + + private string AssertHasConnectionString() + { + if (_connectionString == null) + { + throw new InvalidOperationException($"Connection string is unavailable. Call {nameof(Initialize)} first."); + } + + return _connectionString; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs new file mode 100644 index 0000000000..9862c6e28f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs @@ -0,0 +1,24 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Provides information about the underlying database model, such as foreign key and column names. +/// +public interface IDataModelService +{ + DatabaseProvider DatabaseProvider { get; } + + DbConnection CreateConnection(); + + RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + IReadOnlyDictionary GetColumnMappings(ResourceType resourceType); + + object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName); + + bool IsColumnNullable(AttrAttribute attribute); +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs new file mode 100644 index 0000000000..6f5572001a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs @@ -0,0 +1,69 @@ +using System.Text; +using DapperExample.TranslationToSql.Builders; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Defines foreign key information for a , which is required to produce SQL queries. +/// +[PublicAPI] +public sealed class RelationshipForeignKey +{ + private readonly DatabaseProvider _databaseProvider; + + /// + /// The JSON:API relationship mapped to this foreign key. + /// + public RelationshipAttribute Relationship { get; } + + /// + /// Indicates whether the foreign key column is defined at the left side of the JSON:API relationship. + /// + public bool IsAtLeftSide { get; } + + /// + /// The foreign key column name. + /// + public string ColumnName { get; } + + /// + /// Indicates whether the foreign key column is nullable. + /// + public bool IsNullable { get; } + + public RelationshipForeignKey(DatabaseProvider databaseProvider, RelationshipAttribute relationship, bool isAtLeftSide, string columnName, bool isNullable) + { + ArgumentGuard.NotNull(relationship); + ArgumentGuard.NotNullNorEmpty(columnName); + + _databaseProvider = databaseProvider; + Relationship = relationship; + IsAtLeftSide = isAtLeftSide; + ColumnName = columnName; + IsNullable = isNullable; + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append($"{Relationship.LeftType.ClrType.Name}.{Relationship.Property.Name} => "); + + ResourceType tableType = IsAtLeftSide ? Relationship.LeftType : Relationship.RightType; + + builder.Append(SqlQueryBuilder.FormatIdentifier(tableType.ClrType.Name.Pluralize(), _databaseProvider)); + builder.Append('.'); + builder.Append(SqlQueryBuilder.FormatIdentifier(ColumnName, _databaseProvider)); + + if (IsNullable) + { + builder.Append('?'); + } + + return builder.ToString(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs new file mode 100644 index 0000000000..bd4df111fc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs @@ -0,0 +1,30 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL parameter with a unique name. +/// +internal sealed class ParameterGenerator +{ + private readonly ParameterNameGenerator _nameGenerator = new(); + + public ParameterNode Create(object? value) + { + string name = _nameGenerator.GetNext(); + return new ParameterNode(name, value); + } + + public void Reset() + { + _nameGenerator.Reset(); + } + + private sealed class ParameterNameGenerator : UniqueNameGenerator + { + public ParameterNameGenerator() + : base("@p") + { + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs new file mode 100644 index 0000000000..39d5d9d702 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs @@ -0,0 +1,12 @@ +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL table alias with a unique name. +/// +internal sealed class TableAliasGenerator : UniqueNameGenerator +{ + public TableAliasGenerator() + : base("t") + { + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs new file mode 100644 index 0000000000..3ea42ab529 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Generators; + +internal abstract class UniqueNameGenerator +{ + private readonly string _prefix; + private int _lastIndex; + + protected UniqueNameGenerator(string prefix) + { + ArgumentGuard.NotNullNorEmpty(prefix); + + _prefix = prefix; + } + + public string GetNext() + { + return $"{_prefix}{++_lastIndex}"; + } + + public void Reset() + { + _lastIndex = 0; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs new file mode 100644 index 0000000000..9d4053b5a4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs @@ -0,0 +1,67 @@ +using System.Text; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql; + +/// +/// Converts a SQL parameter into human-readable text. Used for diagnostic purposes. +/// +internal sealed class ParameterFormatter +{ + private static readonly HashSet NumericTypes = new[] + { + typeof(bool), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(short), + typeof(ushort), + typeof(sbyte), + typeof(float), + typeof(double), + typeof(decimal) + }.ToHashSet(); + + public string Format(string parameterName, object? parameterValue) + { + StringBuilder builder = new(); + builder.Append($"{parameterName} = "); + WriteValue(parameterValue, builder); + return builder.ToString(); + } + + private void WriteValue(object? parameterValue, StringBuilder builder) + { + if (parameterValue == null) + { + builder.Append("null"); + } + else if (parameterValue is char) + { + builder.Append($"'{parameterValue}'"); + } + else if (parameterValue is byte byteValue) + { + builder.Append($"0x{byteValue:X2}"); + } + else if (parameterValue is Enum) + { + builder.Append($"{parameterValue.GetType().Name}.{parameterValue}"); + } + else + { + string value = (string)RuntimeTypeConverter.ConvertType(parameterValue, typeof(string))!; + + if (NumericTypes.Contains(parameterValue.GetType())) + { + builder.Append(value); + } + else + { + string escapedValue = value.Replace("'", "''"); + builder.Append($"'{escapedValue}'"); + } + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs new file mode 100644 index 0000000000..37ed2d3ea5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs @@ -0,0 +1,23 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql; + +/// +/// Represents a parameterized SQL query. +/// +[PublicAPI] +public sealed class SqlCommand +{ + public string Statement { get; } + public IDictionary Parameters { get; } + + internal SqlCommand(string statement, IDictionary parameters) + { + ArgumentGuard.NotNull(statement); + ArgumentGuard.NotNull(parameters); + + Statement = statement; + Parameters = parameters; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs new file mode 100644 index 0000000000..24a129189d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs @@ -0,0 +1,151 @@ +using DapperExample.TranslationToSql.TreeNodes; +using JetBrains.Annotations; + +namespace DapperExample.TranslationToSql; + +/// +/// Implements the visitor design pattern that enables traversing a tree. +/// +[PublicAPI] +internal abstract class SqlTreeNodeVisitor +{ + public virtual TResult Visit(SqlTreeNode node, TArgument argument) + { + return node.Accept(this, argument); + } + + public virtual TResult DefaultVisit(SqlTreeNode node, TArgument argument) + { + return default!; + } + + public virtual TResult VisitSelect(SelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitInsert(InsertNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitUpdate(UpdateNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitDelete(DeleteNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitTable(TableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitFrom(FromNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitJoin(JoinNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInTable(ColumnInTableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInSelect(ColumnInSelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnSelector(ColumnSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOneSelector(OneSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCountSelector(CountSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitWhere(WhereNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNot(NotNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLogical(LogicalNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitComparison(ComparisonNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLike(LikeNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitIn(InNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitExists(ExistsNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCount(CountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderBy(OrderByNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByColumn(OrderByColumnNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByCount(OrderByCountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnAssignment(ColumnAssignmentNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitParameter(ParameterNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNullConstant(NullConstantNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs new file mode 100644 index 0000000000..ac447bcdc3 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs @@ -0,0 +1,163 @@ +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collects all s in selectors that are referenced elsewhere in the query. +/// +internal sealed class ColumnSelectorUsageCollector : SqlTreeNodeVisitor +{ + private readonly HashSet _usedColumns = new(); + private readonly ILogger _logger; + + public ISet UsedColumns => _usedColumns; + + public ColumnSelectorUsageCollector(ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(loggerFactory); + + _logger = loggerFactory.CreateLogger(); + } + + public void Collect(SelectNode select) + { + ArgumentGuard.NotNull(select); + + _logger.LogDebug("Started collection of used columns."); + + _usedColumns.Clear(); + InnerVisit(select, ColumnVisitMode.Reference); + + _logger.LogDebug("Finished collection of used columns."); + } + + public override object? VisitSelect(SelectNode node, ColumnVisitMode mode) + { + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in node.Selectors) + { + InnerVisit(tableAccessor, mode); + VisitSequence(tableSelectors, ColumnVisitMode.Declaration); + } + + InnerVisit(node.Where, mode); + InnerVisit(node.OrderBy, mode); + return null; + } + + public override object? VisitFrom(FromNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + return null; + } + + public override object? VisitJoin(JoinNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + InnerVisit(node.OuterColumn, mode); + InnerVisit(node.InnerColumn, mode); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + InnerVisit(node.Selector, ColumnVisitMode.Reference); + return null; + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Reference) + { + _usedColumns.Add(node.Column); + _logger.LogDebug($"Added used column {node.Column}."); + } + + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitWhere(WhereNode node, ColumnVisitMode mode) + { + InnerVisit(node.Filter, mode); + return null; + } + + public override object? VisitNot(NotNode node, ColumnVisitMode mode) + { + InnerVisit(node.Child, mode); + return null; + } + + public override object? VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + InnerVisit(node.Left, mode); + InnerVisit(node.Right, mode); + return null; + } + + public override object? VisitLike(LikeNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitIn(InNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + VisitSequence(node.Values, mode); + return null; + } + + public override object? VisitExists(ExistsNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitCount(CountNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + InnerVisit(node.Count, mode); + return null; + } + + private void InnerVisit(SqlTreeNode? node, ColumnVisitMode mode) + { + if (node != null) + { + Visit(node, mode); + } + } + + private void VisitSequence(IEnumerable nodes, ColumnVisitMode mode) + { + foreach (SqlTreeNode node in nodes) + { + InnerVisit(node, mode); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs new file mode 100644 index 0000000000..6b0e8f8e5c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.Transformations; + +internal enum ColumnVisitMode +{ + /// + /// Definition of a column in a SQL query. + /// + Declaration, + + /// + /// Usage of a column in a SQL query. + /// + Reference +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs new file mode 100644 index 0000000000..0fcd047a3d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs @@ -0,0 +1,58 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collapses nested logical filters. This turns "A AND (B AND C)" into "A AND B AND C". +/// +internal sealed class LogicalCombinator : SqlTreeNodeVisitor +{ + public FilterNode Collapse(FilterNode filter) + { + return TypedVisit(filter); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, object? argument) + { + var newTerms = new List(); + + foreach (FilterNode newTerm in node.Terms.Select(TypedVisit)) + { + if (newTerm is LogicalNode logicalTerm && logicalTerm.Operator == node.Operator) + { + newTerms.AddRange(logicalTerm.Terms); + } + else + { + newTerms.Add(newTerm); + } + } + + return new LogicalNode(node.Operator, newTerms); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, object? argument) + { + return node; + } + + public override SqlTreeNode VisitNot(NotNode node, object? argument) + { + FilterNode newChild = TypedVisit(node.Child); + return new NotNode(newChild); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, object? argument) + { + SqlValueNode newLeft = TypedVisit(node.Left); + SqlValueNode newRight = TypedVisit(node.Right); + + return new ComparisonNode(node.Operator, newLeft, newRight); + } + + private T TypedVisit(T node) + where T : SqlTreeNode + { + return (T)Visit(node, null); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs new file mode 100644 index 0000000000..00bb7b0756 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs @@ -0,0 +1,307 @@ +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Updates references to stale columns in sub-queries, by pulling them out until in scope. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The reference to t1 in the WHERE clause has become stale and needs to be pulled out into scope, which is t2. +///
+internal sealed class StaleColumnReferenceRewriter : SqlTreeNodeVisitor +{ + private readonly IReadOnlyDictionary _oldToNewTableAliasMap; + private readonly ILogger _logger; + private readonly Stack> _tablesInScopeStack = new(); + + public StaleColumnReferenceRewriter(IReadOnlyDictionary oldToNewTableAliasMap, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(oldToNewTableAliasMap); + ArgumentGuard.NotNull(loggerFactory); + + _oldToNewTableAliasMap = oldToNewTableAliasMap; + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode PullColumnsIntoScope(SelectNode select) + { + _tablesInScopeStack.Clear(); + + return TypedVisit(select, ColumnVisitMode.Reference); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ColumnVisitMode mode) + { + throw new NotSupportedException($"Nodes of type '{node.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitSelect(SelectNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + + using IDisposable scope = EnterSelectScope(); + + IReadOnlyDictionary> selectors = VisitSelectors(node.Selectors, mode); + WhereNode? where = TypedVisit(node.Where, mode); + OrderByNode? orderBy = TypedVisit(node.OrderBy, mode); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + + private void IncludeTableAliasInCurrentScope(TableSourceNode tableSource) + { + if (tableSource.Alias != null) + { + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + tablesInScope.Add(tableSource.Alias, tableSource); + } + } + + private IDisposable EnterSelectScope() + { + Dictionary newScope = CopyTopStackElement(); + _tablesInScopeStack.Push(newScope); + + return new PopStackOnDispose>(_tablesInScopeStack); + } + + private Dictionary CopyTopStackElement() + { + if (_tablesInScopeStack.Count == 0) + { + return new Dictionary(); + } + + Dictionary topElement = _tablesInScopeStack.Peek(); + return new Dictionary(topElement); + } + + private IReadOnlyDictionary> VisitSelectors( + IReadOnlyDictionary> selectors, ColumnVisitMode mode) + { + Dictionary> newSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, mode); + IReadOnlyList newTableSelectors = VisitList(tableSelectors, ColumnVisitMode.Declaration); + + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors; + } + + public override SqlTreeNode VisitTable(TableNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + return node; + } + + public override SqlTreeNode VisitFrom(FromNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, mode); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, mode); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInTable(ColumnInTableNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + return MapColumnInTable(node, tablesInScope); + } + + private ColumnNode MapColumnInTable(ColumnInTableNode column, IDictionary tablesInScope) + { + if (column.TableAlias != null && !tablesInScope.ContainsKey(column.TableAlias)) + { + // Stale column found. Keep pulling out until in scope. + string currentAlias = column.TableAlias; + + while (_oldToNewTableAliasMap.ContainsKey(currentAlias)) + { + currentAlias = _oldToNewTableAliasMap[currentAlias]; + + if (tablesInScope.TryGetValue(currentAlias, out TableSourceNode? currentTable)) + { + ColumnNode? outerColumn = currentTable.FindColumn(column.Name, null, column.TableAlias); + + if (outerColumn != null) + { + _logger.LogDebug($"Mapped inaccessible column {column} to {outerColumn}."); + return outerColumn; + } + } + } + + string candidateScopes = string.Join(", ", tablesInScope.Select(table => table.Key)); + throw new InvalidOperationException($"Failed to map inaccessible column {column} to any of the tables in scope: {candidateScopes}."); + } + + return column; + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + ColumnSelectorNode selector = TypedVisit(node.Selector, mode); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, ColumnVisitMode.Declaration); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitOneSelector(OneSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitCountSelector(CountSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitWhere(WhereNode node, ColumnVisitMode mode) + { + FilterNode filter = TypedVisit(node.Filter, mode); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ColumnVisitMode mode) + { + FilterNode child = TypedVisit(node.Child, mode); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + IReadOnlyList terms = VisitList(node.Terms, mode); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + SqlValueNode left = TypedVisit(node.Left, mode); + SqlValueNode right = TypedVisit(node.Right, mode); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + IReadOnlyList values = VisitList(node.Values, mode); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + IReadOnlyList terms = VisitList(node.Terms, mode); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + CountNode count = TypedVisit(node.Count, mode); + return new OrderByCountNode(count, node.IsAscending); + } + + public override SqlTreeNode VisitParameter(ParameterNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitNullConstant(NullConstantNode node, ColumnVisitMode mode) + { + return node; + } + + [return: NotNullIfNotNull("node")] + private T? TypedVisit(T? node, ColumnVisitMode mode) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, mode) : null; + } + + private IReadOnlyList VisitList(IEnumerable nodes, ColumnVisitMode mode) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, mode)).ToList(); + } + + private sealed class PopStackOnDispose : IDisposable + { + private readonly Stack _stack; + + public PopStackOnDispose(Stack stack) + { + _stack = stack; + } + + public void Dispose() + { + _stack.Pop(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs new file mode 100644 index 0000000000..36bb3f0c0a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs @@ -0,0 +1,219 @@ +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Removes unreferenced selectors in sub-queries. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The selectors t1."AccountId" and t1."FirstName" have no references and can be removed. +///
+internal sealed class UnusedSelectorsRewriter : SqlTreeNodeVisitor, SqlTreeNode> +{ + private readonly ColumnSelectorUsageCollector _usageCollector; + private readonly ILogger _logger; + private SelectNode _rootSelect = null!; + private bool _hasChanged; + + public UnusedSelectorsRewriter(ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(loggerFactory); + + _usageCollector = new ColumnSelectorUsageCollector(loggerFactory); + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode RemoveUnusedSelectorsInSubQueries(SelectNode select) + { + ArgumentGuard.NotNull(select); + + _rootSelect = select; + + do + { + _hasChanged = false; + _usageCollector.Collect(_rootSelect); + + _logger.LogDebug("Started removal of unused selectors."); + _rootSelect = TypedVisit(_rootSelect, _usageCollector.UsedColumns); + _logger.LogDebug("Finished removal of unused selectors."); + } + while (_hasChanged); + + return _rootSelect; + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ISet usedColumns) + { + return node; + } + + public override SqlTreeNode VisitSelect(SelectNode node, ISet usedColumns) + { + IReadOnlyDictionary> selectors = VisitSelectors(node, usedColumns); + WhereNode? where = TypedVisit(node.Where, usedColumns); + OrderByNode? orderBy = TypedVisit(node.OrderBy, usedColumns); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + + private IReadOnlyDictionary> VisitSelectors(SelectNode select, ISet usedColumns) + { + Dictionary> newSelectors = new(); + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in select.Selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, usedColumns); + IReadOnlyList newTableSelectors = select == _rootSelect ? tableSelectors : VisitTableSelectors(tableSelectors, usedColumns); + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors; + } + + private List VisitTableSelectors(IEnumerable selectors, ISet usedColumns) + { + List newTableSelectors = new(); + + foreach (SelectorNode selector in selectors) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (!usedColumns.Contains(columnSelector.Column)) + { + _logger.LogDebug($"Removing unused selector {columnSelector}."); + _hasChanged = true; + continue; + } + } + + newTableSelectors.Add(selector); + } + + return newTableSelectors; + } + + public override SqlTreeNode VisitFrom(FromNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, usedColumns); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, usedColumns); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ISet usedColumns) + { + ColumnSelectorNode selector = TypedVisit(node.Selector, usedColumns); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitWhere(WhereNode node, ISet usedColumns) + { + FilterNode filter = TypedVisit(node.Filter, usedColumns); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ISet usedColumns) + { + FilterNode child = TypedVisit(node.Child, usedColumns); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ISet usedColumns) + { + IReadOnlyList terms = VisitList(node.Terms, usedColumns); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ISet usedColumns) + { + SqlValueNode left = TypedVisit(node.Left, usedColumns); + SqlValueNode right = TypedVisit(node.Right, usedColumns); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + IReadOnlyList values = VisitList(node.Values, usedColumns); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ISet usedColumns) + { + IReadOnlyList terms = VisitList(node.Terms, usedColumns); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ISet usedColumns) + { + CountNode count = TypedVisit(node.Count, usedColumns); + return new OrderByCountNode(count, node.IsAscending); + } + + [return: NotNullIfNotNull("node")] + private T? TypedVisit(T? node, ISet usedColumns) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, usedColumns) : null; + } + + private IReadOnlyList VisitList(IEnumerable nodes, ISet usedColumns) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, usedColumns)).ToList(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs new file mode 100644 index 0000000000..1884dc8dbf --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents assignment to a column in an . For example, in: +/// . +/// +internal sealed class ColumnAssignmentNode : SqlTreeNode +{ + public ColumnNode Column { get; } + public SqlValueNode Value { get; } + + public ColumnAssignmentNode(ColumnNode column, SqlValueNode value) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNull(value); + + Column = column; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnAssignment(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs new file mode 100644 index 0000000000..e4b79fe7eb --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInSelectNode : ColumnNode +{ + public ColumnSelectorNode Selector { get; } + + public bool IsVirtual => Selector.Alias != null || Selector.Column is ColumnInSelectNode { IsVirtual: true }; + + public ColumnInSelectNode(ColumnSelectorNode selector, string? tableAlias) + : base(GetColumnName(selector), selector.Column.Type, tableAlias) + { + Selector = selector; + } + + private static string GetColumnName(ColumnSelectorNode selector) + { + ArgumentGuard.NotNull(selector); + + return selector.Identity; + } + + public string GetPersistedColumnName() + { + return Selector.Column is ColumnInSelectNode columnInSelect ? columnInSelect.GetPersistedColumnName() : Selector.Column.Name; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs new file mode 100644 index 0000000000..8e8aab29ce --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInTableNode : ColumnNode +{ + public ColumnInTableNode(string name, ColumnType type, string? tableAlias) + : base(name, type, tableAlias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInTable(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs new file mode 100644 index 0000000000..e4fbcf14e6 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs @@ -0,0 +1,33 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for references to columns in s. +/// +internal abstract class ColumnNode : SqlValueNode +{ + public string Name { get; } + public ColumnType Type { get; } + public string? TableAlias { get; } + + protected ColumnNode(string name, ColumnType type, string? tableAlias) + { + ArgumentGuard.NotNullNorEmpty(name); + + Name = name; + Type = type; + TableAlias = tableAlias; + } + + public int GetTableAliasIndex() + { + if (TableAlias == null) + { + return -1; + } + + string? number = TableAlias[1..]; + return int.Parse(number); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs new file mode 100644 index 0000000000..ab2ab1031f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a column selector in a . For example, in: +/// . +/// +internal sealed class ColumnSelectorNode : SelectorNode +{ + public ColumnNode Column { get; } + + public string Identity => Alias ?? Column.Name; + + public ColumnSelectorNode(ColumnNode column, string? alias) + : base(alias) + { + ArgumentGuard.NotNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs new file mode 100644 index 0000000000..47b3082225 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs @@ -0,0 +1,17 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Lists the column types used in a . +/// +internal enum ColumnType +{ + /// + /// A scalar (non-relationship) column, for example: FirstName. + /// + Scalar, + + /// + /// A foreign key column, for example: OwnerId. + /// + ForeignKey +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs new file mode 100644 index 0000000000..dbf61d5451 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the comparison of two values. For example: = @p1 +/// ]]>. +/// +internal sealed class ComparisonNode : FilterNode +{ + public ComparisonOperator Operator { get; } + public SqlValueNode Left { get; } + public SqlValueNode Right { get; } + + public ComparisonNode(ComparisonOperator @operator, SqlValueNode left, SqlValueNode right) + { + ArgumentGuard.NotNull(left); + ArgumentGuard.NotNull(right); + + Operator = @operator; + Left = left; + Right = right; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs new file mode 100644 index 0000000000..07182d036f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a count on the number of rows returned from a sub-query. For example, in: +/// @p1 +/// ]]>. +/// +internal sealed class CountNode : SqlValueNode +{ + public SelectNode SubSelect { get; } + + public CountNode(SelectNode subSelect) + { + ArgumentGuard.NotNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs new file mode 100644 index 0000000000..07ad67f144 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a row count selector in a . For example, in: +/// . +/// +internal sealed class CountSelectorNode : SelectorNode +{ + public CountSelectorNode(string? alias) + : base(alias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCountSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs new file mode 100644 index 0000000000..aa3968f872 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a DELETE FROM clause. For example: . +/// +internal sealed class DeleteNode : SqlTreeNode +{ + public TableNode Table { get; } + public WhereNode Where { get; } + + public DeleteNode(TableNode table, WhereNode where) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNull(where); + + Table = table; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitDelete(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs new file mode 100644 index 0000000000..b73882122c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter on whether a sub-query contains rows. For example, in: +/// . +/// +internal sealed class ExistsNode : FilterNode +{ + public SelectNode SubSelect { get; } + + public ExistsNode(SelectNode subSelect) + { + ArgumentGuard.NotNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitExists(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs new file mode 100644 index 0000000000..1874fc16e4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs @@ -0,0 +1,8 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for filters that return a boolean value. +/// +internal abstract class FilterNode : SqlTreeNode +{ +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs new file mode 100644 index 0000000000..8ec4ab5c20 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs @@ -0,0 +1,19 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a FROM clause. For example: . +/// +internal sealed class FromNode : TableAccessorNode +{ + public FromNode(TableSourceNode source) + : base(source) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitFrom(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs new file mode 100644 index 0000000000..26d3c2ec47 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter that matches one value in a candidate set. For example: . +/// +internal sealed class InNode : FilterNode +{ + public ColumnNode Column { get; } + public IReadOnlyList Values { get; } + + public InNode(ColumnNode column, IReadOnlyList values) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNullNorEmpty(values); + + Column = column; + Values = values; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitIn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs new file mode 100644 index 0000000000..8ed6770136 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an INSERT INTO clause. For example: . +/// +internal sealed class InsertNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + + public InsertNode(TableNode table, IReadOnlyCollection assignments) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + + Table = table; + Assignments = assignments; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitInsert(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs new file mode 100644 index 0000000000..6ed2e4c73c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a JOIN clause. For example: . +/// +internal sealed class JoinNode : TableAccessorNode +{ + public JoinType JoinType { get; } + public ColumnNode OuterColumn { get; } + public ColumnNode InnerColumn { get; } + + public JoinNode(JoinType joinType, TableSourceNode source, ColumnNode outerColumn, ColumnNode innerColumn) + : base(source) + { + ArgumentGuard.NotNull(outerColumn); + ArgumentGuard.NotNull(innerColumn); + + JoinType = joinType; + OuterColumn = outerColumn; + InnerColumn = innerColumn; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitJoin(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs new file mode 100644 index 0000000000..3a3be7369d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs @@ -0,0 +1,7 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +internal enum JoinType +{ + LeftJoin, + InnerJoin +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs new file mode 100644 index 0000000000..034e5c012e --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a sub-string match filter. For example: . +/// +internal sealed class LikeNode : FilterNode +{ + public ColumnNode Column { get; } + public TextMatchKind MatchKind { get; } + public string Text { get; } + + public LikeNode(ColumnNode column, TextMatchKind matchKind, string text) + { + ArgumentGuard.NotNull(column); + ArgumentGuard.NotNull(text); + + Column = column; + MatchKind = matchKind; + Text = text; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLike(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs new file mode 100644 index 0000000000..40fc95b88c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a logical AND/OR filter. For example: . +/// +internal sealed class LogicalNode : FilterNode +{ + public LogicalOperator Operator { get; } + public IReadOnlyList Terms { get; } + + public LogicalNode(LogicalOperator @operator, params FilterNode[] terms) + : this(@operator, terms.ToList()) + { + } + + public LogicalNode(LogicalOperator @operator, IReadOnlyList terms) + { + ArgumentGuard.NotNull(terms); + + if (terms.Count < 2) + { + throw new ArgumentException("At least two terms are required.", nameof(terms)); + } + + Operator = @operator; + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs new file mode 100644 index 0000000000..38c5d80f26 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the logical negation of another filter. For example: . +/// +internal sealed class NotNode : FilterNode +{ + public FilterNode Child { get; } + + public NotNode(FilterNode child) + { + ArgumentGuard.NotNull(child); + + Child = child; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs new file mode 100644 index 0000000000..8d345d2563 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the value NULL. +/// +internal sealed class NullConstantNode : SqlValueNode +{ + public static readonly NullConstantNode Instance = new(); + + private NullConstantNode() + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs new file mode 100644 index 0000000000..c86aea6d63 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the ordinal selector for the first, unnamed column in a . For example, in: +/// . +/// +internal sealed class OneSelectorNode : SelectorNode +{ + public OneSelectorNode(string? alias) + : base(alias) + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOneSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs new file mode 100644 index 0000000000..372b1e86ff --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on a column in an . For example, in: +/// . +/// +internal sealed class OrderByColumnNode : OrderByTermNode +{ + public ColumnNode Column { get; } + + public OrderByColumnNode(ColumnNode column, bool isAscending) + : base(isAscending) + { + ArgumentGuard.NotNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByColumn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs new file mode 100644 index 0000000000..3d8f8c240a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on the number of rows returned from a sub-query in an . For example, +/// in: . +/// +internal sealed class OrderByCountNode : OrderByTermNode +{ + public CountNode Count { get; } + + public OrderByCountNode(CountNode count, bool isAscending) + : base(isAscending) + { + ArgumentGuard.NotNull(count); + + Count = count; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs new file mode 100644 index 0000000000..dc80ea4395 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an ORDER BY clause. For example: . +/// +internal sealed class OrderByNode : SqlTreeNode +{ + public IReadOnlyList Terms { get; } + + public OrderByNode(IReadOnlyList terms) + { + ArgumentGuard.NotNullNorEmpty(terms); + + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderBy(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs new file mode 100644 index 0000000000..2c3fc80b3e --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for terms in an . +/// +internal abstract class OrderByTermNode : SqlTreeNode +{ + public bool IsAscending { get; } + + protected OrderByTermNode(bool isAscending) + { + IsAscending = isAscending; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs new file mode 100644 index 0000000000..c2a5824f72 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs @@ -0,0 +1,39 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the name and value of a parameter. For example: . +/// +internal sealed class ParameterNode : SqlValueNode +{ + private static readonly ParameterFormatter Formatter = new(); + + public string Name { get; } + public object? Value { get; } + + public ParameterNode(string name, object? value) + { + ArgumentGuard.NotNull(name); + + if (!name.StartsWith('@') || name.Length < 2) + { + throw new ArgumentException("Parameter name must start with an '@' symbol and not be empty.", nameof(name)); + } + + Name = name; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitParameter(this, argument); + } + + public override string ToString() + { + return Formatter.Format(Name, Value); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs new file mode 100644 index 0000000000..1b7b96c3f9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs @@ -0,0 +1,70 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a SELECT clause, which is a shaped selection of rows from database tables. For example: +/// @p1 +/// ORDER BY t1.Age, t1.LastName +/// ]]>. +/// +internal sealed class SelectNode : TableSourceNode +{ + private readonly List _columns = new(); + + public IReadOnlyDictionary> Selectors { get; } + public WhereNode? Where { get; } + public OrderByNode? OrderBy { get; } + + public override IReadOnlyList Columns => _columns; + + public SelectNode(IReadOnlyDictionary> selectors, WhereNode? where, OrderByNode? orderBy, string? alias) + : base(alias) + { + ArgumentGuard.NotNullNorEmpty(selectors); + + Selectors = selectors; + Where = where; + OrderBy = orderBy; + + ReadSelectorColumns(selectors); + } + + private void ReadSelectorColumns(IReadOnlyDictionary> selectors) + { + foreach (ColumnSelectorNode columnSelector in selectors.SelectMany(selector => selector.Value).OfType()) + { + var column = new ColumnInSelectNode(columnSelector, Alias); + _columns.Add(column); + } + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias == Alias) + { + return Columns.FirstOrDefault(column => column.GetPersistedColumnName() == persistedColumnName && (type == null || column.Type == type)); + } + + foreach (TableSourceNode tableSource in Selectors.Keys.Select(tableAccessor => tableAccessor.Source)) + { + ColumnNode? innerColumn = tableSource.FindColumn(persistedColumnName, type, innerTableAlias); + + if (innerColumn != null) + { + ColumnInSelectNode outerColumn = Columns.Single(column => column.Selector.Column == innerColumn); + return outerColumn; + } + } + + return null; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs new file mode 100644 index 0000000000..8a47a8af66 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for selectors in a . +/// +internal abstract class SelectorNode : SqlTreeNode +{ + public string? Alias { get; } + + protected SelectorNode(string? alias) + { + Alias = alias; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs new file mode 100644 index 0000000000..3b2053a963 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs @@ -0,0 +1,18 @@ +using DapperExample.TranslationToSql.Builders; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for all nodes in a SQL query. +/// +internal abstract class SqlTreeNode +{ + public abstract TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument); + + public override string ToString() + { + // This is only used for debugging purposes. + var queryBuilder = new SqlQueryBuilder(DatabaseProvider.PostgreSql); + return queryBuilder.GetCommand(this); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs new file mode 100644 index 0000000000..bacbd5672f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs @@ -0,0 +1,8 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for values, such as parameters, column references and NULL. +/// +internal abstract class SqlValueNode : SqlTreeNode +{ +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs new file mode 100644 index 0000000000..4096789919 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for accessors to tabular data, such as FROM and JOIN. +/// +internal abstract class TableAccessorNode : SqlTreeNode +{ + public TableSourceNode Source { get; } + + protected TableAccessorNode(TableSourceNode source) + { + ArgumentGuard.NotNull(source); + + Source = source; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs new file mode 100644 index 0000000000..03d8353eb5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs @@ -0,0 +1,63 @@ +using Humanizer; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a database table. For example, in: +/// . +/// +internal sealed class TableNode : TableSourceNode +{ + private readonly ResourceType _resourceType; + private readonly IReadOnlyDictionary _columnMappings; + private readonly List _columns = new(); + + public string Name => _resourceType.ClrType.Name.Pluralize(); + + public override IReadOnlyList Columns => _columns; + + public TableNode(ResourceType resourceType, IReadOnlyDictionary columnMappings, string? alias) + : base(alias) + { + ArgumentGuard.NotNull(resourceType); + ArgumentGuard.NotNull(columnMappings); + + _resourceType = resourceType; + _columnMappings = columnMappings; + + ReadColumnMappings(); + } + + private void ReadColumnMappings() + { + foreach ((string columnName, ResourceFieldAttribute? field) in _columnMappings) + { + ColumnType columnType = field is RelationshipAttribute ? ColumnType.ForeignKey : ColumnType.Scalar; + var column = new ColumnInTableNode(columnName, columnType, Alias); + + _columns.Add(column); + } + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitTable(this, argument); + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias != Alias) + { + return null; + } + + return Columns.FirstOrDefault(column => column.Name == persistedColumnName && (type == null || column.Type == type)); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs new file mode 100644 index 0000000000..6628ed11dc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for tabular data sources, such as database tables and sub-queries. +/// +internal abstract class TableSourceNode : SqlTreeNode +{ + public const string IdColumnName = nameof(Identifiable.Id); + + public abstract IReadOnlyList Columns { get; } + public string? Alias { get; } + + protected TableSourceNode(string? alias) + { + Alias = alias; + } + + public ColumnNode GetIdColumn(string? innerTableAlias) + { + return GetColumn(IdColumnName, ColumnType.Scalar, innerTableAlias); + } + + public ColumnNode GetColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + ColumnNode? column = FindColumn(persistedColumnName, type, innerTableAlias); + + if (column == null) + { + throw new ArgumentException($"Column '{persistedColumnName}' not found.", nameof(persistedColumnName)); + } + + return column; + } + + public abstract ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias); +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs new file mode 100644 index 0000000000..3aa5dbdf73 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an UPDATE clause. For example: . +/// +internal sealed class UpdateNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + public WhereNode Where { get; } + + public UpdateNode(TableNode table, IReadOnlyCollection assignments, WhereNode where) + { + ArgumentGuard.NotNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + ArgumentGuard.NotNull(where); + + Table = table; + Assignments = assignments; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitUpdate(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs new file mode 100644 index 0000000000..d8d72601c5 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a WHERE clause. For example: @p1 +/// ]]>. +/// +internal sealed class WhereNode : SqlTreeNode +{ + public FilterNode Filter { get; } + + public WhereNode(FilterNode filter) + { + ArgumentGuard.NotNull(filter); + + Filter = filter; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitWhere(this, argument); + } +} diff --git a/src/Examples/DapperExample/appsettings.json b/src/Examples/DapperExample/appsettings.json new file mode 100644 index 0000000000..b4ddb2dac9 --- /dev/null +++ b/src/Examples/DapperExample/appsettings.json @@ -0,0 +1,24 @@ +{ + "DatabaseProvider": "PostgreSql", + "ConnectionStrings": { + // docker run --rm --detach --name dapper-example-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest + // docker run --rm --detach --name dapper-example-postgresql-management --link dapper-example-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest + "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true", + // docker run --rm --detach --name dapper-example-mysql-db -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=DapperExample -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mysql -p 3306:3306 mysql:latest --default-authentication-plugin=mysql_native_password + // docker run --rm --detach --name dapper-example-mysql-management --link dapper-example-mysql-db:db -p 8081:80 phpmyadmin/phpmyadmin + "DapperExampleMySql": "Host=localhost;Database=DapperExample;User ID=mysql;Password=mysql", + // docker run --rm --detach --name dapper-example-sqlserver -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Passw0rd!" -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest + "DapperExampleSqlServer": "Server=localhost;Database=DapperExample;User ID=sa;Password=Passw0rd!;TrustServerCertificate=true" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "DapperExample": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 8d6533d929..db3a392193 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -28,18 +28,18 @@ static WebApplication CreateWebApplication(string[] args) // Add services to the container. ConfigureServices(builder); - WebApplication webApplication = builder.Build(); + WebApplication app = builder.Build(); // Configure the HTTP request pipeline. - ConfigurePipeline(webApplication); + ConfigurePipeline(app); if (CodeTimingSessionManager.IsEnabled) { string timingResults = CodeTimingSessionManager.Current.GetResults(); - webApplication.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + app.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); } - return webApplication; + return app; } static void ConfigureServices(WebApplicationBuilder builder) @@ -89,21 +89,21 @@ static void SetDbContextDebugOptions(DbContextOptionsBuilder options) options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } -static void ConfigurePipeline(WebApplication webApplication) +static void ConfigurePipeline(WebApplication app) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); - webApplication.UseRouting(); + app.UseRouting(); using (CodeTimingSessionManager.Current.Measure("UseJsonApi()")) { - webApplication.UseJsonApi(); + app.UseJsonApi(); } - webApplication.UseSwagger(); - webApplication.UseSwaggerUI(); + app.UseSwagger(); + app.UseSwaggerUI(); - webApplication.MapControllers(); + app.MapControllers(); } static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) diff --git a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs index e8f009badb..21531c528d 100644 --- a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("DapperExample")] [assembly: InternalsVisibleTo("Benchmarks")] [assembly: InternalsVisibleTo("JsonApiDotNetCore")] [assembly: InternalsVisibleTo("JsonApiDotNetCore.OpenApi")] diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index d310028ae6..43161f99a8 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -93,6 +93,28 @@ private void AssertIsIdentifiableCollection(object newValue) } } + /// + /// Adds a resource to this to-many relationship on the specified resource instance. Throws if the property is read-only or if the field does not belong + /// to the specified resource instance. + /// + public void AddValue(object resource, IIdentifiable resourceToAdd) + { + ArgumentGuard.NotNull(resource); + ArgumentGuard.NotNull(resourceToAdd); + + object? rightValue = GetValue(resource); + List rightResources = CollectionConverter.ExtractResources(rightValue).ToList(); + + if (!rightResources.Exists(nextResource => nextResource == resourceToAdd)) + { + rightResources.Add(resourceToAdd); + + Type collectionType = rightValue?.GetType() ?? Property.PropertyType; + IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(rightResources, collectionType); + base.SetValue(resource, typedCollection); + } + } + /// public override bool Equals(object? obj) { diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index 0bbf249fdb..25eb18425c 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -2,5 +2,6 @@ [assembly: InternalsVisibleTo("JsonApiDotNetCore.OpenApi")] [assembly: InternalsVisibleTo("Benchmarks")] +[assembly: InternalsVisibleTo("DapperExample")] [assembly: InternalsVisibleTo("JsonApiDotNetCoreTests")] [assembly: InternalsVisibleTo("UnitTests")] diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index 04b32b6499..63415cfffc 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class FieldSelectors : Dictionary { - public bool IsEmpty => !this.Any(); + public bool IsEmpty => Count == 0; public bool ContainsReadOnlyAttribute { @@ -24,7 +24,7 @@ public bool ContainsOnlyRelationships { get { - return this.All(selector => selector.Key is RelationshipAttribute); + return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); } } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index d7f80b8aa2..d5fac60c71 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -324,6 +324,11 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, IncludeExpression? innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; + if (relationship is HasOneAttribute) + { + secondaryLayer.Sort = null; + } + var primarySelection = new FieldSelection(); FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs similarity index 79% rename from src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs rename to src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs index c1db07b0fb..9c4351f0f7 100644 --- a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs @@ -1,18 +1,19 @@ -using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace NoEntityFrameworkExample; +namespace JsonApiDotNetCore.Queries.QueryableBuilding; /// -/// Replaces all s with s. +/// Replaces all s with s in-place. /// -internal sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +public sealed class QueryLayerIncludeConverter : QueryExpressionVisitor { private readonly QueryLayer _queryLayer; public QueryLayerIncludeConverter(QueryLayer queryLayer) { + ArgumentGuard.NotNull(queryLayer); + _queryLayer = queryLayer; } @@ -29,7 +30,7 @@ public void ConvertIncludesToSelections() public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) { - foreach (IncludeElementExpression element in expression.Elements) + foreach (IncludeElementExpression element in expression.Elements.OrderBy(element => element.Relationship.PublicName)) { _ = Visit(element, queryLayer); } @@ -41,7 +42,7 @@ public void ConvertIncludesToSelections() { QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); - foreach (IncludeElementExpression nextIncludeElement in expression.Children) + foreach (IncludeElementExpression nextIncludeElement in expression.Children.OrderBy(child => child.Relationship.PublicName)) { Visit(nextIncludeElement, subLayer); } @@ -69,13 +70,9 @@ private static void EnsureNonEmptySelection(QueryLayer queryLayer) { if (queryLayer.Selection == null) { + // Empty selection indicates to fetch all scalar properties. queryLayer.Selection = new FieldSelection(); - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); - - foreach (AttrAttribute attribute in queryLayer.ResourceType.Attributes) - { - selectors.IncludeAttribute(attribute); - } + queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); } } } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index d693469bd3..a1dd644f88 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -58,7 +58,7 @@ public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext c expression = ApplyPagination(expression, layer.Pagination, layer.ResourceType, context); } - if (layer.Selection is { IsEmpty: false }) + if (layer.Selection != null) { expression = ApplySelection(expression, layer.Selection, layer.ResourceType, context); } diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index ce491304ef..3f23013d7c 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -119,10 +119,11 @@ private static ICollection ToPropertySelectors(FieldSelectors { var propertySelectors = new Dictionary(); - if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) + if (fieldSelectors.IsEmpty || fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) { // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. // And only selecting relationships implicitly means to fetch all scalar properties as well. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. IncludeAllScalarProperties(elementType, propertySelectors, entityModel); } diff --git a/test/DapperTests/DapperTests.csproj b/test/DapperTests/DapperTests.csproj new file mode 100644 index 0000000000..c7ce96a37a --- /dev/null +++ b/test/DapperTests/DapperTests.csproj @@ -0,0 +1,17 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + + + diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs new file mode 100644 index 0000000000..194d55d837 --- /dev/null +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -0,0 +1,522 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.AtomicOperations; + +public sealed class AtomicOperationsTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AtomicOperationsTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_use_multiple_operations() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newOwner = _fakers.Person.Generate(); + Person newAssignee = _fakers.Person.Generate(); + Tag newTag = _fakers.Tag.Generate(); + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + const string ownerLocalId = "new-owner"; + const string assigneeLocalId = "new-assignee"; + const string tagLocalId = "new-tag"; + const string todoItemLocalId = "new-todoItem"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = ownerLocalId, + attributes = new + { + firstName = newOwner.FirstName, + lastName = newOwner.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "people", + lid = assigneeLocalId, + attributes = new + { + firstName = newAssignee.FirstName, + lastName = newAssignee.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "tags", + lid = tagLocalId, + attributes = new + { + name = newTag.Name + } + } + }, + new + { + op = "add", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + lid = ownerLocalId + } + } + } + } + }, + new + { + op = "update", + @ref = new + { + type = "todoItems", + lid = todoItemLocalId, + relationship = "assignee" + }, + data = new + { + type = "people", + lid = assigneeLocalId + } + }, + new + { + op = "update", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "tags", + lid = tagLocalId + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "people", + lid = assigneeLocalId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.ShouldHaveCount(7); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("people")); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("people")); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("tags")); + responseDocument.Results[3].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Results[4].Data.Value.Should().BeNull(); + responseDocument.Results[5].Data.SingleValue.ShouldNotBeNull().With(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Results[6].Data.Value.Should().BeNull(); + + long newOwnerId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newAssigneeId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newTagId = long.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + long newTodoItemId = long.Parse(responseDocument.Results[3].Data.SingleValue!.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(newOwnerId); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(newTagId); + }); + + store.SqlCommands.ShouldHaveCount(15); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newOwner.FirstName); + command.Parameters.Should().Contain("@p2", newOwner.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newOwnerId); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newAssignee.FirstName); + command.Parameters.Should().Contain("@p2", newAssignee.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""Tags"" (""Name"", ""TodoItemId"") +VALUES (@p1, @p2) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTag.Name); + command.Parameters.Should().Contain("@p2", null); + }); + + store.SqlCommands[5].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTagId); + }); + + store.SqlCommands[6].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", newOwnerId); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[7].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[8].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[9].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newAssigneeId); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[10].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[11].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""LastModifiedAt"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[12].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", newTagId); + }); + + store.SqlCommands[13].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[14].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""People"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + } + + [Fact] + public async Task Can_rollback_on_error() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.Generate(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + const string personLocalId = "new-person"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = personLocalId, + attributes = new + { + lastName = newPerson.LastName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "people", + lid = personLocalId, + relationship = "assignedTodoItems" + }, + data = new[] + { + new + { + type = "todoItems", + id = unknownTodoItemId.ToString() + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'todoItems' with ID '{unknownTodoItemId}' in relationship 'assignedTodoItems' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List peopleInDatabase = await dbContext.People.ToListAsync(); + peopleInDatabase.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.ShouldContainKey("@p1").With(value => value.ShouldNotBeNull()); + command.Parameters.Should().Contain("@p2", unknownTodoItemId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs new file mode 100644 index 0000000000..084444e896 --- /dev/null +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using DapperExample; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using FluentAssertions.Common; +using FluentAssertions.Extensions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests; + +[PublicAPI] +public sealed class DapperTestContext : IntegrationTest +{ + private const string SqlServerClearAllTablesScript = @" + EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; + EXEC sp_MSForEachTable 'SET QUOTED_IDENTIFIER ON; DELETE FROM ?'; + EXEC sp_MSForEachTable 'ALTER TABLE ? CHECK CONSTRAINT ALL';"; + + public static readonly DateTimeOffset FrozenTime = 29.September(2018).At(16, 41, 56).AsUtc().ToDateTimeOffset(); + + private readonly Lazy> _lazyFactory; + private ITestOutputHelper? _testOutputHelper; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = Factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public WebApplicationFactory Factory => _lazyFactory.Value; + + public DapperTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + } + + private WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"); + + builder.UseSetting("ConnectionStrings:DapperExampleMySql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None"); + + builder.UseSetting("ConnectionStrings:DapperExampleSqlServer", + $"Server=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=sa;Password=Passw0rd!;TrustServerCertificate=true"); + + builder.UseSetting("Logging:LogLevel:DapperExample", "Debug"); + + builder.ConfigureLogging(loggingBuilder => + { + if (_testOutputHelper != null) + { + loggingBuilder.Services.AddSingleton(_ => new XUnitLoggerProvider(_testOutputHelper, "DapperExample.")); + } + }); + + builder.ConfigureServices(services => + { + services.AddSingleton(new FrozenSystemClock + { + UtcNow = FrozenTime + }); + + ServiceDescriptor scopedCaptureStore = services.Single(descriptor => descriptor.ImplementationType == typeof(SqlCaptureStore)); + services.Remove(scopedCaptureStore); + + services.AddSingleton(); + }); + }); + } + + public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public async Task ClearAllTablesAsync(DbContext dbContext) + { + var dataModelService = Factory.Services.GetRequiredService(); + DatabaseProvider databaseProvider = dataModelService.DatabaseProvider; + + if (databaseProvider == DatabaseProvider.SqlServer) + { + await dbContext.Database.ExecuteSqlRawAsync(SqlServerClearAllTablesScript); + } + else + { + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + string? tableName = entityType.GetTableName(); + + string escapedTableName = databaseProvider switch + { + DatabaseProvider.PostgreSql => $"\"{tableName}\"", + DatabaseProvider.MySql => $"`{tableName}`", + _ => throw new NotSupportedException($"Unsupported database provider '{databaseProvider}'.") + }; + + await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM {escapedTableName}"); + } + } + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + + public string AdaptSql(string text, bool hasClientGeneratedId = false) + { + var dataModelService = Factory.Services.GetRequiredService(); + var adapter = new SqlTextAdapter(dataModelService.DatabaseProvider); + return adapter.Adapt(text, hasClientGeneratedId); + } + + protected override HttpClient CreateClient() + { + return Factory.CreateClient(); + } + + public override async Task DisposeAsync() + { + try + { + if (_lazyFactory.IsValueCreated) + { + try + { + await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()); + } + finally + { + await _lazyFactory.Value.DisposeAsync(); + } + } + } + finally + { + await base.DisposeAsync(); + } + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs new file mode 100644 index 0000000000..d23a90765a --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -0,0 +1,1244 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class FilterTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FilterTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_filter_equals_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=equals(color.id,'00FF00')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" = @p1 +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + } + + [Fact] + public async Task Can_filter_any_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=any(color.id,'00FF00','11EE11')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +LEFT JOIN ""RgbColors"" AS t2 ON t1.""Id"" = t2.""TagId"" +WHERE t2.""Id"" IN (@p1, @p2) +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_relationship_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(assignee,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +LEFT JOIN ""People"" AS t3 ON t1.""AssigneeId"" = t3.""Id"" +WHERE (t2.""Id"" = @p1) AND (t3.""Id"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE t3.""Id"" IS NULL +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).DurationInHours = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(durationInHours,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""DurationInHours"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""DurationInHours"" IS NULL +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_on_enum_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Priority = TodoItemPriority.Low); + + todoItems[1].Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(priority,'Medium')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE t1.""Priority"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Priority"" = @p1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + } + + [Fact] + public async Task Can_filter_equals_on_string_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + person.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + person.AssignedTodoItems.ElementAt(1).Description = "Take exam"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/assignedTodoItems?filter=equals(description,'Take exam')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.AssignedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""Description"" = @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""AssigneeId"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Description"" = @p2 +) AS t3 ON t1.""Id"" = t3.""AssigneeId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + } + + [Fact] + public async Task Can_filter_equality_on_attributes_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Assignee = _fakers.Person.Generate()); + + todoItems[1].Assignee!.FirstName = todoItems[1].Assignee!.LastName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(assignee.lastName,assignee.firstName)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t2.""LastName"" = t2.""FirstName""")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t2.""LastName"" = t2.""FirstName"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_any_with_single_constant_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Priority = TodoItemPriority.Low; + person.OwnedTodoItems.ElementAt(1).Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=any(priority,'Medium')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t2.""Id"" = @p1) AND (t1.""Priority"" = @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Priority"" = @p2 +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + } + + [Fact] + public async Task Can_filter_not_not_not_not_equals_on_string_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Description = "X"; + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(not(not(not(equals(description,'X')))))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE t1.""Description"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Description"" = @p1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_nullable_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.Generate(3); + people[0].FirstName = "X"; + people[1].FirstName = null; + people[2].FirstName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=not(equals(firstName,'X'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1 +WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL)")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE (NOT (t1.""FirstName"" = @p1)) OR (t1.""FirstName"" IS NULL) +ORDER BY t1.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_attributes_of_optional_relationship_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Assignee = _fakers.Person.Generate(); + todoItems[1].Assignee!.FirstName = "X"; + todoItems[1].Assignee!.LastName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(and(equals(assignee.firstName,'X'),equals(assignee.lastName,'Y')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE (NOT ((t2.""FirstName"" = @p1) AND (t2.""LastName"" = @p2))) OR (t2.""FirstName"" IS NULL) OR (t2.""LastName"" IS NULL) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } + + [Fact] + public async Task Can_filter_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "One"; + todoItems[1].Description = "Two"; + todoItems[1].Owner.FirstName = "Jack"; + todoItems[2].Description = "Three"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=and(startsWith(description,'T'),not(any(description,'Three','Four')),equals(owner.firstName,'Jack'),contains(description,'o'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%')")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE (t1.""Description"" LIKE 'T%') AND (NOT (t1.""Description"" IN (@p1, @p2))) AND (t2.""FirstName"" = @p3) AND (t1.""Description"" LIKE '%o%') +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + } + + [Fact] + public async Task Can_filter_special_characters_in_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.Generate(6); + tags[0].Name = "A%Z"; + tags[1].Name = "A_Z"; + tags[2].Name = @"A\Z"; + tags[3].Name = "A'Z"; + tags[4].Name = @"A%_\'Z"; + tags[5].Name = "AZ"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = @"/tags?filter=or(contains(name,'A%'),contains(name,'A_'),contains(name,'A\'),contains(name,'A'''),contains(name,'%_\'''))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(5); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[4].StringId); + + responseDocument.Meta.Should().ContainTotal(5); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\')")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE (t1.""Name"" LIKE '%A\%%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\_%' ESCAPE '\') OR (t1.""Name"" LIKE '%A\\%' ESCAPE '\') OR (t1.""Name"" LIKE '%A''%') OR (t1.""Name"" LIKE '%\%\_\\''%' ESCAPE '\') +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_numeric_range_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].DurationInHours = 100; + todoItems[1].DurationInHours = 200; + todoItems[2].DurationInHours = 300; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=or(greaterThan(durationInHours,'250'),lessOrEqual(durationInHours,'100'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE (t1.""DurationInHours"" > @p1) OR (t1.""DurationInHours"" <= @p2) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + } + + [Fact] + public async Task Can_filter_count_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(greaterThan(count(owner.assignedTodoItems),'1'),not(equals(owner,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" +WHERE (( + SELECT COUNT(*) + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + WHERE t1.""OwnerId"" = t2.""Id"" +) > @p1) AND (NOT (t4.""Id"" IS NULL))")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t4 ON t1.""OwnerId"" = t4.""Id"" +WHERE (( + SELECT COUNT(*) + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + WHERE t1.""OwnerId"" = t2.""Id"" +) > @p1) AND (NOT (t4.""Id"" IS NULL)) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + } + + [Fact] + public async Task Can_filter_nested_conditional_has_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => + { + todoItem.Description = "Homework"; + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Owner.LastName = "Smith"; + todoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + }); + + todoItems[1].Owner.AssignedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "Personal"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=has(owner.assignedTodoItems,and(has(tags,equals(name,'Personal')),equals(owner.lastName,'Smith'),equals(description,'Homework')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" + WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( + SELECT 1 + FROM ""Tags"" AS t4 + WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) + )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) +)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""People"" AS t2 + LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" + INNER JOIN ""People"" AS t5 ON t3.""OwnerId"" = t5.""Id"" + WHERE (t1.""OwnerId"" = t2.""Id"") AND (EXISTS ( + SELECT 1 + FROM ""Tags"" AS t4 + WHERE (t3.""Id"" = t4.""TodoItemId"") AND (t4.""Name"" = @p1) + )) AND (t5.""LastName"" = @p2) AND (t3.""Description"" = @p3) +) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + } + + [Fact] + public async Task Can_filter_conditional_has_with_null_check_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.Generate(3); + people.ForEach(person => person.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet()); + + people[0].OwnedTodoItems.ElementAt(0).Assignee = null; + + people[1].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + + people[2].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.Generate(); + people[2].OwnedTodoItems.ElementAt(0).Assignee!.FirstName = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=has(ownedTodoItems,and(not(equals(assignee,null)),equals(assignee.firstName,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) +)")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE EXISTS ( + SELECT 1 + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t3 ON t2.""AssigneeId"" = t3.""Id"" + WHERE (t1.""Id"" = t2.""OwnerId"") AND (NOT (t3.""Id"" IS NULL)) AND (t3.""FirstName"" IS NULL) +) +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_using_logical_operators_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(5); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "0"; + todoItems[0].Priority = TodoItemPriority.High; + todoItems[0].DurationInHours = 1; + + todoItems[1].Description = "1"; + todoItems[1].Priority = TodoItemPriority.Low; + todoItems[1].DurationInHours = 0; + + todoItems[2].Description = "1"; + todoItems[2].Priority = TodoItemPriority.Low; + todoItems[2].DurationInHours = 1; + + todoItems[3].Description = "1"; + todoItems[3].Priority = TodoItemPriority.High; + todoItems[3].DurationInHours = 0; + + todoItems[4].Description = "1"; + todoItems[4].Priority = TodoItemPriority.High; + todoItems[4].DurationInHours = 1; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(equals(description,'1'),or(equals(priority,'High'),equals(durationInHours,'1')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[4].StringId); + + responseDocument.Meta.Should().ContainTotal(3); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3))")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE (t1.""Description"" = @p1) AND ((t1.""Priority"" = @p2) OR (t1.""DurationInHours"" = @p3)) +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + } + + [Fact] + public async Task Cannot_filter_on_unmapped_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=equals(displayName,'John Doe')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Sorting or filtering on the requested attribute is unavailable."); + error.Detail.Should().Be("Sorting or filtering on attribute 'displayName' is unavailable because it is unmapped."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs new file mode 100644 index 0000000000..153e028b01 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class IncludeTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public IncludeTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources_with_multiple_include_chains() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person owner = _fakers.Person.Generate(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = owner); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet()); + todoItems[1].Assignee = _fakers.Person.Generate(); + + todoItems[0].Priority = TodoItemPriority.High; + todoItems[1].Priority = TodoItemPriority.Low; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Data.ManyValue[0].Relationships.With(relationships => + { + relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[0].Owner.StringId); + }); + + relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.Should().BeNull(); + }); + + relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Data.ManyValue[1].Relationships.With(relationships => + { + relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Owner.StringId); + }); + + relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Assignee!.StringId); + }); + + relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Included.ShouldHaveCount(6); + + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(owner.StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(owner.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(owner.LastName)); + + responseDocument.Included[1].Type.Should().Be("tags"); + responseDocument.Included[1].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[0].Tags.ElementAt(0).Name)); + + responseDocument.Included[2].Type.Should().Be("tags"); + responseDocument.Included[2].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + responseDocument.Included[2].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[0].Tags.ElementAt(1).Name)); + + responseDocument.Included[3].Type.Should().Be("people"); + responseDocument.Included[3].Id.Should().Be(todoItems[1].Assignee!.StringId); + responseDocument.Included[3].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(todoItems[1].Assignee!.FirstName)); + responseDocument.Included[3].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItems[1].Assignee!.LastName)); + + responseDocument.Included[4].Type.Should().Be("tags"); + responseDocument.Included[4].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + responseDocument.Included[4].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[1].Tags.ElementAt(0).Name)); + + responseDocument.Included[5].Type.Should().Be("tags"); + responseDocument.Included[5].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + responseDocument.Included[5].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItems[1].Tags.ElementAt(1).Name)); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"", t5.""Id"", t5.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +LEFT JOIN ""TodoItems"" AS t4 ON t3.""Id"" = t4.""AssigneeId"" +LEFT JOIN ""Tags"" AS t5 ON t1.""Id"" = t5.""TodoItemId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t4.""Priority"", t4.""LastModifiedAt"" DESC, t5.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resources_with_includes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(25); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.Generate(15).ToHashSet()); + todoItems.ForEach(todoItem => todoItem.Tags.ForEach(tag => tag.Color = _fakers.RgbColor.Generate())); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=tags.color"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(25); + + responseDocument.Data.ManyValue.ForEach(resource => + { + resource.Type.Should().Be("todoItems"); + resource.Attributes.ShouldOnlyContainKeys("description", "priority", "durationInHours", "createdAt", "modifiedAt"); + resource.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + }); + + responseDocument.Included.ShouldHaveCount(25 * 15 * 2); + + responseDocument.Meta.Should().ContainTotal(25); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""Name"", t3.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +LEFT JOIN ""RgbColors"" AS t3 ON t2.""Id"" = t3.""TagId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, t2.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs new file mode 100644 index 0000000000..137ba693f0 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using DapperExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class PaginationTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public PaginationTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Cannot_use_pagination() + { + // Arrange + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?page[size]=3"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing this request."); + error.Detail.Should().Be("Pagination is not supported."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs new file mode 100644 index 0000000000..bdde400b84 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs @@ -0,0 +1,410 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SortTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SortTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_sort_on_attributes_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Description = "B"; + todoItems[1].Description = "A"; + todoItems[2].Description = "C"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-description,durationInHours,id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY t1.""Description"" DESC, t1.""DurationInHours"", t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_attributes_in_secondary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).DurationInHours = 40; + person.OwnedTodoItems.ElementAt(1).DurationInHours = 100; + person.OwnedTodoItems.ElementAt(2).DurationInHours = 250; + + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(2).ToHashSet(); + + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "B"; + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).Name = "A"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-durationInHours&sort[tags]=name"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t3.""Id"", t3.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t3 ON t2.""Id"" = t3.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""DurationInHours"" DESC, t3.""Name""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Tags = _fakers.Tag.Generate(2).ToHashSet(); + todoItems[1].Tags = _fakers.Tag.Generate(1).ToHashSet(); + todoItems[2].Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t2 + WHERE t1.""Id"" = t2.""TodoItemId"" +) DESC, t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources_with_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id"", t4.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + person.OwnedTodoItems = _fakers.TodoItem.Generate(4).ToHashSet(); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.Generate(2).ToHashSet(); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.Generate(1).ToHashSet(); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.StringId); + + responseDocument.Included.ShouldHaveCount(4); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Included[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Included[3].Id.Should().Be(person.OwnedTodoItems.ElementAt(3).StringId); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +) DESC, t2.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs new file mode 100644 index 0000000000..719b3b2d36 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs @@ -0,0 +1,393 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SparseFieldSets : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SparseFieldSets(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_select_fields_in_primary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner,assignee&fields[todoItems]=description,durationInHours,owner,assignee&fields[people]=lastName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItem.DurationInHours)); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + }); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Assignee.StringId); + }); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + + responseDocument.Included[0].Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Owner.LastName)); + responseDocument.Included[0].Relationships.Should().BeNull(); + + responseDocument.Included[1].Id.Should().Be(todoItem.Assignee.StringId); + responseDocument.Included[1].Attributes.ShouldHaveCount(1); + responseDocument.Included[1].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Assignee.LastName)); + responseDocument.Included[1].Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""Description"", t1.""DurationInHours"", t2.""Id"", t2.""LastName"", t3.""Id"", t3.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_select_attribute_in_primary_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_relationship_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags?fields[tags]=color"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("color").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_select_empty_fieldset() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]="; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Fetches_all_scalar_properties_when_fieldset_contains_readonly_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(person.DisplayName)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Returns_related_resources_on_broken_resource_linkage() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?include=tags&fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Description"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..80206b0750 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class AddToToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AddToToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + + List existingTodoItems = _fakers.TodoItem.Generate(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(3); + }); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs new file mode 100644 index 0000000000..22ebd01a4e --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class FetchRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Value.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_ID() + { + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.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 '{unknownTodoItemId}' does not exist."); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..c118d8ff59 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class RemoveFromToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public RemoveFromToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_nullable_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.AssignedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.Where(todoItem => todoItem.Assignee == null).ToListAsync(); + + todoItemInDatabases.Should().HaveCount(2); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""AssigneeId"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Id"" IN (@p2, @p3) +) AS t3 ON t1.""Id"" = t3.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_required_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(3).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.OwnedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.ToListAsync(); + + todoItemInDatabases.Should().HaveCount(1); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t3.""Id"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""OwnerId"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Id"" IN (@p2, @p3) +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..e1bbcf1190 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,402 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class ReplaceToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public ReplaceToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(2).ToHashSet(); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" IN (@p1, @p2)")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_create_OneToMany_relationship() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + + List existingTodoItems = _fakers.TodoItem.Generate(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(2); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItems.ElementAt(0).Id); + personInDatabase.AssignedTodoItems.ElementAt(1).Id.Should().Be(existingTodoItems.ElementAt(1).Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.ShouldHaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""AssigneeId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.Generate(1).ToHashSet(); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.ShouldHaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..5ed90caa2a --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,1140 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class UpdateToOneRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateToOneRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + existingPerson.Account = _fakers.LoginAccount.Generate(); + existingPerson.Account.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.Should().BeNull(); + + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingPerson.Account.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount.Person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingLoginAccount.Person.Id); + + personInDatabase.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Person.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side_when_already_null() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'recovery' on resource type 'loginAccounts' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" +FROM ""LoginAccounts"" AS t1 +INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.Generate(); + existingAccountRecovery.Account = _fakers.LoginAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.Add(existingAccountRecovery); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/accountRecoveries/{existingAccountRecovery.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'account' on resource type 'accountRecoveries' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""AccountRecoveries"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_clear_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'owner' on resource type 'todoItems' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.Generate(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingLoginAccount.StringId + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.ShouldNotBeNull(); + personInDatabase.Account.Id.Should().Be(existingLoginAccount.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""AccountId"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.Generate(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLoginAccount, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.ShouldNotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson1 = _fakers.Person.Generate(); + existingPerson1.Account = _fakers.LoginAccount.Generate(); + existingPerson1.Account.Recovery = _fakers.AccountRecovery.Generate(); + + Person existingPerson2 = _fakers.Person.Generate(); + existingPerson2.Account = _fakers.LoginAccount.Generate(); + existingPerson2.Account.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingPerson2.Account.StringId + } + }; + + string route = $"/people/{existingPerson1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase1 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson1.Id); + + personInDatabase1.Account.ShouldNotBeNull(); + personInDatabase1.Account.Id.Should().Be(existingPerson2.Account.Id); + + Person personInDatabase2 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson2.Id); + + personInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""AccountId"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson2.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson2.Account.Id); + command.Parameters.Should().Contain("@p2", existingPerson1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.Generate(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount1.Person = _fakers.Person.Generate(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.Generate(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.Generate(); + existingLoginAccount2.Person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingLoginAccount2.Person.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Person.ShouldNotBeNull(); + loginAccountInDatabase1.Person.Id.Should().Be(existingLoginAccount2.Person.Id); + + LoginAccount loginAccountInDatabase2 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Person.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""LoginAccounts"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""Id"" = t2.""AccountId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Person.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount2.Person.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.Generate(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.Generate(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.Generate(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "accountRecoveries", + id = existingLoginAccount2.Recovery.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Recovery.ShouldNotBeNull(); + loginAccountInDatabase1.Recovery.Id.Should().Be(existingLoginAccount2.Recovery.Id); + + LoginAccount? loginAccountInDatabase2 = await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery) + .FirstWithIdOrDefaultAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"", t2.""Id"", t2.""EmailAddress"", t2.""PhoneNumber"" +FROM ""LoginAccounts"" AS t1 +INNER JOIN ""AccountRecoveries"" AS t2 ON t1.""RecoveryId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""RecoveryId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" +SET ""RecoveryId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery1 = _fakers.AccountRecovery.Generate(); + existingAccountRecovery1.Account = _fakers.LoginAccount.Generate(); + + AccountRecovery existingAccountRecovery2 = _fakers.AccountRecovery.Generate(); + existingAccountRecovery2.Account = _fakers.LoginAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.AddRange(existingAccountRecovery1, existingAccountRecovery2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingAccountRecovery2.Account.StringId + } + }; + + string route = $"/accountRecoveries/{existingAccountRecovery1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AccountRecovery accountRecoveryInDatabase1 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery1.Id); + + accountRecoveryInDatabase1.Account.ShouldNotBeNull(); + accountRecoveryInDatabase1.Account.Id.Should().Be(existingAccountRecovery2.Account.Id); + + AccountRecovery accountRecoveryInDatabase2 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery2.Id); + + accountRecoveryInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""EmailAddress"", t1.""PhoneNumber"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""AccountRecoveries"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""Id"" = t2.""RecoveryId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""LoginAccounts"" +SET ""RecoveryId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + command.Parameters.Should().Contain("@p2", existingAccountRecovery2.Account.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.Generate(); + existingTodoItem1.Owner = _fakers.Person.Generate(); + existingTodoItem1.Assignee = _fakers.Person.Generate(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.Generate(); + existingTodoItem2.Owner = _fakers.Person.Generate(); + existingTodoItem2.Assignee = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Assignee.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Assignee.ShouldNotBeNull(); + todoItemInDatabase1.Assignee.Id.Should().Be(existingTodoItem2.Assignee.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""AssigneeId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Assignee.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.Generate(); + existingTodoItem1.Owner = _fakers.Person.Generate(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.Generate(); + existingTodoItem2.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Owner.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Owner).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Owner.ShouldNotBeNull(); + todoItemInDatabase1.Owner.Id.Should().Be(existingTodoItem2.Owner.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""OwnerId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Owner.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs new file mode 100644 index 0000000000..88ee185132 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs @@ -0,0 +1,732 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class CreateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public CreateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + Tag existingTag = _fakers.Tag.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newTodoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().BeNull()); + + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + httpResponse.Headers.Location.Should().Be($"/todoItems/{newTodoItemId}"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Can_create_resource_with_only_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Person existingPerson = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().BeNull(); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.Should().BeEmpty(); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"INSERT INTO ""TodoItems"" (""Description"", ""Priority"", ""DurationInHours"", ""CreatedAt"", ""LastModifiedAt"", ""OwnerId"", ""AssigneeId"") +VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", null); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Cannot_create_resource_without_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Owner field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/relationships/owner/data"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Priority field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/priority"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("Input validation failed."); + error3.Detail.Should().Be("The Description field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/data/attributes/description"); + + store.SqlCommands.Should().BeEmpty(); + } + + [Fact] + public async Task Can_create_resource_with_unmapped_property() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.Generate(); + Person existingPerson = _fakers.Person.Generate(); + + string newUserName = _fakers.LoginAccount.Generate().UserName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingAccountRecovery, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + attributes = new + { + userName = newUserName + }, + relationships = new + { + recovery = new + { + data = new + { + type = "accountRecoveries", + id = existingAccountRecovery.StringId + } + }, + person = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/loginAccounts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("loginAccounts"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("userName").With(value => value.Should().Be(newUserName)); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("lastUsedAt"); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("recovery", "person"); + + long newLoginAccountId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + LoginAccount loginAccountInDatabase = await dbContext.LoginAccounts + .Include(todoItem => todoItem.Recovery) + .Include(todoItem => todoItem.Person) + .FirstWithIdAsync(newLoginAccountId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + loginAccountInDatabase.UserName.Should().Be(newUserName); + loginAccountInDatabase.LastUsedAt.Should().BeNull(); + + loginAccountInDatabase.Recovery.ShouldNotBeNull(); + loginAccountInDatabase.Recovery.Id.Should().Be(existingAccountRecovery.Id); + loginAccountInDatabase.Person.ShouldNotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.ShouldHaveCount(4); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""LoginAccounts"" +WHERE ""RecoveryId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""LoginAccounts"" (""UserName"", ""LastUsedAt"", ""RecoveryId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newUserName); + command.Parameters.Should().Contain("@p2", null); + command.Parameters.Should().Contain("@p3", existingAccountRecovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""People"" +SET ""AccountId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""LastUsedAt"", t1.""UserName"" +FROM ""LoginAccounts"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + }); + } + + [Fact] + public async Task Can_create_resource_with_calculated_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.Generate(); + + var requestBody = new + { + data = new + { + type = "people", + attributes = new + { + firstName = newPerson.FirstName, + lastName = newPerson.LastName + } + } + }; + + const string route = "/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(newPerson.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(newPerson.LastName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newPerson.DisplayName)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + long newPersonId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.FirstWithIdAsync(newPersonId); + + personInDatabase.FirstName.Should().Be(newPerson.FirstName); + personInDatabase.LastName.Should().Be(newPerson.LastName); + personInDatabase.DisplayName.Should().Be(newPerson.DisplayName); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""People"" (""FirstName"", ""LastName"", ""AccountId"") +VALUES (@p1, @p2, @p3) +RETURNING ""Id""")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", newPerson.FirstName); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"" +FROM ""People"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newPersonId); + }); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.Generate(); + + RgbColor newColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors/"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + RgbColor colorInDatabase = await dbContext.RgbColors.Include(rgbColor => rgbColor.Tag).FirstWithIdAsync(newColor.Id); + + colorInDatabase.Red.Should().Be(newColor.Red); + colorInDatabase.Green.Should().Be(newColor.Green); + colorInDatabase.Blue.Should().Be(newColor.Blue); + + colorInDatabase.Tag.ShouldNotBeNull(); + colorInDatabase.Tag.Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" +WHERE ""TagId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") +VALUES (@p1, @p2) +RETURNING ""Id""", true)); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", newColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""RgbColors"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", newColor.Id); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + RgbColor existingColor = _fakers.RgbColor.Generate(); + existingColor.Tag = _fakers.Tag.Generate(); + + Tag existingTag = _fakers.Tag.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.AddInRange(existingColor, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Another resource with the specified ID already exists."); + error.Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""RgbColors"" +WHERE ""TagId"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"INSERT INTO ""RgbColors"" (""Id"", ""TagId"") +VALUES (@p1, @p2) +RETURNING ""Id""", true)); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""RgbColors"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingColor.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs new file mode 100644 index 0000000000..94f6d17b60 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs @@ -0,0 +1,123 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class DeleteResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public DeleteResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + existingTodoItem.Tags.ElementAt(0).Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem? todoItemInDatabase = await dbContext.TodoItems.FirstWithIdOrDefaultAsync(existingTodoItem.Id); + + todoItemInDatabase.Should().BeNull(); + + List tags = await dbContext.Tags.Where(tag => tag.TodoItem == null).ToListAsync(); + + tags.ShouldHaveCount(1); + }); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_delete_unknown_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(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 '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"DELETE FROM ""TodoItems"" +WHERE ""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs new file mode 100644 index 0000000000..327a0b3051 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -0,0 +1,337 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class FetchResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.Generate(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.Generate()); + + todoItems[0].Priority = TodoItemPriority.Low; + todoItems[1].Priority = TodoItemPriority.High; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItems[1].Description)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItems[1].Priority)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItems[1].DurationInHours)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItems[1].CreatedAt)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItems[1].LastModifiedAt)); + responseDocument.Data.ManyValue[0].Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItems[0].Description)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItems[0].Priority)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItems[0].DurationInHours)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItems[0].CreatedAt)); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItems[0].LastModifiedAt)); + responseDocument.Data.ManyValue[1].Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(todoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(todoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(todoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(todoItem.LastModifiedAt)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_unknown_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.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 '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } + + [Fact] + public async Task Can_get_secondary_ToMany_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + todoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItem.Tags.ElementAt(0).Name)); + responseDocument.Data.ManyValue[0].Relationships.ShouldOnlyContainKeys("todoItem", "color"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be(todoItem.Tags.ElementAt(1).Name)); + responseDocument.Data.ManyValue[1].Relationships.ShouldOnlyContainKeys("todoItem", "color"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""Tags"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""TodoItemId"" = t2.""Id"" +WHERE t2.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""Tags"" AS t2 ON t1.""Id"" = t2.""TodoItemId"" +WHERE t1.""Id"" = @p1 +ORDER BY t2.""Id""")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(todoItem.Owner.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(todoItem.Owner.LastName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(todoItem.Owner.DisplayName)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.ShouldHaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t2.""Id"", t2.""FirstName"", t2.""LastName"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..7ac11df3c9 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs @@ -0,0 +1,399 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class UpdateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.Generate(); + existingTag.Color = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tags", + id = existingTag.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + string route = $"/tags/{existingTag.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Tag tagInDatabase = await dbContext.Tags.Include(tag => tag.Color).FirstWithIdAsync(existingTag.Id); + + tagInDatabase.Name.Should().Be(existingTag.Name); + tagInDatabase.Color.ShouldNotBeNull(); + tagInDatabase.Color.Id.Should().Be(existingTag.Color.Id); + }); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT t1.""Id"", t1.""Name"" +FROM ""Tags"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + } + + [Fact] + public async Task Can_partially_update_resource_attributes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(1).ToHashSet(); + + string newDescription = _fakers.TodoItem.Generate().Description; + long newDurationInHours = _fakers.TodoItem.Generate().DurationInHours!.Value; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newDescription, + durationInHours = newDurationInHours + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newDescription)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newDurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(existingTodoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newDescription); + todoItemInDatabase.Priority.Should().Be(existingTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newDurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingTodoItem.Owner.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingTodoItem.Assignee.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTodoItem.Tags.ElementAt(0).Id); + }); + + store.SqlCommands.ShouldHaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""Description"" = @p1, ""DurationInHours"" = @p2, ""LastModifiedAt"" = @p3 +WHERE ""Id"" = @p4")); + + command.Parameters.ShouldHaveCount(4); + command.Parameters.Should().Contain("@p1", newDescription); + command.Parameters.Should().Contain("@p2", newDurationInHours); + command.Parameters.Should().Contain("@p3", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p4", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_completely_update_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.Generate(); + existingTodoItem.Owner = _fakers.Person.Generate(); + existingTodoItem.Assignee = _fakers.Person.Generate(); + existingTodoItem.Tags = _fakers.Tag.Generate(2).ToHashSet(); + + TodoItem newTodoItem = _fakers.TodoItem.Generate(); + + Tag existingTag = _fakers.Tag.Generate(); + Person existingPerson1 = _fakers.Person.Generate(); + Person existingPerson2 = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingTag, existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson1.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson2.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newTodoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newTodoItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newTodoItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(existingTodoItem.CreatedAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("modifiedAt").With(value => value.Should().Be(DapperTestContext.FrozenTime)); + responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.ShouldNotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson1.Id); + todoItemInDatabase.Assignee.ShouldNotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson2.Id); + todoItemInDatabase.Tags.ShouldHaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.ShouldHaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""FirstName"", t3.""LastName"", t4.""Id"", t4.""Name"" +FROM ""TodoItems"" AS t1 +LEFT JOIN ""People"" AS t2 ON t1.""AssigneeId"" = t2.""Id"" +INNER JOIN ""People"" AS t3 ON t1.""OwnerId"" = t3.""Id"" +LEFT JOIN ""Tags"" AS t4 ON t1.""Id"" = t4.""TodoItemId"" +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""TodoItems"" +SET ""Description"" = @p1, ""Priority"" = @p2, ""DurationInHours"" = @p3, ""LastModifiedAt"" = @p4, ""OwnerId"" = @p5, ""AssigneeId"" = @p6 +WHERE ""Id"" = @p7")); + + command.Parameters.ShouldHaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", existingPerson1.Id); + command.Parameters.Should().Contain("@p6", existingPerson2.Id); + command.Parameters.Should().Contain("@p7", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" IN (@p2, @p3)")); + + command.Parameters.ShouldHaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Tags.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItem.Tags.ElementAt(1).Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"UPDATE ""Tags"" +SET ""TodoItemId"" = @p1 +WHERE ""Id"" = @p2")); + + command.Parameters.ShouldHaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"" +FROM ""TodoItems"" AS t1 +WHERE t1.""Id"" = @p1")); + + command.Parameters.ShouldHaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs new file mode 100644 index 0000000000..235ec91f2a --- /dev/null +++ b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs @@ -0,0 +1,602 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.Sql; + +public sealed class SubQueryInJoinTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SubQueryInJoinTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Join_with_table_on_ToOne_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=account"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""LastUsedAt"", t2.""UserName"" +FROM ""People"" AS t1 +LEFT JOIN ""LoginAccounts"" AS t2 ON t1.""AccountId"" = t2.""Id"" +ORDER BY t1.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", t2.""Priority"", t2.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", t2.""Description""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +)")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems.tags&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""Priority"", t4.""Id"", t4.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ""TodoItems"" AS t2 ON t1.""Id"" = t2.""OwnerId"" +LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" +), t4.""Name"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts_on_counts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.Generate(); + todoItem.Owner = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?include=owner.ownedTodoItems.tags,owner.assignedTodoItems.tags&sort[owner.ownedTodoItems]=count(tags)&sort[owner.assignedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""TodoItems"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""CreatedAt"", t1.""Description"", t1.""DurationInHours"", t1.""LastModifiedAt"", t1.""Priority"", t2.""Id"", t2.""FirstName"", t2.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"", t5.""Id"", t5.""Name"", t6.""Id"", t6.""CreatedAt"", t6.""Description"", t6.""DurationInHours"", t6.""LastModifiedAt"", t6.""Priority"", t8.""Id"", t8.""Name"" +FROM ""TodoItems"" AS t1 +INNER JOIN ""People"" AS t2 ON t1.""OwnerId"" = t2.""Id"" +LEFT JOIN ""TodoItems"" AS t3 ON t2.""Id"" = t3.""AssigneeId"" +LEFT JOIN ""Tags"" AS t5 ON t3.""Id"" = t5.""TodoItemId"" +LEFT JOIN ""TodoItems"" AS t6 ON t2.""Id"" = t6.""OwnerId"" +LEFT JOIN ""Tags"" AS t8 ON t6.""Id"" = t8.""TodoItemId"" +ORDER BY t1.""Priority"", t1.""LastModifiedAt"" DESC, ( + SELECT COUNT(*) + FROM ""Tags"" AS t4 + WHERE t3.""Id"" = t4.""TodoItemId"" +), t5.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t7 + WHERE t6.""Id"" = t7.""TodoItemId"" +), t8.""Id""")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=equals(description,'X')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t3.""Id"", t3.""CreatedAt"", t3.""Description"", t3.""DurationInHours"", t3.""LastModifiedAt"", t3.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE t2.""Description"" = @p1 +) AS t3 ON t1.""Id"" = t3.""OwnerId"" +ORDER BY t1.""Id"", t3.""Priority"", t3.""LastModifiedAt"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_has() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=has(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE EXISTS ( + SELECT 1 + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" + ) +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=greaterThan(count(tags),'0')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t4.""Id"", t4.""CreatedAt"", t4.""Description"", t4.""DurationInHours"", t4.""LastModifiedAt"", t4.""Priority"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"" + FROM ""TodoItems"" AS t2 + WHERE ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t2.""Id"" = t3.""TodoItemId"" + ) > @p1 +) AS t4 ON t1.""Id"" = t4.""OwnerId"" +ORDER BY t1.""Id"", t4.""Priority"", t4.""LastModifiedAt"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 0); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_includes_with_nested_filter_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=equals(description,'X')&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t5.""Id"", t5.""CreatedAt"", t5.""Description"", t5.""DurationInHours"", t5.""LastModifiedAt"", t5.""Priority"", t5.Id0 AS Id, t5.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""Id"" AS Id0, t4.""Name"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""Tags"" AS t4 ON t2.""Id"" = t4.""TodoItemId"" + WHERE t2.""Description"" = @p1 +) AS t5 ON t1.""Id"" = t5.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t5.""Id"" = t3.""TodoItemId"" +), t5.""Name"" DESC")); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_nested_sub_queries_with_filters_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=not(equals(description,'X'))&filter[ownedTodoItems.tags]=not(equals(name,'Y'))" + + "&sort[ownedTodoItems]=count(tags),assignee.lastName&sort[ownedTodoItems.tags]=name,-id"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.ShouldHaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(@"SELECT COUNT(*) +FROM ""People"" AS t1")); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql( + @"SELECT t1.""Id"", t1.""FirstName"", t1.""LastName"", t7.""Id"", t7.""CreatedAt"", t7.""Description"", t7.""DurationInHours"", t7.""LastModifiedAt"", t7.""Priority"", t7.Id00 AS Id, t7.""Name"" +FROM ""People"" AS t1 +LEFT JOIN ( + SELECT t2.""Id"", t2.""CreatedAt"", t2.""Description"", t2.""DurationInHours"", t2.""LastModifiedAt"", t2.""OwnerId"", t2.""Priority"", t4.""LastName"", t6.""Id"" AS Id00, t6.""Name"" + FROM ""TodoItems"" AS t2 + LEFT JOIN ""People"" AS t4 ON t2.""AssigneeId"" = t4.""Id"" + LEFT JOIN ( + SELECT t5.""Id"", t5.""Name"", t5.""TodoItemId"" + FROM ""Tags"" AS t5 + WHERE NOT (t5.""Name"" = @p2) + ) AS t6 ON t2.""Id"" = t6.""TodoItemId"" + WHERE NOT (t2.""Description"" = @p1) +) AS t7 ON t1.""Id"" = t7.""OwnerId"" +ORDER BY t1.""Id"", ( + SELECT COUNT(*) + FROM ""Tags"" AS t3 + WHERE t7.""Id"" = t3.""TodoItemId"" +), t7.""LastName"", t7.""Name"", t7.Id00 DESC")); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/SqlTextAdapter.cs b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs new file mode 100644 index 0000000000..14fe65bd22 --- /dev/null +++ b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using DapperExample; + +namespace DapperTests.IntegrationTests; + +internal sealed class SqlTextAdapter +{ + private static readonly Dictionary SqlServerReplacements = new() + { + [new Regex(@"""([^""]+)""", RegexOptions.Compiled)] = "[$+]", + [new Regex($@"(VALUES \([^)]*\)){Environment.NewLine}RETURNING \[Id\]", RegexOptions.Compiled)] = $"OUTPUT INSERTED.[Id]{Environment.NewLine}$1" + }; + + private readonly DatabaseProvider _databaseProvider; + + public SqlTextAdapter(DatabaseProvider databaseProvider) + { + _databaseProvider = databaseProvider; + } + + public string Adapt(string text, bool hasClientGeneratedId) + { + string replaced = text; + + if (_databaseProvider == DatabaseProvider.MySql) + { + replaced = replaced.Replace(@"""", "`"); + + string selectInsertId = hasClientGeneratedId ? $";{Environment.NewLine}SELECT @p1" : $";{Environment.NewLine}SELECT LAST_INSERT_ID()"; + replaced = replaced.Replace($"{Environment.NewLine}RETURNING `Id`", selectInsertId); + + replaced = replaced.Replace(@"\\", @"\\\\").Replace(@" ESCAPE '\'", @" ESCAPE '\\'"); + } + else if (_databaseProvider == DatabaseProvider.SqlServer) + { + foreach ((Regex regex, string replacementPattern) in SqlServerReplacements) + { + replaced = regex.Replace(replaced, replacementPattern); + } + } + + return replaced; + } +} diff --git a/test/DapperTests/IntegrationTests/TestFakers.cs b/test/DapperTests/IntegrationTests/TestFakers.cs new file mode 100644 index 0000000000..7b66367b7c --- /dev/null +++ b/test/DapperTests/IntegrationTests/TestFakers.cs @@ -0,0 +1,61 @@ +using Bogus; +using DapperExample.Models; +using TestBuildingBlocks; +using Person = DapperExample.Models.Person; +using RgbColorType = DapperExample.Models.RgbColor; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace DapperTests.IntegrationTests; + +internal sealed class TestFakers : FakerContainer +{ + private readonly Lazy> _lazyTodoItemFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(todoItem => todoItem.Description, faker => faker.Lorem.Sentence()) + .RuleFor(todoItem => todoItem.Priority, faker => faker.Random.Enum()) + .RuleFor(todoItem => todoItem.DurationInHours, faker => faker.Random.Long(1, 250)) + .RuleFor(todoItem => todoItem.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds()) + .RuleFor(todoItem => todoItem.LastModifiedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyLoginAccountFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(loginAccount => loginAccount.UserName, faker => faker.Internet.UserName()) + .RuleFor(loginAccount => loginAccount.LastUsedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyAccountRecoveryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(accountRecovery => accountRecovery.PhoneNumber, faker => faker.Person.Phone) + .RuleFor(accountRecovery => accountRecovery.EmailAddress, faker => faker.Person.Email)); + + private readonly Lazy> _lazyPersonFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(person => person.FirstName, faker => faker.Name.FirstName()) + .RuleFor(person => person.LastName, faker => faker.Name.LastName())); + + private readonly Lazy> _lazyTagFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(tag => tag.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyRgbColorFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(rgbColor => rgbColor.Id, faker => RgbColorType.Create(faker.Random.Byte(), faker.Random.Byte(), faker.Random.Byte()) + .Id)); + + public Faker TodoItem => _lazyTodoItemFaker.Value; + public Faker Person => _lazyPersonFaker.Value; + public Faker LoginAccount => _lazyLoginAccountFaker.Value; + public Faker AccountRecovery => _lazyAccountRecoveryFaker.Value; + public Faker Tag => _lazyTagFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; +} diff --git a/test/DapperTests/UnitTests/LogicalCombinatorTests.cs b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs new file mode 100644 index 0000000000..065cec7dd6 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs @@ -0,0 +1,49 @@ +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalCombinatorTests +{ + [Fact] + public void Collapses_and_filters() + { + // Arrange + var column = new ColumnInTableNode("column", ColumnType.Scalar, null); + + var conditionLeft1 = new ComparisonNode(ComparisonOperator.GreaterThan, column, new ParameterNode("@p1", 10)); + var conditionRight1 = new ComparisonNode(ComparisonOperator.LessThan, column, new ParameterNode("@p2", 20)); + var and1 = new LogicalNode(LogicalOperator.And, conditionLeft1, conditionRight1); + + var conditionLeft2 = new ComparisonNode(ComparisonOperator.GreaterOrEqual, column, new ParameterNode("@p3", 100)); + var conditionRight2 = new ComparisonNode(ComparisonOperator.LessOrEqual, column, new ParameterNode("@p4", 200)); + var and2 = new LogicalNode(LogicalOperator.And, conditionLeft2, conditionRight2); + + var conditionLeft3 = new LikeNode(column, TextMatchKind.EndsWith, "Z"); + var conditionRight3 = new LikeNode(column, TextMatchKind.StartsWith, "A"); + var and3 = new LogicalNode(LogicalOperator.And, conditionLeft3, conditionRight3); + + var source = new LogicalNode(LogicalOperator.And, and1, new LogicalNode(LogicalOperator.And, and2, and3)); + var combinator = new LogicalCombinator(); + + // Act + FilterNode result = combinator.Collapse(source); + + // Assert + IEnumerable terms = new FilterNode[] + { + conditionLeft1, + conditionRight1, + conditionLeft2, + conditionRight2, + conditionLeft3, + conditionRight3 + }.Select(condition => condition.ToString()); + + string expectedText = '(' + string.Join(") AND (", terms) + ')'; + result.ToString().Should().Be(expectedText); + } +} diff --git a/test/DapperTests/UnitTests/LogicalNodeTests.cs b/test/DapperTests/UnitTests/LogicalNodeTests.cs new file mode 100644 index 0000000000..6ce6dffab1 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalNodeTests.cs @@ -0,0 +1,22 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalNodeTests +{ + [Fact] + public void Throws_on_insufficient_terms() + { + // Arrange + var filter = new ComparisonNode(ComparisonOperator.Equals, new ParameterNode("@p1", null), new ParameterNode("@p2", null)); + + // Act + Action action = () => _ = new LogicalNode(LogicalOperator.And, filter); + + // Assert + action.Should().ThrowExactly().WithMessage("At least two terms are required.*"); + } +} diff --git a/test/DapperTests/UnitTests/ParameterNodeTests.cs b/test/DapperTests/UnitTests/ParameterNodeTests.cs new file mode 100644 index 0000000000..497e72d447 --- /dev/null +++ b/test/DapperTests/UnitTests/ParameterNodeTests.cs @@ -0,0 +1,45 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class ParameterNodeTests +{ + [Fact] + public void Throws_on_invalid_name() + { + // Act + Action action = () => _ = new ParameterNode("p1", null); + + // Assert + action.Should().ThrowExactly().WithMessage("Parameter name must start with an '@' symbol and not be empty.*"); + } + + [Theory] + [InlineData(null, "null")] + [InlineData(-123, "-123")] + [InlineData(123U, "123")] + [InlineData(-123L, "-123")] + [InlineData(123UL, "123")] + [InlineData((short)-123, "-123")] + [InlineData((ushort)123, "123")] + [InlineData('A', "'A'")] + [InlineData((sbyte)123, "123")] + [InlineData((byte)123, "0x7B")] + [InlineData(1.23F, "1.23")] + [InlineData(1.23D, "1.23")] + [InlineData("123", "'123'")] + [InlineData(DayOfWeek.Saturday, "DayOfWeek.Saturday")] + public void Can_format_parameter(object? parameterValue, string formattedValueExpected) + { + // Arrange + var parameter = new ParameterNode("@name", parameterValue); + + // Act + string text = parameter.ToString(); + + // Assert + text.Should().Be("@name = " + formattedValueExpected); + } +} diff --git a/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs new file mode 100644 index 0000000000..aadedc8c85 --- /dev/null +++ b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs @@ -0,0 +1,54 @@ +using DapperExample; +using DapperExample.TranslationToSql.DataModel; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class RelationshipForeignKeyTests +{ + private readonly IResourceGraph _resourceGraph = + new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); + + [Fact] + public void Can_format_foreign_key_for_ToOne_relationship() + { + // Arrange + RelationshipAttribute parentRelationship = _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Parent)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, parentRelationship, true, "ParentId", true); + + // Assert + foreignKey.ToString().Should().Be(@"TestResource.Parent => ""TestResources"".""ParentId""?"); + } + + [Fact] + public void Can_format_foreign_key_for_ToMany_relationship() + { + // Arrange + RelationshipAttribute childrenRelationship = + _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Children)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, childrenRelationship, false, "TestResourceId", false); + + // Assert + foreignKey.ToString().Should().Be(@"TestResource.Children => ""TestResources"".""TestResourceId"""); + } + + [UsedImplicitly] + private sealed class TestResource : Identifiable + { + [HasOne] + public TestResource? Parent { get; set; } + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } +} diff --git a/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs new file mode 100644 index 0000000000..394cdecc5b --- /dev/null +++ b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class SqlTreeNodeVisitorTests +{ + [Fact] + public void Visitor_methods_call_default_visit() + { + // Arrange + var visitor = new TestVisitor(); + + MethodInfo[] visitMethods = visitor.GetType().GetMethods() + .Where(method => method.Name.StartsWith("Visit", StringComparison.Ordinal) && method.Name != "Visit").ToArray(); + + object?[] parameters = + { + null, + null + }; + + // Act + foreach (MethodInfo method in visitMethods) + { + _ = method.Invoke(visitor, parameters); + } + + visitor.HitCount.Should().Be(26); + } + + private sealed class TestVisitor : SqlTreeNodeVisitor + { + public int HitCount { get; private set; } + + public override object? DefaultVisit(SqlTreeNode node, object? argument) + { + HitCount++; + return base.DefaultVisit(node, argument); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index ad6f8a1609..a744028136 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -503,7 +503,7 @@ public async Task Uses_default_page_number_and_size() Blog blog = _fakers.Blog.Generate(); blog.Posts = _fakers.BlogPost.Generate(3); - blog.Posts.ToList().ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); + blog.Posts.ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs index fcae662098..6036bfc6d4 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternInheritanceMatchTests.cs @@ -43,7 +43,7 @@ public sealed class FieldChainPatternInheritanceMatchTests public FieldChainPatternInheritanceMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); var options = new JsonApiOptions(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs index 964cf6fb8c..052ec4a44f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/FieldChains/FieldChainPatternMatchTests.cs @@ -29,7 +29,7 @@ public sealed class FieldChainPatternMatchTests public FieldChainPatternMatchTests(ITestOutputHelper testOutputHelper) { - var loggerProvider = new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message); + var loggerProvider = new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message); _loggerFactory = new LoggerFactory(loggerProvider.AsEnumerable()); var options = new JsonApiOptions(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs index 66b6dd0c81..34ca2ef259 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs @@ -133,7 +133,139 @@ public void Cannot_set_value_to_collection_with_primitive_element() action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); } - private sealed class TestResource : Identifiable + [Fact] + public void Can_add_value_to_List() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = new List + { + new() + } + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + List collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(2); + } + + [Fact] + public void Can_add_existing_value_to_List() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resourceToAdd = new TestResource(); + + var resource = new TestResource + { + Children = new List + { + resourceToAdd + } + }; + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + List collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + [Fact] + public void Can_add_value_to_HashSet() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = new HashSet + { + new() + } + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(2); + } + + [Fact] + public void Can_add_existing_value_to_HashSet() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resourceToAdd = new TestResource(); + + var resource = new TestResource + { + Children = new HashSet + { + resourceToAdd + } + }; + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + [Fact] + public void Can_add_value_to_null_collection() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource + { + Children = null! + }; + + var resourceToAdd = new TestResource(); + + // Act + attribute.AddValue(resource, resourceToAdd); + + // Assert + HashSet collection = attribute.GetValue(resource).Should().BeOfType>().Subject!; + collection.ShouldHaveCount(1); + } + + public sealed class TestResource : Identifiable { [HasMany] public IEnumerable Children { get; set; } = new HashSet(); diff --git a/test/OpenApiEndToEndTests/QueryStrings/GeneratedCode/QueryStringsClient.cs b/test/OpenApiEndToEndTests/QueryStrings/GeneratedCode/QueryStringsClient.cs index e0bd59a9a7..8a90cfa0f5 100644 --- a/test/OpenApiEndToEndTests/QueryStrings/GeneratedCode/QueryStringsClient.cs +++ b/test/OpenApiEndToEndTests/QueryStrings/GeneratedCode/QueryStringsClient.cs @@ -27,7 +27,7 @@ private static ILogger CreateLogger(ITestOutputHelper testOu { var loggerFactory = new LoggerFactory(new[] { - new XUnitLoggerProvider(testOutputHelper, LogOutputFields.Message) + new XUnitLoggerProvider(testOutputHelper, null, LogOutputFields.Message) }); return loggerFactory.CreateLogger(); diff --git a/test/TestBuildingBlocks/CollectionExtensions.cs b/test/TestBuildingBlocks/CollectionExtensions.cs new file mode 100644 index 0000000000..a07c93ddd9 --- /dev/null +++ b/test/TestBuildingBlocks/CollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace TestBuildingBlocks; + +public static class CollectionExtensions +{ + public static void ForEach(this IEnumerable source, Action action) + { + foreach (T element in source) + { + action(element); + } + } +} diff --git a/test/TestBuildingBlocks/XUnitLoggerProvider.cs b/test/TestBuildingBlocks/XUnitLoggerProvider.cs index 2aafd4d396..e19f8cbbc6 100644 --- a/test/TestBuildingBlocks/XUnitLoggerProvider.cs +++ b/test/TestBuildingBlocks/XUnitLoggerProvider.cs @@ -1,6 +1,7 @@ using System.Text; using JsonApiDotNetCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Xunit.Abstractions; namespace TestBuildingBlocks; @@ -10,18 +11,27 @@ public sealed class XUnitLoggerProvider : ILoggerProvider { private readonly ITestOutputHelper _testOutputHelper; private readonly LogOutputFields _outputFields; + private readonly string? _categoryPrefixFilter; - public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, LogOutputFields outputFields = LogOutputFields.All) + public XUnitLoggerProvider(ITestOutputHelper testOutputHelper, string? categoryPrefixFilter, LogOutputFields outputFields = LogOutputFields.All) { ArgumentGuard.NotNull(testOutputHelper); _testOutputHelper = testOutputHelper; + _categoryPrefixFilter = categoryPrefixFilter; _outputFields = outputFields; } public ILogger CreateLogger(string categoryName) { - return new XUnitLogger(_testOutputHelper, _outputFields, categoryName); + ArgumentGuard.NotNull(categoryName); + + if (_categoryPrefixFilter == null || categoryName.StartsWith(_categoryPrefixFilter, StringComparison.Ordinal)) + { + return new XUnitLogger(_testOutputHelper, _outputFields, categoryName); + } + + return NullLogger.Instance; } public void Dispose() @@ -33,13 +43,9 @@ private sealed class XUnitLogger : ILogger private readonly ITestOutputHelper _testOutputHelper; private readonly LogOutputFields _outputFields; private readonly string _categoryName; - private readonly IExternalScopeProvider _scopeProvider = new NoExternalScopeProvider(); public XUnitLogger(ITestOutputHelper testOutputHelper, LogOutputFields outputFields, string categoryName) { - ArgumentGuard.NotNull(testOutputHelper); - ArgumentGuard.NotNull(categoryName); - _testOutputHelper = testOutputHelper; _outputFields = outputFields; _categoryName = categoryName; @@ -57,7 +63,7 @@ public bool IsEnabled(LogLevel logLevel) public IDisposable BeginScope(TState state) where TState : notnull { - return _scopeProvider.Push(state); + return EmptyDisposable.Instance; } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) @@ -95,19 +101,10 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (exception != null && _outputFields.HasFlag(LogOutputFields.Exception)) { - builder.Append('\n'); + builder.Append(Environment.NewLine); builder.Append(exception); } - if (_outputFields.HasFlag(LogOutputFields.Scopes)) - { - _scopeProvider.ForEachScope((scope, nextState) => - { - nextState.Append("\n => "); - nextState.Append(scope); - }, builder); - } - try { _testOutputHelper.WriteLine(builder.ToString()); @@ -133,24 +130,12 @@ private static string GetLogLevelString(LogLevel logLevel) }; } - private sealed class NoExternalScopeProvider : IExternalScopeProvider + private sealed class EmptyDisposable : IDisposable { - public void ForEachScope(Action callback, TState state) - { - } - - public IDisposable Push(object? state) - { - return EmptyDisposable.Instance; - } + public static EmptyDisposable Instance { get; } = new(); - private sealed class EmptyDisposable : IDisposable + public void Dispose() { - public static EmptyDisposable Instance { get; } = new(); - - public void Dispose() - { - } } } } diff --git a/tests.runsettings b/tests.runsettings index ab79d50cb0..43df2c4921 100644 --- a/tests.runsettings +++ b/tests.runsettings @@ -7,8 +7,9 @@ + **/test/**/*.* [*]JsonApiDotNetCore.OpenApi.JsonApiObjects.* - ObsoleteAttribute,GeneratedCodeAttribute + ObsoleteAttribute,GeneratedCodeAttribute,TestSDKAutoGeneratedCode true