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