diff --git a/.editorconfig b/.editorconfig index af9e891a..beeb708a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -140,3 +140,14 @@ dotnet_diagnostic.CA1859.severity = suggestion dotnet_diagnostic.CA1860.severity = suggestion dotnet_diagnostic.CA1861.severity = suggestion dotnet_diagnostic.CA1862.severity = suggestion + +# Checked-in API files are the approval record for shipped package surfaces. +dotnet_public_api_analyzer.require_api_files = true +dotnet_public_api_analyzer.enable_nullability = true +dotnet_diagnostic.RS0016.severity = error +dotnet_diagnostic.RS0017.severity = error +dotnet_diagnostic.RS0024.severity = error +dotnet_diagnostic.RS0025.severity = error +# Existing overload families intentionally retain their historical optional +# parameters; snapshots, rather than RS0026, govern future changes. +dotnet_diagnostic.RS0026.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43a6262..c8984d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: shell: pwsh run: ./eng/test-release-governance.ps1 + - name: Validate compatibility contract consistency + shell: pwsh + run: ./eng/test-compatibility-consistency.ps1 + - name: Validate Dependabot configuration run: check-jsonschema --builtin-schema vendor.dependabot .github/dependabot.yml @@ -128,6 +132,35 @@ jobs: run: >- dotnet ./test/Dapper.FluentMap.ProviderCompatibility.Tests/bin/Release/net10.0/Dapper.FluentMap.ProviderCompatibility.Tests.dll + public-api: + name: Public API and nullable contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore ./Dapper.FluentMap.slnx + + - name: Validate checked-in API baselines and nullable annotations + run: | + dotnet build ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-restore + dotnet build ./src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj --configuration Release --no-restore + dotnet build ./src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj --configuration Release --no-restore + dotnet build ./src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj --configuration Release --no-restore + dotnet build ./src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj --configuration Release --no-restore + roslyn-components: name: Analyzer and generator compatibility runs-on: ubuntu-latest @@ -376,6 +409,12 @@ jobs: $ErrorActionPreference = 'Stop' ./eng/validate-package-metadata.ps1 -PackageDirectory './artifacts/packages' + - name: Validate packaged compatibility contract + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + ./eng/validate-compatibility-consistency.ps1 -PackageDirectory './artifacts/packages' + - name: Validate package artifact set shell: pwsh run: | @@ -457,6 +496,7 @@ jobs: needs: - repository-configuration - compatibility + - public-api - roslyn-components - provider-compatibility if: ${{ vars.SONAR_CI_ENABLED == 'true' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') }} @@ -763,6 +803,7 @@ jobs: needs: - repository-configuration - compatibility + - public-api - roslyn-components - provider-compatibility - native-aot @@ -775,6 +816,7 @@ jobs: run: | echo "repository-configuration=${{ needs.repository-configuration.result }}" echo "compatibility=${{ needs.compatibility.result }}" + echo "public-api=${{ needs.public-api.result }}" echo "roslyn-components=${{ needs.roslyn-components.result }}" echo "provider-compatibility=${{ needs.provider-compatibility.result }}" echo "native-aot=${{ needs.native-aot.result }}" @@ -783,6 +825,7 @@ jobs: if [[ "${{ needs.repository-configuration.result }}" != "success" || \ "${{ needs.compatibility.result }}" != "success" || \ + "${{ needs.public-api.result }}" != "success" || \ "${{ needs.provider-compatibility.result }}" != "success" || \ "${{ needs.native-aot.result }}" != "success" || \ "${{ needs.roslyn-components.result }}" != "success" || \ diff --git a/.github/workflows/compatibility-canary.yml b/.github/workflows/compatibility-canary.yml new file mode 100644 index 00000000..5e7c20a6 --- /dev/null +++ b/.github/workflows/compatibility-canary.yml @@ -0,0 +1,152 @@ +name: Compatibility Canary + +on: + schedule: + - cron: "43 7 * * 2" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: compatibility-canary-${{ github.repository }} + cancel-in-progress: false + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" + +jobs: + supported-dapper: + name: Supported evidence (${{ matrix.label }}, Dapper ${{ matrix.version }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - label: minimum-supported + version: 2.1.79 + - label: latest-supported + version: 2.1.89 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Restore supported lane + run: dotnet restore ./Dapper.FluentMap.slnx -p:DapperPackageVersion=${{ matrix.version }} + + - name: Build supported lane + run: dotnet build ./Dapper.FluentMap.slnx --configuration Release --no-restore -p:DapperPackageVersion=${{ matrix.version }} + + - name: Test supported lane + run: >- + dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj + --configuration Release --no-build + + forward-dapper: + name: Experimental forward Dapper + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Resolve forward-looking Dapper version + id: resolve + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $supported = '2.1.89' + $index = Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/dapper/index.json' + $candidates = @( + $index.versions | + Where-Object { [version](($_ -split '-', 2)[0]) -gt [version]$supported } | + Sort-Object { [version](($_ -split '-', 2)[0]) } + ) + $candidate = if ($candidates.Count -eq 0) { $null } else { $candidates[-1] } + $available = -not [string]::IsNullOrWhiteSpace($candidate) + "available=$($available.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "version=$candidate" >> $env:GITHUB_OUTPUT + if ($available) { + "### Experimental Dapper candidate: $candidate" >> $env:GITHUB_STEP_SUMMARY + } else { + '### No newer Dapper stable/prerelease is currently resolvable.' >> $env:GITHUB_STEP_SUMMARY + } + + - name: Restore experimental lane + if: ${{ steps.resolve.outputs.available == 'true' }} + run: dotnet restore ./Dapper.FluentMap.slnx -p:DapperPackageVersion=${{ steps.resolve.outputs.version }} + + - name: Build experimental lane + if: ${{ steps.resolve.outputs.available == 'true' }} + run: dotnet build ./Dapper.FluentMap.slnx --configuration Release --no-restore -p:DapperPackageVersion=${{ steps.resolve.outputs.version }} + + - name: Test experimental lane + if: ${{ steps.resolve.outputs.available == 'true' }} + run: >- + dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj + --configuration Release --no-build + + provider-clients: + name: Experimental latest provider clients + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Restore latest stable provider clients + run: >- + dotnet restore ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj + -p:SqlClientPackageVersion=* + -p:SqliteClientPackageVersion=* + -p:MySqlConnectorPackageVersion=* + -p:NpgsqlPackageVersion=* + + - name: Report resolved experimental clients + shell: pwsh + run: | + $assets = Get-Content -Raw './test/Dapper.FluentMap.ProviderCompatibility.Tests/obj/project.assets.json' | ConvertFrom-Json + $packages = @('Microsoft.Data.SqlClient/', 'Microsoft.Data.Sqlite/', 'MySqlConnector/', 'Npgsql/') + foreach ($prefix in $packages) { + $resolved = @($assets.libraries.PSObject.Properties.Name | Where-Object { $_ -like "$prefix*" }) + "- experimental: $($resolved -join ', ')" >> $env:GITHUB_STEP_SUMMARY + } + + - name: Build against experimental clients + run: >- + dotnet build ./test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj + --configuration Release --no-restore + -p:SqlClientPackageVersion=* + -p:SqliteClientPackageVersion=* + -p:MySqlConnectorPackageVersion=* + -p:NpgsqlPackageVersion=* + + - name: Run always-available SQLite evidence + env: + DFM_PROVIDER_FILTER: SQLite + run: >- + dotnet ./test/Dapper.FluentMap.ProviderCompatibility.Tests/bin/Release/net10.0/Dapper.FluentMap.ProviderCompatibility.Tests.dll + -failSkips -parallelMode none diff --git a/.github/workflows/performance-report.yml b/.github/workflows/performance-report.yml new file mode 100644 index 00000000..0efec724 --- /dev/null +++ b/.github/workflows/performance-report.yml @@ -0,0 +1,102 @@ +name: Performance Regression Report + +on: + schedule: + - cron: "19 9 * * 3" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: performance-report-${{ github.repository }} + cancel-in-progress: false + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" + +jobs: + materialization: + name: Materialization hot paths (report only) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + global-json-file: global.json + + - name: Restore benchmark project + run: dotnet restore ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj + + - name: Build benchmark project + run: dotnet build ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj --configuration Release --no-restore + + - name: Run materialization benchmark report + run: >- + dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj + --configuration Release --no-build -- + --filter "*DapperWithFluentMapRootMapping*" "*QueryMappedSimple" "*RuntimeQueryMappedSimple" + "*RuntimeQueryGeneratedSimpleExtraColumn*" "*QueryMappedGeneratedPropertyConverter*" + "*QueryMappedTwoSegments*" "*QueryMappedThreeSegments*" + --exporters fulljson + + - name: Capture benchmark run metadata + shell: pwsh + env: + BENCHMARK_COMMAND: >- + dotnet run --project ./benchmarks/Dapper.FluentMap.Benchmarks/Dapper.FluentMap.Benchmarks.csproj + --configuration Release --no-build -- + --filter "*DapperWithFluentMapRootMapping*" "*QueryMappedSimple" "*RuntimeQueryMappedSimple" + "*RuntimeQueryGeneratedSimpleExtraColumn*" "*QueryMappedGeneratedPropertyConverter*" + "*QueryMappedTwoSegments*" "*QueryMappedThreeSegments*" + --exporters fulljson + COMMIT_SHA: "${{ github.sha }}" + RUNNER_NAME: "${{ runner.name }}" + RUNNER_OS: "${{ runner.os }}" + RUNNER_ARCH: "${{ runner.arch }}" + run: | + New-Item -ItemType Directory -Force -Path './artifacts/benchmark-report' | Out-Null + $metadata = [ordered]@{ + command = $env:BENCHMARK_COMMAND + commit = $env:COMMIT_SHA + runner = [ordered]@{ + name = $env:RUNNER_NAME + os = $env:RUNNER_OS + architecture = $env:RUNNER_ARCH + } + dotnetInfo = ((dotnet --info) -join [Environment]::NewLine) + } + $metadata | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath './artifacts/benchmark-report/run-metadata.json' -Encoding utf8 + + - name: Compare with checked-in baseline + shell: pwsh + run: | + $result = Get-ChildItem './.tmp/benchmarks/BenchmarkDotNet.Artifacts/results/*-report-full.json' | + Select-Object -First 1 + if ($null -eq $result) { + throw 'BenchmarkDotNet JSON output was not produced.' + } + ./eng/compare-benchmark-results.ps1 ` + -BaselinePath './benchmarks/baselines/materialization-windows-net10.json' ` + -CurrentPath $result.FullName ` + -OutputDirectory './artifacts/benchmark-report' + Get-Content './artifacts/benchmark-report/benchmark-comparison.md' >> $env:GITHUB_STEP_SUMMARY + + - name: Upload benchmark evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: materialization-benchmark-report + path: | + ./.tmp/benchmarks/BenchmarkDotNet.Artifacts/results/* + ./artifacts/benchmark-report/* + if-no-files-found: error + retention-days: 90 diff --git a/CHANGELOG.md b/CHANGELOG.md index e1651def..4ddafcae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The historical archived package history is not reconstructed here. This changelo ## [Unreleased] +### Added + +- Added reflection-free `GeneratedParameters` for parameterized strict-generated queries, including provider-matrix and Native AOT smoke coverage. +- Added Dommel `InsertMapped*`/`UpdateMapped*` operations that execute per-property write converters while preserving persistence exclusions and Dapper type-handler precedence. +- Added three-input FluentMap-controlled multi-mapping with multiple validated `splitOn` boundaries, per-segment profiles, async execution, null-child semantics and isolated-runtime APIs. +- Added explicit `ConstructUsing(...)` factory strategies for one to four mapped values, with configuration validation and deterministic generated/strict boundaries. +- Added analyzer diagnostic `DFM016` for duplicate explicit construction strategies. +- Added checked-in nullable public API baselines for every shipped assembly, enforced by CI. +- Added report-only scheduled/manual compatibility canaries and materialization benchmark regression reporting with machine-readable artifacts. + +### Changed + +- Generated materializers now accept safe additional reader columns that do not resolve to explicit FluentMap members and provide specific strict diagnostics for missing, duplicate/ambiguous and explicitly mapped additional columns; Dapper convention-only discovery remains a non-strict behavior. +- Two- and three-input multi-mapping now share a single segment materialization pipeline. +- Public projects now emit deliberate nullable reference annotations using staged `Nullable=annotations`; optional query inputs, metadata and multi-mapping null-child delegates reflect actual behavior without changing CLR signatures. +- Generated value-conversion failures now surface as contextual `FluentMapConfigurationException` instances instead of leaking provider-dependent `FormatException` or `InvalidCastException` exceptions. + ## [3.4.0] - 2026-09-27 ### Added diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 4f290fd6..429b3533 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -18,6 +18,8 @@ The `FluentMap.*` PackageIds are NuGet distribution identities. Assemblies and n ## Dapper +The machine-readable source of truth is `eng/compatibility-contract.json`. CI verifies this document, package properties, compatibility lanes, migration guidance and produced NuGet dependency metadata against that contract. + Current package range: ```text @@ -67,6 +69,10 @@ Provider support is split into certification levels: Provider certification requires real integration tests against that provider and database. A Dommel SQL builder being registered is not the same as provider certification. +## Experimental Compatibility Canaries + +The scheduled/manual `Compatibility Canary` workflow checks the supported Dapper minimum/current lanes, resolves a newer Dapper stable or prerelease when one exists, and compiles/runs SQLite evidence against the latest resolvable stable provider clients. These lanes are early-warning experiments only: they neither expand nor reduce the certified matrix above. A canary result becomes a support claim only after the pinned required matrix, documentation and package contract are intentionally updated and reviewed. + ## AOT And Trimming Current status: @@ -75,14 +81,16 @@ Current status: | --- | --- | | Explicit map registration | Preferred for trimmed and Native AOT applications. | | Generated registration | Preferred alternative to assembly scanning for maps in the current compilation. | -| Strict generated runtime | `UseStrictGeneratedMaterialization()` and `QueryGeneratedMapped*` provide a generated-only path for parameterless commands; unsupported shapes and dynamic parameter objects fail deterministically. | +| Strict generated runtime | `UseStrictGeneratedMaterialization()` and `QueryGeneratedMapped*` provide a generated-only path for parameterless commands and explicitly typed `GeneratedParameters`; unsupported shapes and arbitrary parameter objects fail deterministically. | | Assembly scanning | Reflection-based and annotated as trimming-sensitive. | | `QueryMapped*`, `ReadMapped*`, `QueryMultipleMapped`, streaming | Annotated with trimming/dynamic-code warnings because runtime fallback can occur. | | Full Native AOT compatibility | Not claimed. | Trimmed smoke tests cover explicit, generated and DI scenarios. The CI Native AOT lane publishes and runs the strict generated SQLite smoke on `windows-latest`/`win-x64`, treating relevant trimming and AOT warnings as errors. Local Native AOT publishing still requires the Visual C++ linker toolchain and full Native AOT compatibility is not claimed. -Generated materializers accept their exact registered shape and safe permutations when column names are distinct. Missing, additional or duplicate-column shapes remain unsupported and use runtime fallback unless strict generated materialization is enabled. Resolution is based on reader metadata, not SQL parsing. +Generated materializers accept their exact registered shape, safe permutations and additional reader columns that do not resolve to explicitly configured FluentMap members. Missing required columns, duplicate/ambiguous names and additional columns that would resolve to an explicit FluentMap member remain unsupported and use runtime fallback unless strict generated materialization is enabled. The strict path deliberately does not reflect over convention-only entity members; callers that need Dapper's default-member discovery must use the non-strict API. Resolution is based on reader metadata, not SQL parsing. + +`GeneratedParameters` uses only public ADO.NET parameter contracts and requires an explicit `DbType` for every input. It does not inspect parameter-object members or invoke Dapper's anonymous-object parameter generation. The Native AOT smoke covers generated startup registration, parameter binding, reordered columns, a safe additional column and deterministic strict rejection. This is bounded evidence for the strict path, not a claim of full FluentMap Native AOT compatibility. ## Global State Limitations @@ -103,7 +111,7 @@ var runtime = new FluentMapConfigurationBuilder() var customer = runtime.QueryMappedSingle(connection, sql); ``` -That isolation applies to `QueryMapped*`, `QueryGeneratedMapped*`, two-type `splitOn` multi-mapping including per-segment profile overloads, `ReadMapped*`, `QueryMultipleMapped`, streaming, profiles, converters, diagnostics and generated materializer lookup. It does not make normal Dapper queries or Dommel select a runtime per call. +That isolation applies to `QueryMapped*`, `QueryGeneratedMapped*`, two/three-type `splitOn` multi-mapping including per-segment profile overloads, explicit `ConstructUsing(...)` factories, `ReadMapped*`, `QueryMultipleMapped`, streaming, profiles, converters, diagnostics and generated materializer lookup. It does not make normal Dapper queries or Dommel select a runtime per call. ## API Compatibility @@ -122,6 +130,8 @@ The fork also adds public APIs for profiles, naming policies, generated material The maintained 3.x line follows Semantic Versioning. See the GitHub releases page for the current stable version. Public compatibility remains governed by SemVer and the package/API boundaries listed above. +Checked-in `PublicAPI.Shipped.txt`/`PublicAPI.Unshipped.txt` files govern every shipped assembly and include nullable annotations. See [MAINTAINING.md](MAINTAINING.md) for the approval and release process. + ## Unsupported Environments Or Claims - Dapper major versions outside `[2.1.79,3.0.0)` are not currently supported. @@ -129,5 +139,7 @@ The maintained 3.x line follows Semantic Versioning. See the GitHub releases pag - Full Native AOT support is not claimed. - Provider behavior outside the exact tested server/client versions listed above is not certified. - Dommel configuration isolation per `FluentMapRuntime` is not supported. -- Three-or-more-type multi-mapping, graph aggregation and CRUD generation are not implemented. +- Multi-mapping is supported for two or three input types. Four-or-more input types, automatic graph aggregation and generic core CRUD generation are not implemented. +- Explicit delegate construction strategies are supported by runtime materialization. The generator reports `DFM011` and strict generated mode rejects those shapes; no full Native AOT claim is made for delegate factories. +- Property write conversion is supported only by the Dommel adapter's opt-in `InsertMapped*` and `UpdateMapped*` APIs. Historical Dommel `Insert`/`Update` do not execute property converters. - `MappedGridReader` result sets must be consumed sequentially; concurrent reads are rejected deterministically. diff --git a/Directory.Build.props b/Directory.Build.props index 6552d286..b02aa8dd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,10 +3,11 @@ 3.4.0 dev $(FluentMapPackageVersionSuffix) - 2.1.89 + 2.1.79 2.1.89 [$(DapperMinimumSupportedVersion),3.0.0) [3.5.3,4.0.0) + 5.6.0 diff --git a/MAINTAINING.md b/MAINTAINING.md new file mode 100644 index 00000000..854ec11d --- /dev/null +++ b/MAINTAINING.md @@ -0,0 +1,39 @@ +# Maintainer Governance + +This document defines the review process for public API, nullable contracts, compatibility canaries and performance evidence. These checks supplement package validation, provider certification, security analysis and the normal test suite. + +## Public API baselines + +Every shipped project under `src/` has `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`. `Microsoft.CodeAnalysis.PublicApiAnalyzers` validates their union during every build, including the dedicated `Public API and nullable contracts` CI job. + +For an intentional API change: + +1. update implementation, XML documentation and focused tests; +2. run `dotnet format analyzers --diagnostics RS0016 RS0036` to update additions and nullable annotations in `PublicAPI.Unshipped.txt`; record approved removals of shipped APIs manually in `PublicAPI.Unshipped.txt` with the `*REMOVED*` prefix; +3. review the API-file diff as a compatibility contract, including nullability markers; +4. document consumer and SemVer impact; +5. after the API ships, move its entries from `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt` in ordinal order. + +Do not hand-approve a removal or signature replacement without the compatibility and SemVer review required for a public library. Package validation remains enabled and is not replaced by these files. + +## Nullable contracts + +Maintained public projects compile with C# nullable annotations enabled. Warning enforcement is staged as `Nullable=annotations` because enabling full flow analysis currently exposes substantial internal legacy debt; this prevents a blanket suppression migration. Public signatures must still model actual behavior deliberately. + +In particular, optional query parameters and transactions are nullable, ignored generated columns expose a nullable member path, optional profiles/converters are nullable, and multi-mapping delegates receive nullable child segments for all-`NULL` LEFT JOIN segments. Nullable annotation changes preserve CLR binary signatures but can create or remove consumer compiler warnings, so they require API-baseline review and changelog notes. + +## Compatibility canaries + +`Compatibility Canary` is scheduled and manually runnable. It records: + +- the minimum and latest supported Dapper lanes; +- a forward-looking Dapper stable/prerelease when NuGet exposes one newer than the certified lane; +- compilation and SQLite evidence against the latest resolvable stable provider clients. + +The canary is early-warning evidence. A green canary does not add a supported version, and a red canary does not remove one. Only the pinned required CI/provider matrix and `COMPATIBILITY.md` define certification. Investigate failures, record upstream incompatibilities and intentionally update the supported matrix only through a separately reviewed change. + +## Performance reporting + +`Performance Regression Report` runs the materialization hot paths on a scheduled/manual Windows hosted runner, exports BenchmarkDotNet CSV/JSON/Markdown/HTML, compares ShortRun results with `benchmarks/baselines/materialization-windows-net10.json`, and uploads both raw and comparison artifacts for 90 days. + +The initial signals are report-only: +35% mean time or +15% allocated bytes is highlighted for review. Two initial Windows ShortRun measurements showed substantial timing variance while allocation counts remained stable; scheduled hosted-runner history is not yet sufficient for a hard gate. Reproduce a signal on controlled hardware before treating it as a regression. Update the checked-in baseline only after reviewing the code change, environment/runtime change, repeated measurements and generated comparison artifact. diff --git a/MIGRATION.md b/MIGRATION.md index b4707f12..7544706b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -53,7 +53,7 @@ Before changing FluentMap package versions: - run the existing test suite before and after the upgrade; - identify whether the application uses Dommel, `Ignore()` for database-generated columns, assembly scanning, custom Dapper `TypeHandler` implementations, trimming or Native AOT. -Current supported package ranges are documented in [COMPATIBILITY.md](COMPATIBILITY.md). At the time of this guide: +Current supported package ranges are documented in [COMPATIBILITY.md](COMPATIBILITY.md). The Dapper values are governed by `eng/compatibility-contract.json` and validated against package metadata and CI. At the time of this guide: ```text Dapper [2.1.79,3.0.0) @@ -298,7 +298,7 @@ For value objects stored through mapped components, use FluentMap-controlled mat Map(customer => customer.Cpf.Number).ToColumn("cpf"); ``` -The current materializer uses compatible public constructors. Factory methods are not used. +The runtime materializer still selects compatible public constructors by default. Maps that previously could not be materialized can now opt into `ConstructUsing(...)` with one to four explicitly mapped root-property values; nested paths are rejected during configuration. This factory path is supported by runtime materialization and isolated runtimes; generated materializers report `DFM011`, and strict generated mode rejects the unsupported shape without fallback. ## Profiles @@ -338,7 +338,13 @@ Map(product => product.Status) Normal `Dapper.Query()` does not execute property converters. Use Dapper `TypeHandler` for type-wide conversion. -Write converter metadata exists, but Dapper/Dommel writes do not execute it yet. +Configured write converters execute only through the Dommel adapter's opt-in `InsertMapped*` and `UpdateMapped*` methods. Historical Dommel `Insert`/`Update` behavior is unchanged. The property converter runs first; Dapper then applies any type handler registered for the converter output type. + +Three-input `QueryMapped` overloads now complement the historical two-input API. Supply two comma-separated unique `splitOn` boundaries. These APIs compose individual rows only and do not aggregate one-to-many graphs. + +## Nullable Annotations + +Public assemblies now carry nullable reference annotations. CLR signatures and binary compatibility are unchanged, but nullable-enabled consumers may receive more accurate compiler warnings: optional query parameters/transactions and optional metadata are nullable, and multi-mapping child arguments are nullable because an all-`NULL` segment is passed as `null`. Treat new warnings as contract guidance; do not disable nullable analysis globally to hide them. ## Generated Registration @@ -352,6 +358,10 @@ This can replace manual registration for eligible maps in the current compilatio Generated materializers are an optimization. Unsupported cases fall back to runtime materialization unless strict generated materialization is explicitly enabled. +Strict generated queries can use `GeneratedParameters` for parameterized commands. Each value has an explicit `DbType`; anonymous objects and other arbitrary parameter bags remain unsupported so strict mode does not introduce reflection-based parameter discovery. Safe reordered and additional result columns that do not resolve to explicitly configured FluentMap members can stay on the generated path, while missing, duplicate/ambiguous or explicitly mapped additional columns fail deterministically. Convention-only Dapper member discovery remains exclusive to the non-strict path. + +Generated value-conversion failures now throw `FluentMapConfigurationException` with entity, member, column, provider type and target type context. Code that previously caught a provider-dependent `FormatException` or `InvalidCastException` from generated materialization should catch `FluentMapConfigurationException` and inspect its inner exception instead. + ## Configuration Isolation If your application needs multiple FluentMap configurations in the same process, use immutable configuration and runtime instances: diff --git a/MIGRATION.pt-BR.md b/MIGRATION.pt-BR.md index 616f93ec..1eb20da5 100644 --- a/MIGRATION.pt-BR.md +++ b/MIGRATION.pt-BR.md @@ -53,7 +53,7 @@ Antes de alterar as versões dos pacotes FluentMap: - execute a suíte de testes existente antes e depois da atualização; - identifique se a aplicação usa Dommel, `Ignore()` para colunas geradas pelo banco, assembly scanning, implementações customizadas de `TypeHandler` do Dapper, trimming ou Native AOT. -As faixas suportadas atualmente ficam documentadas em [COMPATIBILITY.md](COMPATIBILITY.md). No momento deste guia: +As faixas suportadas atualmente ficam documentadas em [COMPATIBILITY.md](COMPATIBILITY.md). Os valores do Dapper são governados por `eng/compatibility-contract.json` e validados contra metadata de pacote e CI. No momento deste guia: ```text Dapper [2.1.79,3.0.0) @@ -298,7 +298,7 @@ Para value objects armazenados por componentes mapeados, use materialização co Map(customer => customer.Cpf.Number).ToColumn("cpf"); ``` -O materializador atual usa construtores públicos compatíveis. Factory methods não são utilizados. +O materializador de runtime continua selecionando construtores públicos compatíveis por padrão. Maps que antes não podiam ser materializados agora podem optar por `ConstructUsing(...)`, com um a quatro valores de propriedades raiz explicitamente mapeadas; caminhos aninhados são rejeitados durante a configuração. Esse caminho de factory é suportado pela materialização em runtime e por runtimes isolados; materializadores gerados reportam `DFM011`, e o modo strict generated rejeita o shape sem fallback. ## Profiles @@ -338,7 +338,13 @@ Map(product => product.Status) `Dapper.Query()` normal não executa property converters. Use `TypeHandler` do Dapper para conversões globais por tipo. -Existe metadata de write converter, mas escritas Dapper/Dommel ainda não a executam. +Write converters configurados são executados apenas pelos métodos opt-in `InsertMapped*` e `UpdateMapped*` do adapter Dommel. O comportamento histórico de `Insert`/`Update` do Dommel permanece inalterado. O conversor da propriedade executa primeiro; depois, o Dapper aplica eventual type handler registrado para o tipo de saída do conversor. + +Overloads `QueryMapped` com três entradas agora complementam a API histórica de duas entradas. Informe duas fronteiras `splitOn` únicas, separadas por vírgula. Essas APIs apenas compõem linhas individuais e não agregam grafos um-para-muitos. + +## Anotações Nullable + +Os assemblies públicos agora carregam anotações de nullable reference types. As assinaturas CLR e a compatibilidade binária não mudam, mas consumidores com nullable habilitado podem receber warnings mais precisos: parâmetros/transações opcionais e metadados opcionais são nullable, e argumentos filhos de multi-mapping são nullable porque um segmento contendo apenas `NULL` é entregue como `null`. Trate os novos warnings como orientação de contrato; não desabilite a análise nullable globalmente para ocultá-los. ## Registro Gerado @@ -352,6 +358,10 @@ Isso pode substituir registro manual para maps elegíveis da compilação atual. Materializadores gerados são uma otimização. Casos não suportados usam materialização runtime como fallback, exceto quando o modo estrito de materialização gerada é habilitado explicitamente. +Queries strict generated podem usar `GeneratedParameters` para comandos parametrizados. Cada valor possui um `DbType` explícito; objetos anônimos e outros bags de parâmetros arbitrários continuam não suportados, para que o modo estrito não introduza descoberta de parâmetros por reflection. Colunas reordenadas e colunas adicionais que não resolvem para membros configurados explicitamente no FluentMap podem permanecer no caminho gerado; colunas obrigatórias ausentes, nomes duplicados/ambíguos ou colunas adicionais explicitamente mapeadas falham deterministicamente. A descoberta de membros apenas por convenção do Dapper permanece exclusiva do caminho não estrito. + +Falhas de conversão de valores gerados agora lançam `FluentMapConfigurationException` com contexto da entidade, membro, coluna, tipo do provider e tipo de destino. Código que antes capturava `FormatException` ou `InvalidCastException`, dependentes do provider, deve capturar `FluentMapConfigurationException` e inspecionar a exceção interna. + ## Isolamento de Configuração Se a aplicação precisa de múltiplas configurações FluentMap no mesmo processo, use configuração imutável e instâncias de runtime: diff --git a/README.md b/README.md index b2581de5..64cc785f 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ See [MIGRATION.md](MIGRATION.md) when moving from FluentMap 2.x. | --- | --- | | Explicit property-to-column mapping | `EntityMap`, `Map(...).ToColumn(...)` | | Conventions and naming policies | `AddConvention(...)`, `UseNamingPolicy(...)` | -| Immutable constructor mapping | historical Dapper type-map bridge | +| Immutable/factory construction | constructor mapping, `ConstructUsing(...)` | | Nested objects and component value objects | `QueryMapped*` | | Alternate SQL shapes | mapping profiles | -| Two-type multi-mapping | `QueryMapped(...)` | +| Two/three-type multi-mapping | `QueryMapped<...>(..., splitOn: ...)` | | Multiple result sets | `QueryMultipleMapped*`, `ReadMapped*` | | Sync/async streaming | `QueryMappedUnbuffered*` | | Per-property conversion | property converters | @@ -41,7 +41,7 @@ See [MIGRATION.md](MIGRATION.md) when moving from FluentMap 2.x. | Strict generated path | `UseStrictGeneratedMaterialization()`, `QueryGeneratedMapped*` | | Isolated configuration | `FluentMapRuntime` | | Dependency Injection | `AddFluentMap(...)` | -| Dommel persistence metadata | `Dapper.FluentMap.Dommel` | +| Dommel persistence/write conversion | `InsertMapped*`, `UpdateMapped*` | | Compile-time diagnostics | `FluentMap.Analyzers` | Detailed examples are in [USAGE.md](USAGE.md). @@ -115,7 +115,7 @@ Examples include: - nested objects and component value objects; - profiles; -- two-type `splitOn` multi-mapping; +- two/three-type `splitOn` multi-mapping; - mapped multiple result sets; - sync/async streaming; - property converters; @@ -158,6 +158,7 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for: - [Compatibility](COMPATIBILITY.md) - [Changelog](CHANGELOG.md) - [Support](SUPPORT.md) +- [Maintainer governance](MAINTAINING.md) - [Português (Brasil)](README.pt-BR.md) ## Contributing diff --git a/README.pt-BR.md b/README.pt-BR.md index 5043d814..79fee34e 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -30,10 +30,10 @@ Consulte [MIGRATION.pt-BR.md](MIGRATION.pt-BR.md) ao migrar do FluentMap 2.x. | --- | --- | | Mapping explícito de propriedade para coluna | `EntityMap`, `Map(...).ToColumn(...)` | | Convenções e naming policies | `AddConvention(...)`, `UseNamingPolicy(...)` | -| Constructor mapping imutável | bridge histórico de type map do Dapper | +| Construção imutável/factory | mapping de construtor, `ConstructUsing(...)` | | Objetos aninhados e value objects por componentes | `QueryMapped*` | | Formatos SQL alternativos | mapping profiles | -| Multi-mapping de dois tipos | `QueryMapped(...)` | +| Multi-mapping de dois/três tipos | `QueryMapped<...>(..., splitOn: ...)` | | Múltiplos result sets | `QueryMultipleMapped*`, `ReadMapped*` | | Streaming síncrono/assíncrono | `QueryMappedUnbuffered*` | | Conversão por propriedade | property converters | @@ -41,7 +41,7 @@ Consulte [MIGRATION.pt-BR.md](MIGRATION.pt-BR.md) ao migrar do FluentMap 2.x. | Caminho gerado estrito | `UseStrictGeneratedMaterialization()`, `QueryGeneratedMapped*` | | Configuração isolada | `FluentMapRuntime` | | Dependency Injection | `AddFluentMap(...)` | -| Metadata de persistência Dommel | `Dapper.FluentMap.Dommel` | +| Persistência/conversão de escrita Dommel | `InsertMapped*`, `UpdateMapped*` | | Diagnósticos em compilação | `FluentMap.Analyzers` | Exemplos detalhados estão em [USAGE.pt-BR.md](USAGE.pt-BR.md). @@ -115,7 +115,7 @@ Isso inclui: - objetos aninhados e value objects por componentes; - profiles; -- multi-mapping de dois tipos com `splitOn`; +- multi-mapping de dois/três tipos com `splitOn`; - múltiplos result sets mapeados; - streaming síncrono/assíncrono; - property converters; @@ -158,6 +158,7 @@ Consulte [COMPATIBILITY.md](COMPATIBILITY.md) para: - [Compatibilidade](COMPATIBILITY.md) - [Changelog](CHANGELOG.md) - [Suporte](SUPPORT.md) +- [Governança para mantenedores](MAINTAINING.md) - [English](README.md) ## Contribuição diff --git a/USAGE.md b/USAGE.md index a7a54ea8..db2f2356 100644 --- a/USAGE.md +++ b/USAGE.md @@ -145,7 +145,18 @@ var customer = connection.QueryMappedSingle( "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -The current materializer uses compatible public constructors. Factory methods are not used. +The runtime materializer uses compatible public constructors by default. A map can opt into an explicit factory when construction cannot follow that rule: + +```csharp +public CustomerMap() +{ + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + ConstructUsing(customer => customer.Id, customer => customer.Name, Customer.Restore); +} +``` + +`ConstructUsing` supports one to four explicitly mapped root-property values, validates every binding, and is preserved by isolated runtimes. Nested property paths are rejected during configuration because explicit factories currently bind only the root materialization node. Call `ConstructUsing` directly from the map constructor: the source generator analyzes constructor invocations and does not follow helper-method calls. Delegate factories are runtime-materialization strategies: the source generator registers the map but reports `DFM011` and does not emit a generated materializer. Strict generated mode therefore rejects that shape instead of silently using the factory through reflection. ## Mapping Profiles @@ -191,7 +202,7 @@ var legacy = connection.QueryMappedSingle(legacySql); These APIs complement normal Dapper queries; they do not replace them for simple root-level mappings. -## Two-Type Multi-Mapping +## Multi-Mapping For rows that contain two mapped entities, use explicit `splitOn` and a composition delegate: @@ -208,6 +219,17 @@ For common `LEFT JOIN` scenarios, if all columns in the second segment are `NULL This API performs row splitting; it does not aggregate repeated rows into one-to-many object graphs. +Three input segments are also supported through the same segment pipeline, including async and isolated-runtime variants: + +```csharp +var rows = connection.QueryMapped( + sql, + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id"); +``` + +Three-type calls require two comma-separated, unique boundaries in result order. Empty, missing, duplicated, ambiguous or out-of-order boundaries fail deterministically. Any all-`NULL` child segment is passed as `null`. The supported public arity is deliberately capped at three input types; compose rows manually for wider shapes. + ## Multiple Result Sets Use mapped multiple-result APIs when each result set needs FluentMap-controlled materialization: @@ -294,7 +316,15 @@ null/DBNull handling Normal `Dapper.Query()` does not execute property converters. Use Dapper `TypeHandler` for type-wide conversion. -Write-converter metadata exists, but the current Dapper/Dommel write path does not execute it. +Dommel write conversion is opt-in through `InsertMapped*` and `UpdateMapped*` from `Dapper.FluentMap.Dommel`: + +```csharp +var id = connection.InsertMapped(product); +product.Id = Convert.ToInt32(id); +connection.UpdateMapped(product); +``` + +Only properties participating in the selected operation are converted. Converter output is then passed to Dapper, so a registered Dapper type handler for the converter output type runs after the property converter. Converter failures include entity, property, column and operation context and never retry with the original value. Historical Dommel `Insert`/`Update` remain unchanged because Dommel exposes no per-property parameter hook. ## Persistence Metadata And Dommel @@ -374,10 +404,15 @@ var runtime = new FluentMapConfigurationBuilder() var customer = runtime.QueryGeneratedMappedSingle( connection, - "SELECT 7 AS customer_id, 'Ada' AS customer_name;"); + "SELECT @Id AS customer_id, @Name AS customer_name, 'trace' AS trace_id;", + new GeneratedParameters() + .Add("Id", 7, System.Data.DbType.Int32) + .Add("Name", "Ada", System.Data.DbType.String, size: 100)); ``` -Unsupported shapes fail deterministically instead of silently using runtime materialization. +`GeneratedParameters` binds only explicitly named input values with an explicit `DbType`; it does not inspect anonymous objects. Parameterless commands remain supported. Arbitrary parameter objects fail before the connection is opened, with `QueryMapped*` identified as the non-strict alternative. + +Generated materializers accept reordered required columns and additional columns only when the additional names do not resolve to explicitly configured FluentMap members. Missing required columns, duplicate names, explicitly mapped additional columns, incompatible provider values and unsupported nested shapes fail deterministically instead of silently using runtime materialization. Strict generated resolution does not reflect over convention-only entity members; use the non-strict API when Dapper default-member discovery is required. The strict generated path has a narrower supported contract than normal FluentMap-controlled materialization. Review [COMPATIBILITY.md](COMPATIBILITY.md) before using it for trimming or Native AOT deployments. diff --git a/USAGE.pt-BR.md b/USAGE.pt-BR.md index d1ecd18b..01269980 100644 --- a/USAGE.pt-BR.md +++ b/USAGE.pt-BR.md @@ -145,7 +145,18 @@ var customer = connection.QueryMappedSingle( "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -O materializador atual usa construtores públicos compatíveis. Factory methods não são utilizados. +Por padrão, o materializador de runtime usa construtores públicos compatíveis. Um mapping pode optar por uma factory explícita quando essa regra não for suficiente: + +```csharp +public CustomerMap() +{ + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + ConstructUsing(customer => customer.Id, customer => customer.Name, Customer.Restore); +} +``` + +`ConstructUsing` aceita de um a quatro valores de propriedades raiz explicitamente mapeadas, valida cada binding e é preservado por runtimes isolados. Caminhos de propriedades aninhadas são rejeitados durante a configuração porque factories explícitas atualmente vinculam apenas o nó raiz de materialização. Chame `ConstructUsing` diretamente no construtor do map: o source generator analisa as invocações do construtor e não segue chamadas a métodos auxiliares. Factories delegate são estratégias de materialização em runtime: o source generator registra o map, mas reporta `DFM011` e não emite materializador gerado. Portanto, o modo strict generated rejeita esse shape sem fallback silencioso. ## Mapping Profiles @@ -191,7 +202,7 @@ var legacy = connection.QueryMappedSingle(legacySql); Essas APIs complementam queries normais do Dapper; elas não substituem o Dapper para mappings simples no nível raiz. -## Multi-Mapping de Dois Tipos +## Multi-Mapping Para linhas que contêm duas entidades mapeadas, use `splitOn` explícito e um delegate de composição: @@ -208,6 +219,17 @@ Em cenários comuns com `LEFT JOIN`, se todas as colunas do segundo segmento for Essa API faz split de uma linha; ela não agrega linhas repetidas em grafos um-para-muitos. +Três segmentos de entrada também são suportados pelo mesmo pipeline, inclusive nas variantes assíncronas e de runtime isolado: + +```csharp +var rows = connection.QueryMapped( + sql, + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id"); +``` + +Chamadas com três tipos exigem duas fronteiras únicas, separadas por vírgula e na ordem do resultado. Fronteiras vazias, ausentes, duplicadas, ambíguas ou fora de ordem falham deterministicamente. Cada segmento filho contendo apenas `NULL` é entregue como `null`. A aridade pública é deliberadamente limitada a três tipos de entrada; componha manualmente shapes mais largos. + ## Múltiplos Result Sets Use as APIs de múltiplos resultados mapeados quando cada result set precisar de materialização controlada pelo FluentMap: @@ -294,7 +316,15 @@ tratamento de null/DBNull `Dapper.Query()` normal não executa property converters. Use `TypeHandler` do Dapper para conversão global por tipo. -Existe metadata de write converter, mas o caminho atual de escrita Dapper/Dommel não a executa. +A conversão de escrita do Dommel é opt-in por `InsertMapped*` e `UpdateMapped*`, do pacote `Dapper.FluentMap.Dommel`: + +```csharp +var id = connection.InsertMapped(product); +product.Id = Convert.ToInt32(id); +connection.UpdateMapped(product); +``` + +Somente propriedades participantes da operação escolhida são convertidas. A saída do conversor é entregue ao Dapper; portanto, um type handler registrado para o tipo de saída é executado depois do conversor da propriedade. Falhas incluem contexto de entidade, propriedade, coluna e operação, sem nova tentativa com o valor original. Os métodos históricos `Insert`/`Update` do Dommel permanecem inalterados porque o Dommel não oferece hook por parâmetro/propriedade. ## Metadata de Persistência e Dommel @@ -374,10 +404,15 @@ var runtime = new FluentMapConfigurationBuilder() var customer = runtime.QueryGeneratedMappedSingle( connection, - "SELECT 7 AS customer_id, 'Ada' AS customer_name;"); + "SELECT @Id AS customer_id, @Name AS customer_name, 'trace' AS trace_id;", + new GeneratedParameters() + .Add("Id", 7, System.Data.DbType.Int32) + .Add("Name", "Ada", System.Data.DbType.String, size: 100)); ``` -Shapes não suportados falham deterministicamente em vez de usar silenciosamente materialização runtime. +`GeneratedParameters` vincula somente valores de entrada nomeados explicitamente e com `DbType` explícito; ele não inspeciona objetos anônimos. Comandos sem parâmetros continuam suportados. Objetos de parâmetros arbitrários falham antes de abrir a conexão, e o diagnóstico identifica `QueryMapped*` como alternativa não estrita. + +Materializadores gerados aceitam colunas obrigatórias reordenadas e colunas adicionais somente quando os nomes adicionais não resolvem membros configurados explicitamente no FluentMap. Colunas obrigatórias ausentes, nomes duplicados, colunas adicionais explicitamente mapeadas, valores CLR incompatíveis do provider e shapes aninhados não suportados falham deterministicamente, sem fallback silencioso para materialização runtime. A resolução strict generated não usa reflection para descobrir membros disponíveis apenas pela convenção do Dapper; use a API não estrita quando essa descoberta for necessária. O caminho gerado estrito possui um contrato suportado mais restrito do que a materialização normal controlada pelo FluentMap. Revise [COMPATIBILITY.md](COMPATIBILITY.md) antes de usá-lo em deployments com trimming ou Native AOT. diff --git a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs index 87a1cd00..85bedceb 100644 --- a/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs +++ b/benchmarks/Dapper.FluentMap.Benchmarks/Program.cs @@ -31,6 +31,7 @@ public class MaterializationSteadyStateBenchmarks private SqliteConnection _connection = null!; private FluentMapRuntime _runtime = null!; + private FluentMapRuntime _strictRuntime = null!; [GlobalSetup] public async Task GlobalSetup() @@ -47,6 +48,11 @@ public async Task GlobalSetup() _runtime = new FluentMapRuntime(new FluentMapConfigurationBuilder() .Configure(configuration => configuration.AddGeneratedMappings()) .Build()); + _strictRuntime = new FluentMapConfigurationBuilder() + .Configure(configuration => configuration.AddGeneratedMappings()) + .UseStrictGeneratedMaterialization() + .Build() + .CreateRuntime(); _connection = OpenPopulatedConnection(); DapperPure(); @@ -61,6 +67,7 @@ public async Task GlobalSetup() await RuntimeQueryMappedSimpleUnbufferedAsync(); QueryMappedSimpleReorderedGenerated(); RuntimeQueryMappedSimpleReorderedGenerated(); + RuntimeQueryGeneratedSimpleExtraColumn(); QueryMappedSimpleUnbufferedReorderedGenerated(); await QueryMappedSimpleUnbufferedAsyncReorderedGenerated(); QueryMappedImmutableConstructor(); @@ -78,6 +85,8 @@ public async Task GlobalSetup() QueryMappedRuntimeTypeHandler(); QueryMappedGeneratedPropertyConverter(); QueryMappedRuntimePropertyConverter(); + QueryMappedTwoSegments(); + QueryMappedThreeSegments(); } [GlobalCleanup] @@ -187,6 +196,15 @@ public int RuntimeQueryMappedSimpleReorderedGenerated() .Count(); } + [Benchmark] + public int RuntimeQueryGeneratedSimpleExtraColumn() + { + return _strictRuntime.QueryGeneratedMapped( + _connection, + "SELECT 'trace' AS trace_id, Name AS full_name, Id AS customer_id, Age AS customer_age, Balance AS account_balance, CreatedAt AS created_at FROM BenchmarkRows;") + .Count(); + } + [Benchmark] public int QueryMappedSimpleUnbufferedReorderedGenerated() { @@ -335,6 +353,26 @@ public int QueryMappedRuntimePropertyConverter() .Count(); } + [Benchmark] + public int QueryMappedTwoSegments() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS customer_name, Id AS order_id, Balance AS order_total FROM BenchmarkRows;", + (customer, order) => customer.Id + (order?.Id ?? 0), + splitOn: "order_id") + .Count(); + } + + [Benchmark] + public int QueryMappedThreeSegments() + { + return _connection.QueryMapped( + "SELECT Id AS customer_id, Name AS customer_name, Id AS order_id, Balance AS order_total, Id AS shipment_id, City AS shipment_status FROM BenchmarkRows;", + (customer, order, shipment) => customer.Id + (order?.Id ?? 0) + (shipment?.Id ?? 0), + splitOn: "order_id,shipment_id") + .Count(); + } + private static SqliteConnection OpenPopulatedConnection() { var connection = new SqliteConnection("Data Source=:memory:"); @@ -977,3 +1015,51 @@ public ColdValueObjectCustomerMap() Map(customer => customer.Balance.Currency).ToColumn("currency"); } } + +public sealed class BenchmarkJoinCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class BenchmarkJoinCustomerMap : EntityMap +{ + public BenchmarkJoinCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } +} + +public sealed class BenchmarkJoinOrder +{ + public int Id { get; set; } + + public decimal Total { get; set; } +} + +public sealed class BenchmarkJoinOrderMap : EntityMap +{ + public BenchmarkJoinOrderMap() + { + Map(order => order.Id).ToColumn("order_id"); + Map(order => order.Total).ToColumn("order_total"); + } +} + +public sealed class BenchmarkJoinShipment +{ + public int Id { get; set; } + + public string Status { get; set; } = string.Empty; +} + +public sealed class BenchmarkJoinShipmentMap : EntityMap +{ + public BenchmarkJoinShipmentMap() + { + Map(shipment => shipment.Id).ToColumn("shipment_id"); + Map(shipment => shipment.Status).ToColumn("shipment_status"); + } +} diff --git a/benchmarks/baselines/materialization-windows-net10.json b/benchmarks/baselines/materialization-windows-net10.json new file mode 100644 index 00000000..e0cabc51 --- /dev/null +++ b/benchmarks/baselines/materialization-windows-net10.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "1.0", + "runtime": ".NET 10", + "operatingSystem": "Windows", + "job": "ShortRun", + "calibrationRuns": 2, + "calibrationNote": "Initial Windows ShortRun measurements showed material timing variance while allocations remained stable; thresholds are report-only pending scheduled hosted-runner history.", + "timeRegressionThresholdPercent": 35, + "allocationRegressionThresholdPercent": 15, + "reportOnly": true, + "scenarios": [ + { "method": "DapperWithFluentMapRootMapping", "meanNanoseconds": 3066174, "allocatedBytes": 290096 }, + { "method": "QueryMappedSimple", "meanNanoseconds": 4179233, "allocatedBytes": 267784 }, + { "method": "RuntimeQueryMappedSimple", "meanNanoseconds": 3238907, "allocatedBytes": 267784 }, + { "method": "RuntimeQueryGeneratedSimpleExtraColumn", "meanNanoseconds": 3220158, "allocatedBytes": 304867 }, + { "method": "QueryMappedGeneratedPropertyConverter", "meanNanoseconds": 1207906, "allocatedBytes": 146608 }, + { "method": "QueryMappedTwoSegments", "meanNanoseconds": 1795108, "allocatedBytes": 269200 }, + { "method": "QueryMappedThreeSegments", "meanNanoseconds": 5463660, "allocatedBytes": 398976 } + ] +} diff --git a/eng/compare-benchmark-results.ps1 b/eng/compare-benchmark-results.ps1 new file mode 100644 index 00000000..2a903fff --- /dev/null +++ b/eng/compare-benchmark-results.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath, + + [Parameter(Mandatory = $true)] + [string]$CurrentPath, + + [Parameter(Mandatory = $true)] + [string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' + +$baseline = Get-Content -Raw -LiteralPath $BaselinePath | ConvertFrom-Json +$current = Get-Content -Raw -LiteralPath $CurrentPath | ConvertFrom-Json +$shortRun = @($current.Benchmarks | Where-Object { $_.DisplayInfo -like '*ShortRun*' }) +if ($shortRun.Count -eq 0) { + throw 'The BenchmarkDotNet report contains no ShortRun benchmark results.' +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + +$comparisons = foreach ($scenario in $baseline.scenarios) { + $candidates = @($shortRun | Where-Object { $_.Method -eq $scenario.method }) + if ($candidates.Count -ne 1) { + throw "Expected one ShortRun result for '$($scenario.method)', found $($candidates.Count)." + } + + $result = $candidates[0] + if ($null -eq $result.Statistics -or + $null -eq $result.Statistics.Mean -or + $null -eq $result.Memory -or + $null -eq $result.Memory.BytesAllocatedPerOperation) { + throw "Benchmark result for '$($scenario.method)' is missing Statistics.Mean or Memory.BytesAllocatedPerOperation." + } + + $timeDelta = (($result.Statistics.Mean - $scenario.meanNanoseconds) / $scenario.meanNanoseconds) * 100 + $allocationDelta = (($result.Memory.BytesAllocatedPerOperation - $scenario.allocatedBytes) / $scenario.allocatedBytes) * 100 + $material = $timeDelta -gt $baseline.timeRegressionThresholdPercent -or + $allocationDelta -gt $baseline.allocationRegressionThresholdPercent + + [pscustomobject]@{ + method = $scenario.method + baselineMeanNanoseconds = [math]::Round($scenario.meanNanoseconds, 2) + currentMeanNanoseconds = [math]::Round($result.Statistics.Mean, 2) + timeDeltaPercent = [math]::Round($timeDelta, 2) + baselineAllocatedBytes = [long]$scenario.allocatedBytes + currentAllocatedBytes = [long]$result.Memory.BytesAllocatedPerOperation + allocationDeltaPercent = [math]::Round($allocationDelta, 2) + materialRegression = $material + } +} + +$report = [pscustomobject]@{ + schemaVersion = '1.0' + reportOnly = [bool]$baseline.reportOnly + timeRegressionThresholdPercent = $baseline.timeRegressionThresholdPercent + allocationRegressionThresholdPercent = $baseline.allocationRegressionThresholdPercent + generatedAtUtc = [DateTime]::UtcNow.ToString('O') + comparisons = @($comparisons) +} + +$jsonPath = Join-Path $OutputDirectory 'benchmark-comparison.json' +$markdownPath = Join-Path $OutputDirectory 'benchmark-comparison.md' +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding utf8 + +$lines = @( + '# Materialization benchmark comparison', + '', + "Report-only thresholds: time +$($baseline.timeRegressionThresholdPercent)%; allocation +$($baseline.allocationRegressionThresholdPercent)%.", + '', + '| Scenario | Time delta | Allocation delta | Signal |', + '| --- | ---: | ---: | --- |' +) + +foreach ($comparison in $comparisons) { + $signal = if ($comparison.materialRegression) { 'Review' } else { 'Within threshold' } + $lines += "| $($comparison.method) | $($comparison.timeDeltaPercent)% | $($comparison.allocationDeltaPercent)% | $signal |" +} + +$lines += '' +$lines += 'Hosted-runner measurements are evidence for review and do not fail the workflow on threshold crossings.' +$lines | Set-Content -LiteralPath $markdownPath -Encoding utf8 + +Get-Content -LiteralPath $markdownPath diff --git a/eng/compatibility-contract.json b/eng/compatibility-contract.json new file mode 100644 index 00000000..bbcf7872 --- /dev/null +++ b/eng/compatibility-contract.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "1.0", + "dapper": { + "minimumSupportedVersion": "2.1.79", + "latestStableVersion": "2.1.89", + "packageRange": "[2.1.79,3.0.0)" + } +} diff --git a/eng/test-compatibility-consistency.ps1 b/eng/test-compatibility-consistency.ps1 new file mode 100644 index 00000000..475c9568 --- /dev/null +++ b/eng/test-compatibility-consistency.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$validator = Join-Path $PSScriptRoot 'validate-compatibility-consistency.ps1' + +& $validator -RepositoryRoot $repoRoot + +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("fluentmap-compatibility-" + [Guid]::NewGuid().ToString('N')) +try { + foreach ($directory in @('eng', '.github/workflows')) { + [System.IO.Directory]::CreateDirectory((Join-Path $temporaryRoot $directory)) | Out-Null + } + + foreach ($relativePath in @( + 'eng/compatibility-contract.json', + 'Directory.Build.props', + '.github/workflows/ci.yml', + 'COMPATIBILITY.md', + 'MIGRATION.md')) { + $destination = Join-Path $temporaryRoot $relativePath + [System.IO.File]::Copy((Join-Path $repoRoot $relativePath), $destination) + } + + $compatibilityPath = Join-Path $temporaryRoot 'COMPATIBILITY.md' + $drifted = [System.IO.File]::ReadAllText($compatibilityPath).Replace( + 'Dapper [2.1.79,3.0.0)', + 'Dapper [2.1.89,3.0.0)') + [System.IO.File]::WriteAllText($compatibilityPath, $drifted) + + $detected = $false + try { + & $validator -RepositoryRoot $temporaryRoot + } + catch { + $detected = $_.Exception.Message -like '*COMPATIBILITY.md is inconsistent*' + } + + if (-not $detected) { + throw 'Negative compatibility-consistency proof failed: documentation drift was not detected with an actionable file name.' + } + + Write-Host 'PASS compatibility consistency validator detects intentional documentation drift.' +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} diff --git a/eng/validate-compatibility-consistency.ps1 b/eng/validate-compatibility-consistency.ps1 new file mode 100644 index 00000000..a3d43d0d --- /dev/null +++ b/eng/validate-compatibility-consistency.ps1 @@ -0,0 +1,126 @@ +[CmdletBinding()] +param( + [string]$RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$PackageDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Fail { + param([string]$Message) + throw "Compatibility consistency validation failed: $Message" +} + +function Read-RequiredText { + param([string]$RelativePath) + + $path = Join-Path $RepositoryRoot $RelativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Fail "Required file '$RelativePath' was not found." + } + + return Get-Content -LiteralPath $path -Raw +} + +function Assert-Contains { + param( + [string]$RelativePath, + [string]$Text, + [string]$Expected, + [string]$ContractName + ) + + if ($Text.IndexOf($Expected, [System.StringComparison]::Ordinal) -lt 0) { + Fail "$RelativePath is inconsistent for $ContractName. Expected to find '$Expected'." + } +} + +$manifestPath = Join-Path $RepositoryRoot 'eng/compatibility-contract.json' +if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + Fail "The source-of-truth manifest 'eng/compatibility-contract.json' was not found." +} + +$contract = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ([string]$contract.schemaVersion -ne '1.0') { + Fail "Unsupported manifest schemaVersion '$($contract.schemaVersion)'. Expected '1.0'." +} + +$minimum = [string]$contract.dapper.minimumSupportedVersion +$latest = [string]$contract.dapper.latestStableVersion +$packageRange = [string]$contract.dapper.packageRange +$expectedRange = "[$minimum,3.0.0)" + +if ($packageRange -ne $expectedRange) { + Fail "eng/compatibility-contract.json declares Dapper packageRange '$packageRange', expected '$expectedRange' from minimumSupportedVersion." +} + +$props = Read-RequiredText 'Directory.Build.props' +Assert-Contains 'Directory.Build.props' $props "$minimum" 'Dapper minimum supported version' +Assert-Contains 'Directory.Build.props' $props "$latest" 'Dapper latest stable version' +Assert-Contains 'Directory.Build.props' $props '[$(DapperMinimumSupportedVersion),3.0.0)' 'Dapper package range derivation' + +$ci = Read-RequiredText '.github/workflows/ci.yml' +Assert-Contains '.github/workflows/ci.yml' $ci "dapper-version: $minimum" 'Dapper minimum compatibility lane' +Assert-Contains '.github/workflows/ci.yml' $ci "dapper-version: $latest" 'Dapper latest-stable compatibility lane' + +foreach ($document in @('COMPATIBILITY.md', 'MIGRATION.md')) { + $text = Read-RequiredText $document + Assert-Contains $document $text "Dapper $packageRange" 'documented Dapper package range' +} + +if (-not [string]::IsNullOrWhiteSpace($PackageDirectory)) { + if (-not (Test-Path -LiteralPath $PackageDirectory -PathType Container)) { + Fail "Package directory '$PackageDirectory' does not exist." + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packagesToValidate = @('Dapper.FluentMap', 'Dapper.FluentMap.Dommel') + foreach ($packageId in $packagesToValidate) { + $packagePattern = '^' + [Regex]::Escape($packageId) + '\.\d.*\.nupkg$' + $matches = @( + Get-ChildItem -LiteralPath $PackageDirectory -File -Filter '*.nupkg' | + Where-Object { $_.Name -match $packagePattern } + ) + if ($matches.Count -ne 1) { + Fail "Expected exactly one package for '$packageId' in '$PackageDirectory', found $($matches.Count)." + } + + $archive = [System.IO.Compression.ZipFile]::OpenRead($matches[0].FullName) + try { + $nuspecEntry = @($archive.Entries | Where-Object { $_.FullName -like '*.nuspec' }) + if ($nuspecEntry.Count -ne 1) { + Fail "$($matches[0].Name) must contain exactly one nuspec, found $($nuspecEntry.Count)." + } + + $stream = $nuspecEntry[0].Open() + try { + $reader = [System.IO.StreamReader]::new($stream) + try { + [xml]$nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } + + $dependency = $nuspec.SelectSingleNode("//*[local-name()='dependency' and @id='Dapper']") + if ($null -eq $dependency) { + Fail "$($matches[0].Name) does not declare its required Dapper dependency." + } + + $actualRange = [string]$dependency.GetAttribute('version') + if ($actualRange.Replace(' ', '') -ne $packageRange) { + Fail "$($matches[0].Name) declares Dapper range '$actualRange', expected '$packageRange'." + } + } + finally { + $archive.Dispose() + } + } +} + +Write-Host "Compatibility contract is consistent: Dapper minimum $minimum, latest stable $latest, package range $packageRange." diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md index 11dc9324..4fa53683 100644 --- a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -12,3 +12,4 @@ DFM010 | Dapper.FluentMap.Configuration | Error | Mapping profile is registered DFM013 | Dapper.FluentMap.Configuration | Error | Persistence mapping behavior is invalid. DFM014 | Dapper.FluentMap.Configuration | Error | Property converter is invalid. DFM015 | Dapper.FluentMap.Configuration | Error | Property converter is configured more than once. +DFM016 | Dapper.FluentMap.Configuration | Error | Explicit construction strategy is configured more than once. diff --git a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj index 0b53ca6c..55396e2a 100644 --- a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj +++ b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj @@ -4,6 +4,8 @@ Roslyn analyzers for Dapper.FluentMap that report statically detectable mapping and configuration problems during builds. $(FluentMapPackageVersionPrefix) netstandard2.0 + 8.0 + annotations true false FluentMap.Analyzers @@ -14,6 +16,7 @@ + diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs index 823898d3..25a11487 100644 --- a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -23,6 +23,7 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer public const string InvalidPersistenceBehaviorDiagnosticId = "DFM013"; public const string InvalidPropertyConverterDiagnosticId = "DFM014"; public const string DuplicatePropertyConverterDiagnosticId = "DFM015"; + public const string DuplicateConstructionStrategyDiagnosticId = "DFM016"; private const string Category = "Dapper.FluentMap.Configuration"; private const string MappingNamespace = "Dapper.FluentMap.Mapping"; @@ -122,6 +123,16 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer isEnabledByDefault: true, description: "A single FluentMap property mapping can have at most one read converter and at most one write converter."); + private static readonly DiagnosticDescriptor DuplicateConstructionStrategyRule = new DiagnosticDescriptor( + DuplicateConstructionStrategyDiagnosticId, + "Explicit construction strategy is configured more than once", + "Entity map constructor configures more than one ConstructUsing(...) strategy", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "An entity map can configure only one explicit construction strategy.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( InvalidMapExpressionRule, @@ -133,7 +144,8 @@ public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer DuplicateProfileRegistrationRule, InvalidPersistenceBehaviorRule, InvalidPropertyConverterRule, - DuplicatePropertyConverterRule); + DuplicatePropertyConverterRule, + DuplicateConstructionStrategyRule); public override void Initialize(AnalysisContext context) { @@ -144,9 +156,10 @@ public override void Initialize(AnalysisContext context) { var constructorMapInvocations = new ConcurrentBag(); var profileRegistrations = new ConcurrentBag(); + var constructionStrategies = new ConcurrentBag(); startContext.RegisterSyntaxNodeAction( - nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations, profileRegistrations), + nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations, profileRegistrations, constructionStrategies), SyntaxKind.InvocationExpression); startContext.RegisterCompilationEndAction( @@ -154,6 +167,7 @@ public override void Initialize(AnalysisContext context) { AnalyzeConstructorMapInvocations(endContext, constructorMapInvocations); AnalyzeProfileRegistrations(endContext, profileRegistrations); + AnalyzeConstructionStrategies(endContext, constructionStrategies); }); }); } @@ -161,7 +175,8 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeInvocation( SyntaxNodeAnalysisContext context, ConcurrentBag constructorMapInvocations, - ConcurrentBag profileRegistrations) + ConcurrentBag profileRegistrations, + ConcurrentBag constructionStrategies) { var invocation = (InvocationExpressionSyntax)context.Node; var method = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol as IMethodSymbol; @@ -183,6 +198,19 @@ private static void AnalyzeInvocation( return; } + if (IsConstructUsingInvocation(method)) + { + var constructor = context.ContainingSymbol as IMethodSymbol; + if (constructor?.MethodKind == MethodKind.Constructor) + { + constructionStrategies.Add(new ConstructionStrategyInvocation( + constructor.ContainingType, + GetInvocationNameLocation(invocation))); + } + + return; + } + if (IsGenericAddMapInvocation(method)) { AnalyzeGenericAddMapInvocation(context, invocation, method); @@ -354,6 +382,46 @@ private static void AnalyzeConstructorMapInvocations( } } + private static void AnalyzeConstructionStrategies( + CompilationAnalysisContext context, + ConcurrentBag constructionStrategies) + { + var invocations = constructionStrategies + .OrderBy(invocation => invocation.Location.SourceSpan.Start) + .ToList(); + + for (var index = 0; index < invocations.Count; index++) + { + var candidate = invocations[index]; + var duplicatesSameMap = invocations + .Take(index) + .Any(previous => SymbolEqualityComparer.Default.Equals(previous.MapType, candidate.MapType)); + var duplicatesBaseMap = invocations.Any(other => + !ReferenceEquals(other, candidate) && + IsDerivedFrom(candidate.MapType, other.MapType)); + + if (duplicatesSameMap || duplicatesBaseMap) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateConstructionStrategyRule, + candidate.Location)); + } + } + } + + private static bool IsDerivedFrom(INamedTypeSymbol type, INamedTypeSymbol possibleBaseType) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, possibleBaseType)) + { + return true; + } + } + + return false; + } + private static void ReportDuplicateMemberPaths( CompilationAnalysisContext context, IList invocations) @@ -797,6 +865,13 @@ private static bool IsIncludeBaseInvocation(IMethodSymbol method) IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); } + private static bool IsConstructUsingInvocation(IMethodSymbol method) + { + return method.Name == "ConstructUsing" && + method.IsGenericMethod && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + private static bool IsGenericAddMapInvocation(IMethodSymbol method) { return method.Name == "AddMap" && @@ -1404,6 +1479,19 @@ internal ProfileRegistrationInvocation( internal Location Location { get; } } + private sealed class ConstructionStrategyInvocation + { + internal ConstructionStrategyInvocation(INamedTypeSymbol mapType, Location location) + { + MapType = mapType; + Location = location; + } + + internal INamedTypeSymbol MapType { get; } + + internal Location Location { get; } + } + private enum PersistenceAction { None, diff --git a/src/Dapper.FluentMap.Analyzers/PublicAPI.Shipped.txt b/src/Dapper.FluentMap.Analyzers/PublicAPI.Shipped.txt new file mode 100644 index 00000000..93ba1b6d --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/PublicAPI.Shipped.txt @@ -0,0 +1,16 @@ +#nullable enable + +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.DuplicateColumnDiagnosticId = "DFM003" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.DuplicateMemberPathDiagnosticId = "DFM002" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.DuplicateProfileRegistrationDiagnosticId = "DFM010" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.DuplicatePropertyConverterDiagnosticId = "DFM015" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidGenericMapRegistrationDiagnosticId = "DFM005" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidGenericProfileRegistrationDiagnosticId = "DFM009" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidIncludeBaseDiagnosticId = "DFM004" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidMapExpressionDiagnosticId = "DFM001" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidPersistenceBehaviorDiagnosticId = "DFM013" -> string! +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.InvalidPropertyConverterDiagnosticId = "DFM014" -> string! +Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer +Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.FluentMapConfigurationAnalyzer() -> void +override Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.Initialize(Microsoft.CodeAnalysis.Diagnostics.AnalysisContext! context) -> void +override Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.SupportedDiagnostics.get -> System.Collections.Immutable.ImmutableArray diff --git a/src/Dapper.FluentMap.Analyzers/PublicAPI.Unshipped.txt b/src/Dapper.FluentMap.Analyzers/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..2256098b --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +#nullable enable +const Dapper.FluentMap.Analyzers.FluentMapConfigurationAnalyzer.DuplicateConstructionStrategyDiagnosticId = "DFM016" -> string! diff --git a/src/Dapper.FluentMap.Analyzers/README.md b/src/Dapper.FluentMap.Analyzers/README.md index 34b90480..ea18dc8b 100644 --- a/src/Dapper.FluentMap.Analyzers/README.md +++ b/src/Dapper.FluentMap.Analyzers/README.md @@ -2,7 +2,7 @@ Roslyn analyzers for statically provable `Dapper.FluentMap` configuration errors. -Install it alongside the core package when you want compile-time feedback for invalid map expressions, duplicate member paths, duplicate columns, invalid `IncludeBase()` usage, invalid generic map/profile registration, invalid type-based property converters and duplicate converter configuration in a fluent chain. +Install it alongside the core package when you want compile-time feedback for invalid map expressions, duplicate member paths, duplicate columns, invalid `IncludeBase()` usage, invalid generic map/profile registration, invalid type-based property converters, duplicate converter configuration in a fluent chain and duplicate `ConstructUsing(...)` strategies. ```bash dotnet add package FluentMap.Analyzers diff --git a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj index 5d15d9d0..84ab26eb 100644 --- a/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj +++ b/src/Dapper.FluentMap.DependencyInjection/Dapper.FluentMap.DependencyInjection.csproj @@ -5,6 +5,7 @@ $(FluentMapPackageVersionPrefix) netstandard2.0 8.0 + annotations true FluentMap.DependencyInjection dapper;dependency-injection;di;fluent-mapping;mapping;database;fluentmap @@ -13,5 +14,6 @@ + diff --git a/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Shipped.txt b/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 00000000..4a3345ca --- /dev/null +++ b/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Shipped.txt @@ -0,0 +1,4 @@ +#nullable enable + +Microsoft.Extensions.DependencyInjection.FluentMapServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.FluentMapServiceCollectionExtensions.AddFluentMap(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Unshipped.txt b/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/Dapper.FluentMap.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj index dc924d5c..39a8cb4a 100644 --- a/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj +++ b/src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj @@ -5,6 +5,8 @@ Dommel integration for Dapper.FluentMap that configures Dommel table, key, generated-column, and persistence metadata through fluent mappings. $(FluentMapPackageVersionPrefix) netstandard2.0 + 8.0 + annotations true dapper;dommel;fluent-mapping;mapping;crud;database;sql;fluentmap true @@ -14,5 +16,6 @@ + diff --git a/src/Dapper.FluentMap.Dommel/DommelWriteExtensions.cs b/src/Dapper.FluentMap.Dommel/DommelWriteExtensions.cs new file mode 100644 index 00000000..69ef61ec --- /dev/null +++ b/src/Dapper.FluentMap.Dommel/DommelWriteExtensions.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Dapper.FluentMap.Dommel.Resolvers; +using Dapper.FluentMap.Mapping; +using global::Dommel; + +namespace Dapper.FluentMap.Dommel +{ + /// + /// Provides Dommel-generated write operations that execute FluentMap property write converters. + /// + public static class DommelWriteExtensions + { + private static readonly ConcurrentDictionary> WriteConverterInvokers = + new ConcurrentDictionary>(); + + /// + /// Inserts an entity using Dommel mapping and persistence metadata, applying configured property write converters. + /// + /// The mapped entity type. + /// The database connection. + /// The entity to insert. + /// Optional transaction. + /// The scalar value returned by the provider-specific Dommel insert statement. + public static object? InsertMapped( + this IDbConnection connection, + TEntity entity, + IDbTransaction? transaction = null) + where TEntity : class + { + var command = CreateInsertCommand(connection, entity, transaction, CancellationToken.None); + return connection.ExecuteScalar(command); + } + + /// + /// Asynchronously inserts an entity using Dommel mapping and persistence metadata, applying configured property write converters. + /// + public static async Task InsertMappedAsync( + this IDbConnection connection, + TEntity entity, + IDbTransaction? transaction = null, + CancellationToken cancellationToken = default) + where TEntity : class + { + var command = CreateInsertCommand(connection, entity, transaction, cancellationToken); + return await connection.ExecuteScalarAsync(command).ConfigureAwait(false); + } + + /// + /// Updates an entity using Dommel mapping and persistence metadata, applying configured property write converters. + /// + /// when at least one row was updated. + public static bool UpdateMapped( + this IDbConnection connection, + TEntity entity, + IDbTransaction? transaction = null) + where TEntity : class + { + var command = CreateUpdateCommand(connection, entity, transaction, CancellationToken.None); + return connection.Execute(command) > 0; + } + + /// + /// Asynchronously updates an entity using Dommel mapping and persistence metadata, applying configured property write converters. + /// + /// when at least one row was updated. + public static async Task UpdateMappedAsync( + this IDbConnection connection, + TEntity entity, + IDbTransaction? transaction = null, + CancellationToken cancellationToken = default) + where TEntity : class + { + var command = CreateUpdateCommand(connection, entity, transaction, cancellationToken); + return await connection.ExecuteAsync(command).ConfigureAwait(false) > 0; + } + + private static CommandDefinition CreateInsertCommand( + IDbConnection connection, + TEntity entity, + IDbTransaction transaction, + CancellationToken cancellationToken) + where TEntity : class + { + EnsureArguments(connection, entity); + var type = typeof(TEntity); + var sqlBuilder = DommelMapper.GetSqlBuilder(connection); + var keyProperties = new DommelKeyPropertyResolver().ResolveKeyProperties(type); + var generatedKeys = new HashSet( + keyProperties.Where(key => key.IsGenerated).Select(key => key.Property)); + var properties = (DommelPersistenceMetadata.ResolveInsertProperties(type) ?? + new DommelPropertyResolver().ResolveProperties(type).Select(property => property.Property)) + .Where(property => !generatedKeys.Contains(property)) + .ToArray(); + if (properties.Length == 0) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' has no properties eligible for a mapped INSERT."); + } + + var tableName = global::Dommel.Resolvers.Table(type, sqlBuilder); + var columnNames = properties + .Select(property => global::Dommel.Resolvers.Column(property, sqlBuilder, false)) + .ToArray(); + var parameterNames = properties.Select(property => sqlBuilder.PrefixParameter(property.Name)).ToArray(); + var sql = sqlBuilder.BuildInsert(type, tableName, columnNames, parameterNames); + var parameters = CreateParameters(type, entity, properties, "INSERT"); + DommelMapper.LogReceived?.Invoke(sql); + return new CommandDefinition(sql, parameters, transaction, cancellationToken: cancellationToken); + } + + [SuppressMessage( + "Security", + "S2077:Formatting SQL queries is security-sensitive", + Justification = "Only provider-quoted table and column identifiers are composed here; every entity value remains a Dapper parameter.")] + private static CommandDefinition CreateUpdateCommand( + IDbConnection connection, + TEntity entity, + IDbTransaction transaction, + CancellationToken cancellationToken) + where TEntity : class + { + EnsureArguments(connection, entity); + var type = typeof(TEntity); + var sqlBuilder = DommelMapper.GetSqlBuilder(connection); + var keyProperties = new DommelKeyPropertyResolver().ResolveKeyProperties(type); + if (keyProperties.Length == 0) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' has no key property available for a mapped UPDATE predicate."); + } + + var keySet = new HashSet(keyProperties.Select(key => key.Property)); + var updateProperties = new DommelPropertyResolver() + .ResolveProperties(type) + .Where(property => !property.IsGenerated && !keySet.Contains(property.Property)) + .Select(property => property.Property) + .ToArray(); + if (updateProperties.Length == 0) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' has no properties eligible for a mapped UPDATE."); + } + + var tableName = global::Dommel.Resolvers.Table(type, sqlBuilder); + var assignments = updateProperties.Select(property => + global::Dommel.Resolvers.Column(property, sqlBuilder, false) + " = " + + sqlBuilder.PrefixParameter(property.Name)); + var predicates = keyProperties.Select(key => + global::Dommel.Resolvers.Column(key.Property, sqlBuilder, false) + " = " + + sqlBuilder.PrefixParameter(key.Property.Name)); + var sql = "update " + tableName + " set " + string.Join(", ", assignments) + + " where " + string.Join(" and ", predicates); + var parameterProperties = updateProperties.Concat(keyProperties.Select(key => key.Property)).Distinct().ToArray(); + var parameters = CreateParameters(type, entity, parameterProperties, "UPDATE"); + DommelMapper.LogReceived?.Invoke(sql); + return new CommandDefinition(sql, parameters, transaction, cancellationToken: cancellationToken); + } + + private static DynamicParameters CreateParameters( + Type entityType, + TEntity entity, + IEnumerable properties, + string operation) + where TEntity : class + { + var parameters = new DynamicParameters(); + FluentMapper.EntityMaps.TryGetValue(entityType, out var entityMap); + + foreach (var property in properties) + { + var value = property.GetValue(entity, null); + var propertyMap = entityMap == null + ? null + : DommelPersistenceMetadata.ResolvePropertyMap(entityType, entityMap, property.Name); + var conversion = propertyMap == null + ? PropertyConversionMetadata.Default + : PropertyMapConversion.GetConversion(propertyMap); + var columnName = propertyMap == null || string.IsNullOrWhiteSpace(propertyMap.ColumnName) + ? property.Name + : propertyMap.ColumnName; + + if (conversion.HasWriteConverter && value != null) + { + try + { + value = InvokeWriteConverter(conversion.WriteConverter, value); + } + catch (Exception exception) + { + throw new FluentMapConfigurationException( + $"Write converter failed during Dommel {operation} for entity '{entityType.FullName}', property '{property.Name}', column '{columnName}', converter '{conversion.WriteConverter.ConverterType.FullName}'. The original property value was not written.", + exception); + } + } + + parameters.Add(property.Name, value); + } + + return parameters; + } + + private static object InvokeWriteConverter(PropertyConverterMetadata converter, object value) + { + return WriteConverterInvokers.GetOrAdd(converter, CreateWriteConverterInvoker)(value); + } + + private static Func CreateWriteConverterInvoker(PropertyConverterMetadata converter) + { + var input = Expression.Parameter(typeof(object), "value"); + var converterValue = Expression.Constant(converter.Converter); + Expression call; + + if (converter.Converter is Delegate) + { + call = Expression.Invoke( + Expression.Convert(converterValue, converter.Converter.GetType()), + Expression.Convert(input, converter.PropertyType)); + } + else + { + var interfaceType = typeof(IWritePropertyConverter<,>).MakeGenericType( + converter.PropertyType, + converter.DatabaseType); + call = Expression.Call( + Expression.Convert(converterValue, interfaceType), + interfaceType.GetMethod(nameof(IWritePropertyConverter.ConvertToDatabase)), + Expression.Convert(input, converter.PropertyType)); + } + + return Expression.Lambda>( + Expression.Convert(call, typeof(object)), + input).Compile(); + } + + private static void EnsureArguments(IDbConnection connection, TEntity entity) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (entity == null) + { + throw new ArgumentNullException(nameof(entity)); + } + } + } +} diff --git a/src/Dapper.FluentMap.Dommel/PublicAPI.Shipped.txt b/src/Dapper.FluentMap.Dommel/PublicAPI.Shipped.txt new file mode 100644 index 00000000..cb6955e6 --- /dev/null +++ b/src/Dapper.FluentMap.Dommel/PublicAPI.Shipped.txt @@ -0,0 +1,35 @@ +#nullable enable + +Dapper.FluentMap.Dommel.FluentMapConfigurationExtensions +Dapper.FluentMap.Dommel.Mapping.DommelEntityMap +Dapper.FluentMap.Dommel.Mapping.DommelEntityMap.DommelEntityMap() -> void +Dapper.FluentMap.Dommel.Mapping.DommelEntityMap.TableName.get -> string! +Dapper.FluentMap.Dommel.Mapping.DommelEntityMap.ToTable(string! tableName) -> void +Dapper.FluentMap.Dommel.Mapping.DommelEntityMap.ToTable(string! tableName, string! schemaName) -> void +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.DommelPropertyMap(System.Reflection.PropertyInfo! info) -> void +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.GeneratedOption.get -> System.ComponentModel.DataAnnotations.Schema.DatabaseGeneratedOption? +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.GeneratedOption.set -> void +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.Identity.get -> bool +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.Identity.set -> void +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.IsIdentity() -> Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap! +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.IsKey() -> Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap! +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.Key.get -> bool +Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap.SetGeneratedOption(System.ComponentModel.DataAnnotations.Schema.DatabaseGeneratedOption option) -> Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap! +Dapper.FluentMap.Dommel.Mapping.IDommelEntityMap +Dapper.FluentMap.Dommel.Mapping.IDommelEntityMap.TableName.get -> string! +Dapper.FluentMap.Dommel.Resolvers.DommelColumnNameResolver +Dapper.FluentMap.Dommel.Resolvers.DommelColumnNameResolver.DommelColumnNameResolver() -> void +Dapper.FluentMap.Dommel.Resolvers.DommelColumnNameResolver.ResolveColumnName(System.Reflection.PropertyInfo! propertyInfo) -> string! +Dapper.FluentMap.Dommel.Resolvers.DommelKeyPropertyResolver +Dapper.FluentMap.Dommel.Resolvers.DommelKeyPropertyResolver.DommelKeyPropertyResolver() -> void +Dapper.FluentMap.Dommel.Resolvers.DommelKeyPropertyResolver.ResolveKeyProperties(System.Type! type) -> Dommel.ColumnPropertyInfo![]! +Dapper.FluentMap.Dommel.Resolvers.DommelPropertyResolver +Dapper.FluentMap.Dommel.Resolvers.DommelPropertyResolver.DommelPropertyResolver() -> void +Dapper.FluentMap.Dommel.Resolvers.DommelTableNameResolver +Dapper.FluentMap.Dommel.Resolvers.DommelTableNameResolver.DommelTableNameResolver() -> void +Dapper.FluentMap.Dommel.Resolvers.DommelTableNameResolver.ResolveTableName(System.Type! type) -> string! +override Dapper.FluentMap.Dommel.Mapping.DommelEntityMap.GetPropertyMap(System.Reflection.PropertyInfo! info) -> Dapper.FluentMap.Dommel.Mapping.DommelPropertyMap! +override Dapper.FluentMap.Dommel.Resolvers.DommelPropertyResolver.FilterComplexTypes(System.Collections.Generic.IEnumerable! properties) -> System.Collections.Generic.IEnumerable! +override Dapper.FluentMap.Dommel.Resolvers.DommelPropertyResolver.ResolveProperties(System.Type! type) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.Dommel.FluentMapConfigurationExtensions.ForDommel(this Dapper.FluentMap.Configuration.FluentMapConfiguration! config) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! diff --git a/src/Dapper.FluentMap.Dommel/PublicAPI.Unshipped.txt b/src/Dapper.FluentMap.Dommel/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..97438314 --- /dev/null +++ b/src/Dapper.FluentMap.Dommel/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +Dapper.FluentMap.Dommel.DommelWriteExtensions +static Dapper.FluentMap.Dommel.DommelWriteExtensions.InsertMapped(this System.Data.IDbConnection! connection, TEntity! entity, System.Data.IDbTransaction? transaction = null) -> object? +static Dapper.FluentMap.Dommel.DommelWriteExtensions.InsertMappedAsync(this System.Data.IDbConnection! connection, TEntity! entity, System.Data.IDbTransaction? transaction = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Dapper.FluentMap.Dommel.DommelWriteExtensions.UpdateMapped(this System.Data.IDbConnection! connection, TEntity! entity, System.Data.IDbTransaction? transaction = null) -> bool +static Dapper.FluentMap.Dommel.DommelWriteExtensions.UpdateMappedAsync(this System.Data.IDbConnection! connection, TEntity! entity, System.Data.IDbTransaction? transaction = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs index 4982fbbf..c64bbc2a 100644 --- a/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs +++ b/src/Dapper.FluentMap.Dommel/Resolvers/DommelPersistenceMetadata.cs @@ -26,9 +26,16 @@ internal static bool TryGetPersistence(PropertyInfo property, out PropertyPersis } var mapWithPersistence = propertyMap as IPropertyMapWithPersistenceMetadata; - persistence = mapWithPersistence == null - ? (propertyMap.Ignored ? PropertyPersistenceMetadata.Ignored : PropertyPersistenceMetadata.Default) - : mapWithPersistence.Persistence; + if (mapWithPersistence != null) + { + persistence = mapWithPersistence.Persistence; + } + else + { + persistence = propertyMap.Ignored + ? PropertyPersistenceMetadata.Ignored + : PropertyPersistenceMetadata.Default; + } return true; } @@ -45,13 +52,28 @@ internal static IEnumerable ResolveInsertProperties(Type type) .ResolveProperties(type) .Where(property => { - PropertyPersistenceMetadata persistence; - return !TryGetPersistence(property.Property, out persistence) || - persistence.ParticipatesInInsert; + var propertyMap = ResolvePropertyMap(type, entityMap, property.Property.Name); + if (propertyMap == null) + { + return true; + } + + var mapWithPersistence = propertyMap as IPropertyMapWithPersistenceMetadata; + var persistence = mapWithPersistence != null + ? mapWithPersistence.Persistence + : ResolveDefaultPersistence(propertyMap); + return persistence.ParticipatesInInsert; }) .Select(property => property.Property); } + private static PropertyPersistenceMetadata ResolveDefaultPersistence(IPropertyMap propertyMap) + { + return propertyMap.Ignored + ? PropertyPersistenceMetadata.Ignored + : PropertyPersistenceMetadata.Default; + } + internal static IPropertyMap ResolvePropertyMap(Type type, IEntityMap entityMap, string propertyName) { return ResolvePropertyMaps(type, entityMap).FirstOrDefault(map => map.PropertyInfo.Name == propertyName); diff --git a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj index 1d313a64..5f8f11fb 100644 --- a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj +++ b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj @@ -4,6 +4,8 @@ Roslyn source generators for Dapper.FluentMap that generate supported map registration and materialization helpers at build time. $(FluentMapPackageVersionPrefix) netstandard2.0 + 8.0 + annotations true false FluentMap.Generators @@ -14,6 +16,7 @@ + diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs index f24597b7..900183e2 100644 --- a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -752,6 +752,18 @@ private static void AppendReadExpression( builder.Append(leaf.TypeName); builder.Append(">(record, "); builder.Append(leaf.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(materializer.EntityTypeName)); + builder.Append(", "); + builder.Append(materializer.ProfileTypeName == null + ? "null" + : EscapeStringLiteral(materializer.ProfileTypeName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.MemberPath)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.ColumnName)); + builder.Append(", "); + builder.Append(EscapeStringLiteral(leaf.TypeName)); builder.Append(')'); return; } @@ -822,7 +834,14 @@ private static string FormatGeneratedColumns(GeneratedMaterializationNode node) private static void AppendReadHelper(StringBuilder builder) { builder.AppendLine(); - builder.AppendLine(" private static T Read(global::System.Data.IDataRecord record, int ordinal)"); + builder.AppendLine(" private static T Read("); + builder.AppendLine(" global::System.Data.IDataRecord record,"); + builder.AppendLine(" int ordinal,"); + builder.AppendLine(" string entityTypeName,"); + builder.AppendLine(" string profileTypeName,"); + builder.AppendLine(" string memberPath,"); + builder.AppendLine(" string columnName,"); + builder.AppendLine(" string targetTypeName)"); builder.AppendLine(" {"); builder.AppendLine(" if (record.IsDBNull(ordinal))"); builder.AppendLine(" {"); @@ -830,7 +849,21 @@ private static void AppendReadHelper(StringBuilder builder) builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" var value = record.GetValue(ordinal);"); - builder.AppendLine(" return ConvertValue(value);"); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" return ConvertValue(value);"); + builder.AppendLine(" }"); + builder.AppendLine(" catch (global::System.Exception exception) when (!(exception is global::Dapper.FluentMap.FluentMapConfigurationException))"); + builder.AppendLine(" {"); + builder.AppendLine(" var profileContext = profileTypeName == null"); + builder.AppendLine(" ? string.Empty"); + builder.AppendLine(" : \" Profile: '\" + profileTypeName + \"'.\";"); + builder.AppendLine(" throw new global::Dapper.FluentMap.FluentMapConfigurationException("); + builder.AppendLine(" \"Generated materialization failed for entity '\" + entityTypeName + \"'.\" + profileContext +"); + builder.AppendLine(" \" Member path: '\" + memberPath + \"'. Column: '\" + columnName + \"'. Provider CLR type: '\" + value.GetType().FullName +"); + builder.AppendLine(" \"'. Target type: '\" + targetTypeName + \"'. Generated materializers do not fall back to runtime reflection conversion.\","); + builder.AppendLine(" exception);"); + builder.AppendLine(" }"); builder.AppendLine(" }"); builder.AppendLine(); builder.AppendLine(" private static TTarget ReadConverted("); @@ -929,6 +962,12 @@ private static GeneratedMaterializerInfo TryCreateGeneratedMaterializer( return null; } + if (ContainsExplicitConstructionInvocation(constructor, semanticModel, cancellationToken)) + { + skipReason = "explicit ConstructUsing(...) strategies are supported by runtime materialization but are not emitted by generated materializers"; + return null; + } + var mapInvocations = new List(); foreach (var invocation in constructor.Body.DescendantNodes().OfType()) { @@ -1091,6 +1130,25 @@ private static bool ContainsIncludeBaseInvocation( return false; } + private static bool ContainsExplicitConstructionInvocation( + ConstructorDeclarationSyntax constructor, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken) + { + foreach (var invocation in constructor.Body.DescendantNodes().OfType()) + { + var method = semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol; + if (method != null && + method.Name == "ConstructUsing" && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2")) + { + return true; + } + } + + return false; + } + private static bool TryCreateDirectMapInvocation( InvocationExpressionSyntax mapInvocation, INamedTypeSymbol mapType, diff --git a/src/Dapper.FluentMap.Generators/PublicAPI.Shipped.txt b/src/Dapper.FluentMap.Generators/PublicAPI.Shipped.txt new file mode 100644 index 00000000..0f44983d --- /dev/null +++ b/src/Dapper.FluentMap.Generators/PublicAPI.Shipped.txt @@ -0,0 +1,11 @@ +#nullable enable + +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.DuplicateGeneratedEntityMapDiagnosticId = "DFM007" -> string! +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.DuplicateGeneratedProfileMapDiagnosticId = "DFM008" -> string! +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.InvalidGeneratedReadConverterDiagnosticId = "DFM012" -> string! +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.InvalidGenericMapRegistrationDiagnosticId = "DFM005" -> string! +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.SkippedGeneratedMapDiagnosticId = "DFM006" -> string! +const Dapper.FluentMap.Generators.MappingRegistrationGenerator.SkippedGeneratedMaterializerDiagnosticId = "DFM011" -> string! +Dapper.FluentMap.Generators.MappingRegistrationGenerator +Dapper.FluentMap.Generators.MappingRegistrationGenerator.Initialize(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context) -> void +Dapper.FluentMap.Generators.MappingRegistrationGenerator.MappingRegistrationGenerator() -> void diff --git a/src/Dapper.FluentMap.Generators/PublicAPI.Unshipped.txt b/src/Dapper.FluentMap.Generators/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/Dapper.FluentMap.Generators/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md index 35f8ff78..23ecbe86 100644 --- a/src/Dapper.FluentMap.Generators/README.md +++ b/src/Dapper.FluentMap.Generators/README.md @@ -19,4 +19,4 @@ FluentMapper.Initialize(config => }); ``` -Generated registration avoids reflection-based assembly scanning for maps in the current compilation. It does not scan referenced assemblies, execute map constructors during generation, parse SQL or replace `FluentMapper.Validate()`. Missing, additional or duplicate-column shapes continue to use the runtime fallback, or fail deterministically when strict generated materialization is enabled. +Generated registration avoids reflection-based assembly scanning for maps in the current compilation. It does not scan referenced assemblies, execute map constructors during generation, parse SQL or replace `FluentMapper.Validate()`. Explicit delegate `ConstructUsing(...)` factories remain runtime-only and produce `DFM011`; strict generated materialization rejects those shapes rather than using a hidden fallback. Missing, mapped-additional or duplicate-column shapes likewise fail deterministically in strict mode. diff --git a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs index 76b2484b..266654e4 100644 --- a/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/ImmutableFluentMapConfiguration.cs @@ -123,12 +123,14 @@ private EntityMappingConfiguration( Type entityType, Type mapType, IReadOnlyList propertyMaps, - IReadOnlyList includedBaseTypes) + IReadOnlyList includedBaseTypes, + EntityConstructionStrategy constructionStrategy) { EntityType = entityType; MapType = mapType; PropertyMaps = propertyMaps; IncludedBaseTypes = includedBaseTypes; + ConstructionStrategy = constructionStrategy; } /// @@ -151,6 +153,8 @@ private EntityMappingConfiguration( /// public IReadOnlyList IncludedBaseTypes { get; } + internal EntityConstructionStrategy ConstructionStrategy { get; } + internal static EntityMappingConfiguration Create(Type entityType, IEntityMap map) { if (entityType == null) @@ -164,13 +168,15 @@ internal static EntityMappingConfiguration Create(Type entityType, IEntityMap ma } var includedBaseTypes = map as IEntityMapWithIncludedBaseTypes; + var mapWithConstruction = map as IEntityMapWithConstructionStrategy; return new EntityMappingConfiguration( entityType, map.GetType(), new ReadOnlyCollection( map.PropertyMaps.Select(PropertyMappingConfiguration.Create).ToList()), new ReadOnlyCollection( - includedBaseTypes == null ? new List() : includedBaseTypes.IncludedBaseTypes.ToList())); + includedBaseTypes == null ? new List() : includedBaseTypes.IncludedBaseTypes.ToList()), + mapWithConstruction?.ConstructionStrategy); } } @@ -184,13 +190,15 @@ private ProfileMappingConfiguration( Type profileType, Type mapType, IReadOnlyList propertyMaps, - IReadOnlyList includedBaseTypes) + IReadOnlyList includedBaseTypes, + EntityConstructionStrategy constructionStrategy) { EntityType = entityType; ProfileType = profileType; MapType = mapType; PropertyMaps = propertyMaps; IncludedBaseTypes = includedBaseTypes; + ConstructionStrategy = constructionStrategy; } /// @@ -218,6 +226,8 @@ private ProfileMappingConfiguration( /// public IReadOnlyList IncludedBaseTypes { get; } + internal EntityConstructionStrategy ConstructionStrategy { get; } + internal static ProfileMappingConfiguration Create(Type entityType, Type profileType, IEntityMap map) { var entityMap = EntityMappingConfiguration.Create(entityType, map); @@ -226,7 +236,8 @@ internal static ProfileMappingConfiguration Create(Type entityType, Type profile profileType, entityMap.MapType, entityMap.PropertyMaps, - entityMap.IncludedBaseTypes); + entityMap.IncludedBaseTypes, + entityMap.ConstructionStrategy); } } @@ -376,7 +387,7 @@ public sealed class GeneratedMaterializerConfiguration { private GeneratedMaterializerConfiguration( Type entityType, - Type profileType, + Type? profileType, IReadOnlyList columns, Func materializer) { @@ -394,7 +405,7 @@ private GeneratedMaterializerConfiguration( /// /// Gets the mapping profile type, or for the default map. /// - public Type ProfileType { get; } + public Type? ProfileType { get; } /// /// Gets the ordered column shape expected by the generated materializer. @@ -417,7 +428,7 @@ internal sealed class GeneratedMaterializerRegistrationSnapshot { internal GeneratedMaterializerRegistrationSnapshot( Type entityType, - Type profileType, + Type? profileType, IReadOnlyList columns, Func materializer) { @@ -429,7 +440,7 @@ internal GeneratedMaterializerRegistrationSnapshot( internal Type EntityType { get; } - internal Type ProfileType { get; } + internal Type? ProfileType { get; } internal IReadOnlyList Columns { get; } diff --git a/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs b/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs index 41fa5020..08281574 100644 --- a/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs +++ b/src/Dapper.FluentMap/Configuration/RuntimeConfigurationRegistryFactory.cs @@ -54,13 +54,19 @@ internal static MappingRegistry Create(ImmutableFluentMapConfiguration configura private sealed class SnapshotEntityMap : IEntityMap, IEntityMapWithIncludedBaseTypes, + IEntityMapWithConstructionStrategy, IRuntimeEntityMapMetadata { - private SnapshotEntityMap(Type mapType, IList propertyMaps, IList includedBaseTypes) + private SnapshotEntityMap( + Type mapType, + IList propertyMaps, + IList includedBaseTypes, + EntityConstructionStrategy constructionStrategy) { MapType = mapType; PropertyMaps = propertyMaps; IncludedBaseTypes = includedBaseTypes; + ConstructionStrategy = constructionStrategy; } public IList PropertyMaps { get; } @@ -69,12 +75,15 @@ private SnapshotEntityMap(Type mapType, IList propertyMaps, IList< public Type MapType { get; } + public EntityConstructionStrategy ConstructionStrategy { get; } + internal static SnapshotEntityMap FromEntityConfiguration(EntityMappingConfiguration map) { return new SnapshotEntityMap( map.MapType, map.PropertyMaps.Select(SnapshotPropertyMap.FromConfiguration).Cast().ToList(), - map.IncludedBaseTypes.ToList()); + map.IncludedBaseTypes.ToList(), + map.ConstructionStrategy); } internal static SnapshotEntityMap FromProfileConfiguration(ProfileMappingConfiguration map) @@ -82,7 +91,8 @@ internal static SnapshotEntityMap FromProfileConfiguration(ProfileMappingConfigu return new SnapshotEntityMap( map.MapType, map.PropertyMaps.Select(SnapshotPropertyMap.FromConfiguration).Cast().ToList(), - map.IncludedBaseTypes.ToList()); + map.IncludedBaseTypes.ToList(), + map.ConstructionStrategy); } } diff --git a/src/Dapper.FluentMap/Dapper.FluentMap.csproj b/src/Dapper.FluentMap/Dapper.FluentMap.csproj index f6b00787..e8889d88 100644 --- a/src/Dapper.FluentMap/Dapper.FluentMap.csproj +++ b/src/Dapper.FluentMap/Dapper.FluentMap.csproj @@ -6,6 +6,7 @@ $(FluentMapPackageVersionPrefix) netstandard2.0 8.0 + annotations true dapper;fluent-mapping;mapping;micro-orm;database;sql;poco;fluentmap true @@ -14,5 +15,6 @@ + diff --git a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs index cd343190..e6db64da 100644 --- a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs @@ -43,12 +43,12 @@ internal MappingExplanation( /// /// Gets the mapping profile marker type, when this explanation targets a profile. /// - public Type ProfileType { get; } + public Type? ProfileType { get; } /// /// Gets the registered entity map type, when one exists. /// - public Type EntityMapType { get; } + public Type? EntityMapType { get; } /// /// Gets the registered convention types for the entity. diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs index cde7e202..cecf080b 100644 --- a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -84,12 +84,12 @@ internal MemberMappingExplanation( /// /// Gets the base entity type that declared an inherited mapping, when applicable. /// - public Type InheritedFrom { get; } + public Type? InheritedFrom { get; } /// /// Gets the convention type that produced the mapping, when applicable. /// - public Type ConventionType { get; } + public Type? ConventionType { get; } /// /// Gets constructor parameters that can receive this mapped column. diff --git a/src/Dapper.FluentMap/FluentMapRuntime.cs b/src/Dapper.FluentMap/FluentMapRuntime.cs index 0aac0691..d4de9076 100644 --- a/src/Dapper.FluentMap/FluentMapRuntime.cs +++ b/src/Dapper.FluentMap/FluentMapRuntime.cs @@ -101,8 +101,8 @@ public IEnumerable QueryMapped< TEntity>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -130,8 +130,8 @@ public IEnumerable QueryMapped< TProfile>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -162,9 +162,9 @@ public IEnumerable QueryMapped< TReturn>( IDbConnection connection, string sql, - Func map, - object param = null, - IDbTransaction transaction = null, + Func map, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, string splitOn = "Id") @@ -186,6 +186,176 @@ public IEnumerable QueryMapped< runtime: this); } + /// + /// Executes a query, splits each row into three segments using this runtime and composes the return value. + /// + /// + /// must contain two comma-separated, unique boundaries in result order. + /// All-database-null child segments are passed to as . + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TFirst : class + where TSecond : class + where TThird : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + map, + splitOn, + null, + null, + null, + this); + } + + /// + /// Asynchronously executes a query, materializes three segments using this runtime and composes the return value. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TFirst : class + where TSecond : class + where TThird : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.Buffered, cancellationToken), + map, + splitOn, + null, + null, + null, + this); + } + + /// + /// Asynchronously executes a query and materializes three profiled segments using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.Buffered, cancellationToken), + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + this); + } + + /// + /// Executes a query and materializes three profiled segments using this runtime. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedExtensions.ExecuteMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + this); + } + /// /// Executes a query, splits each row at , materializes two profiled segments using this runtime and composes the return value. /// @@ -201,9 +371,9 @@ public IEnumerable QueryMapped< TSecondProfile>( IDbConnection connection, string sql, - Func map, - object param = null, - IDbTransaction transaction = null, + Func map, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, string splitOn = "Id") @@ -237,8 +407,8 @@ public TEntity QueryMappedSingle< TEntity>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -257,8 +427,8 @@ public TEntity QueryMappedSingle< TProfile>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -273,7 +443,7 @@ public TEntity QueryMappedSingle< /// The entity type to materialize. /// The database connection. /// The SQL query to execute. - /// Optional query parameters. + /// Optional . Arbitrary parameter objects are rejected before command execution. /// Optional transaction. /// Optional command timeout. /// Optional command type. @@ -281,11 +451,12 @@ public TEntity QueryMappedSingle< /// /// Thrown when the runtime cannot resolve a generated materializer for the entity, profile and result-column shape. /// + /// Thrown when is not or a instance. public IEnumerable QueryGeneratedMapped( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -295,14 +466,12 @@ public IEnumerable QueryGeneratedMapped( throw new ArgumentNullException(nameof(sql)); } - if (param != null) - { - throw new NotSupportedException("Strict generated materialization does not support dynamic parameter objects. Use a parameterless command or a non-strict query API."); - } + var generatedParameters = ValidateGeneratedParameters(param, profileType: null); return QueryMappedExtensions.ExecuteGeneratedMapped( connection, sql, + generatedParameters, transaction, commandTimeout, commandType, @@ -317,7 +486,7 @@ public IEnumerable QueryGeneratedMapped( /// The mapping profile marker type to use. /// The database connection. /// The SQL query to execute. - /// Optional query parameters. + /// Optional . Arbitrary parameter objects are rejected before command execution. /// Optional transaction. /// Optional command timeout. /// Optional command type. @@ -325,11 +494,12 @@ public IEnumerable QueryGeneratedMapped( /// /// Thrown when the runtime cannot resolve a generated materializer for the entity, profile and result-column shape. /// + /// Thrown when is not or a instance. public IEnumerable QueryGeneratedMapped( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -340,14 +510,12 @@ public IEnumerable QueryGeneratedMapped( throw new ArgumentNullException(nameof(sql)); } - if (param != null) - { - throw new NotSupportedException("Strict generated materialization does not support dynamic parameter objects. Use a parameterless command or a non-strict query API."); - } + var generatedParameters = ValidateGeneratedParameters(param, typeof(TProfile)); return QueryMappedExtensions.ExecuteGeneratedMapped( connection, sql, + generatedParameters, transaction, commandTimeout, commandType, @@ -361,8 +529,8 @@ public IEnumerable QueryGeneratedMapped( public TEntity QueryGeneratedMappedSingle( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -376,8 +544,8 @@ public TEntity QueryGeneratedMappedSingle( public TEntity QueryGeneratedMappedSingle( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -386,6 +554,28 @@ public TEntity QueryGeneratedMappedSingle( return QueryGeneratedMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); } + private static GeneratedParameters ValidateGeneratedParameters(object param, Type profileType) + where TEntity : class + { + if (param == null) + { + return null; + } + + if (param is GeneratedParameters generatedParameters) + { + return generatedParameters; + } + + var profileContext = profileType == null + ? "default profile" + : $"profile '{profileType.FullName}'"; + throw new NotSupportedException( + $"Strict generated query parameters for entity '{typeof(TEntity).FullName}' ({profileContext}) must use '{typeof(GeneratedParameters).FullName}'. " + + $"Parameter object type '{param.GetType().FullName}' is unsupported because strict mode does not discover parameters through reflection or dynamic code. " + + "Use GeneratedParameters with an explicit DbType for every value, or use QueryMapped* for the non-strict runtime path."); + } + /// /// Creates a lazy unbuffered query that materializes rows using this runtime. /// @@ -396,8 +586,8 @@ public IEnumerable QueryMappedUnbuffered< TEntity>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -425,8 +615,8 @@ public IEnumerable QueryMappedUnbuffered< TProfile>( IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -454,8 +644,8 @@ public IAsyncEnumerable QueryMappedUnbufferedAsync< TEntity>( DbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, CancellationToken cancellationToken = default) @@ -488,7 +678,7 @@ public IAsyncEnumerable QueryMappedUnbufferedAsync< /// [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] - public MappedGridReader QueryMultipleMapped(IDbConnection connection, string sql, object param = null) + public MappedGridReader QueryMultipleMapped(IDbConnection connection, string sql, object? param = null) { if (connection == null) { @@ -513,8 +703,8 @@ public MappedGridReader QueryMultipleMapped(IDbConnection connection, string sql public Task QueryMultipleMappedAsync( DbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, CancellationToken cancellationToken = default) diff --git a/src/Dapper.FluentMap/GeneratedParameters.cs b/src/Dapper.FluentMap/GeneratedParameters.cs new file mode 100644 index 00000000..cccafe9d --- /dev/null +++ b/src/Dapper.FluentMap/GeneratedParameters.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Data; + +namespace Dapper.FluentMap +{ + /// + /// Provides explicitly described command parameters for strict generated queries without + /// discovering members from an arbitrary parameter object. + /// + /// + /// Each parameter requires an explicit . This keeps parameter binding + /// deterministic and suitable for the repository's supported Native AOT strict-generated path. + /// + public sealed class GeneratedParameters + { + private readonly List _parameters = new List(); + private readonly HashSet _names = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds one input parameter. + /// + /// The parameter name, with or without a common provider prefix. + /// The provider parameter value. is sent as . + /// The database type used when binding the parameter. + /// The optional provider parameter size. + /// This parameter collection. + /// Thrown when is empty or duplicated, or is negative. + public GeneratedParameters Add(string name, object? value, DbType dbType, int? size = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Parameter name cannot be null, empty or whitespace.", nameof(name)); + } + + var normalizedName = NormalizeName(name); + if (normalizedName.Length == 0) + { + throw new ArgumentException("Parameter name must contain a name after its provider prefix.", nameof(name)); + } + + if (size < 0) + { + throw new ArgumentException("Parameter size cannot be negative.", nameof(size)); + } + + if (!_names.Add(normalizedName)) + { + throw new ArgumentException($"Parameter '{name}' is already registered.", nameof(name)); + } + + _parameters.Add(new ParameterValue(normalizedName, value, dbType, size)); + return this; + } + + internal void AddParameters(IDbCommand command) + { + if (command == null) + { + throw new ArgumentNullException(nameof(command)); + } + + foreach (var configured in _parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = configured.Name; + parameter.Value = configured.Value ?? DBNull.Value; + parameter.DbType = configured.DbType; + parameter.Direction = ParameterDirection.Input; + if (configured.Size.HasValue) + { + parameter.Size = configured.Size.Value; + } + + command.Parameters.Add(parameter); + } + } + + private static string NormalizeName(string name) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0 && (trimmed[0] == '@' || trimmed[0] == ':' || trimmed[0] == '?')) + { + return trimmed.Substring(1); + } + + return trimmed; + } + + private sealed class ParameterValue + { + internal ParameterValue(string name, object value, DbType dbType, int? size) + { + Name = name; + Value = value; + DbType = dbType; + Size = size; + } + + internal string Name { get; } + + internal object Value { get; } + + internal DbType DbType { get; } + + internal int? Size { get; } + } + } +} diff --git a/src/Dapper.FluentMap/Mapping/EntityConstructionStrategy.cs b/src/Dapper.FluentMap/Mapping/EntityConstructionStrategy.cs new file mode 100644 index 00000000..784b8b6e --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/EntityConstructionStrategy.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Dapper.FluentMap.Mapping +{ + internal interface IEntityMapWithConstructionStrategy + { + EntityConstructionStrategy ConstructionStrategy { get; } + } + + internal sealed class EntityConstructionStrategy + { + internal EntityConstructionStrategy( + Type entityType, + IEnumerable bindings, + Func factory) + { + EntityType = entityType ?? throw new ArgumentNullException(nameof(entityType)); + Bindings = (bindings ?? throw new ArgumentNullException(nameof(bindings))).ToArray(); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + + if (Bindings.Count == 0) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{entityType.FullName}' requires at least one mapped property binding."); + } + + var duplicate = Bindings + .GroupBy(binding => binding.MemberPath) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate != null) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{entityType.FullName}' binds property path '{duplicate.Key}' more than once."); + } + } + + internal Type EntityType { get; } + + internal IReadOnlyList Bindings { get; } + + internal Func Factory { get; } + } + + internal sealed class ConstructionValueBinding + { + internal ConstructionValueBinding(MemberPath memberPath, Type valueType) + { + MemberPath = memberPath ?? throw new ArgumentNullException(nameof(memberPath)); + ValueType = valueType ?? throw new ArgumentNullException(nameof(valueType)); + } + + internal MemberPath MemberPath { get; } + + internal Type ValueType { get; } + } +} diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index a66251e2..374fbbc3 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -59,7 +59,7 @@ internal interface IEntityMapWithIncludedBaseTypes public abstract class EntityMapBase< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] TEntity, - TPropertyMap> : IEntityMap, IEntityMapWithIncludedBaseTypes + TPropertyMap> : IEntityMap, IEntityMapWithIncludedBaseTypes, IEntityMapWithConstructionStrategy where TPropertyMap : IPropertyMap { /// @@ -78,8 +78,12 @@ protected EntityMapBase() IList IEntityMapWithIncludedBaseTypes.IncludedBaseTypes => IncludedBaseTypes; + EntityConstructionStrategy IEntityMapWithConstructionStrategy.ConstructionStrategy => ConstructionStrategy; + private IList IncludedBaseTypes { get; } + private EntityConstructionStrategy ConstructionStrategy { get; set; } + /// /// Returns an instance of which can perform custom mapping /// for the specified property on . @@ -126,6 +130,119 @@ protected void IncludeBase() IncludedBaseTypes.Add(baseType); } + /// + /// Configures an explicit factory that constructs the entity from one explicitly mapped property value. + /// + /// The first mapped value type. + /// The mapped property path supplied as the first factory argument. + /// The factory invoked for each materialized row. + protected void ConstructUsing( + Expression> value1, + Func factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + SetConstructionStrategy( + new[] { CreateConstructionBinding(value1) }, + values => factory((TValue1)values[0])); + } + + /// + /// Configures an explicit factory that constructs the entity from two explicitly mapped property values. + /// + /// The first mapped value type. + /// The second mapped value type. + /// The mapped property path supplied as the first factory argument. + /// The mapped property path supplied as the second factory argument. + /// The factory invoked for each materialized row. + protected void ConstructUsing( + Expression> value1, + Expression> value2, + Func factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + SetConstructionStrategy( + new[] { CreateConstructionBinding(value1), CreateConstructionBinding(value2) }, + values => factory((TValue1)values[0], (TValue2)values[1])); + } + + /// + /// Configures an explicit factory that constructs the entity from three explicitly mapped property values. + /// + /// The first mapped value type. + /// The second mapped value type. + /// The third mapped value type. + /// The mapped property path supplied as the first factory argument. + /// The mapped property path supplied as the second factory argument. + /// The mapped property path supplied as the third factory argument. + /// The factory invoked for each materialized row. + protected void ConstructUsing( + Expression> value1, + Expression> value2, + Expression> value3, + Func factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + SetConstructionStrategy( + new[] + { + CreateConstructionBinding(value1), + CreateConstructionBinding(value2), + CreateConstructionBinding(value3) + }, + values => factory((TValue1)values[0], (TValue2)values[1], (TValue3)values[2])); + } + + /// + /// Configures an explicit factory that constructs the entity from four explicitly mapped property values. + /// + /// The first mapped value type. + /// The second mapped value type. + /// The third mapped value type. + /// The fourth mapped value type. + /// The mapped property path supplied as the first factory argument. + /// The mapped property path supplied as the second factory argument. + /// The mapped property path supplied as the third factory argument. + /// The mapped property path supplied as the fourth factory argument. + /// The factory invoked for each materialized row. + protected void ConstructUsing( + Expression> value1, + Expression> value2, + Expression> value3, + Expression> value4, + Func factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + SetConstructionStrategy( + new[] + { + CreateConstructionBinding(value1), + CreateConstructionBinding(value2), + CreateConstructionBinding(value3), + CreateConstructionBinding(value4) + }, + values => factory( + (TValue1)values[0], + (TValue2)values[1], + (TValue3)values[2], + (TValue4)values[3])); + } + /// /// When overridden in a derived class, gets the property mapping for the specified property. /// @@ -133,6 +250,25 @@ protected void IncludeBase() /// An instance of . protected abstract TPropertyMap GetPropertyMap(PropertyInfo info); + private static ConstructionValueBinding CreateConstructionBinding( + Expression> expression) + { + return new ConstructionValueBinding(ReflectionHelper.GetMemberPath(expression), typeof(TValue)); + } + + private void SetConstructionStrategy( + IEnumerable bindings, + Func factory) + { + if (ConstructionStrategy != null) + { + throw new FluentMapConfigurationException( + $"Entity '{typeof(TEntity).FullName}' already has an explicit construction strategy."); + } + + ConstructionStrategy = new EntityConstructionStrategy(typeof(TEntity), bindings, factory); + } + private void ThrowIfDuplicateMapping(IPropertyMap map) { var memberPath = PropertyMapIdentity.GetMemberPath(map); diff --git a/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs index 7aea6bfb..59496a1f 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyConversionMetadata.cs @@ -162,12 +162,12 @@ private PropertyConversionMetadata( /// /// Gets the read converter descriptor, or when no read converter is configured. /// - public PropertyConverterMetadata ReadConverter { get; } + public PropertyConverterMetadata? ReadConverter { get; } /// /// Gets the write converter descriptor, or when no write converter is configured. /// - public PropertyConverterMetadata WriteConverter { get; } + public PropertyConverterMetadata? WriteConverter { get; } internal PropertyConversionMetadata WithReadConverter(PropertyConverterMetadata converter) { diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs index a343680c..0fb2cfab 100644 --- a/src/Dapper.FluentMap/MappingConfigurationValidator.cs +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -48,6 +48,45 @@ internal static void ValidateComposedEntityMap(Type entityType, IEntityMap entit var maps = GetEntityMapDescriptors(entityType, propertyMaps, entityMap.GetType(), "composed entity map").ToList(); ValidateColumnConflicts(entityType, maps, "composed entity map", entityMap.GetType()); ValidateNestedMaterializationPaths(entityType, maps, "composed entity map", entityMap.GetType()); + ValidateConstructionStrategy(entityType, entityMap, propertyMaps); + } + + private static void ValidateConstructionStrategy( + Type entityType, + IEntityMap entityMap, + IEnumerable propertyMaps) + { + var strategy = (entityMap as IEntityMapWithConstructionStrategy)?.ConstructionStrategy; + if (strategy == null) + { + return; + } + + if (strategy.EntityType != entityType) + { + throw new FluentMapConfigurationException( + $"Explicit construction strategy for '{FormatType(strategy.EntityType)}' cannot be used by entity map '{FormatType(entityMap.GetType())}' for '{FormatType(entityType)}'."); + } + + var mappedPaths = propertyMaps + .Where(map => !map.Ignored) + .Select(PropertyMapIdentity.GetMemberPath) + .ToList(); + + foreach (var binding in strategy.Bindings) + { + if (binding.MemberPath.IsNested) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' binds nested property path '{binding.MemberPath}'. ConstructUsing(...) supports only root property paths."); + } + + if (!mappedPaths.Contains(binding.MemberPath)) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' binds property path '{binding.MemberPath}', but that path is not an active explicit mapping. Map the property path before passing it to ConstructUsing(...)."); + } + } } internal static void ValidateConvention(Type entityType, Convention convention) diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index 1ae526c8..eae6b29c 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -18,6 +18,11 @@ namespace Dapper.FluentMap { internal sealed class MappingRegistry { + private static readonly GeneratedMaterializerEntry NoCompatibleGeneratedMaterializer = + GeneratedMaterializerEntry.Create( + Array.Empty(), + _ => null); + private readonly bool _installDapperTypeMaps; private readonly ConcurrentDictionary _propertyMapCache = @@ -234,6 +239,18 @@ internal IPropertyMap GetProfilePropertyMap(Type type, Type profileType, string .PropertyMap; } + internal EntityConstructionStrategy GetConstructionStrategy(Type type, Type profileType) + { + IEntityMap entityMap; + var found = profileType == null + ? EntityMaps.TryGetValue(type, out entityMap) + : ProfileMaps.TryGetValue(new MappingProfileKey(type, profileType), out entityMap); + + return found + ? (entityMap as IEntityMapWithConstructionStrategy)?.ConstructionStrategy + : null; + } + internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) { var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); @@ -290,6 +307,8 @@ internal void AddGeneratedMaterializer(GeneratedMaterializerDescriptor< throw new FluentMapConfigurationException( $"Entity '{descriptor.EntityType.FullName}' already has a generated materializer registered for the same column shape{profileContext}."); } + + InvalidateType(descriptor.EntityType); } internal void AddGeneratedMaterializer( @@ -328,6 +347,8 @@ internal void AddGeneratedMaterializer( throw new FluentMapConfigurationException( $"Entity '{entityType.FullName}' already has a generated materializer registered for the same column shape{profileContext}."); } + + InvalidateType(entityType); } internal bool TryGetGeneratedMaterializer( @@ -348,6 +369,12 @@ internal bool TryGetGeneratedMaterializer( EnsureProfileRegistered(type, profileType); + if (HasDuplicateColumnNames(columnNames)) + { + materializer = null; + return false; + } + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); GeneratedMaterializerEntry entry; if (_generatedMaterializers.TryGetValue(cacheKey, out entry) && @@ -359,17 +386,28 @@ internal bool TryGetGeneratedMaterializer( if (_generatedMaterializerResolutionCache.TryGetValue(cacheKey, out entry)) { + if (ReferenceEquals(entry, NoCompatibleGeneratedMaterializer)) + { + materializer = null; + return false; + } + materializer = entry.Materialize; return true; } - if (TryCreateReorderedGeneratedMaterializer(type, profileType, columnNames, out entry)) + if (TryCreateRuntimeCompatibleGeneratedMaterializer( + type, + profileType, + columnNames, + out entry)) { _generatedMaterializerResolutionCache.TryAdd(cacheKey, entry); materializer = entry.Materialize; return true; } + _generatedMaterializerResolutionCache.TryAdd(cacheKey, NoCompatibleGeneratedMaterializer); materializer = null; return false; } @@ -381,6 +419,12 @@ internal bool TryGetStrictGeneratedMaterializer( out Func materializer) { EnsureProfileRegistered(type, profileType); + if (HasDuplicateColumnNames(columnNames)) + { + materializer = null; + return false; + } + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); if (_generatedMaterializers.TryGetValue(cacheKey, out var entry) && @@ -390,25 +434,14 @@ internal bool TryGetStrictGeneratedMaterializer( return true; } - if (!HasDuplicateColumnNames(columnNames)) + if (TryCreateStrictCompatibleGeneratedMaterializer( + type, + profileType, + columnNames, + out entry)) { - foreach (var candidate in _generatedMaterializers - .Where(pair => pair.Key.Type == type && pair.Key.ProfileType == profileType) - .OrderBy(pair => FormatColumnShape(pair.Key.ColumnNames), StringComparer.Ordinal)) - { - if (candidate.Key.ColumnNames.Count != columnNames.Length || - HasDuplicateColumnNames(candidate.Key.ColumnNames) || - !HaveSameColumnSet(candidate.Key.ColumnNames, columnNames) || - !GeneratedMaterializerMatchesExplicitMapping(type, profileType, candidate.Value.Columns)) - { - continue; - } - - var ordinalMap = CreateOrdinalMap(candidate.Key.ColumnNames, columnNames); - var candidateMaterializer = candidate.Value.Materialize; - materializer = record => candidateMaterializer(new OrdinalMappedDataRecord(record, ordinalMap)); - return true; - } + materializer = entry.Materialize; + return true; } materializer = null; @@ -432,11 +465,44 @@ private bool GeneratedMaterializerMatchesExplicitMapping( return true; } - private bool TryCreateReorderedGeneratedMaterializer( + private bool TryCreateRuntimeCompatibleGeneratedMaterializer( + Type type, + Type profileType, + string[] columnNames, + out GeneratedMaterializerEntry reorderedEntry) + { + var defaultTypeMap = new DefaultTypeMap(type); + return TryCreateCompatibleGeneratedMaterializer( + type, + profileType, + columnNames, + columns => GeneratedMaterializerMatchesEffectiveMapping(type, profileType, columns), + columnName => defaultTypeMap.GetMember(columnName) != null, + out reorderedEntry); + } + + private bool TryCreateStrictCompatibleGeneratedMaterializer( Type type, Type profileType, string[] columnNames, out GeneratedMaterializerEntry reorderedEntry) + { + return TryCreateCompatibleGeneratedMaterializer( + type, + profileType, + columnNames, + columns => GeneratedMaterializerMatchesExplicitMapping(type, profileType, columns), + defaultMemberResolver: null, + out reorderedEntry); + } + + private bool TryCreateCompatibleGeneratedMaterializer( + Type type, + Type profileType, + string[] columnNames, + Func, bool> mappingMatches, + Func defaultMemberResolver, + out GeneratedMaterializerEntry reorderedEntry) { reorderedEntry = null; @@ -447,13 +513,20 @@ private bool TryCreateReorderedGeneratedMaterializer( foreach (var candidate in _generatedMaterializers .Where(pair => pair.Key.Type == type && pair.Key.ProfileType == profileType) - .OrderBy(pair => FormatColumnShape(pair.Key.ColumnNames), StringComparer.Ordinal)) + .OrderByDescending(pair => pair.Key.ColumnNames.Count) + .ThenBy(pair => FormatColumnShape(pair.Key.ColumnNames), StringComparer.Ordinal)) { var generatedColumnNames = candidate.Key.ColumnNames; - if (generatedColumnNames.Count != columnNames.Length || + if (generatedColumnNames.Count > columnNames.Length || HasDuplicateColumnNames(generatedColumnNames) || - !HaveSameColumnSet(generatedColumnNames, columnNames) || - !GeneratedMaterializerMatchesEffectiveMapping(type, profileType, candidate.Value.Columns)) + !ContainsGeneratedColumnSet(generatedColumnNames, columnNames) || + !AdditionalColumnsAreUnmapped( + type, + profileType, + generatedColumnNames, + columnNames, + defaultMemberResolver) || + !mappingMatches(candidate.Value.Columns)) { continue; } @@ -497,14 +570,68 @@ internal string DescribeMissingGeneratedMaterializer(Type type, Type profileType return $"Entity '{type.FullName}' ({profileContext}) has no registered generated materializer. Requested result columns: {requestedShape}."; } + var duplicateColumns = columnNames + .GroupBy(columnName => columnName, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .OrderBy(columnName => columnName, StringComparer.Ordinal) + .ToList(); + if (duplicateColumns.Count > 0) + { + return $"Entity '{type.FullName}' ({profileContext}) requested result columns {requestedShape} contain duplicate/ambiguous mapped column names: {FormatColumnShape(duplicateColumns)}."; + } + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); GeneratedMaterializerEntry exactEntry; if (_generatedMaterializers.TryGetValue(cacheKey, out exactEntry) && - !GeneratedMaterializerMatchesEffectiveMapping(type, profileType, exactEntry.Columns)) + !GeneratedMaterializerMatchesExplicitMapping(type, profileType, exactEntry.Columns)) { return $"Entity '{type.FullName}' ({profileContext}) has a generated materializer for requested result columns {requestedShape}, but its member/converter contract does not match the effective FluentMap mapping."; } + foreach (var candidate in candidates) + { + var missingColumns = candidate.Key.ColumnNames + .Where(columnName => !columnNames.Contains(columnName, StringComparer.Ordinal)) + .ToList(); + if (missingColumns.Count > 0) + { + continue; + } + + var unsafeAdditionalColumns = GetUnsafeAdditionalColumns( + type, + profileType, + candidate.Key.ColumnNames, + columnNames, + defaultMemberResolver: null); + if (unsafeAdditionalColumns.Count > 0) + { + return $"Entity '{type.FullName}' ({profileContext}) requested result columns {requestedShape} contain additional columns that resolve to mapped members and cannot be ignored safely: {FormatColumnShape(unsafeAdditionalColumns)}."; + } + + if (!GeneratedMaterializerMatchesExplicitMapping(type, profileType, candidate.Value.Columns)) + { + return $"Entity '{type.FullName}' ({profileContext}) has a generated materializer compatible with requested result columns {requestedShape}, but its member/converter contract does not match the effective FluentMap mapping."; + } + } + + var closestCandidate = candidates + .Select(candidate => new + { + Shape = candidate.Key.ColumnNames, + Missing = candidate.Key.ColumnNames + .Where(columnName => !columnNames.Contains(columnName, StringComparer.Ordinal)) + .ToList() + }) + .OrderBy(candidate => candidate.Missing.Count) + .ThenBy(candidate => FormatColumnShape(candidate.Shape), StringComparer.Ordinal) + .First(); + if (closestCandidate.Missing.Count > 0) + { + return $"Entity '{type.FullName}' ({profileContext}) requested result columns {requestedShape} are missing required generated columns {FormatColumnShape(closestCandidate.Missing)}. Closest registered generated shape: {FormatColumnShape(closestCandidate.Shape)}."; + } + var availableShapes = string.Join(", ", candidates.Select(pair => FormatColumnShape(pair.Key.ColumnNames))); return $"Entity '{type.FullName}' ({profileContext}) has generated materializers, but none match requested result columns {requestedShape}. Registered generated shapes: {availableShapes}."; } @@ -800,11 +927,53 @@ private static bool HasDuplicateColumnNames(IEnumerable columnNames) return false; } - private static bool HaveSameColumnSet(IReadOnlyList generatedColumnNames, string[] requestedColumnNames) + private static bool ContainsGeneratedColumnSet(IReadOnlyList generatedColumnNames, string[] requestedColumnNames) { var requested = new HashSet(requestedColumnNames, StringComparer.Ordinal); - return requested.Count == generatedColumnNames.Count && - generatedColumnNames.All(columnName => requested.Contains(columnName)); + return generatedColumnNames.All(columnName => requested.Contains(columnName)); + } + + private bool AdditionalColumnsAreUnmapped( + Type type, + Type profileType, + IReadOnlyList generatedColumnNames, + IEnumerable requestedColumnNames, + Func defaultMemberResolver) + { + return GetUnsafeAdditionalColumns( + type, + profileType, + generatedColumnNames, + requestedColumnNames, + defaultMemberResolver).Count == 0; + } + + private List GetUnsafeAdditionalColumns( + Type type, + Type profileType, + IReadOnlyList generatedColumnNames, + IEnumerable requestedColumnNames, + Func defaultMemberResolver) + { + var generated = new HashSet(generatedColumnNames, StringComparer.Ordinal); + var explicitPropertyMaps = GetExplicitPropertyMaps(type, profileType); + var unsafeColumns = new List(); + + foreach (var columnName in requestedColumnNames.Where(columnName => !generated.Contains(columnName))) + { + var fluentMap = GetProfilePropertyMap(type, profileType, columnName); + var matchesMappedMemberName = explicitPropertyMaps.Any(map => + !map.Ignored && + string.Equals(map.PropertyInfo.Name, columnName, StringComparison.OrdinalIgnoreCase)); + if ((fluentMap != null && !fluentMap.Ignored) || + matchesMappedMemberName || + (fluentMap == null && defaultMemberResolver != null && defaultMemberResolver(columnName))) + { + unsafeColumns.Add(columnName); + } + } + + return unsafeColumns; } private static int[] CreateOrdinalMap(IReadOnlyList generatedColumnNames, string[] requestedColumnNames) diff --git a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs index be97ea65..088ad18c 100644 --- a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs +++ b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerColumn.cs @@ -9,11 +9,11 @@ public sealed class GeneratedMaterializerColumn { private GeneratedMaterializerColumn( string columnName, - string memberPath, + string? memberPath, bool ignored, - Type readConverterType, - Type readConverterDatabaseType, - Type readConverterPropertyType) + Type? readConverterType, + Type? readConverterDatabaseType, + Type? readConverterPropertyType) { if (string.IsNullOrWhiteSpace(columnName)) { @@ -41,7 +41,7 @@ private GeneratedMaterializerColumn( /// /// Gets the mapped member path expected for this column, or for ignored columns. /// - public string MemberPath { get; } + public string? MemberPath { get; } /// /// Gets a value indicating whether the current mapping must ignore this column. @@ -51,17 +51,17 @@ private GeneratedMaterializerColumn( /// /// Gets the read converter type applied by the generated materializer, or . /// - public Type ReadConverterType { get; } + public Type? ReadConverterType { get; } /// /// Gets the database/provider CLR type accepted by the generated read converter, or . /// - public Type ReadConverterDatabaseType { get; } + public Type? ReadConverterDatabaseType { get; } /// /// Gets the property CLR type returned by the generated read converter, or . /// - public Type ReadConverterPropertyType { get; } + public Type? ReadConverterPropertyType { get; } /// /// Creates a descriptor for a materialized column. diff --git a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs index f4928e2d..b27c46bb 100644 --- a/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs +++ b/src/Dapper.FluentMap/Materialization/GeneratedMaterializerDescriptor.cs @@ -34,7 +34,7 @@ public GeneratedMaterializerDescriptor( /// The ordered column shape and member bindings expected by the materializer. /// The generated row materializer. public GeneratedMaterializerDescriptor( - Type profileType, + Type? profileType, IEnumerable columns, GeneratedRowMaterializer materializer) { @@ -75,7 +75,7 @@ public GeneratedMaterializerDescriptor( /// /// Gets the mapping profile type, or for the default map. /// - public Type ProfileType { get; } + public Type? ProfileType { get; } /// /// Gets the ordered column shape and member bindings expected by the materializer. diff --git a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs index 4e5edc24..2626428f 100644 --- a/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs +++ b/src/Dapper.FluentMap/Materialization/MappedRowMaterializer.cs @@ -33,32 +33,47 @@ internal static Func CreateMaterializer< Type profileType, FluentMapRuntime runtime) where TEntity : class + { + var materializer = CreateMaterializer(reader, typeof(TEntity), profileType, runtime); + return record => (TEntity)materializer(record); + } + + internal static Func CreateMaterializer( + IDataRecord reader, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] Type entityType, + Type profileType, + FluentMapRuntime runtime) { if (runtime == null) { throw new ArgumentNullException(nameof(runtime)); } + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + var columnNames = GetColumnNames(reader); Func generatedMaterializer; var hasGeneratedMaterializer = runtime.StrictGeneratedMaterialization ? runtime.Registry.TryGetStrictGeneratedMaterializer( - typeof(TEntity), profileType, columnNames, out generatedMaterializer) + entityType, profileType, columnNames, out generatedMaterializer) : runtime.Registry.TryGetGeneratedMaterializer( - typeof(TEntity), profileType, columnNames, out generatedMaterializer); + entityType, profileType, columnNames, out generatedMaterializer); if (hasGeneratedMaterializer) { - return record => (TEntity)generatedMaterializer(record); + return generatedMaterializer; } if (runtime.StrictGeneratedMaterialization) { - ThrowMissingGeneratedMaterializer(profileType, runtime, columnNames); + ThrowMissingGeneratedMaterializer(entityType, profileType, runtime, columnNames); } - var plan = runtime.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); - return record => (TEntity)plan.Materialize(record); + var plan = runtime.Registry.GetMaterializationPlan(entityType, profileType, columnNames); + return plan.Materialize; } internal static Func CreateGeneratedMaterializer( @@ -93,11 +108,20 @@ private static void ThrowMissingGeneratedMaterializer( FluentMapRuntime runtime, string[] columnNames) where TEntity : class + { + ThrowMissingGeneratedMaterializer(typeof(TEntity), profileType, runtime, columnNames); + } + + private static void ThrowMissingGeneratedMaterializer( + Type entityType, + Type profileType, + FluentMapRuntime runtime, + string[] columnNames) { throw new FluentMapConfigurationException( "Strict generated materialization is enabled, but FluentMap could not resolve a compatible generated materializer. " + - runtime.Registry.DescribeMissingGeneratedMaterializer(typeof(TEntity), profileType, columnNames) + - " Register a generated materializer for this exact entity/profile/result-column shape, query the documented generated shape, or disable strict generated materialization for this runtime."); + runtime.Registry.DescribeMissingGeneratedMaterializer(entityType, profileType, columnNames) + + " Register a compatible generated materializer, query a documented generated shape, or use QueryMapped* with strict generated materialization disabled for the non-strict runtime fallback."); } private static string[] GetColumnNames(IDataRecord reader) diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs index 5159b449..23d02d22 100644 --- a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -37,7 +37,9 @@ internal static NestedMaterializationPlan Create(Type entityType, Type profileTy } var defaultTypeMap = new DefaultTypeMap(entityType); - var rootNode = MaterializationNode.Root(entityType); + var rootNode = MaterializationNode.Root( + entityType, + registry.GetConstructionStrategy(entityType, profileType)); for (var i = 0; i < columnNames.Count; i++) { @@ -436,15 +438,22 @@ private sealed class MaterializationNode private int[] _subtreeColumnIndexes; private Func _parameterlessFactory; private ConstructorPlan _constructorPlan; + private ExplicitFactoryPlan _explicitFactoryPlan; private NestedLeaf[] _postConstructorLeaves; private MaterializationNode[] _postConstructorChildren; - private MaterializationNode(Type type, PropertyInfo parentProperty, string memberPath, bool isRoot) + private MaterializationNode( + Type type, + PropertyInfo parentProperty, + string memberPath, + bool isRoot, + EntityConstructionStrategy constructionStrategy = null) { Type = type; ParentProperty = parentProperty; MemberPath = memberPath; _isRoot = isRoot; + ConstructionStrategy = constructionStrategy; if (parentProperty != null) { @@ -463,11 +472,13 @@ private MaterializationNode(Type type, PropertyInfo parentProperty, string membe internal Action Setter { get; } + private EntityConstructionStrategy ConstructionStrategy { get; } + internal bool CanAssignToParent => _isRoot || Setter != null; - internal static MaterializationNode Root(Type type) + internal static MaterializationNode Root(Type type, EntityConstructionStrategy constructionStrategy) { - return new MaterializationNode(type, null, type.Name, isRoot: true); + return new MaterializationNode(type, null, type.Name, isRoot: true, constructionStrategy); } internal void AddPropertyPath( @@ -573,7 +584,7 @@ internal void Apply(object parent, IDataRecord record) return; } - var existing = _constructorPlan == null && Getter != null + var existing = _constructorPlan == null && _explicitFactoryPlan == null && Getter != null ? Getter(parent) : null; var value = Materialize(record, existing, forceNew: false); @@ -604,6 +615,12 @@ private void SelectConstructionPlan(Type entityType) { _parameterlessFactory = CreateParameterlessFactory(Type); + if (ConstructionStrategy != null) + { + SelectExplicitFactoryPlan(entityType); + return; + } + var requiresConstructor = _parameterlessFactory == null || _leaves.Any(leaf => !leaf.CanAssign) || _children.Any(child => !child.CanAssignToParent); @@ -644,6 +661,61 @@ private void SelectConstructionPlan(Type entityType) } } + private void SelectExplicitFactoryPlan(Type entityType) + { + var bindings = new List(); + foreach (var configuredBinding in ConstructionStrategy.Bindings) + { + var leaf = _leaves.SingleOrDefault(candidate => + string.Equals(candidate.MemberPath, configuredBinding.MemberPath.ToString(), StringComparison.Ordinal)); + var child = _children.SingleOrDefault(candidate => + string.Equals(candidate.MemberPath, configuredBinding.MemberPath.ToString(), StringComparison.Ordinal)); + + if (leaf == null && child == null) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' requires mapped property path '{configuredBinding.MemberPath}', but the current result shape does not contain its mapped column."); + } + + var materializedType = leaf == null ? child.Type : leaf.TargetType; + if (!IsParameterCompatible(configuredBinding.ValueType, materializedType)) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' binds property path '{configuredBinding.MemberPath}' as '{FormatType(configuredBinding.ValueType)}', but materialization produces '{FormatType(materializedType)}'."); + } + + bindings.Add(leaf == null + ? ExplicitFactoryBinding.ForChild(configuredBinding, child) + : ExplicitFactoryBinding.ForLeaf(configuredBinding, leaf)); + } + + _explicitFactoryPlan = new ExplicitFactoryPlan( + entityType, + MemberPath, + ConstructionStrategy.Factory, + bindings); + _postConstructorLeaves = _leaves + .Where(leaf => !_explicitFactoryPlan.Uses(leaf)) + .ToArray(); + _postConstructorChildren = _children + .Where(child => !_explicitFactoryPlan.Uses(child)) + .ToArray(); + + var unsupportedLeaf = _postConstructorLeaves.FirstOrDefault(leaf => !leaf.CanAssign); + if (unsupportedLeaf != null) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' does not bind read-only mapped property '{unsupportedLeaf.MemberPath}'. Column: '{unsupportedLeaf.ColumnName}'."); + } + + var unsupportedChild = _postConstructorChildren.FirstOrDefault(child => !child.CanAssignToParent); + if (unsupportedChild != null) + { + throw new FluentMapConfigurationException( + $"Explicit construction for entity '{FormatType(entityType)}' does not bind read-only nested value object '{unsupportedChild.MemberPath}'."); + } + } + private ConstructorPlan SelectConstructor(Type entityType) { var candidates = Type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) @@ -742,7 +814,9 @@ private ParameterBinding TryBindParameter(ParameterInfo parameter) private object Materialize(IDataRecord record, object existing, bool forceNew) { - var current = _constructorPlan != null + var current = _explicitFactoryPlan != null + ? _explicitFactoryPlan.Materialize(record) + : _constructorPlan != null ? _constructorPlan.Materialize(record) : forceNew || existing == null ? _parameterlessFactory() @@ -774,6 +848,91 @@ internal IEnumerable GetColumnNames() } } + private sealed class ExplicitFactoryPlan + { + private readonly Type _entityType; + private readonly string _memberPath; + private readonly Func _factory; + private readonly ExplicitFactoryBinding[] _bindings; + + internal ExplicitFactoryPlan( + Type entityType, + string memberPath, + Func factory, + IEnumerable bindings) + { + _entityType = entityType; + _memberPath = memberPath; + _factory = factory; + _bindings = bindings.ToArray(); + } + + internal bool Uses(NestedLeaf leaf) + { + return _bindings.Any(binding => binding.Leaf == leaf); + } + + internal bool Uses(MaterializationNode child) + { + return _bindings.Any(binding => binding.Child == child); + } + + internal object Materialize(IDataRecord record) + { + var values = _bindings.Select(binding => binding.GetValue(record)).ToArray(); + try + { + var entity = _factory(values); + if (entity == null) + { + throw new InvalidOperationException("The configured factory returned null."); + } + + return entity; + } + catch (Exception exception) + { + throw new FluentMapConfigurationException( + $"Explicit construction factory failed for entity '{FormatType(_entityType)}' at member path '{_memberPath}'. Bound property paths: {string.Join(", ", _bindings.Select(binding => "'" + binding.ConfiguredBinding.MemberPath + "'"))}. See the inner exception for the factory failure.", + exception); + } + } + } + + private sealed class ExplicitFactoryBinding + { + private ExplicitFactoryBinding( + ConstructionValueBinding configuredBinding, + NestedLeaf leaf, + MaterializationNode child) + { + ConfiguredBinding = configuredBinding; + Leaf = leaf; + Child = child; + } + + internal ConstructionValueBinding ConfiguredBinding { get; } + + internal NestedLeaf Leaf { get; } + + internal MaterializationNode Child { get; } + + internal static ExplicitFactoryBinding ForLeaf(ConstructionValueBinding binding, NestedLeaf leaf) + { + return new ExplicitFactoryBinding(binding, leaf, null); + } + + internal static ExplicitFactoryBinding ForChild(ConstructionValueBinding binding, MaterializationNode child) + { + return new ExplicitFactoryBinding(binding, null, child); + } + + internal object GetValue(IDataRecord record) + { + return Leaf == null ? Child.MaterializeValue(record) : Leaf.GetValue(record); + } + } + private sealed class NestedLeaf { private readonly Action _setter; diff --git a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs index 6040a49d..35e17e4d 100644 --- a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs +++ b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("Dapper.FluentMap.Tests")] [assembly: InternalsVisibleTo("Dapper.FluentMap.GeneratedRegistration.Tests")] +[assembly: InternalsVisibleTo("Dapper.FluentMap.Dommel")] diff --git a/src/Dapper.FluentMap/PublicAPI.Shipped.txt b/src/Dapper.FluentMap/PublicAPI.Shipped.txt new file mode 100644 index 00000000..261b397b --- /dev/null +++ b/src/Dapper.FluentMap/PublicAPI.Shipped.txt @@ -0,0 +1,362 @@ +#nullable enable + +abstract Dapper.FluentMap.Mapping.EntityMapBase.GetPropertyMap(System.Reflection.PropertyInfo! info) -> TPropertyMap +Dapper.FluentMap.Configuration.ConventionMappingConfiguration +Dapper.FluentMap.Configuration.ConventionMappingConfiguration.ConventionType.get -> System.Type! +Dapper.FluentMap.Configuration.ConventionMappingConfiguration.EntityType.get -> System.Type! +Dapper.FluentMap.Configuration.ConventionMappingConfiguration.PropertyMaps.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.EntityMappingConfiguration +Dapper.FluentMap.Configuration.EntityMappingConfiguration.EntityType.get -> System.Type! +Dapper.FluentMap.Configuration.EntityMappingConfiguration.IncludedBaseTypes.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.EntityMappingConfiguration.MapType.get -> System.Type! +Dapper.FluentMap.Configuration.EntityMappingConfiguration.PropertyMaps.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.FluentConventionConfiguration +Dapper.FluentMap.Configuration.FluentConventionConfiguration.FluentConventionConfiguration(Dapper.FluentMap.Conventions.Convention! convention) -> void +Dapper.FluentMap.Configuration.FluentConventionConfiguration.ForEntitiesInAssembly(System.Reflection.Assembly! assembly, params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentConventionConfiguration.ForEntitiesInCurrentAssembly(params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentConventionConfiguration.ForEntity() -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentConventionConfiguration.GetType() -> System.Type! +Dapper.FluentMap.Configuration.FluentMapConfiguration +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddConvention() -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddGeneratedMaterializer(System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddGeneratedMaterializer(Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor! descriptor) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddGeneratedMaterializer(System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddMap(Dapper.FluentMap.Mapping.IEntityMap! mapper) -> void +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddMap() -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddMapsFromAssembly(System.Reflection.Assembly! assembly, params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddMapsFromAssemblyContaining(params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.AddProfile() -> Dapper.FluentMap.Configuration.FluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.FluentMapConfiguration() -> void +Dapper.FluentMap.Configuration.FluentMapConfiguration.GetType() -> System.Type! +Dapper.FluentMap.Configuration.FluentMapConfiguration.UseNamingPolicy(Dapper.FluentMap.Naming.NamingPolicy! namingPolicy, bool caseSensitive = true) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfiguration.UseNamingPolicy(System.Func! transformer, bool caseSensitive = true) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddConvention() -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddGeneratedMaterializer(System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddGeneratedMaterializer(Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor! descriptor) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddGeneratedMaterializer(System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddMap(Dapper.FluentMap.Mapping.IEntityMap! mapper) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddMap() -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddMapsFromAssembly(System.Reflection.Assembly! assembly, params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddMapsFromAssemblyContaining(params string![]! namespaces) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.AddProfile() -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.Build() -> Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.Configure(System.Action! configure) -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.FluentMapConfigurationBuilder() -> void +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.UseNamingPolicy(Dapper.FluentMap.Naming.NamingPolicy! namingPolicy, bool caseSensitive = true) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.UseNamingPolicy(System.Func! transformer, bool caseSensitive = true) -> Dapper.FluentMap.Configuration.FluentConventionConfiguration! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.UseStrictGeneratedMaterialization() -> Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder! +Dapper.FluentMap.Configuration.FluentMapConfigurationBuilder.Validate() -> void +Dapper.FluentMap.Configuration.GeneratedMaterializerConfiguration +Dapper.FluentMap.Configuration.GeneratedMaterializerConfiguration.Columns.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.GeneratedMaterializerConfiguration.EntityType.get -> System.Type! +Dapper.FluentMap.Configuration.GeneratedMaterializerConfiguration.ProfileType.get -> System.Type? +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.CreateRuntime() -> Dapper.FluentMap.FluentMapRuntime! +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.EntityMaps.get -> System.Collections.Generic.IReadOnlyDictionary! +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.GeneratedMaterializers.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.ProfileMaps.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.StrictGeneratedMaterialization.get -> bool +Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration.TypeConventions.get -> System.Collections.Generic.IReadOnlyDictionary!>! +Dapper.FluentMap.Configuration.ProfileMappingConfiguration +Dapper.FluentMap.Configuration.ProfileMappingConfiguration.EntityType.get -> System.Type! +Dapper.FluentMap.Configuration.ProfileMappingConfiguration.IncludedBaseTypes.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.ProfileMappingConfiguration.MapType.get -> System.Type! +Dapper.FluentMap.Configuration.ProfileMappingConfiguration.ProfileType.get -> System.Type! +Dapper.FluentMap.Configuration.ProfileMappingConfiguration.PropertyMaps.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Configuration.PropertyMappingConfiguration +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.CaseSensitive.get -> bool +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.ColumnName.get -> string! +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.Conversion.get -> Dapper.FluentMap.Mapping.PropertyConversionMetadata! +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.Ignored.get -> bool +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.MemberPath.get -> string! +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.Persistence.get -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +Dapper.FluentMap.Configuration.PropertyMappingConfiguration.PropertyInfo.get -> System.Reflection.PropertyInfo! +Dapper.FluentMap.Conventions.Convention +Dapper.FluentMap.Conventions.Convention.Convention() -> void +Dapper.FluentMap.Conventions.Convention.ConventionConfigurations.get -> System.Collections.Generic.IList! +Dapper.FluentMap.Conventions.Convention.GetType() -> System.Type! +Dapper.FluentMap.Conventions.Convention.Properties() -> Dapper.FluentMap.Conventions.PropertyConventionConfiguration! +Dapper.FluentMap.Conventions.Convention.Properties() -> Dapper.FluentMap.Conventions.PropertyConventionConfiguration! +Dapper.FluentMap.Conventions.Convention.PropertyMaps.get -> System.Collections.Generic.IList! +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.ConventionPropertyConfiguration() -> void +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.GetType() -> System.Type! +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.HasColumnName(string! columnName) -> Dapper.FluentMap.Conventions.ConventionPropertyConfiguration! +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.HasPrefix(string! prefix) -> Dapper.FluentMap.Conventions.ConventionPropertyConfiguration! +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.IsCaseInsensitive() -> Dapper.FluentMap.Conventions.ConventionPropertyConfiguration! +Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.Transform(System.Func! transformer) -> Dapper.FluentMap.Conventions.ConventionPropertyConfiguration! +Dapper.FluentMap.Conventions.PropertyConventionConfiguration +Dapper.FluentMap.Conventions.PropertyConventionConfiguration.Configure(System.Action! configure) -> void +Dapper.FluentMap.Conventions.PropertyConventionConfiguration.GetType() -> System.Type! +Dapper.FluentMap.Conventions.PropertyConventionConfiguration.PropertyConventionConfiguration() -> void +Dapper.FluentMap.Conventions.PropertyConventionConfiguration.Where(System.Func! predicate) -> Dapper.FluentMap.Conventions.PropertyConventionConfiguration! +Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation +Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation.Constructor.get -> System.Reflection.ConstructorInfo! +Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation.Name.get -> string! +Dapper.FluentMap.Diagnostics.ConstructorParameterExplanation.ParameterType.get -> System.Type! +Dapper.FluentMap.Diagnostics.MappingExplanation +Dapper.FluentMap.Diagnostics.MappingExplanation.ConventionTypes.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Diagnostics.MappingExplanation.Diagnostics.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Diagnostics.MappingExplanation.EntityMapType.get -> System.Type? +Dapper.FluentMap.Diagnostics.MappingExplanation.EntityType.get -> System.Type! +Dapper.FluentMap.Diagnostics.MappingExplanation.Members.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Diagnostics.MappingExplanation.ProfileType.get -> System.Type? +Dapper.FluentMap.Diagnostics.MappingMaterialization +Dapper.FluentMap.Diagnostics.MappingMaterialization.Dapper = 0 -> Dapper.FluentMap.Diagnostics.MappingMaterialization +Dapper.FluentMap.Diagnostics.MappingMaterialization.Nested = 1 -> Dapper.FluentMap.Diagnostics.MappingMaterialization +Dapper.FluentMap.Diagnostics.MappingMaterialization.ValueObject = 2 -> Dapper.FluentMap.Diagnostics.MappingMaterialization +Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingSource.Convention = 2 -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingSource.DapperDefault = 4 -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingSource.Explicit = 0 -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingSource.Inherited = 1 -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MappingSource.NamingPolicy = 3 -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.Diagnostics.MemberMappingExplanation +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.CaseSensitive.get -> bool +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.ColumnName.get -> string! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.ConstructorParameters.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.ConventionType.get -> System.Type? +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.Conversion.get -> Dapper.FluentMap.Mapping.PropertyConversionMetadata! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.Ignored.get -> bool +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.InheritedFrom.get -> System.Type? +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.Materialization.get -> Dapper.FluentMap.Diagnostics.MappingMaterialization +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.MemberPath.get -> string! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.Persistence.get -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.PropertyInfo.get -> System.Reflection.PropertyInfo! +Dapper.FluentMap.Diagnostics.MemberMappingExplanation.Source.get -> Dapper.FluentMap.Diagnostics.MappingSource +Dapper.FluentMap.FluentMapConfigurationException +Dapper.FluentMap.FluentMapConfigurationException.FluentMapConfigurationException() -> void +Dapper.FluentMap.FluentMapConfigurationException.FluentMapConfigurationException(string! message) -> void +Dapper.FluentMap.FluentMapConfigurationException.FluentMapConfigurationException(string! message, System.Exception! innerException) -> void +Dapper.FluentMap.FluentMapper +Dapper.FluentMap.FluentMapRuntime +Dapper.FluentMap.FluentMapRuntime.Configuration.get -> Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration! +Dapper.FluentMap.FluentMapRuntime.Explain() -> Dapper.FluentMap.Diagnostics.MappingExplanation! +Dapper.FluentMap.FluentMapRuntime.Explain() -> Dapper.FluentMap.Diagnostics.MappingExplanation! +Dapper.FluentMap.FluentMapRuntime.FluentMapRuntime(Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration! configuration) -> void +Dapper.FluentMap.FluentMapRuntime.QueryGeneratedMapped(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryGeneratedMapped(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryGeneratedMappedSingle(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +Dapper.FluentMap.FluentMapRuntime.QueryGeneratedMappedSingle(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, System.Func! map, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, System.Func! map, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMappedSingle(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +Dapper.FluentMap.FluentMapRuntime.QueryMappedSingle(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +Dapper.FluentMap.FluentMapRuntime.QueryMappedUnbuffered(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMappedUnbuffered(System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMappedUnbufferedAsync(System.Data.Common.DbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMultipleMapped(System.Data.IDbConnection! connection, string! sql, object? param = null) -> Dapper.FluentMap.MappedGridReader! +Dapper.FluentMap.FluentMapRuntime.QueryMultipleMappedAsync(System.Data.Common.DbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Dapper.FluentMap.FluentMapRuntime.Validate() -> void +Dapper.FluentMap.MappedGridReader +Dapper.FluentMap.MappedGridReader.Dispose() -> void +Dapper.FluentMap.MappedGridReader.DisposeAsync() -> System.Threading.Tasks.ValueTask +Dapper.FluentMap.MappedGridReader.IsConsumed.get -> bool +Dapper.FluentMap.MappedGridReader.ReadMapped() -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.MappedGridReader.ReadMapped() -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.MappedGridReader.ReadMappedAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Dapper.FluentMap.MappedGridReader.ReadMappedAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Dapper.FluentMap.MappedGridReader.ReadMappedSingle() -> TEntity! +Dapper.FluentMap.MappedGridReader.ReadMappedSingle() -> TEntity! +Dapper.FluentMap.MappedGridReader.ReadMappedSingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Dapper.FluentMap.MappedGridReader.ReadMappedSingleAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Dapper.FluentMap.Mapping.EntityMap +Dapper.FluentMap.Mapping.EntityMap.EntityMap() -> void +Dapper.FluentMap.Mapping.EntityMapBase +Dapper.FluentMap.Mapping.EntityMapBase.EntityMapBase() -> void +Dapper.FluentMap.Mapping.EntityMapBase.IncludeBase() -> void +Dapper.FluentMap.Mapping.EntityMapBase.Map(System.Linq.Expressions.Expression!>! expression) -> TPropertyMap +Dapper.FluentMap.Mapping.EntityMapBase.PropertyMaps.get -> System.Collections.Generic.IList! +Dapper.FluentMap.Mapping.IEntityMap +Dapper.FluentMap.Mapping.IEntityMap.PropertyMaps.get -> System.Collections.Generic.IList! +Dapper.FluentMap.Mapping.IEntityMap +Dapper.FluentMap.Mapping.IMappingProfile +Dapper.FluentMap.Mapping.IProfileMap +Dapper.FluentMap.Mapping.IPropertyConverter +Dapper.FluentMap.Mapping.IPropertyMap +Dapper.FluentMap.Mapping.IPropertyMap.CaseSensitive.get -> bool +Dapper.FluentMap.Mapping.IPropertyMap.ColumnName.get -> string! +Dapper.FluentMap.Mapping.IPropertyMap.Ignored.get -> bool +Dapper.FluentMap.Mapping.IPropertyMap.PropertyInfo.get -> System.Reflection.PropertyInfo! +Dapper.FluentMap.Mapping.IPropertyMapWithConversionMetadata +Dapper.FluentMap.Mapping.IPropertyMapWithConversionMetadata.Conversion.get -> Dapper.FluentMap.Mapping.PropertyConversionMetadata! +Dapper.FluentMap.Mapping.IPropertyMapWithPersistenceMetadata +Dapper.FluentMap.Mapping.IPropertyMapWithPersistenceMetadata.Persistence.get -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +Dapper.FluentMap.Mapping.IReadPropertyConverter +Dapper.FluentMap.Mapping.IReadPropertyConverter.ConvertFromDatabase(TDatabase value) -> TProperty +Dapper.FluentMap.Mapping.IWritePropertyConverter +Dapper.FluentMap.Mapping.IWritePropertyConverter.ConvertToDatabase(TProperty value) -> TDatabase +Dapper.FluentMap.Mapping.PropertyConversionDirection +Dapper.FluentMap.Mapping.PropertyConversionDirection.Read = 0 -> Dapper.FluentMap.Mapping.PropertyConversionDirection +Dapper.FluentMap.Mapping.PropertyConversionDirection.Write = 1 -> Dapper.FluentMap.Mapping.PropertyConversionDirection +Dapper.FluentMap.Mapping.PropertyConversionMetadata +Dapper.FluentMap.Mapping.PropertyConversionMetadata.HasReadConverter.get -> bool +Dapper.FluentMap.Mapping.PropertyConversionMetadata.HasWriteConverter.get -> bool +Dapper.FluentMap.Mapping.PropertyConversionMetadata.ReadConverter.get -> Dapper.FluentMap.Mapping.PropertyConverterMetadata? +Dapper.FluentMap.Mapping.PropertyConversionMetadata.WriteConverter.get -> Dapper.FluentMap.Mapping.PropertyConverterMetadata? +Dapper.FluentMap.Mapping.PropertyConverterMetadata +Dapper.FluentMap.Mapping.PropertyConverterMetadata.ConverterType.get -> System.Type! +Dapper.FluentMap.Mapping.PropertyConverterMetadata.DatabaseType.get -> System.Type! +Dapper.FluentMap.Mapping.PropertyConverterMetadata.Direction.get -> Dapper.FluentMap.Mapping.PropertyConversionDirection +Dapper.FluentMap.Mapping.PropertyConverterMetadata.PropertyType.get -> System.Type! +Dapper.FluentMap.Mapping.PropertyMap +Dapper.FluentMap.Mapping.PropertyMap.PropertyMap(System.Reflection.PropertyInfo! info) -> void +Dapper.FluentMap.Mapping.PropertyMap.PropertyMap(System.Reflection.PropertyInfo! info, string! columnName) -> void +Dapper.FluentMap.Mapping.PropertyMap.PropertyMap(System.Reflection.PropertyInfo! info, string! columnName, bool caseSensitive) -> void +Dapper.FluentMap.Mapping.PropertyMapBase +Dapper.FluentMap.Mapping.PropertyMapBase.CaseSensitive.get -> bool +Dapper.FluentMap.Mapping.PropertyMapBase.ColumnName.get -> string! +Dapper.FluentMap.Mapping.PropertyMapBase.Computed() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.Conversion.get -> Dapper.FluentMap.Mapping.PropertyConversionMetadata! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertFromDatabaseUsing() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertFromDatabaseUsing(Dapper.FluentMap.Mapping.IReadPropertyConverter! converter) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertFromDatabaseUsing(Dapper.FluentMap.Mapping.ReadPropertyConverter! converter) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertToDatabaseUsing() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertToDatabaseUsing(Dapper.FluentMap.Mapping.IWritePropertyConverter! converter) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertToDatabaseUsing(Dapper.FluentMap.Mapping.WritePropertyConverter! converter) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertUsing() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ConvertUsing(Dapper.FluentMap.Mapping.IPropertyConverter! converter) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.DatabaseDefaultOnInsert() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ExcludeFromInsert() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ExcludeFromUpdate() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.GetType() -> System.Type! +Dapper.FluentMap.Mapping.PropertyMapBase.Ignore() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.Ignored.get -> bool +Dapper.FluentMap.Mapping.PropertyMapBase.MarkAsComputed() -> void +Dapper.FluentMap.Mapping.PropertyMapBase.MarkAsIdentity() -> void +Dapper.FluentMap.Mapping.PropertyMapBase.MarkAsKey() -> void +Dapper.FluentMap.Mapping.PropertyMapBase.MarkAsNotGenerated() -> void +Dapper.FluentMap.Mapping.PropertyMapBase.Persistence.get -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +Dapper.FluentMap.Mapping.PropertyMapBase.PropertyInfo.get -> System.Reflection.PropertyInfo! +Dapper.FluentMap.Mapping.PropertyMapBase.PropertyMapBase(System.Reflection.PropertyInfo! info) -> void +Dapper.FluentMap.Mapping.PropertyMapBase.ReadOnly() -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.ToColumn(string! columnName, bool caseSensitive = true) -> TPropertyMap! +Dapper.FluentMap.Mapping.PropertyMapBase.UsePersistence(Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! persistence) -> void +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.HasDatabaseDefaultOnInsert.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.IgnoredByFluentMap.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.IsComputed.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.IsGenerated.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.IsIdentity.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.IsKey.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.ParticipatesInInsert.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.ParticipatesInMaterialization.get -> bool +Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.ParticipatesInUpdate.get -> bool +Dapper.FluentMap.Mapping.ReadPropertyConverter +Dapper.FluentMap.Mapping.WritePropertyConverter +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.ColumnName.get -> string! +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.Ignored.get -> bool +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.MemberPath.get -> string? +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.ReadConverterDatabaseType.get -> System.Type? +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.ReadConverterPropertyType.get -> System.Type? +Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.ReadConverterType.get -> System.Type? +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.Columns.get -> System.Collections.Generic.IReadOnlyList! +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.EntityType.get -> System.Type! +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.GeneratedMaterializerDescriptor(System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> void +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.GeneratedMaterializerDescriptor(System.Type? profileType, System.Collections.Generic.IEnumerable! columns, Dapper.FluentMap.Materialization.GeneratedRowMaterializer! materializer) -> void +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.Materializer.get -> Dapper.FluentMap.Materialization.GeneratedRowMaterializer! +Dapper.FluentMap.Materialization.GeneratedMaterializerDescriptor.ProfileType.get -> System.Type? +Dapper.FluentMap.Materialization.GeneratedRowMaterializer +Dapper.FluentMap.Naming.NamingPolicy +Dapper.FluentMap.Naming.NamingPolicy.GetColumnName(string! memberName) -> string! +Dapper.FluentMap.Naming.NamingPolicy.GetType() -> System.Type! +Dapper.FluentMap.Naming.NamingPolicy.Then(Dapper.FluentMap.Naming.NamingPolicy! next) -> Dapper.FluentMap.Naming.NamingPolicy! +Dapper.FluentMap.Naming.NamingPolicy.Then(System.Func! transformer) -> Dapper.FluentMap.Naming.NamingPolicy! +Dapper.FluentMap.Naming.NamingPolicy.WithPrefix(string! prefix) -> Dapper.FluentMap.Naming.NamingPolicy! +Dapper.FluentMap.Naming.NamingPolicy.WithSuffix(string! suffix) -> Dapper.FluentMap.Naming.NamingPolicy! +Dapper.FluentMap.QueryMappedExtensions +Dapper.FluentMap.TypeMaps.FluentConventionTypeMap +Dapper.FluentMap.TypeMaps.FluentConventionTypeMap.FluentConventionTypeMap() -> void +Dapper.FluentMap.TypeMaps.FluentMapTypeMap +Dapper.FluentMap.TypeMaps.FluentMapTypeMap.FluentMapTypeMap() -> void +Dapper.FluentMap.TypeMaps.MultiTypeMap +Dapper.FluentMap.TypeMaps.MultiTypeMap.FindConstructor(string![]! names, System.Type![]! types) -> System.Reflection.ConstructorInfo! +Dapper.FluentMap.TypeMaps.MultiTypeMap.FindExplicitConstructor() -> System.Reflection.ConstructorInfo! +Dapper.FluentMap.TypeMaps.MultiTypeMap.GetConstructorParameter(System.Reflection.ConstructorInfo! constructor, string! columnName) -> Dapper.SqlMapper.IMemberMap! +Dapper.FluentMap.TypeMaps.MultiTypeMap.GetMember(string! columnName) -> Dapper.SqlMapper.IMemberMap! +Dapper.FluentMap.TypeMaps.MultiTypeMap.MultiTypeMap(params Dapper.SqlMapper.ITypeMap![]! mappers) -> void +Dapper.FluentMap.Utils.FluentMapConfigurationExtensions +Dapper.FluentMap.Utils.ReflectionHelper +override Dapper.FluentMap.Configuration.FluentConventionConfiguration.Equals(object! obj) -> bool +override Dapper.FluentMap.Configuration.FluentConventionConfiguration.GetHashCode() -> int +override Dapper.FluentMap.Configuration.FluentConventionConfiguration.ToString() -> string! +override Dapper.FluentMap.Configuration.FluentMapConfiguration.Equals(object! obj) -> bool +override Dapper.FluentMap.Configuration.FluentMapConfiguration.GetHashCode() -> int +override Dapper.FluentMap.Configuration.FluentMapConfiguration.ToString() -> string! +override Dapper.FluentMap.Conventions.Convention.Equals(object! obj) -> bool +override Dapper.FluentMap.Conventions.Convention.GetHashCode() -> int +override Dapper.FluentMap.Conventions.Convention.ToString() -> string! +override Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.Equals(object! obj) -> bool +override Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.GetHashCode() -> int +override Dapper.FluentMap.Conventions.ConventionPropertyConfiguration.ToString() -> string! +override Dapper.FluentMap.Conventions.PropertyConventionConfiguration.Equals(object! obj) -> bool +override Dapper.FluentMap.Conventions.PropertyConventionConfiguration.GetHashCode() -> int +override Dapper.FluentMap.Conventions.PropertyConventionConfiguration.ToString() -> string! +override Dapper.FluentMap.Diagnostics.MappingExplanation.ToString() -> string! +override Dapper.FluentMap.Mapping.EntityMap.GetPropertyMap(System.Reflection.PropertyInfo! info) -> Dapper.FluentMap.Mapping.PropertyMap! +override Dapper.FluentMap.Mapping.PropertyMapBase.Equals(object! obj) -> bool +override Dapper.FluentMap.Mapping.PropertyMapBase.GetHashCode() -> int +override Dapper.FluentMap.Mapping.PropertyMapBase.ToString() -> string! +override Dapper.FluentMap.Naming.NamingPolicy.Equals(object! obj) -> bool +override Dapper.FluentMap.Naming.NamingPolicy.GetHashCode() -> int +override Dapper.FluentMap.Naming.NamingPolicy.ToString() -> string! +static Dapper.FluentMap.FluentMapper.Configuration.get -> Dapper.FluentMap.Configuration.ImmutableFluentMapConfiguration! +static Dapper.FluentMap.FluentMapper.Explain() -> Dapper.FluentMap.Diagnostics.MappingExplanation! +static Dapper.FluentMap.FluentMapper.Explain() -> Dapper.FluentMap.Diagnostics.MappingExplanation! +static Dapper.FluentMap.FluentMapper.GetEntityMaps() -> System.Collections.Generic.IReadOnlyDictionary! +static Dapper.FluentMap.FluentMapper.GetTypeConventions() -> System.Collections.Generic.IReadOnlyDictionary!>! +static Dapper.FluentMap.FluentMapper.Initialize(System.Action! configure) -> void +static Dapper.FluentMap.FluentMapper.Runtime.get -> Dapper.FluentMap.FluentMapRuntime! +static Dapper.FluentMap.FluentMapper.Validate() -> void +static Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.Ignore(string! columnName) -> Dapper.FluentMap.Materialization.GeneratedMaterializerColumn! +static Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.Map(string! columnName, string! memberPath) -> Dapper.FluentMap.Materialization.GeneratedMaterializerColumn! +static Dapper.FluentMap.Materialization.GeneratedMaterializerColumn.Map(string! columnName, string! memberPath, System.Type! readConverterType, System.Type! readConverterDatabaseType, System.Type! readConverterPropertyType) -> Dapper.FluentMap.Materialization.GeneratedMaterializerColumn! +static Dapper.FluentMap.Naming.NamingPolicy.Custom(System.Func! transformer) -> Dapper.FluentMap.Naming.NamingPolicy! +static Dapper.FluentMap.Naming.NamingPolicy.Identity.get -> Dapper.FluentMap.Naming.NamingPolicy! +static Dapper.FluentMap.Naming.NamingPolicy.Prefix(string! prefix) -> Dapper.FluentMap.Naming.NamingPolicy! +static Dapper.FluentMap.Naming.NamingPolicy.SnakeCase.get -> Dapper.FluentMap.Naming.NamingPolicy! +static Dapper.FluentMap.Naming.NamingPolicy.Suffix(string! suffix) -> Dapper.FluentMap.Naming.NamingPolicy! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, System.Func! map, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, System.Func! map, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, string! splitOn = "Id") -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> System.Threading.Tasks.Task!>! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Threading.Tasks.Task!>! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedSingle(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedSingle(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> TEntity! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedSingleAsync(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Threading.Tasks.Task! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbuffered(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbuffered(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbuffered(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbuffered(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, string! sql, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedUnbufferedAsync(this System.Data.Common.DbConnection! connection, string! sql, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IAsyncEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMultipleMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command) -> Dapper.FluentMap.MappedGridReader! +static Dapper.FluentMap.QueryMappedExtensions.QueryMultipleMapped(this System.Data.IDbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> Dapper.FluentMap.MappedGridReader! +static Dapper.FluentMap.QueryMappedExtensions.QueryMultipleMappedAsync(this System.Data.Common.DbConnection! connection, Dapper.CommandDefinition command) -> System.Threading.Tasks.Task! +static Dapper.FluentMap.QueryMappedExtensions.QueryMultipleMappedAsync(this System.Data.Common.DbConnection! connection, string! sql, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Dapper.FluentMap.TypeMaps.MultiTypeMap.MatchColumnNames(Dapper.FluentMap.Mapping.IPropertyMap! map, string! columnName) -> bool +static Dapper.FluentMap.TypeMaps.MultiTypeMap.TypePropertyMapCache.get -> System.Collections.Concurrent.ConcurrentDictionary! +static Dapper.FluentMap.Utils.FluentMapConfigurationExtensions.ApplyMapsFromAssemblies(this Dapper.FluentMap.Configuration.FluentMapConfiguration! configuration, params System.Reflection.Assembly![]! assemblies) -> void +static Dapper.FluentMap.Utils.ReflectionHelper.GetMemberInfo(System.Linq.Expressions.LambdaExpression! lambda) -> System.Reflection.MemberInfo! +static readonly Dapper.FluentMap.FluentMapper.EntityMaps -> System.Collections.Concurrent.ConcurrentDictionary! +static readonly Dapper.FluentMap.FluentMapper.TypeConventions -> System.Collections.Concurrent.ConcurrentDictionary!>! +static readonly Dapper.FluentMap.Mapping.PropertyConversionMetadata.Default -> Dapper.FluentMap.Mapping.PropertyConversionMetadata! +static readonly Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.Default -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +static readonly Dapper.FluentMap.Mapping.PropertyPersistenceMetadata.Ignored -> Dapper.FluentMap.Mapping.PropertyPersistenceMetadata! +virtual Dapper.FluentMap.Mapping.ReadPropertyConverter.Invoke(TDatabase value) -> TProperty +virtual Dapper.FluentMap.Mapping.WritePropertyConverter.Invoke(TProperty value) -> TDatabase +virtual Dapper.FluentMap.Materialization.GeneratedRowMaterializer.Invoke(System.Data.IDataRecord! record) -> TEntity! diff --git a/src/Dapper.FluentMap/PublicAPI.Unshipped.txt b/src/Dapper.FluentMap/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..011a2f7f --- /dev/null +++ b/src/Dapper.FluentMap/PublicAPI.Unshipped.txt @@ -0,0 +1,20 @@ +#nullable enable +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMapped(System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +Dapper.FluentMap.FluentMapRuntime.QueryMappedAsync(System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Dapper.FluentMap.FluentMapRuntime.QueryMappedAsync(System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Dapper.FluentMap.GeneratedParameters +Dapper.FluentMap.GeneratedParameters.Add(string! name, object? value, System.Data.DbType dbType, int? size = null) -> Dapper.FluentMap.GeneratedParameters! +Dapper.FluentMap.GeneratedParameters.GeneratedParameters() -> void +Dapper.FluentMap.Mapping.EntityMapBase.ConstructUsing(System.Linq.Expressions.Expression!>! value1, System.Linq.Expressions.Expression!>! value2, System.Linq.Expressions.Expression!>! value3, System.Linq.Expressions.Expression!>! value4, System.Func! factory) -> void +Dapper.FluentMap.Mapping.EntityMapBase.ConstructUsing(System.Linq.Expressions.Expression!>! value1, System.Linq.Expressions.Expression!>! value2, System.Linq.Expressions.Expression!>! value3, System.Func! factory) -> void +Dapper.FluentMap.Mapping.EntityMapBase.ConstructUsing(System.Linq.Expressions.Expression!>! value1, System.Linq.Expressions.Expression!>! value2, System.Func! factory) -> void +Dapper.FluentMap.Mapping.EntityMapBase.ConstructUsing(System.Linq.Expressions.Expression!>! value1, System.Func! factory) -> void +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMapped(this System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null) -> System.Collections.Generic.IEnumerable! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn) -> System.Threading.Tasks.Task!>! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, Dapper.CommandDefinition command, System.Func! map, string! splitOn) -> System.Threading.Tasks.Task!>! +static Dapper.FluentMap.QueryMappedExtensions.QueryMappedAsync(this System.Data.IDbConnection! connection, string! sql, System.Func! map, string! splitOn, object? param = null, System.Data.IDbTransaction? transaction = null, int? commandTimeout = null, System.Data.CommandType? commandType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs index c882e4eb..2aab0660 100644 --- a/src/Dapper.FluentMap/QueryMappedExtensions.cs +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -35,8 +35,8 @@ public static IEnumerable QueryMapped< TEntity>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -76,8 +76,8 @@ public static IEnumerable QueryMapped< TProfile>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -152,8 +152,8 @@ public static IEnumerable QueryMappedUnbuffered< TEntity>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -193,8 +193,8 @@ public static IEnumerable QueryMappedUnbuffered< TProfile>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -303,8 +303,8 @@ public static IAsyncEnumerable QueryMappedUnbufferedAsync< TEntity>( this DbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, CancellationToken cancellationToken = default) @@ -377,8 +377,8 @@ public static IAsyncEnumerable QueryMappedUnbufferedAsync< TProfile>( this DbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, CancellationToken cancellationToken = default) @@ -469,8 +469,8 @@ public static TEntity QueryMappedSingle< TEntity>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -498,8 +498,8 @@ public static TEntity QueryMappedSingle< TProfile>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -528,8 +528,8 @@ public static Task> QueryMappedAsync< TProfile>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -587,8 +587,8 @@ public static async Task QueryMappedSingleAsync< TProfile>( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) where TEntity : class @@ -622,6 +622,10 @@ public static async Task QueryMappedSingleAsync< /// The composed rows. [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + [SuppressMessage( + "ApiDesign", + "RS0027:API with optional parameter(s) should have the most parameters among its public overloads", + Justification = "The established two-segment overload retains its optional splitOn contract; new three-segment overloads require two explicit boundaries.")] public static IEnumerable QueryMapped< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, @@ -630,9 +634,9 @@ public static IEnumerable QueryMapped< TReturn>( this IDbConnection connection, string sql, - Func map, - object param = null, - IDbTransaction transaction = null, + Func map, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, string splitOn = "Id") @@ -656,6 +660,10 @@ public static IEnumerable QueryMapped< /// [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + [SuppressMessage( + "ApiDesign", + "RS0027:API with optional parameter(s) should have the most parameters among its public overloads", + Justification = "The established two-segment overload retains its optional splitOn contract; new three-segment overloads require two explicit boundaries.")] public static IEnumerable QueryMapped< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, @@ -664,7 +672,7 @@ public static IEnumerable QueryMapped< TReturn>( this IDbConnection connection, CommandDefinition command, - Func map, + Func map, string splitOn = "Id") where TFirst : class where TSecond : class @@ -679,6 +687,294 @@ public static IEnumerable QueryMapped< runtime: FluentMapper.Runtime); } + /// + /// Executes a query, splits each row into three FluentMap-controlled segments and composes the return value. + /// + /// + /// must contain two comma-separated, unique column names in result order. + /// An all-database-null second or third segment is passed to as . + /// This API composes one row at a time and does not aggregate one-to-many graphs. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + this IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TFirst : class + where TSecond : class + where TThird : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + map, + splitOn); + } + + /// + /// Executes a command, splits each row into three FluentMap-controlled segments and composes the return value. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + this IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn) + where TFirst : class + where TSecond : class + where TThird : class + { + return ExecuteMapped( + connection, command, map, splitOn, null, null, null, FluentMapper.Runtime); + } + + /// + /// Executes a query and materializes three segments with an explicit mapping profile per segment. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + this IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return ExecuteMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType), + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + FluentMapper.Runtime); + } + + /// + /// Executes a command and materializes three segments with an explicit mapping profile per segment. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + this IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + return ExecuteMapped( + connection, + command, + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + FluentMapper.Runtime); + } + + /// + /// Asynchronously executes a query, materializes three FluentMap-controlled segments and composes the return value. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + this IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TFirst : class + where TSecond : class + where TThird : class + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return ExecuteMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.Buffered, cancellationToken), + map, + splitOn, + null, + null, + null, + FluentMapper.Runtime); + } + + /// + /// Asynchronously executes a command, materializes three FluentMap-controlled segments and composes the return value. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + this IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn) + where TFirst : class + where TSecond : class + where TThird : class + { + return ExecuteMappedAsync( + connection, + command, + map, + splitOn, + null, + null, + null, + FluentMapper.Runtime); + } + + /// + /// Asynchronously executes a query and materializes three segments with an explicit mapping profile per segment. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + this IDbConnection connection, + string sql, + Func map, + string splitOn, + object? param = null, + IDbTransaction? transaction = null, + int? commandTimeout = null, + CommandType? commandType = null, + CancellationToken cancellationToken = default) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return ExecuteMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType, CommandFlags.Buffered, cancellationToken), + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + FluentMapper.Runtime); + } + + /// + /// Asynchronously executes a command and materializes three segments with an explicit mapping profile per segment. + /// + [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn, + TFirstProfile, + TSecondProfile, + TThirdProfile>( + this IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn) + where TFirst : class + where TSecond : class + where TThird : class + where TFirstProfile : IMappingProfile + where TSecondProfile : IMappingProfile + where TThirdProfile : IMappingProfile + { + return ExecuteMappedAsync( + connection, + command, + map, + splitOn, + typeof(TFirstProfile), + typeof(TSecondProfile), + typeof(TThirdProfile), + FluentMapper.Runtime); + } + /// /// Executes a query, splits each row at , materializes two profiled FluentMap-controlled segments and composes the return value. /// @@ -698,6 +994,10 @@ public static IEnumerable QueryMapped< /// The composed rows. [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + [SuppressMessage( + "ApiDesign", + "RS0027:API with optional parameter(s) should have the most parameters among its public overloads", + Justification = "The established profiled two-segment overload retains its optional splitOn contract; new three-segment overloads require two explicit boundaries.")] public static IEnumerable QueryMapped< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, @@ -708,9 +1008,9 @@ public static IEnumerable QueryMapped< TSecondProfile>( this IDbConnection connection, string sql, - Func map, - object param = null, - IDbTransaction transaction = null, + Func map, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, string splitOn = "Id") @@ -736,6 +1036,10 @@ public static IEnumerable QueryMapped< /// [RequiresUnreferencedCode(QueryMappedApiAnnotations.RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(QueryMappedApiAnnotations.RequiresDynamicCodeMessage)] + [SuppressMessage( + "ApiDesign", + "RS0027:API with optional parameter(s) should have the most parameters among its public overloads", + Justification = "The established profiled two-segment overload retains its optional splitOn contract; new three-segment overloads require two explicit boundaries.")] public static IEnumerable QueryMapped< [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, @@ -746,7 +1050,7 @@ public static IEnumerable QueryMapped< TSecondProfile>( this IDbConnection connection, CommandDefinition command, - Func map, + Func map, string splitOn = "Id") where TFirst : class where TSecond : class @@ -778,8 +1082,8 @@ public static IEnumerable QueryMapped< public static MappedGridReader QueryMultipleMapped( this IDbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null) { @@ -821,8 +1125,8 @@ public static MappedGridReader QueryMultipleMapped( public static Task QueryMultipleMappedAsync( this DbConnection connection, string sql, - object param = null, - IDbTransaction transaction = null, + object? param = null, + IDbTransaction? transaction = null, int? commandTimeout = null, CommandType? commandType = null, CancellationToken cancellationToken = default) @@ -877,6 +1181,7 @@ internal static IEnumerable ExecuteMapped< internal static IEnumerable ExecuteGeneratedMapped( IDbConnection connection, string sql, + GeneratedParameters parameters, IDbTransaction transaction, int? commandTimeout, CommandType? commandType, @@ -916,6 +1221,8 @@ internal static IEnumerable ExecuteGeneratedMapped( command.CommandType = commandType.Value; } + parameters?.AddParameters(command); + using (var reader = command.ExecuteReader()) { var results = new List(); @@ -947,7 +1254,7 @@ internal static IEnumerable ExecuteMapped< TReturn>( IDbConnection connection, CommandDefinition command, - Func map, + Func map, string splitOn, Type firstProfileType, Type secondProfileType, @@ -955,19 +1262,97 @@ internal static IEnumerable ExecuteMapped< where TFirst : class where TSecond : class { - if (connection == null) + if (map == null) { - throw new ArgumentNullException(nameof(connection)); + throw new ArgumentNullException(nameof(map)); } + return ExecuteMappedSegments( + connection, + command, + new[] { typeof(TFirst), typeof(TSecond) }, + new[] { firstProfileType, secondProfileType }, + values => map((TFirst)values[0], (TSecond)values[1]), + splitOn, + runtime); + } + + internal static IEnumerable ExecuteMapped< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn, + Type firstProfileType, + Type secondProfileType, + Type thirdProfileType, + FluentMapRuntime runtime) + where TFirst : class + where TSecond : class + where TThird : class + { if (map == null) { throw new ArgumentNullException(nameof(map)); } - if (string.IsNullOrWhiteSpace(splitOn)) + return ExecuteMappedSegments( + connection, + command, + new[] { typeof(TFirst), typeof(TSecond), typeof(TThird) }, + new[] { firstProfileType, secondProfileType, thirdProfileType }, + values => map((TFirst)values[0], (TSecond)values[1], (TThird)values[2]), + splitOn, + runtime); + } + + internal static Task> ExecuteMappedAsync< + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TFirst, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TSecond, + [DynamicallyAccessedMembers(QueryMappedApiAnnotations.MaterializedEntityMemberTypes)] TThird, + TReturn>( + IDbConnection connection, + CommandDefinition command, + Func map, + string splitOn, + Type firstProfileType, + Type secondProfileType, + Type thirdProfileType, + FluentMapRuntime runtime) + where TFirst : class + where TSecond : class + where TThird : class + { + if (map == null) + { + throw new ArgumentNullException(nameof(map)); + } + + return ExecuteMappedSegmentsAsync( + connection, + command, + new[] { typeof(TFirst), typeof(TSecond), typeof(TThird) }, + new[] { firstProfileType, secondProfileType, thirdProfileType }, + values => map((TFirst)values[0], (TSecond)values[1], (TThird)values[2]), + splitOn, + runtime); + } + + private static IEnumerable ExecuteMappedSegments( + IDbConnection connection, + CommandDefinition command, + Type[] entityTypes, + Type[] profileTypes, + Func map, + string splitOn, + FluentMapRuntime runtime) + { + if (connection == null) { - throw new ArgumentException("A splitOn column name is required.", nameof(splitOn)); + throw new ArgumentNullException(nameof(connection)); } if (runtime == null) @@ -975,26 +1360,69 @@ internal static IEnumerable ExecuteMapped< throw new ArgumentNullException(nameof(runtime)); } + var boundaries = ParseSplitBoundaries(splitOn, entityTypes.Length); using (var reader = SqlMapper.ExecuteReader(connection, command)) { - var splitIndex = GetSplitIndex(reader, splitOn); - var firstSegment = new SegmentDataRecord(reader, 0, splitIndex); - var secondSegment = new SegmentDataRecord(reader, splitIndex, reader.FieldCount - splitIndex); - var firstMaterializer = MappedRowMaterializer.CreateMaterializer(firstSegment, firstProfileType, runtime); - var secondMaterializer = MappedRowMaterializer.CreateMaterializer(secondSegment, secondProfileType, runtime); + var segments = CreateSegments(reader, boundaries, entityTypes.Length); + var materializers = CreateSegmentMaterializers(segments, entityTypes, profileTypes, runtime); var results = new List(); while (reader.Read()) { - var first = firstMaterializer(firstSegment); - var second = IsAllNull(secondSegment) ? null : secondMaterializer(secondSegment); - results.Add(map(first, second)); + results.Add(map(MaterializeSegments(segments, materializers))); } return results; } } + internal static async Task> ExecuteMappedSegmentsAsync( + IDbConnection connection, + CommandDefinition command, + Type[] entityTypes, + Type[] profileTypes, + Func map, + string splitOn, + FluentMapRuntime runtime) + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (runtime == null) + { + throw new ArgumentNullException(nameof(runtime)); + } + + var boundaries = ParseSplitBoundaries(splitOn, entityTypes.Length); + using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) + { + var segments = CreateSegments(reader, boundaries, entityTypes.Length); + var materializers = CreateSegmentMaterializers(segments, entityTypes, profileTypes, runtime); + var results = new List(); + + while (await ReadSegmentRowAsync(reader, command.CancellationToken).ConfigureAwait(false)) + { + results.Add(map(MaterializeSegments(segments, materializers))); + } + + return results; + } + } + + private static Task ReadSegmentRowAsync(IDataReader reader, CancellationToken cancellationToken) + { + var dbReader = reader as DbDataReader; + if (dbReader != null) + { + return dbReader.ReadAsync(cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(reader.Read()); + } + internal static async Task ExecuteMultipleMappedAsync( DbConnection connection, CommandDefinition command, @@ -1014,35 +1442,102 @@ internal static async Task ExecuteMultipleMappedAsync( return new MappedGridReader(reader, runtime); } - private static int GetSplitIndex(IDataRecord reader, string splitOn) + private static string[] ParseSplitBoundaries(string splitOn, int segmentCount) { - var splitIndex = -1; - for (var i = 0; i < reader.FieldCount; i++) + if (string.IsNullOrWhiteSpace(splitOn)) + { + throw new ArgumentException("One splitOn column name is required for each segment after the first.", nameof(splitOn)); + } + + var boundaries = splitOn.Split(',').Select(boundary => boundary.Trim()).ToArray(); + if (boundaries.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException("splitOn cannot contain an empty boundary.", nameof(splitOn)); + } + + if (boundaries.Length != segmentCount - 1) + { + throw new ArgumentException( + $"Multi-mapping with {segmentCount} segments requires exactly {segmentCount - 1} splitOn boundaries; {boundaries.Length} were provided.", + nameof(splitOn)); + } + + var duplicateBoundary = boundaries + .GroupBy(boundary => boundary, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateBoundary != null) { - if (!string.Equals(reader.GetName(i), splitOn, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + "The splitOn boundary '" + duplicateBoundary.Key + "' is duplicated and ambiguous."); + } + + return boundaries; + } + + private static SegmentDataRecord[] CreateSegments( + IDataRecord reader, + IReadOnlyList boundaries, + int segmentCount) + { + var indexes = new List { 0 }; + foreach (var boundary in boundaries) + { + var matches = Enumerable.Range(0, reader.FieldCount) + .Where(index => string.Equals(reader.GetName(index), boundary, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matches.Length == 0) { - continue; + throw new InvalidOperationException("The splitOn column '" + boundary + "' was not found in the result set."); } - if (splitIndex >= 0) + if (matches.Length > 1) { - throw new InvalidOperationException("The splitOn column '" + splitOn + "' is ambiguous in the result set."); + throw new InvalidOperationException("The splitOn column '" + boundary + "' is ambiguous in the result set."); } - splitIndex = i; - } + if (matches[0] <= indexes[indexes.Count - 1]) + { + throw new InvalidOperationException( + "The splitOn column '" + boundary + "' is out of order or produced an empty row segment."); + } - if (splitIndex < 0) - { - throw new InvalidOperationException("The splitOn column '" + splitOn + "' was not found in the result set."); + indexes.Add(matches[0]); } - if (splitIndex == 0 || splitIndex >= reader.FieldCount) + indexes.Add(reader.FieldCount); + return Enumerable.Range(0, segmentCount) + .Select(index => new SegmentDataRecord( + reader, + indexes[index], + indexes[index + 1] - indexes[index])) + .ToArray(); + } + + private static Func[] CreateSegmentMaterializers( + SegmentDataRecord[] segments, + Type[] entityTypes, + Type[] profileTypes, + FluentMapRuntime runtime) + { + return Enumerable.Range(0, segments.Length) + .Select(index => MappedRowMaterializer.CreateMaterializer( + segments[index], entityTypes[index], profileTypes[index], runtime)) + .ToArray(); + } + + private static object[] MaterializeSegments( + SegmentDataRecord[] segments, + Func[] materializers) + { + var values = new object[segments.Length]; + for (var index = 0; index < segments.Length; index++) { - throw new InvalidOperationException("The splitOn column '" + splitOn + "' produced an empty row segment."); + values[index] = index > 0 && IsAllNull(segments[index]) + ? null + : materializers[index](segments[index]); } - return splitIndex; + return values; } private static bool IsAllNull(IDataRecord record) diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs index de7cf8df..ae73e7c1 100644 --- a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -545,6 +545,74 @@ public CustomerMap() AssertDiagnosticLineContains(source, diagnostic, "ConvertToDatabaseUsing()"); } + [Fact] + public async Task DuplicateConstructionStrategyShouldReportDfm016() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Customer(int id) { Id = id; } + public int Id { get; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + ConstructUsing(c => c.Id, id => new Customer(id)); + ConstructUsing(c => c.Id, id => new Customer(id)); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync( + source, + FluentMapConfigurationAnalyzer.DuplicateConstructionStrategyDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "more than one ConstructUsing"); + AssertDiagnosticLineContains(source, diagnostic, "ConstructUsing(c => c.Id, id => new Customer(id))"); + } + + [Fact] + public async Task InheritedConstructionStrategyShouldReportDfm016OnDerivedMap() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public Customer(int id) { Id = id; } + public int Id { get; } +} + +public class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + ConstructUsing(c => c.Id, id => new Customer(id)); + } +} + +public sealed class DerivedCustomerMap : CustomerMap +{ + public DerivedCustomerMap() + { + ConstructUsing(c => c.Id, id => new Customer(id)); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync( + source, + FluentMapConfigurationAnalyzer.DuplicateConstructionStrategyDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "more than one ConstructUsing"); + AssertDiagnosticLineContains(source, diagnostic, "ConstructUsing(c => c.Id, id => new Customer(id))"); + Assert.Equal(23, diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1); + } + [Fact] public async Task ValidMappingConfigurationShouldNotReportDiagnostics() { diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs index e6512051..1fb19e6e 100644 --- a/test/Dapper.FluentMap.AotSmoke/Program.cs +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Data; using System.Diagnostics.CodeAnalysis; using System.Linq; #if !AOT_SMOKE_GENERATED && !AOT_SMOKE_DI_GENERATED && !AOT_SMOKE_DI_EXPLICIT @@ -202,6 +203,12 @@ static void AssertStrictGeneratedQueryMappedMaterializer(FluentMapRuntime runtim using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); + if (!runtime.Configuration.GeneratedMaterializers.Any(materializer => + materializer.EntityType == typeof(ReorderedCustomer))) + { + throw new InvalidOperationException("Generated startup registration did not include the strict smoke entity."); + } + var customer = runtime.QueryGeneratedMappedSingle( connection, "SELECT 42 AS customer_id;"); @@ -210,6 +217,34 @@ static void AssertStrictGeneratedQueryMappedMaterializer(FluentMapRuntime runtim throw new InvalidOperationException("Generated flat QueryMapped materializer was not used correctly."); } + var parameters = new GeneratedParameters() + .Add("Id", 43, DbType.Int32) + .Add("Name", "Parameterized", DbType.String, size: 64); + var parameterized = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT @Id AS customer_id, @Name AS customer_name;", + parameters); + if (parameterized.Id != 43 || parameterized.Name != "Parameterized") + { + throw new InvalidOperationException("Parameterized strict generated query was not materialized correctly."); + } + + var reordered = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 'Reordered' AS customer_name, 44 AS customer_id;"); + if (reordered.Id != 44 || reordered.Name != "Reordered") + { + throw new InvalidOperationException("Reordered strict generated shape was not materialized correctly."); + } + + var additional = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 'trace' AS trace_id, 45 AS customer_id, 'Additional' AS customer_name;"); + if (additional.Id != 45 || additional.Name != "Additional") + { + throw new InvalidOperationException("Safe additional-column strict generated shape was not materialized correctly."); + } + var valueObjectCustomer = runtime.QueryGeneratedMappedSingle( connection, "SELECT '12345678909' AS cpf;"); @@ -258,6 +293,22 @@ public CustomerMap() } } +public sealed class ReorderedCustomer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class ReorderedCustomerMap : EntityMap +{ + public ReorderedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } +} + public sealed class LegacyProfile : IMappingProfile { } diff --git a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs index 2df4f39b..b19b396a 100644 --- a/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs +++ b/test/Dapper.FluentMap.Dommel.Tests/DommelPersistenceIntegrationTests.cs @@ -335,6 +335,209 @@ code TEXT NOT NULL } } + [Fact] + public void InsertMappedAndUpdateMappedShouldExecuteWriteConverters() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new WriteConvertedEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE write_converted_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + insert_excluded TEXT, + update_excluded TEXT, + default_value TEXT DEFAULT 'database-default', + computed_value TEXT +);"); + + var logs = CaptureDommelLogs(); + var entity = new WriteConvertedEntity + { + Code = "insert", + InsertExcluded = "must-not-insert", + UpdateExcluded = "insert-only", + DefaultValue = "must-use-default", + ComputedValue = "must-not-write" + }; + + var id = Convert.ToInt32(connection.InsertMapped(entity)); + var insertSql = logs.Last(log => log.IndexOf("insert into", StringComparison.OrdinalIgnoreCase) >= 0); + AssertSqlDoesNotContain(insertSql, "insert_excluded", "default_value", "computed_value"); + var inserted = connection.QuerySingle(@" +SELECT id AS Id, code AS Code, insert_excluded AS InsertExcluded, + update_excluded AS UpdateExcluded, default_value AS DefaultValue, + computed_value AS ComputedValue +FROM write_converted_entities WHERE id = @id;", new { id }); + + Assert.Equal("db:insert", inserted.Code); + Assert.Null(inserted.InsertExcluded); + Assert.Equal("db:insert-only", inserted.UpdateExcluded); + Assert.Equal("database-default", inserted.DefaultValue); + Assert.Null(inserted.ComputedValue); + + entity.Id = id; + entity.Code = "update"; + entity.InsertExcluded = "update-only"; + entity.UpdateExcluded = "must-not-update"; + + Assert.True(connection.UpdateMapped(entity)); + + var updated = connection.QuerySingle(@" +SELECT id AS Id, code AS Code, insert_excluded AS InsertExcluded, + update_excluded AS UpdateExcluded, default_value AS DefaultValue, + computed_value AS ComputedValue +FROM write_converted_entities WHERE id = @id;", new { id }); + Assert.Equal("db:update", updated.Code); + Assert.Equal("db:update-only", updated.InsertExcluded); + Assert.Equal("db:insert-only", updated.UpdateExcluded); + Assert.Equal("db:must-use-default", updated.DefaultValue); + Assert.Null(updated.ComputedValue); + } + } + + [Fact] + public void WriteConverterFailureShouldIncludeDommelOperationAndPropertyContext() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new WriteConversionBoundaryEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE write_conversion_boundary_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL +);"); + + var exception = Assert.Throws(() => + connection.InsertMapped(new WriteConversionBoundaryEntity { Code = "reject" })); + + Assert.Contains("INSERT", exception.Message); + Assert.Contains(typeof(WriteConversionBoundaryEntity).FullName, exception.Message); + Assert.Contains(nameof(WriteConversionBoundaryEntity.Code), exception.Message); + Assert.Contains("code", exception.Message); + Assert.IsType(exception.InnerException); + Assert.Equal(0, connection.QuerySingle("SELECT COUNT(*) FROM write_conversion_boundary_entities;")); + } + } + + [Fact] + public void ConverterOutputShouldThenUseDapperTypeHandler() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + SqlMapper.AddTypeHandler(new ConvertedCodeTypeHandler()); + + try + { + FluentMapper.Initialize(config => + { + config.AddMap(new TypeHandledWriteEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE type_handled_write_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL +);"); + + var id = Convert.ToInt32(connection.InsertMapped(new TypeHandledWriteEntity { Code = "value" })); + + Assert.Equal( + "handler:converted:value", + connection.QuerySingle( + "SELECT code FROM type_handled_write_entities WHERE id = @id;", + new { id })); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + } + } + + [Fact] + public void MappedWritesShouldRejectEntitiesWithoutEligibleValues() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new IdentityOnlyEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + var insertException = Assert.Throws(() => + connection.InsertMapped(new IdentityOnlyEntity())); + var updateException = Assert.Throws(() => + connection.UpdateMapped(new IdentityOnlyEntity { Id = 1 })); + + Assert.Contains(typeof(IdentityOnlyEntity).FullName, insertException.Message); + Assert.Contains("INSERT", insertException.Message); + Assert.Contains(typeof(IdentityOnlyEntity).FullName, updateException.Message); + Assert.Contains("UPDATE", updateException.Message); + } + } + + [Fact] + public async System.Threading.Tasks.Task AsyncMappedWritesShouldApplyWriteConverters() + { + PreTest(); + SQLitePCL.Batteries_V2.Init(); + + FluentMapper.Initialize(config => + { + config.AddMap(new WriteConversionBoundarySuccessEntityMap()); + config.ForDommel(); + }); + + using (var connection = OpenConnection()) + { + connection.Execute(@" +CREATE TABLE write_conversion_boundary_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL +);"); + var entity = new WriteConversionBoundaryEntity { Code = "async-insert" }; + var id = Convert.ToInt32(await connection.InsertMappedAsync( + entity, + cancellationToken: TestContext.Current.CancellationToken)); + + entity.Id = id; + entity.Code = "async-update"; + Assert.True(await connection.UpdateMappedAsync( + entity, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal( + "db:async-update", + connection.QuerySingle( + "SELECT code FROM write_conversion_boundary_entities WHERE id = @id;", + new { id })); + } + } + private static void PreTest() { FluentMapper.EntityMaps.Clear(); @@ -541,5 +744,117 @@ public string ConvertToDatabase(string value) throw new InvalidOperationException("Dommel does not execute property write converters in this stage."); } } + + private sealed class WriteConversionBoundarySuccessEntityMap : DommelEntityMap + { + public WriteConversionBoundarySuccessEntityMap() + { + ToTable("write_conversion_boundary_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Code) + .ToColumn("code") + .ConvertToDatabaseUsing(value => "db:" + value); + } + } + + private sealed class WriteConvertedEntity + { + public int Id { get; set; } + + public string Code { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public string ComputedValue { get; set; } + } + + private sealed class WriteConvertedEntityMap : DommelEntityMap + { + public WriteConvertedEntityMap() + { + ToTable("write_converted_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Code).ToColumn("code").ConvertToDatabaseUsing(value => "db:" + value); + Map(entity => entity.InsertExcluded).ToColumn("insert_excluded").ExcludeFromInsert().ConvertToDatabaseUsing(value => "db:" + value); + Map(entity => entity.UpdateExcluded).ToColumn("update_excluded").ExcludeFromUpdate().ConvertToDatabaseUsing(value => "db:" + value); + Map(entity => entity.DefaultValue).ToColumn("default_value").DatabaseDefaultOnInsert().ConvertToDatabaseUsing(value => "db:" + value); + Map(entity => entity.ComputedValue).ToColumn("computed_value").Computed(); + } + } + + private sealed class WriteConvertedRow + { + public int Id { get; set; } + + public string Code { get; set; } + + public string InsertExcluded { get; set; } + + public string UpdateExcluded { get; set; } + + public string DefaultValue { get; set; } + + public string ComputedValue { get; set; } + } + + private sealed class TypeHandledWriteEntity + { + public int Id { get; set; } + + public string Code { get; set; } + } + + private sealed class IdentityOnlyEntity + { + public int Id { get; set; } + } + + private sealed class IdentityOnlyEntityMap : DommelEntityMap + { + public IdentityOnlyEntityMap() + { + ToTable("identity_only_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + } + } + + private sealed class TypeHandledWriteEntityMap : DommelEntityMap + { + public TypeHandledWriteEntityMap() + { + ToTable("type_handled_write_entities"); + Map(entity => entity.Id).ToColumn("id").IsIdentity(); + Map(entity => entity.Code) + .ToColumn("code") + .ConvertToDatabaseUsing(value => new ConvertedCode("converted:" + value)); + } + } + + private sealed class ConvertedCode + { + public ConvertedCode(string value) + { + Value = value; + } + + public string Value { get; } + } + + private sealed class ConvertedCodeTypeHandler : SqlMapper.TypeHandler + { + public override ConvertedCode Parse(object value) + { + return new ConvertedCode(Convert.ToString(value)); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, ConvertedCode value) + { + parameter.Value = "handler:" + value.Value; + } + } } } diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs index 504949a0..1183bcb9 100644 --- a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -66,6 +66,10 @@ public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures( Assert.Equal(8, internalCustomer.Id); Assert.Equal(9, derived.Id); Assert.Equal("Lovelace", derived.Name); + var inheritedId = FluentMapper.Explain() + .Members.Single(member => member.MemberPath == nameof(GeneratedBaseCustomer.Id)); + Assert.Equal(typeof(GeneratedBaseCustomer), inheritedId.PropertyInfo.DeclaringType); + Assert.Equal(typeof(GeneratedBaseCustomer), inheritedId.PropertyInfo.ReflectedType); Assert.Equal(10, immutable.Id); Assert.Equal("Grace", immutable.Name); Assert.Equal(12, queryMappedImmutable.Id); @@ -129,13 +133,33 @@ public void GeneratedRegistrationShouldUseGeneratedMaterializersForReorderedFlat "SELECT 'Reordered' AS note, 44 AS age;"); var converted = connection.QueryMappedSingle( "SELECT '42' AS optional_score, 'A' AS status, 45 AS customer_id;"); + var strictExtra = FluentMapper.Runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 'trace' AS trace_id, 'Additional' AS note, 46 AS age;"); + var strictParameterized = FluentMapper.Runtime.QueryGeneratedMappedSingle( + connection, + "SELECT @Age AS age, @Note AS note;", + new GeneratedParameters() + .Add("Age", 47, System.Data.DbType.Int32) + .Add("Note", "Parameterized", System.Data.DbType.String, size: 64)); Assert.Equal(44, nullable.Age); Assert.Equal("Reordered", nullable.Note); Assert.Equal(45, converted.Id); Assert.Equal(GeneratedAccountStatus.Active, converted.Status); Assert.Equal(42, converted.OptionalScore); + Assert.Equal(46, strictExtra.Age); + Assert.Equal("Additional", strictExtra.Note); + Assert.Equal(47, strictParameterized.Age); + Assert.Equal("Parameterized", strictParameterized.Note); Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + + var incompatibleValue = Assert.Throws(() => + FluentMapper.Runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 'not-an-integer' AS age, 'Invalid' AS note;")); + Assert.Contains(typeof(GeneratedNullableCustomer).FullName, incompatibleValue.Message); + Assert.Contains(nameof(GeneratedNullableCustomer.Age), incompatibleValue.Message); } } finally diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs index 4e51c71c..b64dc6ee 100644 --- a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -68,7 +68,8 @@ public void Configure() Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("entity.Id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Id = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("Generated materialization failed for entity", result.GeneratedSource, StringComparison.Ordinal); } [Fact] @@ -98,7 +99,7 @@ public CustomerMap() Assert.Empty(result.DfmDiagnostics); Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Map(\"full_name\", \"FullName\")", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("entity.FullName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.FullName = Read(record, 1,", result.GeneratedSource, StringComparison.Ordinal); } [Fact] @@ -132,8 +133,8 @@ public CustomerMap() var result = RunGenerator(source); Assert.Empty(result.DfmDiagnostics); - Assert.Contains("var id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("var fullName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("var id = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("var fullName = Read(record, 1,", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("entity = new global::Customer(id, fullName);", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("FluentMapConfigurationException", result.GeneratedSource, StringComparison.Ordinal); } @@ -163,7 +164,7 @@ public CustomerMap() var result = RunGenerator(source); Assert.Empty(result.DfmDiagnostics); - Assert.Contains("entity.Age = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Age = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("return default(T);", result.GeneratedSource, StringComparison.Ordinal); } @@ -194,7 +195,7 @@ public CustomerMap() Assert.Empty(result.DfmDiagnostics); Assert.Contains("GeneratedMaterializerColumn.Map(\"customer_id\", \"Id\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Ignore(\"secret\")", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("entity.Id = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.Id = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); } @@ -245,8 +246,8 @@ public CustomerMap() Assert.Contains("GeneratedMaterializerColumn.Map(\"insert_excluded\", \"InsertExcluded\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Map(\"update_excluded\", \"UpdateExcluded\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Ignore(\"secret\")", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("entity.ReadOnlyName = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("entity.ComputedTotal = Read(record, 2);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.ReadOnlyName = Read(record, 1,", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("entity.ComputedTotal = Read(record, 2,", result.GeneratedSource, StringComparison.Ordinal); Assert.DoesNotContain("entity.Secret =", result.GeneratedSource, StringComparison.Ordinal); } @@ -596,7 +597,7 @@ public CustomerMap() Assert.Contains(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Map(\"city\", \"Address.City\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("if (!record.IsDBNull(0))", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("node1.City = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node1.City = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("entity.Address = null;", result.GeneratedSource, StringComparison.Ordinal); } @@ -684,8 +685,8 @@ public CustomerMap() Assert.Empty(result.DfmDiagnostics); Assert.Contains("GeneratedMaterializerColumn.Map(\"rank_level\", \"Rank.Level\")", result.GeneratedSource, StringComparison.Ordinal); Assert.Contains("GeneratedMaterializerColumn.Map(\"seniority_level\", \"Seniority.Level\")", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("node1.Level = Read(record, 0);", result.GeneratedSource, StringComparison.Ordinal); - Assert.Contains("node2.Level = Read(record, 1);", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node1.Level = Read(record, 0,", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains("node2.Level = Read(record, 1,", result.GeneratedSource, StringComparison.Ordinal); } [Fact] @@ -735,6 +736,38 @@ public CustomerMap() Assert.DoesNotContain(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); } + [Fact] + public void ExplicitConstructionStrategyShouldRegisterMapAndReportRuntimeOnlyBoundary() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + private Customer(int id) { Id = id; } + public int Id { get; } + public static Customer Restore(int id) => new Customer(id); +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + ConstructUsing(customer => customer.Id, Customer.Restore); + } +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMaterializerDiagnosticId, diagnostic.Id); + Assert.Contains("ConstructUsing", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains("runtime materialization", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".AddGeneratedMaterializer(", result.GeneratedSource, StringComparison.Ordinal); + } + [Fact] public void DuplicateProfileMappingsShouldReportDiagnostic() { diff --git a/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj b/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj index 806619ee..47ccc86d 100644 --- a/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj +++ b/test/Dapper.FluentMap.ProviderCompatibility.Tests/Dapper.FluentMap.ProviderCompatibility.Tests.csproj @@ -2,14 +2,18 @@ net10.0 false + 7.1.0 + 10.0.12 + 2.6.2 + 10.0.3 - - - - + + + + diff --git a/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs b/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs index a5f4f180..d7a66ce1 100644 --- a/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs +++ b/test/Dapper.FluentMap.ProviderCompatibility.Tests/ProviderCompatibilityTests.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Dapper; +using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Dommel; using Dapper.FluentMap.Dommel.Mapping; using Dapper.FluentMap.Mapping; @@ -265,6 +266,46 @@ record => } } + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void StrictGeneratedParameterizedQueryShouldSupportSafeAdditionalProviderColumn(string providerName) + { + var provider = ProviderCase.Create(providerName); + provider.SkipIfUnavailable(); + + var runtime = new FluentMapConfigurationBuilder() + .AddMap(new MultipleCustomerMap()) + .AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(MultipleCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(MultipleCustomer.Name)) + }, + record => new MultipleCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }) + .UseStrictGeneratedMaterialization() + .Build() + .CreateRuntime(); + var parameters = new GeneratedParameters() + .Add("Id", 22, DbType.Int32) + .Add("Name", "Strict Provider", DbType.String, size: 100); + + using (var connection = provider.OpenConnection()) + { + var customer = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT @Id AS customer_id, @Name AS customer_name, 1 AS provider_trace;", + parameters); + + Assert.Equal(22, customer.Id); + Assert.Equal("Strict Provider", customer.Name); + } + } + [Theory] [MemberData(nameof(Providers))] [Trait("Category", "ProviderCompatibility")] @@ -339,6 +380,44 @@ public void QueryMappedShouldComposeProviderJoinRows(string providerName) } } + [Theory] + [MemberData(nameof(Providers))] + [Trait("Category", "ProviderCompatibility")] + public void QueryMappedShouldComposeThreeProviderSegments(string providerName) + { + var provider = ProviderCase.Create(providerName); + provider.SkipIfUnavailable(); + PreTest(typeof(MultipleCustomer), typeof(MultipleOrder), typeof(MultipleShipment)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new MultipleCustomerMap()); + configuration.AddMap(new MultipleOrderMap()); + configuration.AddMap(new MultipleShipmentMap()); + }); + + using (var connection = provider.OpenConnection()) + { + var row = connection.QueryMapped>( + provider.ThreeSegmentMultiMappingSql(), + (customer, order, shipment) => Tuple.Create(customer, order, shipment), + splitOn: "order_id,shipment_id") + .Single(); + + Assert.Equal(11, row.Item1.Id); + Assert.Equal(99, row.Item2.Id); + Assert.Equal(501, row.Item3.Id); + Assert.Equal("sent", row.Item3.Status); + } + } + finally + { + PreTest(typeof(MultipleCustomer), typeof(MultipleOrder), typeof(MultipleShipment)); + } + } + [Theory] [MemberData(nameof(Providers))] [Trait("Category", "ProviderCompatibility")] @@ -483,13 +562,13 @@ public void DommelPersistenceShouldHonorGeneratedDefaultsAndReadOnlyMetadata(str Computed = "client-computed" }; - var id = Convert.ToInt32(connection.Insert(entity)); + var id = Convert.ToInt32(connection.InsertMapped(entity)); var inserted = connection.Get(id); - Assert.Equal("inserted", inserted.Normal); + Assert.Equal("db:inserted", inserted.Normal); Assert.Equal("read-only-default", inserted.ReadOnly); Assert.Equal("default-value-default", inserted.DefaultValue); - Assert.Equal("inserted-computed", inserted.Computed); + Assert.Equal("db:inserted-computed", inserted.Computed); entity.Id = id; entity.Normal = "updated"; @@ -497,13 +576,13 @@ public void DommelPersistenceShouldHonorGeneratedDefaultsAndReadOnlyMetadata(str entity.DefaultValue = "updated-default"; entity.Computed = "updated-computed"; - Assert.True(connection.Update(entity)); + Assert.True(connection.UpdateMapped(entity)); var updated = connection.Get(id); - Assert.Equal("updated", updated.Normal); + Assert.Equal("db:updated", updated.Normal); Assert.Equal("read-only-default", updated.ReadOnly); Assert.Equal("updated-default", updated.DefaultValue); - Assert.Equal("updated-computed", updated.Computed); + Assert.Equal("db:updated-computed", updated.Computed); } } finally @@ -882,6 +961,13 @@ public string MultiMappingSql() "99 AS order_id, " + DecimalLiteral(12.34m) + " AS total;"; } + public string ThreeSegmentMultiMappingSql() + { + return "SELECT 11 AS customer_id, " + TextLiteral("Multiple") + " AS customer_name, " + + "99 AS order_id, " + DecimalLiteral(12.34m) + " AS total, " + + "501 AS shipment_id, " + TextLiteral("sent") + " AS shipment_status;"; + } + public string SingleCustomerSql(int id, string name) { return "SELECT " + Literal(id) + " AS customer_id, " + TextLiteral(name) + " AS customer_name;"; @@ -1135,6 +1221,22 @@ public MultipleOrderMap() } } + private sealed class MultipleShipment + { + public int Id { get; set; } + + public string Status { get; set; } + } + + private sealed class MultipleShipmentMap : EntityMap + { + public MultipleShipmentMap() + { + Map(shipment => shipment.Id).ToColumn("shipment_id"); + Map(shipment => shipment.Status).ToColumn("shipment_status"); + } + } + private sealed class StreamCustomer { public int Id { get; set; } @@ -1172,7 +1274,9 @@ public ProviderPersistenceEntityMap() { ToTable(MappedTableName); Map(entity => entity.Id).ToColumn("id").IsIdentity(); - Map(entity => entity.Normal).ToColumn("normal"); + Map(entity => entity.Normal) + .ToColumn("normal") + .ConvertToDatabaseUsing(value => "db:" + value); Map(entity => entity.ReadOnly).ToColumn("read_only").ReadOnly(); Map(entity => entity.DefaultValue).ToColumn("default_value").DatabaseDefaultOnInsert(); Map(entity => entity.Computed).ToColumn("computed").Computed(); diff --git a/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs b/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs index daa3f8e9..047622ec 100644 --- a/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs +++ b/test/Dapper.FluentMap.Tests/AdvancedQueryHardeningTests.cs @@ -165,7 +165,7 @@ public async Task QueryMappedUnbufferedAsyncShouldRemainStableAcrossParallelProf } [Fact] - public void QueryMultipleMappedShouldUseGeneratedAndRuntimeMaterializersOnIndependentParallelReaders() + public void QueryMultipleMappedShouldUseGeneratedMaterializersForSafeExtraColumnsOnIndependentParallelReaders() { PreTest(typeof(ConcurrentCustomer)); @@ -219,8 +219,8 @@ record => .ToList(); Assert.All(results, Assert.True); - Assert.Equal(30, generatedRows); - Assert.Equal(1, FluentMapper.Registry.MaterializationPlanCacheEntryCount); + Assert.Equal(60, generatedRows); + Assert.Equal(0, FluentMapper.Registry.MaterializationPlanCacheEntryCount); } finally { diff --git a/test/Dapper.FluentMap.Tests/ExplicitConstructionStrategyTests.cs b/test/Dapper.FluentMap.Tests/ExplicitConstructionStrategyTests.cs new file mode 100644 index 00000000..ec8cda54 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ExplicitConstructionStrategyTests.cs @@ -0,0 +1,397 @@ +using System; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ExplicitConstructionStrategyTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseExplicitFactoryForPrivateConstructor() + { + FluentMapper.Reset(typeof(FactoryCustomer)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new FactoryCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 17 AS customer_id, 'Ada' AS customer_name;"); + + Assert.Equal(17, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.True(customer.CreatedByFactory); + } + } + finally + { + FluentMapper.Reset(typeof(FactoryCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void IsolatedRuntimeShouldPreserveExplicitFactory() + { + var runtime = new FluentMapConfigurationBuilder() + .AddMap(new FactoryCustomerMap()) + .Build() + .CreateRuntime(); + + using (var connection = OpenConnection()) + { + var customer = runtime.QueryMappedSingle( + connection, + "SELECT 18 AS customer_id, 'Grace' AS customer_name;"); + + Assert.Equal(18, customer.Id); + Assert.Equal("Grace", customer.Name); + Assert.True(customer.CreatedByFactory); + } + } + + [Fact] + public void ValidationShouldRejectConstructionBindingWithoutActiveExplicitMap() + { + FluentMapper.Reset(typeof(FactoryCustomer)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(configuration => configuration.AddMap(new IncompleteFactoryCustomerMap()))); + + Assert.Contains(nameof(FactoryCustomer.Name), exception.Message); + Assert.Contains("ConstructUsing", exception.Message); + } + finally + { + FluentMapper.Reset(typeof(FactoryCustomer)); + } + } + + [Fact] + public void ValidationShouldRejectNestedConstructionBindingBeforeMaterialization() + { + FluentMapper.Reset(typeof(NestedFactoryEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(configuration => configuration.AddMap(new NestedFactoryEntityMap()))); + + Assert.Contains("Nested.Name", exception.Message); + Assert.Contains("root property paths", exception.Message); + } + finally + { + FluentMapper.Reset(typeof(NestedFactoryEntity)); + } + } + + [Fact] + public void DuplicateConstructionStrategyShouldFailDuringMapConfiguration() + { + var exception = Assert.Throws(() => new DuplicateFactoryCustomerMap()); + + Assert.Contains(typeof(FactoryCustomer).FullName, exception.Message); + Assert.Contains("already has", exception.Message); + } + + [Fact] + public void ConstructionFactoriesShouldPreserveBindingOrderAcrossSupportedArities() + { + var oneValueStrategy = GetConstructionStrategy(new OneValueFactoryCustomerMap()); + var threeValueStrategy = GetConstructionStrategy(new ThreeValueFactoryAggregateMap()); + var fourValueStrategy = GetConstructionStrategy(new FourValueFactoryAggregateMap()); + + var customer = (FactoryCustomer)oneValueStrategy.Factory(new object[] { 21 }); + var threeValue = (FactoryAggregate)threeValueStrategy.Factory(new object[] { 1, "two", 3L }); + var fourValue = (FactoryAggregate)fourValueStrategy.Factory(new object[] { 4, "five", 6L, true }); + + Assert.Equal(21, customer.Id); + Assert.Equal(new[] { "Id" }, GetBindingPaths(oneValueStrategy)); + Assert.Equal((1, "two", 3L, false), (threeValue.First, threeValue.Second, threeValue.Third, threeValue.Fourth)); + Assert.Equal(new[] { "First", "Second", "Third" }, GetBindingPaths(threeValueStrategy)); + Assert.Equal((4, "five", 6L, true), (fourValue.First, fourValue.Second, fourValue.Third, fourValue.Fourth)); + Assert.Equal(new[] { "First", "Second", "Third", "Fourth" }, GetBindingPaths(fourValueStrategy)); + } + + [Fact] + public void ConstructUsingShouldRejectNullFactoriesAcrossSupportedArities() + { + var map = new ConstructionFactoryProbeMap(); + + Assert.Equal("factory", Assert.Throws(() => map.ConfigureOne(null)).ParamName); + Assert.Equal("factory", Assert.Throws(() => map.ConfigureTwo(null)).ParamName); + Assert.Equal("factory", Assert.Throws(() => map.ConfigureThree(null)).ParamName); + Assert.Equal("factory", Assert.Throws(() => map.ConfigureFour(null)).ParamName); + } + + [Fact] + [Trait("Category", "Integration")] + public void FactoryFailureShouldIncludeEntityAndBoundPropertyContext() + { + var runtime = new FluentMapConfigurationBuilder() + .AddMap(new ThrowingFactoryCustomerMap()) + .Build() + .CreateRuntime(); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + runtime.QueryMappedSingle( + connection, + "SELECT 19 AS customer_id, 'Failure' AS customer_name;")); + + Assert.Contains(typeof(FactoryCustomer).FullName, exception.Message); + Assert.Contains(nameof(FactoryCustomer.Id), exception.Message); + Assert.Contains(nameof(FactoryCustomer.Name), exception.Message); + Assert.IsType(exception.InnerException); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void StrictGeneratedRuntimeShouldRejectRuntimeOnlyFactoryWithoutFallback() + { + var runtime = new FluentMapConfigurationBuilder() + .AddMap(new FactoryCustomerMap()) + .UseStrictGeneratedMaterialization() + .Build() + .CreateRuntime(); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + runtime.QueryMappedSingle( + connection, + "SELECT 20 AS customer_id, 'Strict' AS customer_name;")); + + Assert.Contains("Strict generated materialization", exception.Message); + Assert.Contains("runtime fallback", exception.Message); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static EntityConstructionStrategy GetConstructionStrategy(EntityMap map) + where TEntity : class + { + return ((IEntityMapWithConstructionStrategy)map).ConstructionStrategy; + } + + private static string[] GetBindingPaths(EntityConstructionStrategy strategy) + { + var paths = new string[strategy.Bindings.Count]; + for (var index = 0; index < strategy.Bindings.Count; index++) + { + paths[index] = strategy.Bindings[index].MemberPath.ToString(); + } + + return paths; + } + + private sealed class FactoryCustomer + { + private FactoryCustomer(int id, string name) + { + Id = id; + Name = name; + CreatedByFactory = true; + } + + public int Id { get; } + + public string Name { get; } + + public bool CreatedByFactory { get; } + + internal static FactoryCustomer Restore(int id, string name) + { + return new FactoryCustomer(id, name); + } + + internal static FactoryCustomer RestoreIdOnly(int id) + { + return new FactoryCustomer(id, string.Empty); + } + } + + private class FactoryCustomerMap : EntityMap + { + public FactoryCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + ConstructUsing(customer => customer.Id, customer => customer.Name, FactoryCustomer.Restore); + } + } + + private sealed class IncompleteFactoryCustomerMap : EntityMap + { + public IncompleteFactoryCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + ConstructUsing(customer => customer.Id, customer => customer.Name, FactoryCustomer.Restore); + } + } + + private sealed class OneValueFactoryCustomerMap : EntityMap + { + public OneValueFactoryCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + ConstructUsing(customer => customer.Id, FactoryCustomer.RestoreIdOnly); + } + } + + private sealed class FactoryAggregate + { + private FactoryAggregate(int first, string second, long third, bool fourth) + { + First = first; + Second = second; + Third = third; + Fourth = fourth; + } + + public int First { get; } + + public string Second { get; } + + public long Third { get; } + + public bool Fourth { get; } + + internal static FactoryAggregate RestoreThree(int first, string second, long third) + { + return new FactoryAggregate(first, second, third, false); + } + + internal static FactoryAggregate RestoreFour(int first, string second, long third, bool fourth) + { + return new FactoryAggregate(first, second, third, fourth); + } + } + + private sealed class ThreeValueFactoryAggregateMap : EntityMap + { + public ThreeValueFactoryAggregateMap() + { + Map(entity => entity.First); + Map(entity => entity.Second); + Map(entity => entity.Third); + ConstructUsing( + entity => entity.First, + entity => entity.Second, + entity => entity.Third, + FactoryAggregate.RestoreThree); + } + } + + private sealed class FourValueFactoryAggregateMap : EntityMap + { + public FourValueFactoryAggregateMap() + { + Map(entity => entity.First); + Map(entity => entity.Second); + Map(entity => entity.Third); + Map(entity => entity.Fourth); + ConstructUsing( + entity => entity.First, + entity => entity.Second, + entity => entity.Third, + entity => entity.Fourth, + FactoryAggregate.RestoreFour); + } + } + + private sealed class ConstructionFactoryProbeMap : EntityMap + { + internal void ConfigureOne(Func factory) + { + ConstructUsing(entity => entity.First, factory); + } + + internal void ConfigureTwo(Func factory) + { + ConstructUsing(entity => entity.First, entity => entity.Second, factory); + } + + internal void ConfigureThree(Func factory) + { + ConstructUsing(entity => entity.First, entity => entity.Second, entity => entity.Third, factory); + } + + internal void ConfigureFour(Func factory) + { + ConstructUsing( + entity => entity.First, + entity => entity.Second, + entity => entity.Third, + entity => entity.Fourth, + factory); + } + } + + private sealed class NestedFactoryEntity + { + private NestedFactoryEntity(string name) + { + Nested = new NestedFactoryValue { Name = name }; + } + + public NestedFactoryValue Nested { get; } + + internal static NestedFactoryEntity Restore(string name) + { + return new NestedFactoryEntity(name); + } + } + + private sealed class NestedFactoryValue + { + public string Name { get; set; } + } + + private sealed class NestedFactoryEntityMap : EntityMap + { + public NestedFactoryEntityMap() + { + Map(entity => entity.Nested.Name).ToColumn("nested_name"); + ConstructUsing(entity => entity.Nested.Name, NestedFactoryEntity.Restore); + } + } + + private sealed class DuplicateFactoryCustomerMap : FactoryCustomerMap + { + public DuplicateFactoryCustomerMap() + { + ConstructUsing(customer => customer.Id, FactoryCustomer.RestoreIdOnly); + } + } + + private sealed class ThrowingFactoryCustomerMap : EntityMap + { + public ThrowingFactoryCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + ConstructUsing( + customer => customer.Id, + customer => customer.Name, + (id, name) => throw new InvalidOperationException("Domain factory rejected the values.")); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs index 9b539084..38851abb 100644 --- a/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs +++ b/test/Dapper.FluentMap.Tests/GeneratedMaterializerContractTests.cs @@ -326,10 +326,103 @@ public void QueryGeneratedMappedShouldRejectDynamicParameterObjects() "SELECT @Id AS customer_id, 'GeneratedOnly' AS full_name;", new { Id = 86 })); - Assert.Contains("dynamic parameter objects", exception.Message, StringComparison.Ordinal); + Assert.Contains("unsupported", exception.Message, StringComparison.Ordinal); + Assert.Contains(typeof(GeneratedContractCustomer).FullName, exception.Message); + Assert.Contains(typeof(GeneratedParameters).FullName, exception.Message); + Assert.Contains("QueryMapped", exception.Message); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryGeneratedMappedShouldBindExplicitGeneratedParametersWithoutRuntimeFallback() + { + var runtime = CreateRuntime(builder => + { + builder + .AddMap(new GeneratedContractCustomerMap()) + .AddGeneratedMaterializer(DefaultColumns(), ReadDefaultGeneratedCustomer) + .UseStrictGeneratedMaterialization(); + }); + var parameters = new GeneratedParameters() + .Add("Id", 87, DbType.Int32) + .Add("Name", "Parameterized", DbType.String, size: 64); + + using (var connection = OpenConnection()) + { + var customer = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT @Id AS customer_id, @Name AS full_name;", + parameters); + + Assert.Equal(87, customer.Id); + Assert.Equal("generated:Parameterized", customer.Name); + Assert.Equal(0, runtime.MaterializationPlanCacheEntryCount); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ProfiledQueryGeneratedMappedShouldBindExplicitGeneratedParameters() + { + var runtime = CreateRuntime(builder => + { + builder + .AddProfile() + .AddGeneratedMaterializer( + LegacyColumns(), + ReadLegacyGeneratedCustomer) + .UseStrictGeneratedMaterialization(); + }); + + using (var connection = OpenConnection()) + { + var customer = runtime.QueryGeneratedMappedSingle( + connection, + "SELECT @Id AS legacy_id, @Name AS legal_name;", + new GeneratedParameters() + .Add("Id", 88, DbType.Int32) + .Add("Name", "Profile", DbType.String, size: 64)); + + Assert.Equal(88, customer.Id); + Assert.Equal("legacy:Profile", customer.Name); + } + } + + [Fact] + public void QueryGeneratedMappedShouldRejectUnsupportedParametersBeforeOpeningConnection() + { + var runtime = CreateRuntime(builder => + { + builder + .AddMap(new GeneratedContractCustomerMap()) + .AddGeneratedMaterializer(DefaultColumns(), ReadDefaultGeneratedCustomer); + }); + + using (var connection = new SqliteConnection("Data Source=:memory:")) + { + Assert.Throws(() => + runtime.QueryGeneratedMappedSingle( + connection, + "not executable", + new { Id = 1 })); + + Assert.Equal(ConnectionState.Closed, connection.State); } } + [Fact] + public void GeneratedParametersShouldRejectEmptyAndEquivalentDuplicateNames() + { + var parameters = new GeneratedParameters().Add("@Id", 1, DbType.Int32); + + Assert.Throws(() => parameters.Add(" ", 2, DbType.Int32)); + Assert.Throws(() => parameters.Add("@", 2, DbType.Int32)); + Assert.Throws(() => parameters.Add("Other", 2, DbType.Int32, size: -1)); + var duplicate = Assert.Throws(() => parameters.Add("id", 2, DbType.Int32)); + Assert.Contains("already registered", duplicate.Message); + } + [Fact] [Trait("Category", "Integration")] public void StrictGeneratedRuntimeShouldRejectMissingGeneratedMaterializer() @@ -379,9 +472,9 @@ public void StrictGeneratedRuntimeShouldRejectUnsupportedColumnShape() connection, "SELECT 92 AS customer_id;")); - Assert.Contains("none match requested result columns", exception.Message); + Assert.Contains("missing required generated columns", exception.Message); Assert.Contains("['customer_id']", exception.Message); - Assert.Contains("['customer_id', 'full_name']", exception.Message); + Assert.Contains("['full_name']", exception.Message); Assert.DoesNotContain("SELECT", exception.Message); Assert.Equal(0, runtime.MaterializationPlanCacheEntryCount); } @@ -413,6 +506,82 @@ public void StrictGeneratedRuntimeShouldUseGeneratedMaterializerForReorderedColu } } + [Fact] + [Trait("Category", "Integration")] + public void GeneratedAndRuntimeMaterializationShouldBeEquivalentWithSafeAdditionalColumn() + { + var generatedRuntime = CreateRuntime(builder => + { + builder + .AddMap(new GeneratedContractCustomerMap()) + .AddGeneratedMaterializer(DefaultColumns(), ReadEquivalentGeneratedCustomer) + .UseStrictGeneratedMaterialization(); + }); + var runtime = CreateRuntime(builder => builder.AddMap(new GeneratedContractCustomerMap())); + const string sql = "SELECT 'trace' AS trace_id, 'Extra' AS full_name, 95 AS customer_id;"; + + using (var connection = OpenConnection()) + { + var generated = generatedRuntime.QueryGeneratedMappedSingle(connection, sql); + var reflected = runtime.QueryMappedSingle(connection, sql); + + Assert.Equal(reflected.Id, generated.Id); + Assert.Equal(reflected.Name, generated.Name); + Assert.Equal(0, generatedRuntime.MaterializationPlanCacheEntryCount); + Assert.Equal(1, runtime.MaterializationPlanCacheEntryCount); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void StrictGeneratedRuntimeShouldRejectDuplicateMappedColumnNames() + { + var runtime = CreateRuntime(builder => + { + builder + .AddMap(new GeneratedContractCustomerMap()) + .AddGeneratedMaterializer(DefaultColumns(), ReadDefaultGeneratedCustomer) + .UseStrictGeneratedMaterialization(); + }); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 96 AS customer_id, 97 AS customer_id, 'Duplicate' AS full_name;")); + + Assert.Contains("duplicate/ambiguous", exception.Message); + Assert.Contains("customer_id", exception.Message); + Assert.Contains(typeof(GeneratedContractCustomer).FullName, exception.Message); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void StrictGeneratedRuntimeShouldRejectAdditionalColumnThatMapsToEntityMember() + { + var runtime = CreateRuntime(builder => + { + builder + .AddMap(new GeneratedContractCustomerMap()) + .AddGeneratedMaterializer(DefaultColumns(), ReadDefaultGeneratedCustomer) + .UseStrictGeneratedMaterialization(); + }); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + runtime.QueryGeneratedMappedSingle( + connection, + "SELECT 98 AS customer_id, 'Mapped' AS full_name, 'Ambiguous' AS Name;")); + + Assert.Contains("additional columns", exception.Message); + Assert.Contains("['Name']", exception.Message); + Assert.Contains("cannot be ignored safely", exception.Message); + } + } + [Fact] public void StrictGeneratedLookupShouldChooseReorderedCandidateDeterministically() { @@ -458,6 +627,49 @@ public void StrictGeneratedLookupShouldChooseReorderedCandidateDeterministically } } + [Fact] + public void StrictGeneratedLookupShouldPreferMostSpecificCompatibleShape() + { + PreTest(typeof(GeneratedContractShape)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new GeneratedContractShapeMap()); + configuration.AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("alpha", nameof(GeneratedContractShape.First)), + GeneratedMaterializerColumn.Map("beta", nameof(GeneratedContractShape.Second)) + }, + record => new GeneratedContractShape { Source = "two-columns" }); + configuration.AddGeneratedMaterializer( + AlphabeticalShapeColumns(), + record => new GeneratedContractShape { Source = "three-columns" }); + }); + + var found = FluentMapper.Registry.TryGetStrictGeneratedMaterializer( + typeof(GeneratedContractShape), + profileType: null, + columnNames: new[] { "trace_id", "gamma", "alpha", "beta" }, + out var materializer); + + using (var reader = CreateReader( + new[] { "trace_id", "gamma", "alpha", "beta" }, + new object[] { "trace", 3, 1, 2 })) + { + Assert.True(found); + Assert.True(reader.Read()); + Assert.Equal("three-columns", Assert.IsType(materializer(reader)).Source); + } + } + finally + { + PreTest(typeof(GeneratedContractShape)); + } + } + [Fact] public void ResetShouldClearReorderedGeneratedMaterializerResolution() { @@ -822,6 +1034,15 @@ private static GeneratedContractCustomer ReadDefaultGeneratedCustomer(IDataRecor }; } + private static GeneratedContractCustomer ReadEquivalentGeneratedCustomer(IDataRecord record) + { + return new GeneratedContractCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }; + } + private static GeneratedContractCustomer ReadLegacyGeneratedCustomer(IDataRecord record) { return new GeneratedContractCustomer diff --git a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs index 9084e459..eb9431fc 100644 --- a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs @@ -122,7 +122,7 @@ public void FluentMapperInitializeShouldAddDapperTypeMap() } [Fact] - public void PropertyMapShouldMapInheritedProperies() + public void InheritedPropertyShouldKeepDeclaringIdentityAndDerivedMapContext() { // Arrange PreTest(); @@ -132,10 +132,19 @@ public void PropertyMapShouldMapInheritedProperies() var idMap = map.PropertyMaps.First(); var nameMap = map.PropertyMaps.Skip(1).First(); - // Assert - // todo: should be ReflectedType so the type is DerivedTestEntity + // Assert: expression metadata identifies the member declared by the base type. + // The EntityMap supplies the derived mapping context; reflection does not + // rewrite either DeclaringType or ReflectedType to the expression parameter type. Assert.Equal(typeof(TestEntity), idMap.PropertyInfo.DeclaringType); + Assert.Equal(typeof(TestEntity), idMap.PropertyInfo.ReflectedType); Assert.Equal(typeof(DerivedTestEntity), nameMap.PropertyInfo.DeclaringType); + Assert.Equal(typeof(DerivedTestEntity), nameMap.PropertyInfo.ReflectedType); + + FluentMapper.Initialize(c => c.AddMap(map)); + var inheritedMember = SqlMapper.GetTypeMap(typeof(DerivedTestEntity)).GetMember("intId"); + + Assert.NotNull(inheritedMember); + Assert.Equal(typeof(TestEntity).GetProperty(nameof(TestEntity.Id)), inheritedMember.Property); } [Fact] @@ -158,6 +167,22 @@ public void PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName() Assert.Equal(2, map.PropertyMaps.Count); } + [Fact] + public void InheritedAndNestedMemberPathsWithSameTerminalNameShouldNotCollide() + { + FluentMapper.Reset(typeof(InheritedPathEntity)); + + var map = new InheritedPathMap(); + + Assert.Equal(2, map.PropertyMaps.Count); + + FluentMapper.Initialize(configuration => configuration.AddMap(map)); + var explanation = FluentMapper.Explain(); + + Assert.Contains(explanation.Members, member => member.MemberPath == "Id" && member.ColumnName == "entity_id"); + Assert.Contains(explanation.Members, member => member.MemberPath == "Details.Id" && member.ColumnName == "details_id"); + } + [Fact] public void DuplicateNestedPropertyPathShouldThrow_Exception() { @@ -244,6 +269,30 @@ public DuplicateNestedLevelMap() } } + private class InheritedPathBase + { + public int Id { get; set; } + } + + private class InheritedPathEntity : InheritedPathBase + { + public InheritedPathDetails Details { get; set; } + } + + private class InheritedPathDetails + { + public int Id { get; set; } + } + + private class InheritedPathMap : EntityMap + { + public InheritedPathMap() + { + Map(entity => entity.Id).ToColumn("entity_id"); + Map(entity => entity.Details.Id).ToColumn("details_id"); + } + } + private class NestedLevelEntity { public RankInfo Rank { get; set; } diff --git a/test/Dapper.FluentMap.Tests/QueryMappedMultiMappingTests.cs b/test/Dapper.FluentMap.Tests/QueryMappedMultiMappingTests.cs index 8053635e..6b49a381 100644 --- a/test/Dapper.FluentMap.Tests/QueryMappedMultiMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/QueryMappedMultiMappingTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Dapper; using Dapper.FluentMap.Configuration; @@ -484,6 +485,263 @@ public void QueryMappedShouldCloseOwnedConnectionWhenProjectionThrows() } } + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeThreeSegmentsThroughSharedPipeline() + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new JoinCustomerMap()); + configuration.AddMap(new JoinOrderMap()); + configuration.AddMap(new JoinShipmentMap()); + }); + + using (var connection = OpenConnection()) + { + var row = connection.QueryMapped( + "SELECT 1 AS customer_id, 'Ada' AS customer_name, 10 AS order_id, 12.5 AS total, 100 AS shipment_id, 'sent' AS shipment_status;", + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id") + .Single(); + + Assert.Equal(1, row.Customer.Id); + Assert.Equal(10, row.Order.Id); + Assert.Equal(100, row.Shipment.Id); + Assert.Equal("sent", row.Shipment.Status); + } + } + finally + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async System.Threading.Tasks.Task QueryMappedAsyncShouldMaterializeThreeSegments() + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new JoinCustomerMap()); + configuration.AddMap(new JoinOrderMap()); + configuration.AddMap(new JoinShipmentMap()); + }); + + using (var connection = OpenConnection()) + { + var rows = await connection.QueryMappedAsync( + "SELECT 2 AS customer_id, 'Grace' AS customer_name, 20 AS order_id, 25.5 AS total, 200 AS shipment_id, 'ready' AS shipment_status;", + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id", + cancellationToken: TestContext.Current.CancellationToken); + var row = rows.Single(); + + Assert.Equal(2, row.Customer.Id); + Assert.Equal(20, row.Order.Id); + Assert.Equal(200, row.Shipment.Id); + } + } + finally + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ThreeSegmentQueryShouldPassAllNullChildrenAsNull() + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new JoinCustomerMap()); + configuration.AddMap(new JoinOrderMap()); + configuration.AddMap(new JoinShipmentMap()); + }); + + using (var connection = OpenConnection()) + { + var row = connection.QueryMapped( + "SELECT 3 AS customer_id, 'Null children' AS customer_name, NULL AS order_id, NULL AS total, NULL AS shipment_id, NULL AS shipment_status;", + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id") + .Single(); + + Assert.NotNull(row.Customer); + Assert.Null(row.Order); + Assert.Null(row.Shipment); + } + } + finally + { + PreTest(typeof(JoinCustomer), typeof(JoinOrder), typeof(JoinShipment)); + } + } + + [Theory] + [InlineData("order_id")] + [InlineData("order_id,")] + [InlineData("order_id,order_id")] + [Trait("Category", "Integration")] + public void ThreeSegmentQueryShouldValidateEverySplitBoundary(string splitOn) + { + using (var connection = OpenConnection()) + { + var exception = Assert.ThrowsAny(() => + connection.QueryMapped( + "SELECT 1 AS customer_id, 2 AS order_id, 3 AS shipment_id;", + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: splitOn) + .ToList()); + + Assert.Contains("splitOn", exception.Message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ThreeSegmentQueryShouldHonorPerSegmentProfilesAndIsolatedRuntime() + { + var runtime = new FluentMapConfigurationBuilder() + .AddProfile() + .AddProfile() + .AddProfile() + .Build() + .CreateRuntime(); + + using (var connection = OpenConnection()) + { + var row = runtime.QueryMapped< + JoinCustomer, + JoinOrder, + JoinShipment, + CustomerOrderShipment, + LegacyProfile, + ArchiveProfile, + TrackingProfile>( + connection, + "SELECT 4 AS legacy_customer_id, 'Profiled' AS legal_name, 40 AS archived_order_id, 44.5 AS archived_total, 400 AS tracked_shipment_id, 'tracked' AS tracked_status;", + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "archived_order_id,tracked_shipment_id") + .Single(); + + Assert.Equal(4, row.Customer.Id); + Assert.Equal(40, row.Order.Id); + Assert.Equal(400, row.Shipment.Id); + Assert.Equal("tracked", row.Shipment.Status); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ThreeSegmentGeneratedAndRuntimeMaterializationShouldBeEquivalent() + { + var materializerCounts = new Dictionary(); + var generatedRuntime = CreateThreeSegmentRuntime( + registerGenerated: true, + materializedType => + { + materializerCounts.TryGetValue(materializedType, out var count); + materializerCounts[materializedType] = count + 1; + }); + var reflectionRuntime = CreateThreeSegmentRuntime(registerGenerated: false); + + using (var connection = OpenConnection()) + { + const string sql = "SELECT 5 AS customer_id, 'Equivalent' AS customer_name, 50 AS order_id, 50.5 AS total, 500 AS shipment_id, 'equivalent' AS shipment_status;"; + var generated = generatedRuntime.QueryMapped( + connection, + sql, + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id").Single(); + var reflection = reflectionRuntime.QueryMapped( + connection, + sql, + (customer, order, shipment) => new CustomerOrderShipment(customer, order, shipment), + splitOn: "order_id,shipment_id").Single(); + + Assert.Equal(reflection.Customer.Id, generated.Customer.Id); + Assert.Equal(reflection.Customer.Name, generated.Customer.Name); + Assert.Equal(reflection.Order.Id, generated.Order.Id); + Assert.Equal(reflection.Order.Total, generated.Order.Total); + Assert.Equal(reflection.Shipment.Id, generated.Shipment.Id); + Assert.Equal(reflection.Shipment.Status, generated.Shipment.Status); + Assert.Equal(1, materializerCounts[typeof(JoinCustomer)]); + Assert.Equal(1, materializerCounts[typeof(JoinOrder)]); + Assert.Equal(1, materializerCounts[typeof(JoinShipment)]); + } + } + + private static FluentMapRuntime CreateThreeSegmentRuntime( + bool registerGenerated, + Action generatedMaterialized = null) + { + var builder = new FluentMapConfigurationBuilder() + .AddMap(new JoinCustomerMap()) + .AddMap(new JoinOrderMap()) + .AddMap(new JoinShipmentMap()); + + if (registerGenerated) + { + builder + .AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("customer_id", nameof(JoinCustomer.Id)), + GeneratedMaterializerColumn.Map("customer_name", nameof(JoinCustomer.Name)) + }, + record => + { + generatedMaterialized?.Invoke(typeof(JoinCustomer)); + return new JoinCustomer + { + Id = Convert.ToInt32(record.GetValue(0)), + Name = Convert.ToString(record.GetValue(1)) + }; + }) + .AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("order_id", nameof(JoinOrder.Id)), + GeneratedMaterializerColumn.Map("total", nameof(JoinOrder.Total)) + }, + record => + { + generatedMaterialized?.Invoke(typeof(JoinOrder)); + return new JoinOrder + { + Id = Convert.ToInt32(record.GetValue(0)), + Total = Convert.ToDecimal(record.GetValue(1)) + }; + }) + .AddGeneratedMaterializer( + new[] + { + GeneratedMaterializerColumn.Map("shipment_id", nameof(JoinShipment.Id)), + GeneratedMaterializerColumn.Map("shipment_status", nameof(JoinShipment.Status)) + }, + record => + { + generatedMaterialized?.Invoke(typeof(JoinShipment)); + return new JoinShipment + { + Id = Convert.ToInt32(record.GetValue(0)), + Status = Convert.ToString(record.GetValue(1)) + }; + }); + } + + return builder.Build().CreateRuntime(); + } + private static CustomerOrder MaterializeCustomerOrder(bool registerGeneratedMaterializers) { PreTest(typeof(JoinCustomer), typeof(JoinOrder)); @@ -565,6 +823,22 @@ public CustomerOrder(JoinCustomer customer, JoinOrder order) public JoinOrder Order { get; } } + private sealed class CustomerOrderShipment + { + public CustomerOrderShipment(JoinCustomer customer, JoinOrder order, JoinShipment shipment) + { + Customer = customer; + Order = order; + Shipment = shipment; + } + + public JoinCustomer Customer { get; } + + public JoinOrder Order { get; } + + public JoinShipment Shipment { get; } + } + private sealed class JoinCustomer { public int Id { get; set; } @@ -579,6 +853,13 @@ private sealed class JoinOrder public decimal Total { get; set; } } + private sealed class JoinShipment + { + public int Id { get; set; } + + public string Status { get; set; } + } + private sealed class DefaultSplitOrder { public int Id { get; set; } @@ -604,6 +885,15 @@ public JoinOrderMap() } } + private sealed class JoinShipmentMap : EntityMap + { + public JoinShipmentMap() + { + Map(shipment => shipment.Id).ToColumn("shipment_id"); + Map(shipment => shipment.Status).ToColumn("shipment_status"); + } + } + private sealed class LegacyProfile : IMappingProfile { } @@ -612,6 +902,10 @@ private sealed class ArchiveProfile : IMappingProfile { } + private sealed class TrackingProfile : IMappingProfile + { + } + private sealed class LegacyJoinCustomerMap : EntityMap, IProfileMap { public LegacyJoinCustomerMap() @@ -630,6 +924,15 @@ public ArchivedJoinOrderMap() } } + private sealed class TrackedJoinShipmentMap : EntityMap, IProfileMap + { + public TrackedJoinShipmentMap() + { + Map(shipment => shipment.Id).ToColumn("tracked_shipment_id"); + Map(shipment => shipment.Status).ToColumn("tracked_status"); + } + } + private sealed class DefaultSplitOrderMap : EntityMap { public DefaultSplitOrderMap()