diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 3919f2223e..7f98da7f1a 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
- "version": "2023.2.1",
+ "version": "2023.3.0-eap08",
"commands": [
"jb"
]
@@ -15,13 +15,13 @@
]
},
"dotnet-reportgenerator-globaltool": {
- "version": "5.1.25",
+ "version": "5.2.0",
"commands": [
"reportgenerator"
]
},
"docfx": {
- "version": "2.70.4",
+ "version": "2.74.0",
"commands": [
"docfx"
]
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index c59acf46d6..02e871bd7c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,6 +1,6 @@
---
name: Bug report
-about: Create a report to help us improve
+about: Create a report to help us improve.
title: ''
labels: 'bug'
assignees: ''
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..8bf0a9112f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,14 @@
+blank_issues_enabled: true
+contact_links:
+- name: Documentation
+ url: https://www.jsonapi.net/usage/resources/index.html
+ about: Read our comprehensive documentation.
+- name: Sponsor JsonApiDotNetCore
+ url: https://github.com/sponsors/json-api-dotnet
+ about: Help the continued development.
+- name: Ask on Gitter
+ url: https://gitter.im/json-api-dotnet-core/Lobby
+ about: Get in touch with the whole community.
+- name: Ask on Stack Overflow
+ url: https://stackoverflow.com/questions/tagged/json-api
+ about: The best place for asking general-purpose questions.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index f629ca472d..019f7a9767 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,6 +1,6 @@
---
name: Feature request
-about: Suggest an idea for this project
+about: Suggest an idea for this project.
title: ''
labels: 'enhancement'
assignees: ''
diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md
index 51304f7f03..3e3c1ba085 100644
--- a/.github/ISSUE_TEMPLATE/question.md
+++ b/.github/ISSUE_TEMPLATE/question.md
@@ -1,6 +1,6 @@
---
name: Question
-about: Ask a question
+about: Ask a question.
title: ''
labels: 'question'
assignees: ''
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a796be61f0..4d7442a3c9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -47,7 +47,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
- dotnet-version: 6.0.x
+ dotnet-version: |
+ 6.0.x
+ 8.0.x
- name: Setup PowerShell (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
@@ -99,10 +101,10 @@ jobs:
# Get the version prefix/suffix from the git tag. For example: 'v1.0.0-preview1-final' => '1.0.0' and 'preview1-final'
$segments = $env:GITHUB_REF_NAME -split "-"
$versionPrefix = $segments[0].TrimStart('v')
- $versionSuffix = $segments.Count -eq 1 ? '' : $segments[1..$($segments.Length-1)] -join '-'
+ $versionSuffix = $segments.Length -eq 1 ? '' : $segments[1..$($segments.Length - 1)] -join '-'
[xml]$xml = Get-Content Directory.Build.props
- $configuredVersionPrefix = $xml.Project.PropertyGroup[0].JsonApiDotNetCoreVersionPrefix
+ $configuredVersionPrefix = $xml.Project.PropertyGroup.JsonApiDotNetCoreVersionPrefix | Select-Object -First 1
if ($configuredVersionPrefix -ne $versionPrefix) {
Write-Error "Version prefix from git release tag '$versionPrefix' does not match version prefix '$configuredVersionPrefix' stored in Directory.Build.props."
# To recover from this:
@@ -124,27 +126,20 @@ jobs:
- name: Build
shell: pwsh
run: |
- if ($env:PACKAGE_VERSION_SUFFIX) {
- dotnet build --no-restore --configuration Release --version-suffix=$env:PACKAGE_VERSION_SUFFIX
- }
- else {
- dotnet build --no-restore --configuration Release
- }
+ dotnet build --no-restore --configuration Release /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX
- name: Test
run: |
- dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" -- RunConfiguration.CollectSourceInformation=true DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true
+ dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true"
- name: Upload coverage to codecov.io
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v3
+ with:
+ fail_ci_if_error: true
+ verbose: true
- name: Generate packages
shell: pwsh
run: |
- if ($env:PACKAGE_VERSION_SUFFIX) {
- dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages --version-suffix=$env:PACKAGE_VERSION_SUFFIX
- }
- else {
- dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages
- }
+ dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX
- name: Upload packages to artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v3
@@ -188,7 +183,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
- dotnet-version: 6.0.x
+ dotnet-version: |
+ 6.0.x
+ 8.0.x
- name: Git checkout
uses: actions/checkout@v4
- name: Restore tools
@@ -199,7 +196,7 @@ jobs:
run: |
$inspectCodeOutputPath = Join-Path $env:RUNNER_TEMP 'jetbrains-inspectcode-results.xml'
Write-Output "INSPECT_CODE_OUTPUT_PATH=$inspectCodeOutputPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$inspectCodeOutputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal
+ dotnet jb inspectcode JsonApiDotNetCore.sln --build --dotnetcoresdk=$(dotnet --version) --output="$inspectCodeOutputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal
- name: Verify outcome
shell: pwsh
run: |
@@ -239,7 +236,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
- dotnet-version: 6.0.x
+ dotnet-version: |
+ 6.0.x
+ 8.0.x
- name: Git checkout
uses: actions/checkout@v4
with:
@@ -260,13 +259,13 @@ jobs:
$baseCommitHash = git rev-parse HEAD~1
Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash in pull request."
- dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff
+ dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $headCommitHash -b $baseCommitHash --fail-on-diff --print-diff
- name: CleanupCode (on branch)
if: github.event_name == 'push' || github.event_name == 'release'
shell: pwsh
run: |
Write-Output "Running code cleanup on all files."
- dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff
+ dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN --fail-on-diff --print-diff
publish:
timeout-minutes: 60
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000000..5b1868eae5
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,43 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ 'master', 'release/**' ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ 'master', 'release/**' ]
+ schedule:
+ - cron: '0 0 * * 5'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: 'ubuntu-latest'
+ timeout-minutes: 60
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+ steps:
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: |
+ 6.0.x
+ 8.0.x
+ - name: Git checkout
+ uses: actions/checkout@v4
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: ${{ matrix.language }}
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/deps-review.yml b/.github/workflows/deps-review.yml
new file mode 100644
index 0000000000..b9945082d5
--- /dev/null
+++ b/.github/workflows/deps-review.yml
@@ -0,0 +1,14 @@
+name: 'Dependency Review'
+on: [pull_request]
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-review:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Checkout Repository'
+ uses: actions/checkout@v4
+ - name: 'Dependency Review'
+ uses: actions/dependency-review-action@v3
diff --git a/Build.ps1 b/Build.ps1
index 4f0912079d..3abc926e6a 100644
--- a/Build.ps1
+++ b/Build.ps1
@@ -1,3 +1,5 @@
+$versionSuffix="pre"
+
function VerifySuccessExitCode {
if ($LastExitCode -ne 0) {
throw "Command failed with exit code $LastExitCode."
@@ -6,18 +8,22 @@ function VerifySuccessExitCode {
Write-Host "$(pwsh --version)"
Write-Host "Active .NET SDK: $(dotnet --version)"
+Write-Host "Using version suffix: $versionSuffix"
+
+Remove-Item -Recurse -Force artifacts -ErrorAction SilentlyContinue
+Remove-Item -Recurse -Force * -Include coverage.cobertura.xml
dotnet tool restore
VerifySuccessExitCode
-dotnet build --configuration Release --version-suffix="pre"
+dotnet build --configuration Release /p:VersionSuffix=$versionSuffix
VerifySuccessExitCode
-dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true
+dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage"
VerifySuccessExitCode
dotnet reportgenerator -reports:**\coverage.cobertura.xml -targetdir:artifacts\coverage -filefilters:-*.g.cs
VerifySuccessExitCode
-dotnet pack --no-build --configuration Release --output artifacts/packages --version-suffix="pre"
+dotnet pack --no-build --configuration Release --output artifacts/packages /p:VersionSuffix=$versionSuffix
VerifySuccessExitCode
diff --git a/Directory.Build.props b/Directory.Build.props
index 1c96340f4f..cdd3c5d232 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,44 +1,32 @@
-
- net6.0
- 6.0.*
- 7.0.*
- 7.0.*
- 4.7.*
- 2.14.1
- 5.4.0
- $(MSBuildThisFileDirectory)CodingGuidelines.ruleset
- 9999
- enable
- enable
- false
- false
-
-
-
-
-
-
-
-
-
- true
+
+ $(NoWarn);AV2210
-
+
$(NoWarn);1591
true
true
-
- $(NoWarn);AV2210
+
+ true
-
+
+
+
+
+
+
- 6.0.*
- 2.3.*
- 17.7.*
+ enable
+ latest
+ enable
+ false
+ false
+ $(MSBuildThisFileDirectory)CodingGuidelines.ruleset
+ $(MSBuildThisFileDirectory)tests.runsettings
+ 5.5.0
diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln
index 2f8e9f9127..e821d4175d 100644
--- a/JsonApiDotNetCore.sln
+++ b/JsonApiDotNetCore.sln
@@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
CodingGuidelines.ruleset = CodingGuidelines.ruleset
CSharpGuidelinesAnalyzer.config = CSharpGuidelinesAnalyzer.config
Directory.Build.props = Directory.Build.props
+ tests.runsettings = tests.runsettings
+ package-versions.props = package-versions.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{026FBC6C-AF76-4568-9B87-EC73457899FD}"
@@ -55,6 +57,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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -281,6 +287,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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -304,6 +334,8 @@ 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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4}
diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index 2602272e97..a945c3a40d 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -66,6 +66,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$);
WARNING
WARNING
WARNING
+ SUGGESTION
SUGGESTION
SUGGESTION
DO_NOT_SHOW
@@ -92,7 +93,9 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$);
WARNING
SUGGESTION
SUGGESTION
+ SUGGESTION
WARNING
+ SUGGESTION
<?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSReformatInactiveBranches>True</CSReformatInactiveBranches></Profile>
JADNC Full Cleanup
Required
@@ -113,6 +116,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$);
True
True
True
+ INDENT
1
1
False
@@ -125,6 +129,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$);
False
False
True
+ 1
NEVER
NEVER
False
@@ -145,6 +150,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$);
True
WRAP_IF_LONG
160
+ CHOP_IF_LONG
WRAP_IF_LONG
CHOP_ALWAYS
CHOP_ALWAYS
@@ -659,8 +665,12 @@ $left$ = $right$;
True
True
True
+ True
True
+ True
True
+ True
+ True
True
True
True
diff --git a/README.md b/README.md
index 90bb47b405..f34dfd1bee 100644
--- a/README.md
+++ b/README.md
@@ -34,8 +34,7 @@ These are some steps you can take to help you understand what this project is an
- [Performance Reports](https://github.com/json-api-dotnet/PerformanceReports)
- [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb)
-- [JsonApiDotNetCore.Marten](https://github.com/wayne-o/JsonApiDotNetCore.Marten)
-- [Todo List App](https://github.com/json-api-dotnet/TodoListExample)
+- [Ember.js Todo List App](https://github.com/json-api-dotnet/TodoListExample)
## Examples
@@ -77,17 +76,24 @@ app.MapControllers();
The following chart should help you pick the best version, based on your environment.
See also our [versioning policy](./VERSIONING_POLICY.md).
-| JsonApiDotNetCore | Status | .NET | Entity Framework Core |
-| ----------------- | ----------- | -------- | --------------------- |
-| 3.x | Stable | Core 2.x | 2.x |
-| 4.x | Stable | Core 3.1 | 3.1 |
-| | | Core 3.1 | 5 |
-| | | 5 | 5 |
-| | | 6 | 5 |
-| 5.0.0-5.0.2 | Stable | 6 | 6 |
-| 5.0.3+ | Stable | 6 | 6 |
-| | | 6 | 7 |
-| | | 7 | 7 |
+| JsonApiDotNetCore | Status | .NET | Entity Framework Core |
+| ----------------- | ------------ | -------- | --------------------- |
+| 3.x | Stable | Core 2.x | 2.x |
+| 4.x | Stable | Core 3.1 | 3.1, 5 |
+| | | 5 | 5 |
+| | | 6 | 5 |
+| 5.0.0-5.0.2 | Stable | 6 | 6 |
+| 5.0.3-5.4.0 | Stable | 6 | 6, 7 |
+| | | 7 | 7 |
+| 5.5+ | Stable | 6 | 6, 7 |
+| | | 7 | 7 |
+| | | 8 | 8 |
+| master | Preview | 6 | 6, 7 |
+| | | 7 | 7 |
+| | | 8 | 8 |
+| openapi | Experimental | 6 | 6, 7 |
+| | | 7 | 7 |
+| | | 8 | 8 |
## Contributing
diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings
index 96f358da23..5c641e606f 100644
--- a/WarningSeverities.DotSettings
+++ b/WarningSeverities.DotSettings
@@ -124,6 +124,7 @@
WARNING
WARNING
WARNING
+ WARNING
WARNING
WARNING
WARNING
@@ -163,6 +164,7 @@
WARNING
WARNING
WARNING
+ WARNING
WARNING
WARNING
WARNING
@@ -240,6 +242,7 @@
WARNING
WARNING
WARNING
+ WARNING
WARNING
WARNING
WARNING
@@ -258,10 +261,12 @@
WARNING
WARNING
WARNING
+ WARNING
WARNING
WARNING
WARNING
WARNING
WARNING
WARNING
+ WARNING
\ No newline at end of file
diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj
index 23a6876af9..9dbb9ba093 100644
--- a/benchmarks/Benchmarks.csproj
+++ b/benchmarks/Benchmarks.csproj
@@ -1,17 +1,19 @@
Exe
- $(TargetFrameworkName)
+ net8.0
true
+
+
-
-
+
+
diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs
index 2f466a3fcb..0b2f88134a 100644
--- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs
+++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs
@@ -1,7 +1,6 @@
using System.ComponentModel.Design;
using BenchmarkDotNet.Attributes;
using Benchmarks.Tools;
-using JsonApiDotNetCore;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries.Parsing;
@@ -55,8 +54,14 @@ public QueryStringParserBenchmarks()
var paginationParser = new PaginationParser();
var paginationReader = new PaginationQueryStringParameterReader(paginationParser, request, resourceGraph, options);
- IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader,
- sparseFieldSetReader, paginationReader);
+ IQueryStringParameterReader[] readers =
+ [
+ includeReader,
+ filterReader,
+ sortReader,
+ sparseFieldSetReader,
+ paginationReader
+ ];
_queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance);
}
diff --git a/cleanupcode.ps1 b/cleanupcode.ps1
index ba1b0ca4c0..3ab4d620ae 100644
--- a/cleanupcode.ps1
+++ b/cleanupcode.ps1
@@ -28,17 +28,17 @@ if ($revision) {
if ($baseCommitHash -eq $headCommitHash) {
Write-Output "Running code cleanup on staged/unstaged files."
- dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified
+ dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified
VerifySuccessExitCode
}
else {
Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash, including staged/unstaged files."
- dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified,commits -a $headCommitHash -b $baseCommitHash
+ dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN -f staged,modified,commits -a $headCommitHash -b $baseCommitHash
VerifySuccessExitCode
}
}
else {
Write-Output "Running code cleanup on all files."
- dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN
+ dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --verbosity=WARN
VerifySuccessExitCode
}
diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1
index 4b13408460..ea6b2bd8f2 100644
--- a/docs/generate-examples.ps1
+++ b/docs/generate-examples.ps1
@@ -34,7 +34,7 @@ function Start-WebServer {
Write-Output "Starting web server"
$startTimeUtc = Get-Date -AsUTC
$job = Start-Job -ScriptBlock {
- dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj --configuration Debug --property:TreatWarningsAsErrors=True --urls=http://0.0.0.0:14141
+ dotnet run --project ..\src\Examples\GettingStarted\GettingStarted.csproj --framework net8.0 --configuration Debug --property:TreatWarningsAsErrors=True --urls=http://0.0.0.0:14141
}
$webProcessId = $null
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/docs/usage/common-pitfalls.md b/docs/usage/common-pitfalls.md
index 7941face82..f1f3fed3d6 100644
--- a/docs/usage/common-pitfalls.md
+++ b/docs/usage/common-pitfalls.md
@@ -87,11 +87,13 @@ Neither sounds very compelling. If stored procedures is what you need, you're be
Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [`[ApiController]`](https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute) violates the JSON:API specification.
Despite JsonApiDotNetCore trying its best to deal with it, the experience won't be as good as leaving it out.
-#### Replace injectable services *after* calling `AddJsonApi()`
-Registering your own services in the IoC container afterwards increases the chances that your replacements will take effect.
-Also, register with `services.AddResourceDefinition/AddResourceService/AddResourceRepository()` instead of `services.AddScoped()`.
+#### Register/override injectable services
+Register your JSON:API resource services, resource definitions and repositories with `services.AddResourceService/AddResourceDefinition/AddResourceRepository()` instead of `services.AddScoped()`.
When using [Auto-discovery](~/usage/resource-graph.md#auto-discovery), you don't need to register these at all.
+> [!NOTE]
+> In older versions of JsonApiDotNetCore, registering your own services in the IoC container *afterwards* increased the chances that your replacements would take effect.
+
#### Never use the Entity Framework Core In-Memory Database Provider
When using this provider, many invalid mappings go unnoticed, leading to strange errors or wrong behavior. A real SQL engine fails to create the schema when mappings are invalid.
If you're in need of a quick setup, use [SQLite](https://www.sqlite.org/). After adding its [NuGet package](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite), it's as simple as:
diff --git a/inspectcode.ps1 b/inspectcode.ps1
index b379bce1c6..14c3eb1736 100644
--- a/inspectcode.ps1
+++ b/inspectcode.ps1
@@ -10,7 +10,7 @@ if ($LastExitCode -ne 0) {
$outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml')
$resultPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.html')
-dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal
+dotnet jb inspectcode JsonApiDotNetCore.sln --dotnetcoresdk=$(dotnet --version) --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal
if ($LastExitCode -ne 0) {
throw "Code inspection failed with exit code $LastExitCode"
diff --git a/package-versions.props b/package-versions.props
new file mode 100644
index 0000000000..dd842f59f6
--- /dev/null
+++ b/package-versions.props
@@ -0,0 +1,42 @@
+
+
+
+ 4.1.0
+ 0.4.1
+ 2.14.1
+
+
+ 0.13.*
+ 34.0.*
+ 4.7.*
+ 6.0.*
+ 2.1.*
+ 6.12.*
+ 2.3.*
+ 1.3.*
+ 8.0.*
+ 17.8.*
+ 2.5.*
+
+
+
+
+ 8.0.0
+
+
+ 8.0.*
+ 8.0.*-*
+ $(AspNetCoreVersion)
+
+
+
+
+ 6.0.0
+
+
+ 6.0.*
+ 2.1.*
+ 7.0.*
+ 7.0.*
+
+
diff --git a/run-docker-postgres.ps1 b/run-docker-postgres.ps1
index 153b93a846..0cd42b3893 100644
--- a/run-docker-postgres.ps1
+++ b/run-docker-postgres.ps1
@@ -1,12 +1,18 @@
#Requires -Version 7.0
-# This script starts a docker container with postgres database, used for running tests.
+# This script starts a PostgreSQL database in a docker container, which is required for running tests locally.
+# When the -UI switch is passed, pgAdmin (a web-based PostgreSQL management tool) is started in a second container, which lets you query the database.
+# To connect to pgAdmin, open http://localhost:5050 and login with user "admin@admin.com", password "postgres". Use hostname "db" when registering the server.
-docker container stop jsonapi-dotnet-core-testing
+param(
+ [switch] $UI=$False
+)
-docker run --rm --name jsonapi-dotnet-core-testing `
- -e POSTGRES_DB=JsonApiDotNetCoreExample `
- -e POSTGRES_USER=postgres `
- -e POSTGRES_PASSWORD=postgres `
- -p 5432:5432 `
- postgres:15
+docker container stop jsonapi-postgresql-db
+docker container stop jsonapi-postgresql-management
+
+docker run --pull always --rm --detach --name jsonapi-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest
+
+if ($UI) {
+ docker run --pull always --rm --detach --name jsonapi-postgresql-management --link jsonapi-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest
+}
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..f49c3e4b40
--- /dev/null
+++ b/src/Examples/DapperExample/DapperExample.csproj
@@ -0,0 +1,21 @@
+
+
+ net8.0;net6.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..67c19bea4a
--- /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 = [];
+
+ 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..dc7c1802c8
--- /dev/null
+++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs
@@ -0,0 +1,51 @@
+using System.ComponentModel;
+using DapperExample.Models;
+using JetBrains.Annotations;
+using JsonApiDotNetCore;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
+using JsonApiDotNetCore.Queries.Expressions;
+using JsonApiDotNetCore.Resources;
+
+namespace DapperExample.Definitions;
+
+[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
+public sealed class TodoItemDefinition : JsonApiResourceDefinition
+{
+ private readonly IClock _clock;
+
+ public TodoItemDefinition(IResourceGraph resourceGraph, IClock clock)
+ : base(resourceGraph)
+ {
+ ArgumentGuard.NotNull(clock);
+
+ _clock = clock;
+ }
+
+ public override SortExpression OnApplySort(SortExpression? existingSort)
+ {
+ return existingSort ?? GetDefaultSortOrder();
+ }
+
+ private SortExpression GetDefaultSortOrder()
+ {
+ return CreateSortExpressionFromLambda([
+ (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 = _clock.UtcNow;
+ }
+ else if (writeOperation == WriteOperationKind.UpdateResource)
+ {
+ resource.LastModifiedAt = _clock.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/IClock.cs b/src/Examples/DapperExample/IClock.cs
new file mode 100644
index 0000000000..0319c42480
--- /dev/null
+++ b/src/Examples/DapperExample/IClock.cs
@@ -0,0 +1,6 @@
+namespace DapperExample;
+
+public interface IClock
+{
+ DateTimeOffset UtcNow { get; }
+}
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..00ab54ca97
--- /dev/null
+++ b/src/Examples/DapperExample/Program.cs
@@ -0,0 +1,114 @@
+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.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..4d30e430c7
--- /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 = [];
+
+ 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 = [];
+
+ 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