diff --git a/.agents/skills/assertion-quality/SKILL.md b/.agents/skills/assertion-quality/SKILL.md deleted file mode 100644 index fa8b630..0000000 --- a/.agents/skills/assertion-quality/SKILL.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -name: assertion-quality -description: "Analyzes the variety and depth of assertions across test suites in any language. Use when the user asks to evaluate assertion quality, find shallow tests, identify assertion-free tests (no assertions or only trivial ones like Assert.IsNotNull / toBeTruthy()), flag self-referential or tautological assertions, measure assertion diversity, or audit whether tests verify different facets of behavior. Polyglot: .NET, Python, TS/JS, Java, Go, Ruby, Rust, Swift, Kotlin, PowerShell, C++. DO NOT USE FOR: writing new tests (use code-testing-agent / writing-mstest-tests), mutation reasoning about whether tests would catch a bug (use test-gap-analysis), or a general severity-ranked anti-pattern audit (use test-anti-patterns), fixing or rewriting assertions, or writing, fixing, or modernizing MSTest tests, assertions, or attributes (use writing-mstest-tests)." -license: MIT ---- - -# Assertion Diversity Analysis - -Analyze test code in any supported language to measure how varied and meaningful the assertions are. Produce a metrics report that reveals whether tests verify different facets of correctness — not just "output equals X" but also structure, exceptions, state transitions, side effects, and invariants. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase's language and framework (e.g., `dotnet.md` for .NET, `python.md` for pytest, `typescript.md` for Jest, `go.md` for the standard `testing` package). You MUST read the relevant extension file before classifying assertions, because assertion APIs differ significantly across frameworks. - -## Why Assertion Diversity Matters - -Low assertion diversity signals shallow testing. Tests may pass while bugs hide in unasserted logic. Common symptoms: - -| Problem | Symptom | Consequence | -|---------|---------|-------------| -| Trivial assertions | Test contains only `Assert.IsNotNull(result)` / `assert result is not None` / `expect(x).toBeDefined()` | Test passes but doesn't verify correctness | -| Single-value obsession | Always check one field or return value | Bugs in unasserted logic slip through | -| No negative assertions | Never check what shouldn't happen | Regressions sneak in through false positives | -| No state checks | Don't verify object state changes | Missed side-effects or lifecycle issues | -| No structural checks | Only assert top-level value | Bugs in nested objects go unnoticed | -| Assertion-free tests | Tests that call but don't verify | Code coverage lies; false security | - -## When to Use - -- User asks to evaluate assertion quality or depth -- User asks "are my tests actually testing anything meaningful?" -- User wants to know if test assertions are too shallow or trivial -- User asks for assertion coverage metrics or diversity analysis -- User suspects tests give false confidence despite passing -- The `code-testing-generator` agent (or any test-generation workflow) calls this skill as a pre-completion self-review step on freshly generated tests, before declaring the run finished - -## When Not to Use - -- User wants to write new tests (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants to detect anti-patterns beyond assertions (use `test-anti-patterns`) -- User wants to fix or rewrite assertions (help them directly) -- User asks about code coverage percentages (out of scope — this analyzes assertion quality, not line coverage) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Test code | Yes | One or more test files or a test project directory to analyze | -| Production code | No | The code under test, to evaluate whether assertions cover the important behaviors | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file (e.g., `extensions/dotnet.md` for .NET, `extensions/python.md` for pytest, `extensions/typescript.md` for Jest/Vitest, `extensions/go.md` for Go). The extension file lists the framework-specific assertion APIs you will classify in Step 3. - -### Step 2: Gather the test code - -Read all test files the user provides. If the user points to a directory or project, scan for all test files using the markers in the language extension file (e.g., `[TestMethod]` for MSTest, `def test_*` for pytest, `it()` / `test()` for Jest, `func TestXxx` for Go). - -### Step 3: Classify every assertion - -For each test method, identify all assertions and classify them into these language-neutral categories: - -| Category | What it verifies | Examples across languages | -|----------|------------------|----------------------------| -| **Equality** | Return value matches expected | `Assert.AreEqual` (MSTest), `Assert.Equal` (xUnit), `assert x == y` (pytest), `expect(x).toBe(y)` (Jest), `assertEquals` (JUnit), `if got != want { t.Error... }` / `assert.Equal(t, want, got)` (Go), `x shouldBe y` (Kotest), `Should -Be` (Pester), `EXPECT_EQ` (GoogleTest) | -| **Boolean** | Condition holds | `Assert.IsTrue`, `assert flag` (Python), `expect(x).toBeTruthy()` (Jest), `assertTrue` (JUnit), `assert.True(t, ok)` (testify), `x.shouldBeTrue()` (Kotest), `Should -BeTrue` (Pester), `EXPECT_TRUE` | -| **Null / None / Nil** | Presence/absence of value | `Assert.IsNull` (.NET), `assert x is None` (pytest), `expect(x).toBeNull()` (Jest), `assertNull` (JUnit), `assert.Nil(t, v)` (testify), `XCTAssertNil` (XCTest), `Should -BeNullOrEmpty` (Pester) | -| **Exception / Error** | Error handling behavior | `Assert.Throws()`, `pytest.raises(E)`, `expect(fn).toThrow(E)`, `assertThrows`, `assert.Error(t, err)` / `assert.ErrorIs`, `#[should_panic]` (Rust), `XCTAssertThrowsError`, `Should -Throw`, `EXPECT_THROW` | -| **Type checks** | Runtime type correctness | `Assert.IsInstanceOfType`, `assert isinstance(x, T)`, `expect(x).toBeInstanceOf(T)`, `assertInstanceOf`, `assert.IsType(t, T{}, v)`, `assert!(matches!(value, Pattern))` (Rust), `Should -BeOfType` | -| **String** | Text content and format | `StringAssert.Contains`, `assert sub in s`, `expect(s).toMatch(/x/)`, `assertTrue(s.contains(...))`, `assert.Contains(t, s, sub)`, `s shouldContain sub`, `Should -Match`, `EXPECT_THAT(s, HasSubstr(...))` | -| **Collection** | Collection contents and structure | `CollectionAssert.Contains`, `assert item in collection`, `expect(arr).toContain(x)`, `assertIterableEquals`, `assert.Contains(t, slice, item)`, `col shouldContainExactly listOf(...)`, `Should -Contain`, `EXPECT_THAT(c, ElementsAre(...))` | -| **Comparison** | Ordering and magnitude | `Assert.IsTrue(x > y)`, `Is.GreaterThan`, `assert x > y`, `expect(x).toBeGreaterThan(y)`, `assertTrue(x > y)`, `assert.Greater(t, x, y)` (testify) | -| **Approximate** | Floating-point or tolerance-based | `Assert.AreEqual(expected, actual, delta)`, `pytest.approx(y)`, `expect(x).toBeCloseTo(y)`, `assertEquals(x, y, delta)`, `assert.InDelta(t, x, y, delta)`, `EXPECT_NEAR`, `EXPECT_DOUBLE_EQ` | -| **Negative** | What should NOT happen | `Assert.AreNotEqual`, `assert x != y`, `expect(x).not.toBe(y)`, `assertNotEquals`, `assert.NotEqual(t, x, y)`, `refute` (Minitest / Ruby), `Should -Not -Be` | -| **State / Side-effect** | State transitions and side effects | Assertions on object properties after mutation; mock-call verifications: `mock.Verify(...)` (Moq), `mock_method.assert_called_with(...)` (Python `unittest.mock`), `expect(mock).toHaveBeenCalledWith(...)` (Jest), `verify(mock).method(...)` (Mockito), `Should -Invoke` (Pester), `expect { code }.to change(obj, :attr)` (RSpec) | -| **Structural / Deep** | Deep object correctness | `Assert.AreEqual` with rich-equality types, `assertThat(obj).usingRecursiveComparison()` (AssertJ), `.toEqual({...})` (Jest deep equality), `cmp.Diff` (Go go-cmp), snapshot tests (`.toMatchSnapshot()`, `syrupy`, `SnapshotTesting`), `assertThat(col).extracting(...)` (AssertJ chains) | - -A single assertion can belong to multiple categories (e.g., `Assert.AreNotEqual` is both Equality and Negative; `expect(mock).toHaveBeenCalledWith(...)` is both State/Side-effect and a specific-call assertion). - -Read the loaded language extension file for the exact framework-specific list of assertion APIs. - -### Step 4: Compute metrics - -Calculate these metrics for the test suite: - -#### Per-test metrics -- **Assertion count**: Number of assertions in each test method -- **Assertion categories**: Which categories each test uses - -#### Suite-wide metrics -- **Average assertions per test**: Total assertions / total test methods -- **Assertion type spread**: Number of distinct assertion categories used across the suite (out of 12) -- **Tests with zero assertions**: Count and percentage of test methods with no assertions at all -- **Tests with only trivial assertions**: Count and percentage of tests where every assertion is only a null check or `Assert.IsTrue(true)` — trivial means no meaningful value verification -- **Tests with self-referential assertions**: Count and percentage of tests whose assertions compare an input to a round-tripped or identity-transformed version of itself (e.g., `Assert.AreEqual(input, Parse(input.ToString()))`) or assert a field against itself (`Assert.AreEqual(dto.Name, dto.Name)`). These are tautological — they verify the plumbing, not the behavior. -- **Tests with negative assertions**: Count and percentage (target: at least 10% of tests should verify what should NOT happen) -- **Tests with exception assertions**: Count and percentage -- **Tests with state/side-effect assertions**: Count and percentage -- **Tests with structural/deep assertions**: Count and percentage -- **Single-category tests**: Count and percentage of tests that use only one assertion category - -### Step 5: Apply calibration rules - -Before reporting, calibrate findings: - -- **Trivial means truly trivial.** A null/None/nil check alone is trivial (`Assert.IsNotNull(result)`, `assert result is not None`, `expect(x).toBeDefined()`). But a null check followed by a meaningful value assertion is not trivial — the null check is a guard before the real assertion. Only flag a test as "trivial" if it has no meaningful value assertions. -- **Boolean assertions checking meaningful conditions are not trivial.** `Assert.IsTrue(result.IsValid)` / `assert result.is_valid` / `expect(result.isValid).toBe(true)` check a specific property — these are Boolean assertions, not trivial ones. Always-true assertions (`Assert.IsTrue(true)`, `assert True`, `expect(true).toBe(true)`) are trivial. -- **Consider the test's intent.** A test for a void method that verifies state change on a dependency is legitimate even if it only uses one Boolean assertion. -- **Exception tests are inherently low-assertion-count.** `Assert.ThrowsException(() => ...)` / `with pytest.raises(E): ...` / `expect(fn).toThrow(E)` / `#[should_panic]` may be the only assertion — that's fine for exception-focused tests. Don't penalize them for low assertion count. -- **Mock-call verifications and bare assertion forms count.** Treat `verify(mock).method(...)` (Mockito), `expect(mock).toHaveBeenCalledWith(...)` (Jest), `Should -Invoke` (Pester), `bare assert` (pytest), `if got != want { t.Errorf(...) }` (Go) all as real assertions of the appropriate category. Do not treat them as missing-framework-API smells. -- **Snapshot assertions** (`.toMatchSnapshot()`, `syrupy`, `SnapshotTesting`) count as Structural/Deep assertions. Flag stale or never-updated snapshots separately. -- **Property-based tests** (`@given` Hypothesis, `proptest!`, `forAll` Kotest) generate assertions implicitly through generated cases — count the inner assertion logic, not the outer scaffold. -- **Don't conflate diversity with volume.** A test with 20 equality assertions has high volume but low diversity. A test with one equality, one null check, and one exception assertion has low volume but good diversity. -- **Self-referential assertions are not meaningful equality checks.** Asserting that an output equals an input round-trip looks like a real equality assertion but is tautological when the operation under test is expected to be identity. Flag these separately from normal equality assertions. If the test's *purpose* is to verify a round-trip (serialize/deserialize, encode/decode), the assertion is valid — but it should be accompanied by assertions on non-trivial inputs that exercise the transformation. -- **If assertions are well-diversified, say so.** A report concluding the suite has good diversity is perfectly valid. - -### Step 6: Report findings - -**Scale the report depth to the size and complexity of the suite.** The structure below is the full template for a substantial suite (roughly 15+ tests or a multi-file project). For a small or simple input (a single file with only a handful of tests), do not emit every section — a padded multi-section dashboard on a trivial input reads as noise and buries the answer. Instead, answer the user's question directly and concisely: which tests are assertion-free or trivial-only, the overall assertion-quality verdict, and concrete recommendations (still distinguishing intentional smoke tests from tests masquerading as real verification). Use only the sections that carry real signal for the input at hand; a short metric summary plus the assertion-free list and recommendations is often enough. Never omit the rubric-relevant substance (assertion-free/trivial identification, the quality verdict, and concrete recommendations) — only trim structural overhead that adds no information. - -Present the analysis in this structure: - -1. **Summary Dashboard** — A quick-reference table of key metrics: - ``` - | Metric | Value | Assessment | - |-------------------------------|--------|------------| - | Total tests | 25 | — | - | Average assertions per test | 2.4 | Moderate | - | Assertion type spread | 5/12 | Low | - | Tests with zero assertions | 3 (12%)| Concerning | - | Tests with only trivial asserts | 4 (16%)| Acceptable | - | Tests with negative assertions | 2 (8%) | Below target | - | Single-category tests | 15 (60%)| High | - ``` - -2. **Category Breakdown** — For each assertion category, show: - - How many tests use it - - Representative examples from the code - - Whether it's overused or underused relative to the code under test - -3. **Gap Analysis** — Based on the production code (if available), identify: - - Behaviors that are tested but only with equality checks - - Error paths with no exception assertions - - State-changing methods with no state verification - - Collections returned but never checked for contents - -4. **Recommendations** — Prioritized list of improvements: - - Which tests would benefit most from additional assertion types - - Which assertion categories are missing and why they matter - - Concrete examples of assertions that could be added - -5. **Assertion-free tests** — If any exist, list each one with its method name and what it appears to be testing, so the user can decide whether to add assertions or mark them as intentional smoke tests. - -## Validation - -- [ ] Every assertion in the test suite was classified into at least one category -- [ ] Metrics are computed correctly (counts add up) -- [ ] Trivial-assertion tests are correctly identified (not over-flagged) -- [ ] Exception tests are not penalized for low assertion count -- [ ] Boolean assertions on meaningful properties are not classified as trivial -- [ ] Recommendations are concrete (name specific test methods and suggest specific assertion types) -- [ ] If the suite has good diversity, the report acknowledges this - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Penalizing exception tests for low assertion count | Exception assertions are complete on their own — skip count warnings for these | -| Flagging null/None/nil checks before value checks as trivial | Only flag tests where the null/None/nil check is the ONLY assertion | -| Counting any Boolean assertion as trivial | Only always-true assertions (`Assert.IsTrue(true)`, `assert True`, `expect(true).toBe(true)`) are trivial | -| Ignoring framework differences | Each framework has distinct assertion APIs — always read the matching language extension first. MSTest's `Assert.AreEqual`, xUnit's `Assert.Equal`, NUnit's `Is.EqualTo`, pytest's bare `assert ==`, Jest's `expect().toBe()`, Go's `if … { t.Error… }` all map to the **Equality** category | -| Treating bare assertion forms as missing-framework | Bare `assert` (pytest), `if got != want { t.Error... }` (Go), and `assert!()` (Rust) are canonical — count them in the right category | -| Treating mock-call verifications as assertion-free | `verify(mock).method(...)`, `expect(mock).toHaveBeenCalledWith(...)`, `Should -Invoke` are State/Side-effect assertions | -| Recommending diversity for diversity's sake | Only suggest adding assertion types that would catch real bugs in the code under test | -| Missing implicit assertions | Exception assertions are both Exception and Negative; snapshot/property-based tests are real assertions with implicit structure | -| Async tests with unawaited assertions | TUnit, Jest with `.resolves`/`.rejects`, pytest-asyncio, Swift Testing, and Kotest all silently pass tests where assertions are not `await`ed — treat as assertion-free even when assertion calls are present | diff --git a/.agents/skills/coverage-analysis/SKILL.md b/.agents/skills/coverage-analysis/SKILL.md deleted file mode 100644 index a6de1e0..0000000 --- a/.agents/skills/coverage-analysis/SKILL.md +++ /dev/null @@ -1,533 +0,0 @@ ---- -name: coverage-analysis -description: > - Project-wide code coverage and CRAP (Change Risk Anti-Patterns) score - analysis for .NET projects. Calculates CRAP scores per method and surfaces - risk hotspots — complex code with low coverage that is dangerous to modify. - Use to diagnose why coverage is stuck or plateaued, identify what methods - block improvement, or get project-wide coverage analysis with risk ranking. - USE FOR: coverage stuck, coverage plateau, can't increase coverage, what's - blocking coverage, coverage gap, CRAP scores, risk hotspots, where to add - tests, coverage analysis, coverage report. - DO NOT USE FOR: targeted single-method CRAP analysis (use crap-score); - auditing test code for coverage-touching or other anti-patterns (use - test-anti-patterns); writing tests; running tests (use run-tests). Requires - or produces coverage (Cobertura) and CRAP metrics. -license: MIT ---- - -# Coverage Analysis - -## Purpose - -Raw coverage percentages answer "what code was executed?" — they don't answer what you actually need to know: - -- **What tests should I write next?** — ranked by risk and impact -- **Which uncovered code is risky vs. trivial?** — CRAP scores separate the two -- **Why has coverage plateaued?** — identify the files blocking further gains -- **Is this code safe to refactor?** — complex + uncovered = dangerous to change - -This skill bridges that gap: from a bare .NET solution to a prioritized risk hotspot list, with no manual tool configuration required. - -## When to Use - -Use this skill when the user mentions test coverage, coverage gaps, code risk, CRAP scores, where to add tests, why coverage plateaued, or wants to know which code is safest to refactor — even if they don't explicitly say "coverage analysis". - -## When Not to Use - -- **Targeted single-method CRAP analysis** — use the `crap-score` skill instead -- **Writing or generating tests** — this skill identifies where tests are needed, not write them -- **General test execution** unrelated to coverage or CRAP analysis -- **Coverage reporting without CRAP context** — use `dotnet test` with coverage collection directly - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| Project/solution path | No | Current directory | Path to the .NET solution or project | -| Line coverage threshold | No | 80% | Minimum acceptable line coverage | -| Branch coverage threshold | No | 70% | Minimum acceptable branch coverage | -| CRAP threshold | No | 30 | Maximum acceptable CRAP score before flagging | -| Top N hotspots | No | 10 | Number of risk hotspots to surface | - -### Prerequisites - -- .NET SDK installed (`dotnet` on PATH) -- At least one test project referencing the production code (xUnit, NUnit, or MSTest) — only required for the from-scratch path; not needed when the user supplies an existing Cobertura XML -- **Optional, only for the from-scratch path:** internet/NuGet access for `dotnet add package coverlet.collector` (or `Microsoft.Testing.Extensions.CodeCoverage`) when a test project has no coverage provider yet. Skip when the user supplies an existing Cobertura XML. -- **Optional, only for Phase 5:** internet access for `dotnet tool install` (ReportGenerator). Core CRAP/coverage analysis works from Cobertura XML alone — ReportGenerator only adds HTML/CSV reports as an optional post-summary extra. - -The skill auto-detects coverage provider state per test project and selects the least-invasive execution strategy: - -- unified Microsoft CodeCoverage when all projects use it, -- unified Coverlet when no project uses Microsoft CodeCoverage, -- per-project provider execution when the solution is truly mixed. - -No pre-existing runsettings files or manually installed tools required. - -## Workflow - -> **MANDATORY: deliver the final assistant response with the CRAP/risk-hotspot summary BEFORE any optional work.** As soon as `Compute-CrapScores.ps1` and `Extract-MethodCoverage.ps1` return data, your **next** assistant response must contain the user-facing analysis (CRAP table, blocking methods, recommendations). Do not run ReportGenerator (Phase 5), do not install global tools, and do not start any heavy parallel work before that response is delivered. The user is judged on the final assistant message, not on side-effect files. -> -> If a phase fails, times out, or budget is running low, skip remaining optional work and immediately return a partial summary containing: (1) what was found in the Cobertura XML, (2) any CRAP/risk-hotspot data already extracted, (3) which methods are blocking coverage, and (4) failures encountered. - -If the user provides a path to existing Cobertura XML (or coverage data is already present in `TestResults/`), **skip Phase 2 entirely** (no test execution) **and skip Phase 5 by default** (no ReportGenerator install or HTML report) — go directly from Phase 3 (analysis scripts) to Phase 4 (user-facing summary). Only run Phase 5 if the user explicitly asks for HTML/CSV reports. The Risk Hotspots table and CRAP scores are mandatory in every output — they are the skill's core value-add over raw coverage numbers. - -The workflow runs in five phases. Phases 1–4 are required; Phase 5 (ReportGenerator HTML/CSV reports) is strictly optional and runs **after** the user-facing summary has been delivered. Do not parallelize Phase 5 with earlier phases — the heavy `dotnet tool install` for ReportGenerator can crash the session before Phase 4 completes. - -### Phase 1 — Setup (sequential) - -#### Step 1: Locate the solution or project - -Given the user's path (default: current directory), find the entry point: - -```powershell -$root = "" - -# Prefer solution file; fall back to project file -$sln = Get-ChildItem -Path $root -Filter "*.sln" -Recurse -Depth 2 -ErrorAction SilentlyContinue | - Select-Object -First 1 -if ($sln) { - Write-Host "ENTRY_TYPE:Solution"; Write-Host "ENTRY:$($sln.FullName)" -} else { - $project = Get-ChildItem -Path $root -Filter "*.csproj" -Recurse -Depth 2 -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($project) { - Write-Host "ENTRY_TYPE:Project"; Write-Host "ENTRY:$($project.FullName)" - } else { - Write-Host "ENTRY_TYPE:NotFound" - } -} - -# Test projects: search path first, then git root, then parent -$searchRoots = @($root) -$gitRoot = (git -C $root rev-parse --show-toplevel 2>$null) -if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) } -if ($gitRoot -and $gitRoot -ne $root) { $searchRoots += $gitRoot } -$parentPath = Split-Path $root -Parent -if ($parentPath -and $parentPath -ne $root -and $parentPath -ne $gitRoot) { $searchRoots += $parentPath } - -$testProjects = @() -foreach ($sr in $searchRoots) { - # Primary: match by .csproj content (test framework references) - $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '([/\\]obj[/\\]|[/\\]bin[/\\])' } | - Where-Object { (Select-String -Path $_.FullName -Pattern 'Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest\.TestAdapter|"MSTest"|MSTest\.TestFramework|TUnit' -Quiet) }) - if ($testProjects.Count -gt 0) { - if ($sr -ne $root) { Write-Host "SEARCHED:$sr" } - break - } -} - -# Fallback: match by file name convention -if ($testProjects.Count -eq 0) { - foreach ($sr in $searchRoots) { - $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '(?i)(test|spec)' }) - if ($testProjects.Count -gt 0) { - if ($sr -ne $root) { Write-Host "SEARCHED:$sr" } - break - } - } -} -Write-Host "TEST_PROJECTS:$($testProjects.Count)" -$testProjects | ForEach-Object { Write-Host "TEST_PROJECT:$($_.FullName)" } - -# Resolve the test output root (where coverage-analysis artifacts will be written) -if ($testProjects.Count -eq 0) { - if ($gitRoot) { - $testOutputRoot = $gitRoot - } else { - $testOutputRoot = $root - } -} elseif ($testProjects.Count -eq 1) { - $testOutputRoot = $testProjects[0].DirectoryName -} else { - # Multiple test projects — find their deepest common parent directory - $dirs = $testProjects | ForEach-Object { $_.DirectoryName } - $common = $dirs[0] - foreach ($d in $dirs[1..($dirs.Count-1)]) { - $sep = [System.IO.Path]::DirectorySeparatorChar - while (-not $d.StartsWith("$common$sep", [System.StringComparison]::OrdinalIgnoreCase) -and $d -ne $common) { - $prevCommon = $common - $common = Split-Path $common -Parent - # Terminate if we can no longer move up (at filesystem root or no parent) - if ([string]::IsNullOrEmpty($common) -or $common -eq $prevCommon) { - $common = $null - break - } - } - } - if ([string]::IsNullOrEmpty($common)) { - # Fallback when no common parent directory exists (e.g., projects on different drives) - if ($gitRoot) { - $testOutputRoot = $gitRoot - } else { - $testOutputRoot = $root - } - } else { - $testOutputRoot = $common - } -} -Write-Host "TEST_OUTPUT_ROOT:$testOutputRoot" -``` - -- If `ENTRY_TYPE:NotFound` and test projects were found → use the test projects directly as entry points (run `dotnet test` on each test `.csproj`). -- If `ENTRY_TYPE:NotFound` and no test projects found → stop: `No .sln or test projects found under . Provide the path to your .NET solution or project.` -- If `TEST_PROJECTS:0` and `EXISTING_COBERTURA_COUNT` > 0 (Step 2b) → continue with existing Cobertura XML analysis (no `dotnet test` run). -- If `TEST_PROJECTS:0` and `EXISTING_COBERTURA_COUNT` == 0 → stop: `No test projects found (expected projects with 'Test' or 'Spec' in the name), and no existing Cobertura XML was provided. Add a test project or provide a Cobertura file path.` - -#### Step 2: Create the output directory - -```powershell -$coverageDir = Join-Path $testOutputRoot "TestResults" "coverage-analysis" -if (Test-Path $coverageDir) { Remove-Item $coverageDir -Recurse -Force } -New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null -Write-Host "COVERAGE_DIR:$coverageDir" -``` - -This step only manages the `TestResults/coverage-analysis/` subdirectory (skill-owned outputs). It must never delete user-supplied Cobertura files — those live one level up at `TestResults/coverage.cobertura.xml` (or wherever the user pointed). If the user provided a path that *is* `TestResults/coverage-analysis/...`, copy the file aside before this step recreates the directory. - -#### Step 2b: Discover or accept existing Cobertura XML (required for the existing-data path) - -If the user supplied a Cobertura XML path explicitly, use it. Otherwise probe well-known locations and any path the user mentioned: - -```powershell -# 1. Honor a user-supplied path first (highest priority) -$coberturaFiles = @() -if ($userSuppliedCoberturaPath -and (Test-Path $userSuppliedCoberturaPath)) { - $coberturaFiles = @(Get-Item $userSuppliedCoberturaPath) -} - -# 2. Otherwise scan TestResults/ at the repo/test root for any *.cobertura.xml -if ($coberturaFiles.Count -eq 0) { - $searchPaths = @( - (Join-Path $testOutputRoot "TestResults"), - (Join-Path $root "TestResults") - ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique - foreach ($sp in $searchPaths) { - $found = @(Get-ChildItem -Path $sp -Filter "*.cobertura.xml" -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '[/\\]coverage-analysis[/\\]raw[/\\]' }) - if ($found.Count -gt 0) { $coberturaFiles = $found; break } - } -} - -Write-Host "EXISTING_COBERTURA_COUNT:$($coberturaFiles.Count)" -$coberturaFiles | ForEach-Object { Write-Host "EXISTING_COBERTURA:$($_.FullName)" } -``` - -- If `EXISTING_COBERTURA_COUNT` > 0 → **skip Phase 2 entirely** and pass these paths to the Phase 3 scripts. -- If `EXISTING_COBERTURA_COUNT` == 0 → run Phase 2 to generate fresh coverage; the file paths to feed Phase 3 will be discovered from `/raw/` after `dotnet test`. - -#### Step 2c: Recommend ignoring `TestResults/` - -```powershell -$pattern = "**/TestResults/" -$gitRoot = (git -C $testOutputRoot rev-parse --show-toplevel 2>$null) -if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) } -if ($gitRoot) { - $gitignorePath = Join-Path $gitRoot ".gitignore" - $alreadyIgnored = $false - if (Test-Path $gitignorePath) { - $alreadyIgnored = (Select-String -Path $gitignorePath -Pattern '^\s*(\*\*/)?TestResults/?\s*$' -Quiet) - } - if ($alreadyIgnored) { - Write-Host "GITIGNORE_RECOMMENDATION:already-present" - } else { - Write-Host "GITIGNORE_RECOMMENDATION:$pattern" - } -} else { - Write-Host "GITIGNORE_RECOMMENDATION:$pattern" -} -``` - -### Phase 2 — Test execution (skip when Cobertura XML already exists) - -Run only when no Cobertura XML is present. If the user already has coverage data, skip directly to Phase 3. - -#### Step 3: Detect coverage provider and run `dotnet test` with coverage collection - -Before running tests, detect which coverage provider the test projects use. Projects may reference -`Microsoft.Testing.Extensions.CodeCoverage` (Microsoft's built-in provider, common on .NET 9+) or -`coverlet.collector` (open-source, the default in xUnit templates). The provider determines which -`dotnet test` arguments to use — both produce Cobertura XML. - -```powershell -# Detect coverage provider per test project -$coverageProvider = "unknown" # will be set to "ms-codecoverage" or "coverlet" -$msCodeCovProjects = @() -$coverletProjects = @() -$neitherProjects = @() - -foreach ($tp in $testProjects) { - $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet - $hasCoverlet = Select-String -Path $tp.FullName -Pattern 'coverlet\.collector' -Quiet - if ($hasMsCodeCov) { $msCodeCovProjects += $tp } - elseif ($hasCoverlet) { $coverletProjects += $tp } - else { $neitherProjects += $tp } -} - -# Determine the provider strategy -if ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -eq 0) { - $coverageProvider = "ms-codecoverage" - Write-Host "COVERAGE_PROVIDER:ms-codecoverage (ms:$($msCodeCovProjects.Count), none:$($neitherProjects.Count))" -} elseif ($coverletProjects.Count -gt 0 -and $msCodeCovProjects.Count -eq 0) { - $coverageProvider = "coverlet" - Write-Host "COVERAGE_PROVIDER:coverlet (coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))" -} elseif ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -gt 0) { - $coverageProvider = "mixed-project" - Write-Host "COVERAGE_PROVIDER:mixed-project (ms:$($msCodeCovProjects.Count), coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))" -} else { - $coverageProvider = "coverlet" - Write-Host "COVERAGE_PROVIDER:none-detected — defaulting to coverlet" -} -``` - -If any discovered test projects have no provider, add one based on the selected strategy: - -```powershell -if ($coverageProvider -eq "ms-codecoverage" -and $neitherProjects.Count -gt 0) { - Write-Host "ADDING_MS_CODECOVERAGE:$($neitherProjects.Count) project(s)" - foreach ($tp in $neitherProjects) { - dotnet add $tp.FullName package Microsoft.Testing.Extensions.CodeCoverage --no-restore - Write-Host " ADDED_MS_CODECOVERAGE:$($tp.FullName)" - } - foreach ($tp in $neitherProjects) { - dotnet restore $tp.FullName --quiet - } -} - -if (($coverageProvider -eq "coverlet" -or $coverageProvider -eq "mixed-project") -and $neitherProjects.Count -gt 0) { - Write-Host "ADDING_COVERLET:$($neitherProjects.Count) project(s)" - foreach ($tp in $neitherProjects) { - dotnet add $tp.FullName package coverlet.collector --no-restore - Write-Host " ADDED:$($tp.FullName)" - } - foreach ($tp in $neitherProjects) { - dotnet restore $tp.FullName --quiet - } -} -``` - -Log each addition to the console so the developer sees what changed. Document the additions in the final report (see Output Format). - -Run one `dotnet test` per entry point for the selected strategy: - -- In `ms-codecoverage` or `coverlet` mode: run a single command for the solution entry (or one per test project if no `.sln` was found). -- In `mixed-project` mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts. - -**Coverlet** (`coverlet.collector`): - -```powershell -$rawDir = Join-Path "" "raw" -dotnet test "" ` - --collect:"XPlat Code Coverage" ` - --results-directory $rawDir ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true -``` - -**Microsoft CodeCoverage** (`Microsoft.Testing.Extensions.CodeCoverage`): - -The command syntax depends on the .NET SDK version. In .NET 9, Microsoft.Testing.Platform arguments -must be passed after the `--` separator. In .NET 10+, `--coverage` is a top-level `dotnet test` flag. - -```powershell -$rawDir = Join-Path "" "raw" - -# Detect SDK version for correct argument placement -$sdkVersion = (dotnet --version 2>$null) -$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 } - -if ($major -ge 10) { - # .NET 10+: --coverage is a first-class dotnet test flag - dotnet test "" ` - --results-directory $rawDir ` - --coverage ` - --coverage-output-format cobertura ` - --coverage-output $rawDir -} else { - # .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator - dotnet test "" ` - --results-directory $rawDir ` - -- --coverage --coverage-output-format cobertura --coverage-output $rawDir -} -``` - -**Mixed-project mode** (`Microsoft.Testing.Extensions.CodeCoverage` + `coverlet.collector` in the same solution): - -```powershell -$rawDir = Join-Path "" "raw" -$sdkVersion = (dotnet --version 2>$null) -$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 } - -foreach ($tp in $testProjects) { - $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet - if ($hasMsCodeCov) { - if ($major -ge 10) { - dotnet test $tp.FullName --results-directory $rawDir --coverage --coverage-output-format cobertura --coverage-output $rawDir - } else { - dotnet test $tp.FullName --results-directory $rawDir -- --coverage --coverage-output-format cobertura --coverage-output $rawDir - } - } else { - dotnet test $tp.FullName ` - --collect:"XPlat Code Coverage" ` - --results-directory $rawDir ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" ` - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true - } -} -``` - -Exit code handling: - -- **0** — all tests passed, coverage collected -- **1** — some tests failed (coverage still collected — proceed with a warning) -- **Other** — build failure; stop and report the error - -After the run, locate coverage files: - -```powershell -$coberturaFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "coverage.cobertura.xml" -Recurse -Write-Host "COBERTURA_COUNT:$($coberturaFiles.Count)" -$coberturaFiles | ForEach-Object { Write-Host "COBERTURA:$($_.FullName)" } -$vsCovFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "*.coverage" -Recurse -ErrorAction SilentlyContinue -if ($vsCovFiles) { Write-Host "VS_BINARY_COVERAGE:$($vsCovFiles.Count)" } -``` - -If `COBERTURA_COUNT` is 0: - -- If `VS_BINARY_COVERAGE` > 0: warn the user — *"Found .coverage files (VS binary format) but no Cobertura XML. These were likely produced by Visual Studio's built-in collector, which outputs a binary format by default. This skill needs Cobertura XML. Re-running with the detected provider configured for Cobertura output."* Then re-run the appropriate `dotnet test` command above (Coverlet or Microsoft CodeCoverage) with Cobertura format. -- If no `.coverage` files either: stop and report — *"Coverage files not generated. Ensure `dotnet test` completed successfully and check the build output for errors."* - -### Phase 3 — Analysis (sequential) - -Run the two bundled PowerShell scripts. Both are cheap and complete in seconds. **Do not** install or invoke ReportGenerator here — that belongs in optional Phase 5, after the user-facing summary has been delivered. - -#### Step 4: Calculate CRAP scores using the bundled script - -Run `scripts/Compute-CrapScores.ps1` (co-located with this SKILL.md). It reads all Cobertura XML files, applies `CRAP(m) = comp² × (1 − cov)³ + comp` per method, and returns the top-N hotspots as JSON. - -To locate the script: find the directory containing this skill's `SKILL.md` file (the skill loader provides this context), then resolve `scripts/Compute-CrapScores.ps1` relative to it. If the script path cannot be determined, calculate CRAP scores inline using the formula below. - -```powershell -& "/scripts/Compute-CrapScores.ps1" ` - -CoberturaPath @() ` - -CrapThreshold ` - -TopN -``` - -Script outputs: `OVERALL_LINE_COVERAGE:`, `OVERALL_BRANCH_COVERAGE:` (aggregated project-wide rates across all provided Cobertura files), `TOTAL_METHODS:`, `FLAGGED_METHODS:`, `HOTSPOTS:` (top-N sorted by CrapScore descending). The OVERALL_* values are exactly what the Phase 4 summary needs for the "Line Coverage" / "Branch Coverage" rows — no separate XML parsing tool call is required. - -#### Step 5: Extract per-method coverage gaps - -Run `scripts/Extract-MethodCoverage.ps1` to get per-method coverage data for the Coverage Gaps table: - -```powershell -& "/scripts/Extract-MethodCoverage.ps1" ` - -CoberturaPath @() ` - -CoverageThreshold ` - -BranchThreshold ` - -Filter below-threshold -``` - -Script outputs: JSON array of methods below the coverage threshold, sorted by coverage ascending. Use this data to populate the Coverage Gaps by File table in the report. - -### Phase 4 — User-facing summary (MANDATORY — your next assistant response) - -As soon as Phase 3 completes, **your immediately next assistant response must contain the user-facing analysis** — do not interleave any other tool calls before it. This is the response the user (and any judge) sees. Skipping or deferring this in favor of Phase 5 (ReportGenerator) is a hard failure. - -The response must include, at minimum: - -1. Overall line and branch coverage — read directly from the `OVERALL_LINE_COVERAGE:` / `OVERALL_BRANCH_COVERAGE:` lines emitted by `Compute-CrapScores.ps1` (no extra Cobertura parsing required) -2. The Risk Hotspots table built from `Compute-CrapScores.ps1` `HOTSPOTS:` output (CRAP scores, complexity, coverage) -3. Identification of the highest-risk method(s) and what is blocking coverage -4. 1–3 prioritized, specific recommendations (which method to test, expected CRAP/coverage impact) - -Use `references/output-format.md` verbatim for fixed headings, table structures, symbols, and emoji. Use `references/guidelines.md` for prioritization rules and style. - -If Phase 5 has not yet run when you compose this summary, mark the `## 📁 Reports` section's HTML/Text/CSV/GitHub-markdown rows as `Not generated (optional — request HTML reports to enable)`. Only the `coverage-analysis.md` and raw Cobertura paths are guaranteed to exist. - -Attempt to save the same content to `TestResults/coverage-analysis/coverage-analysis.md` before delivering the response (use the editor's create/edit tool — do not shell out). If the file write fails, still deliver the summary and note the file-write failure explicitly. - -### Phase 5 — Optional: ReportGenerator HTML/CSV reports (post-summary) - -Phase 5 is **strictly optional** and runs **only after** Phase 4 has been delivered. Skip Phase 5 entirely when: - -- The user supplied existing Cobertura XML and only asked for analysis (the default for the existing-data path). -- The user is diagnosing a coverage plateau or asking "what's blocking me?" — they want the answer, not a static-site report. -- ReportGenerator is not already installed and you have no clear signal the user wants HTML reports. - -Run Phase 5 only when the user explicitly asks for HTML/CSV reports, or when the project flow requires them (e.g., a CI artifact upload step). - -#### Step 6: Verify or install ReportGenerator (only if running Phase 5) - -```powershell -$rgAvailable = $false -$rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue -if ($rgCommand) { - $rgAvailable = $true - Write-Host "RG_INSTALLED:already-present" -} else { - $rgToolPath = Join-Path "" ".tools" - dotnet tool install dotnet-reportgenerator-globaltool --tool-path $rgToolPath - if ($LASTEXITCODE -eq 0) { - $env:PATH = "$rgToolPath$([System.IO.Path]::PathSeparator)$env:PATH" - $rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue - if ($rgCommand) { - $rgAvailable = $true - Write-Host "RG_INSTALLED:true (tool-path: $rgToolPath)" - } else { - Write-Host "RG_INSTALLED:false" - Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available" - } - } else { - Write-Host "RG_INSTALLED:false" - Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available" - } -} -Write-Host "RG_AVAILABLE:$rgAvailable" -``` - -If installation fails (no internet), keep `RG_AVAILABLE:false`, leave the existing user-facing summary as the final output, and note that HTML reports were skipped. - -#### Step 7: Generate HTML/CSV reports - -```powershell -$reportsDir = Join-Path "" "reports" -if ($rgAvailable) { - reportgenerator ` - -reports:"" ` - -targetdir:$reportsDir ` - -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" ` - -title:"Coverage Report" ` - -tag:"coverage-analysis-skill" - - Get-Content (Join-Path $reportsDir "Summary.txt") -ErrorAction SilentlyContinue -} else { - Write-Host "REPORTGENERATOR_SKIPPED:true" -} -``` - -After Phase 5 completes successfully, you may follow up with a short message pointing the user to the generated HTML report (one paragraph, no need to repeat the summary). - -## Validation - -- Verify that at least one `coverage.cobertura.xml` file was generated after `dotnet test` (or already exists when the user supplied one) -- Confirm the assistant response contained the CRAP/risk-hotspot table — saving the markdown file is secondary -- Confirm `TestResults/coverage-analysis/coverage-analysis.md` was written and contains data -- Spot-check one method's CRAP score: `comp² × (1 − cov)³ + comp` — a method with 100% coverage should have CRAP = complexity -- If Phase 5 ran, verify `TestResults/coverage-analysis/reports/index.html` exists; otherwise the report file should mark HTML/Text/CSV rows as `Not generated` - -## Common Pitfalls - -- **No Cobertura XML generated** — the test project may lack a coverage provider. The skill auto-adds one, but if `dotnet add package` fails (offline/proxy), coverage collection silently produces nothing. Check for `.coverage` binary files as a fallback indicator. -- **Test failures (exit code 1)** — coverage is still collected from passing tests. Do not abort; proceed with partial data and note the failures in the summary. -- **Premature end before user-facing summary** — never start Phase 5 (ReportGenerator install/run) before the Phase 4 assistant response is delivered. The heavy `dotnet tool install` can crash the session or exhaust budget, leaving the user with no analysis even though the CRAP scores were already computed. -- **ReportGenerator install failure** — if `dotnet tool install` fails (no internet) during Phase 5, leave the existing Phase 4 summary as the final output and note that HTML reports were skipped. Do not retry or block on the install. -- **Method name mismatches in Cobertura** — async methods, lambdas, and local functions may have compiler-generated names. The scripts use the Cobertura method name/signature directly; verify against source if results look unexpected. -- **Mixed coverage providers** — when a solution contains both Coverlet and Microsoft CodeCoverage projects, the skill runs per-project to avoid dual-provider conflicts. This is slower but correct. diff --git a/.agents/skills/coverage-analysis/references/guidelines.md b/.agents/skills/coverage-analysis/references/guidelines.md deleted file mode 100644 index 344f69e..0000000 --- a/.agents/skills/coverage-analysis/references/guidelines.md +++ /dev/null @@ -1,59 +0,0 @@ -# Guidelines - -**Don't modify source or production code.** The only permitted project file modifications are adding a coverage provider package to test projects that currently have no provider: `coverlet.collector` (coverlet/mixed modes) or `Microsoft.Testing.Extensions.CodeCoverage` (ms-codecoverage mode). Do not add a second provider to projects that already have one. Always log package additions and document revert commands in the report. Write all other output to `TestResults/coverage-analysis/` under the test project directory. - -**Always show and open the generated markdown report — but only after the assistant response with the CRAP/risk-hotspot summary has been delivered.** Saving and opening `TestResults/coverage-analysis/coverage-analysis.md` is a follow-up action; it must never delay the user-facing summary. - -**Don't generate new tests during the initial analysis run.** This skill surfaces where tests are needed. Test generation is a separate follow-up step outside the scope of this skill. - -**Use inline `dotnet test` arguments, not runsettings files.** Runsettings files require the developer to already know what they're doing — the whole point of this skill is that they shouldn't have to. Inline data collector args produce the same result with zero configuration. - -**Show the risk hotspots table even when all thresholds pass.** A project at 90% line coverage can still have a method with cyclomatic complexity 20 and 0% branch coverage. The thresholds measure averages; the hotspot table finds outliers. Don't hide it just because the summary looks green. - -**Always compute and surface CRAP scores.** The Risk Hotspots table is mandatory in every analysis output, whether analyzing pre-existing data, freshly collected data, or diagnosing a plateau. Never skip CRAP score computation — it is the primary differentiator between this skill and raw `dotnet test` coverage output. - -**Continue past test failures (exit code 1).** If some tests fail, coverage is still collected from the passing tests — partial data is better than no data. Note the failures in the summary and proceed. Aborting would leave the developer with nothing actionable. - -**Run `dotnet test` only once per entry point during normal flow.** When a solution is found, run it once against the solution. When no solution is found, run it once per test project. A single recovery rerun is allowed only if the first run produced no Cobertura XML and only `.coverage` binary output. - -**CRAP threshold of 30 is the default for a reason.** Scores above 30 are widely cited (by the original researchers) as "needs immediate attention." Scores between 15 and 30 are moderate — flag them in the table but don't make them sound catastrophic. Scores ≤ 5 are generally fine. - -**Priority assignment for coverage gaps:** - -- **HIGH** — file has both a CRAP score above threshold AND coverage below threshold (the double failure is what makes it urgent) -- **MED** — coverage below threshold OR CRAP score above threshold, but not both -- **LOW** — coverage below threshold with all methods having complexity ≤ 2 (trivial code — missing coverage here is unlikely to hide real bugs) - ---- - -## Coverage Intelligence — Going Beyond the Numbers - -**Prioritize uncovered code that is** complex (cyclomatic complexity > 5), on critical paths (auth, payment, data access, error handling), or changed frequently. **Deprioritize** trivial getters (complexity 1–2), generated files (EF migrations, `*.Designer.cs`, `*.g.cs`), and DI/configuration glue code. - -**Coverage plateau diagnosis** — if coverage has stopped increasing, check for: `[Exclude]` attributes hiding large code sections, tests that execute code but assert nothing (inflated coverage without verification), or integration code that needs external dependencies (databases, file system). - -**AI-generated test quality** — coverage delta alone is insufficient. Flag methods where CRAP score is still above threshold after coverage increased (tests may be happy-path only), and methods covered by a single test with no branch variation. - ---- - -## Style - -- **Keep risk hotspots prominent and immediately after the summary section** — developers should find the highest-risk methods quickly -- **Quantify recommendations** — "adding 3 tests for `ProcessOrder` would cut the CRAP score from 48 to ~6" -- **Be direct** — skip preamble, get to the table -- **Emoji for visual scanning in generated output** (defined in `references/output-format.md`): - - | Symbol | Meaning | - |--------|---------| - | 🔥 | hotspots | - | 📋 | gaps | - | 💡 | recommendations | - | 📁 | reports | - | ✅ | passing | - | ❌ | failing | - | ⚠️ | warning | - | 🔴 | HIGH priority | - | 🟡 | MED priority | - | 🟢 | LOW priority | - -- **Always use Unicode emoji in generated output** — never shortcodes like `:x:` or `:fire:` diff --git a/.agents/skills/coverage-analysis/references/output-format.md b/.agents/skills/coverage-analysis/references/output-format.md deleted file mode 100644 index 7e3c5b6..0000000 --- a/.agents/skills/coverage-analysis/references/output-format.md +++ /dev/null @@ -1,87 +0,0 @@ -# Output Format - -Copy the template below **verbatim** for all fixed elements (headings, table headers, emoji, symbols). Only replace `` values with actual data. Do not substitute emoji with text equivalents, do not change `·` to `-`, do not change `×` to `x`, and do not drop section emoji prefixes. - -```markdown -# Coverage Analysis - - -| Metric | Value | -|--------|-------| -| **Date** | | -| **Line Coverage** | % | -| **Branch Coverage** | % | -| **Risk Hotspots** | (CRAP > ) | -| **Tests** | passed · failed | - -## Summary - -| Metric | Value | Threshold | Status | -|--------|-------|-----------|--------| -| **Line Coverage** | % | % | ✅ / ❌ | -| **Branch Coverage** | % | % | ✅ / ❌ | -| **Methods Analyzed** | | — | — | -| **Risk Hotspots** | | 0 | ✅ / ⚠️ | -| **Test Result** | | — | ✅ / ⚠️ | - -> Coverage collected from ** of test project(s)**. -> Outputs saved to: `/` (markdown summary + raw Cobertura XML). -> *If Phase 5 ran:* HTML/CSV reports also at `/reports/`. - -If any coverage provider package was added to test projects, include this note after the summary: - -> ℹ️ **Coverage provider package updates** -> - `coverlet.collector` added to `` project(s): ``, `` -> - `Microsoft.Testing.Extensions.CodeCoverage` added to `` project(s): `` -> -> To revert: `git checkout -- ` - -If all test projects already had a coverage provider, omit this note. - ---- - -## 🔥 Risk Hotspots (Top by CRAP Score) - -Methods flagged as high-risk: complex code with low test coverage that is dangerous to change. - -| Rank | Method | Class | File | Complexity | Coverage | CRAP Score | -|------|--------|-------|------|-----------|---------|-----------| -| 1 | `` | `` | `` | | % | **** | -| … | … | … | … | … | … | … | - -> **CRAP Score** = `Complexity² × (1 − Coverage)³ + Complexity`. -> Scores above are flagged. A score ≤ 5 is considered safe. - ---- - -## 📋 Coverage Gaps by File - -Files below the line or branch coverage threshold, ordered by uncovered lines descending: - -| File | Line Coverage | Branch Coverage | Uncovered Lines | Priority | -|------|--------------|----------------|----------------|---------| -| `` | % | % | | 🔴 HIGH / 🟡 MED / 🟢 LOW | -| … | … | … | … | … | - ---- - -## 💡 Recommendations - -1. **Write tests for the top risk hotspot first** — `` in `` has a CRAP score of (complexity , % coverage). Reducing it to 80% coverage would drop the score to ~. -2. **Focus on ``** — uncovered lines, below threshold. -3. **** - ---- - -## 📁 Reports - -| Report | Path | -|--------|------| -| Markdown summary (this file) | `/coverage-analysis.md` | -| Raw Cobertura XML | `` | -| HTML (browsable) | `/reports/index.html` *or* `Not generated (optional — request HTML reports to enable)` | -| Text summary | `/reports/Summary.txt` *or* `Not generated` | -| GitHub markdown | `/reports/SummaryGithub.md` *or* `Not generated` | -| CSV data | `/reports/Summary.csv` *or* `Not generated` | -``` - -If ReportGenerator (Phase 5) has not run, mark the HTML/Text/GitHub-markdown/CSV rows as `Not generated (optional — request HTML reports to enable)`. Do not invent paths for files that have not been produced. For **Raw Cobertura XML**, list the actual XML file path(s) used in analysis (for from-scratch runs this is typically under `/raw/`; for existing-data runs this may be under `TestResults/` or another user-supplied location). diff --git a/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 b/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 deleted file mode 100644 index b0c8d9f..0000000 --- a/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 +++ /dev/null @@ -1,165 +0,0 @@ -# Compute-CrapScores.ps1 -# -# Reads a Cobertura XML coverage file and calculates CRAP scores per method. -# Uses Alberto Savoia's original CRAP formula: -# CRAP(m) = comp(m)^2 * (1 - cov(m))^3 + comp(m) -# -# Usage: -# .\Compute-CrapScores.ps1 -CoberturaPath ,,... [-CrapThreshold ] [-TopN ] -# -# Outputs: -# - OVERALL_LINE_COVERAGE: (aggregate line coverage across input files, as percent) -# - OVERALL_BRANCH_COVERAGE: (aggregate branch coverage across input files, as percent) -# - TOTAL_METHODS: -# - FLAGGED_METHODS: -# - HOTSPOTS: (top N by CRAP score) - -param( - [Parameter(Mandatory)][string[]]$CoberturaPath, - [int]$CrapThreshold = 30, - [int]$TopN = 10 -) - -# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File). -# Line hits are accumulated so a line is counted as covered if any input coverage file covered it. -$methodMap = @{} -$overallLineRate = 0.0 -$overallBranchRate = 0.0 -$totalLinesCovered = 0 -$totalLinesValid = 0 -$totalBranchesCovered = 0 -$totalBranchesValid = 0 -$fallbackLineRates = [System.Collections.Generic.List[double]]::new() -$fallbackBranchRates = [System.Collections.Generic.List[double]]::new() - -foreach ($filePath in $CoberturaPath) { - if (-not (Test-Path $filePath)) { - Write-Error "Cobertura file not found: $filePath" - exit 2 - } - - try { - [xml]$cobertura = Get-Content $filePath -Encoding UTF8 -ErrorAction Stop - } catch { - Write-Error "Failed to parse Cobertura XML: $filePath. $_" - exit 2 - } - - # Prefer aggregate numerator/denominator attributes when present. - if ($null -ne $cobertura.coverage.'lines-covered' -and $null -ne $cobertura.coverage.'lines-valid') { - $totalLinesCovered += [double]$cobertura.coverage.'lines-covered' - $totalLinesValid += [double]$cobertura.coverage.'lines-valid' - } elseif ($cobertura.coverage.'line-rate') { - $fallbackLineRates.Add([double]$cobertura.coverage.'line-rate') - } - if ($null -ne $cobertura.coverage.'branches-covered' -and $null -ne $cobertura.coverage.'branches-valid') { - $totalBranchesCovered += [double]$cobertura.coverage.'branches-covered' - $totalBranchesValid += [double]$cobertura.coverage.'branches-valid' - } elseif ($cobertura.coverage.'branch-rate') { - $fallbackBranchRates.Add([double]$cobertura.coverage.'branch-rate') - } - - foreach ($package in $cobertura.coverage.packages.package) { - foreach ($class in $package.classes.class) { - $className = $class.name - $fileName = $class.filename - - foreach ($method in $class.methods.method) { - $key = "$className|$($method.name)|$($method.signature)|$fileName" - - # Cyclomatic complexity is stored as an XML attribute in Cobertura format - $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 } - if ($complexity -lt 1) { $complexity = 1 } - - if (-not $methodMap.ContainsKey($key)) { - $methodMap[$key] = @{ - Class = $className - Method = $method.name - Signature = $method.signature - File = $fileName - Complexity = $complexity - LineHits = @{} - } - } - - # Accumulate hit counts per line number across files - foreach ($line in $method.lines.line) { - $lineNo = $line.number - $hits = [int]$line.hits - if ($methodMap[$key].LineHits.ContainsKey($lineNo)) { - $methodMap[$key].LineHits[$lineNo] += $hits - } else { - $methodMap[$key].LineHits[$lineNo] = $hits - } - } - } - } - } -} - -$results = [System.Collections.Generic.List[PSCustomObject]]::new() - -foreach ($entry in $methodMap.Values) { - $totalLines = $entry.LineHits.Count - $coveredLines = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count - $lineCoverage = if ($totalLines -gt 0) { $coveredLines / $totalLines } else { 0.0 } - - $complexity = $entry.Complexity - - # Alberto Savoia's CRAP formula: comp^2 * (1 - cov)^3 + comp - # The cubic exponent on (1-cov) sharply penalizes low coverage: - # at 0% coverage the risk multiplier is 1.0; at 50% it drops to 0.125. - # Higher scores = more complex AND less covered = riskier to change - $uncovered = 1.0 - $lineCoverage - $crapScore = [Math]::Round(($complexity * $complexity * [Math]::Pow($uncovered, 3)) + $complexity, 2) - - $results.Add([PSCustomObject]@{ - Class = $entry.Class - Method = $entry.Method - Signature = $entry.Signature - File = $entry.File - TotalLines = $totalLines - CoveredLines = $coveredLines - LineCoverage = [Math]::Round($lineCoverage * 100, 1) - Complexity = $complexity - CrapScore = $crapScore - }) -} - -$hotspots = $results | Sort-Object CrapScore -Descending | Select-Object -First $TopN -$flagged = $results | Where-Object { $_.CrapScore -gt $CrapThreshold } - -if ($totalLinesValid -gt 0) { - $overallLineRate = $totalLinesCovered / $totalLinesValid -} else { - # Fallback approximation when Cobertura aggregate counters and per-file rates are unavailable. - # This uses merged method line totals and may under/over-estimate if Cobertura - # includes executable lines outside method nodes. - $mergedTotalLines = ($results | Measure-Object -Property TotalLines -Sum).Sum - $mergedCoveredLines = ($results | Measure-Object -Property CoveredLines -Sum).Sum - if ($mergedTotalLines -gt 0) { - $overallLineRate = [double]$mergedCoveredLines / [double]$mergedTotalLines - } elseif ($fallbackLineRates.Count -gt 0) { - $overallLineRate = ($fallbackLineRates | Measure-Object -Average).Average - } else { - $overallLineRate = 0.0 - } -} - -if ($totalBranchesValid -gt 0) { - $overallBranchRate = $totalBranchesCovered / $totalBranchesValid -} elseif ($fallbackBranchRates.Count -gt 0) { - $overallBranchRate = ($fallbackBranchRates | Measure-Object -Average).Average -} else { - $overallBranchRate = 0.0 -} - -Write-Host "OVERALL_LINE_COVERAGE:$([Math]::Round($overallLineRate * 100, 1))" -Write-Host "OVERALL_BRANCH_COVERAGE:$([Math]::Round($overallBranchRate * 100, 1))" -Write-Host "TOTAL_METHODS:$($results.Count)" -Write-Host "FLAGGED_METHODS:$($flagged.Count)" -if ($hotspots) { - Write-Output "HOTSPOTS:$(@($hotspots) | ConvertTo-Json -Compress)" -} else { - Write-Output "HOTSPOTS:[]" -} diff --git a/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 b/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 deleted file mode 100644 index 999a827..0000000 --- a/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 +++ /dev/null @@ -1,193 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string[]]$CoberturaPath, - - [Parameter(Mandatory=$false)] - [int]$CoverageThreshold = 80, - - [Parameter(Mandatory=$false)] - [int]$BranchThreshold = 70, - - [Parameter(Mandatory=$false)] - [ValidateSet('uncovered', 'below-threshold', 'all')] - [string]$Filter = 'all' -) - -<# -.SYNOPSIS -Extract method-level coverage from Cobertura XML and output as JSON. - -.DESCRIPTION -Parses one or more Cobertura code coverage XML files and extracts per-method coverage metrics: -- Method name and class -- Line coverage percentage -- Branch coverage percentage -- Lines covered / total -- Branches covered / total -- Complexity (if available) - -When multiple files are provided, line hits are merged across files so a line is counted -as covered if any test project covered it. - -Filters by coverage status (uncovered, below threshold, or all). -Output is JSON for easy post-processing into tables, CSV, or other formats. - -.PARAMETER CoberturaPath -Path(s) to Cobertura coverage.cobertura.xml file(s). Accepts multiple paths for multi-test-project merging. - -.PARAMETER CoverageThreshold -Minimum acceptable line coverage percentage. Methods below this threshold are flagged (default: 80). - -.PARAMETER BranchThreshold -Minimum acceptable branch coverage percentage for methods that contain branches (default: 70). - -.PARAMETER Filter -Which methods to include: - 'uncovered' - methods with 0% coverage only - 'below-threshold' - methods with line coverage < CoverageThreshold OR branch coverage < BranchThreshold (for methods with branches) - 'all' - all methods (default) - -.EXAMPLE -PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath "coverage.cobertura.xml" -CoverageThreshold 80 -BranchThreshold 70 -Filter uncovered -Outputs a JSON array of uncovered methods. - -.EXAMPLE -PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath @("tests1/coverage.cobertura.xml","tests2/coverage.cobertura.xml") -Merges coverage from multiple test projects and outputs combined method-level metrics. - -.OUTPUTS -Writes JSON array to stdout. -Sets exit code 0 on success, 2 on missing/invalid file. -#> - -foreach ($p in $CoberturaPath) { - if (-not (Test-Path $p)) { - Write-Error "Cobertura file not found: $p" - exit 2 - } -} - -# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File). -# Line hits and branch data are accumulated so coverage reflects all test projects. -$methodMap = @{} - -foreach ($p in $CoberturaPath) { - try { - [xml]$xml = Get-Content $p -Encoding UTF8 -ErrorAction Stop - } catch { - Write-Error "Failed to parse Cobertura XML: $_" - exit 2 - } - - foreach ($package in $xml.coverage.packages.package) { - foreach ($class in $package.classes.class) { - $className = $class.name - $classFilename = $class.filename - - foreach ($method in $class.methods.method) { - $key = "$className|$($method.name)|$($method.signature)|$classFilename" - - if (-not $methodMap.ContainsKey($key)) { - $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 } - if ($complexity -lt 1) { $complexity = 1 } - $methodMap[$key] = @{ - Class = $className - Method = $method.name - Signature = $method.signature - File = $classFilename - Complexity = $complexity - LineHits = @{} - BranchData = @{} - } - } - - # Accumulate line hits across files - foreach ($line in $method.lines.line) { - $lineNo = $line.number - $hits = [int]$line.hits - if ($methodMap[$key].LineHits.ContainsKey($lineNo)) { - $methodMap[$key].LineHits[$lineNo] += $hits - } else { - $methodMap[$key].LineHits[$lineNo] = $hits - } - - # Accumulate branch data - if ($line.branch -eq 'true' -and $line.'condition-coverage') { - if ($line.'condition-coverage' -match '\((\d+)/(\d+)\)') { - $covered = [int]$Matches[1] - $total = [int]$Matches[2] - if ($methodMap[$key].BranchData.ContainsKey($lineNo)) { - # Merge branch coverage across files by accumulating covered branches (capped at total) - $existingCovered = $methodMap[$key].BranchData[$lineNo].Covered - $existingTotal = $methodMap[$key].BranchData[$lineNo].Total - if ($existingTotal -ne $total) { - Write-Warning ("Branch total mismatch for {0} at line {1}: {2} vs {3}" -f $key, $lineNo, $existingTotal, $total) - } - $mergedTotal = [Math]::Max($existingTotal, $total) - $mergedCovered = [Math]::Min($existingCovered + $covered, $mergedTotal) - $methodMap[$key].BranchData[$lineNo] = @{ Covered = $mergedCovered; Total = $mergedTotal } - } else { - $methodMap[$key].BranchData[$lineNo] = @{ Covered = $covered; Total = $total } - } - } - } - } - } - } - } -} - -$methods = [System.Collections.Generic.List[PSCustomObject]]::new() - -foreach ($entry in $methodMap.Values) { - $totalLines = $entry.LineHits.Count - $coveredLineCount = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count - $lineCoveragePercent = if ($totalLines -gt 0) { [math]::Round(($coveredLineCount / $totalLines) * 100, 1) } else { 0 } - - $branchesTotal = 0 - $branchesCovered = 0 - foreach ($bd in $entry.BranchData.Values) { - $branchesCovered += $bd.Covered - $branchesTotal += $bd.Total - } - $branchCoveragePercent = if ($branchesTotal -gt 0) { [math]::Round(($branchesCovered / $branchesTotal) * 100, 1) } else { 0 } - - # Apply filter - if ($Filter -eq 'uncovered' -and $lineCoveragePercent -gt 0) { continue } - if ($Filter -eq 'below-threshold') { - $lineOk = $lineCoveragePercent -ge $CoverageThreshold - $branchOk = ($branchesTotal -eq 0) -or ($branchCoveragePercent -ge $BranchThreshold) - if ($lineOk -and $branchOk) { continue } - } - - $methods.Add([PSCustomObject]@{ - Class = $entry.Class - Method = $entry.Method - Signature = $entry.Signature - File = $entry.File - Complexity = $entry.Complexity - LineCoverage = $lineCoveragePercent - BranchCoverage = $branchCoveragePercent - CoveredLines = $coveredLineCount - TotalLines = $totalLines - UncoveredLines = ($totalLines - $coveredLineCount) - CoveredBranches = $branchesCovered - TotalBranches = $branchesTotal - }) -} -# Sort by uncovered lines descending, then by line coverage ascending -$sorted = $methods | Sort-Object -Property @{Expression='UncoveredLines';Descending=$true}, @{Expression='LineCoverage';Descending=$false}, Class, Method - -# Output as JSON (empty array guard for zero results) -if ($sorted.Count -eq 0) { - Write-Output "[]" -} else { - $json = @($sorted) | ConvertTo-Json - Write-Output $json -} - -# Summary -Write-Host "METHODS_FILTERED:$($methods.Count)" -ForegroundColor Green -$uncovered = $methods | Where-Object { $_.LineCoverage -eq 0 } | Measure-Object | Select-Object -ExpandProperty Count -Write-Host "UNCOVERED_METHODS:$uncovered" -ForegroundColor $(if ($uncovered -gt 0) { 'Yellow' } else { 'Green' }) -exit 0 diff --git a/.agents/skills/detect-static-dependencies/SKILL.md b/.agents/skills/detect-static-dependencies/SKILL.md deleted file mode 100644 index 46bda03..0000000 --- a/.agents/skills/detect-static-dependencies/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: detect-static-dependencies -description: > - Scan C# source files for hard-to-test static dependencies — DateTime.Now/UtcNow, - File.*, Directory.*, Environment.*, HttpClient, Console.*, Process.*, and other - untestable statics. Produces a ranked report of static call sites by frequency. - USE FOR: find untestable statics, scan for static dependencies, testability audit, - identify hard-to-mock code, find DateTime.Now usage, detect static coupling, - testability report, static analysis for testability. - DO NOT USE FOR: generating wrappers (use generate-testability-wrappers), - migrating code (use migrate-static-to-wrapper), general code review, - or finding statics that are already behind abstractions. -license: MIT ---- - -# Detect Static Dependencies - -Scan a C# codebase for calls to hard-to-test static APIs and produce a ranked report showing which statics appear most frequently, which files are most affected, and which abstractions already exist in the .NET ecosystem to replace them. - -## When to Use - -- Auditing a project's testability before adding unit tests -- Understanding the scope of static coupling in a legacy codebase -- Prioritizing which statics to wrap first (highest-frequency wins) -- Creating a migration plan for incremental testability improvements - -## Response Guidelines - -- Scale the response to the user's request. A question about a specific category (e.g., "find time statics") should focus on that category with file locations and counts, not produce a full report across all categories. -- When the user provides a specific file or directory path, scan only that scope — do not expand to the entire solution unless asked. -- The full structured report format in Step 4 is for comprehensive audit requests. For focused questions, return only the relevant subset (e.g., category summary + affected files for the requested category). - -## When Not to Use - -- The user wants wrappers generated (hand off to `generate-testability-wrappers`) -- The user wants mechanical migration done (hand off to `migrate-static-to-wrapper`) -- The statics are already behind interfaces or `TimeProvider` -- The code is not C# / .NET - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Target path | Yes | A file, directory, project (.csproj), or solution (.sln) to scan | -| Exclusion patterns | No | Glob patterns to skip (e.g., `**/obj/**`, `**/Migrations/**`) | -| Category filter | No | Limit to specific categories: `time`, `filesystem`, `environment`, `network`, `console`, `process` | - -## Workflow - -### Step 1: Determine scan scope - -Resolve the target to a set of `.cs` files: -- If a `.cs` file, scan that single file. -- If a directory, scan all `.cs` files recursively (excluding `obj/`, `bin/`). -- If a `.csproj`, find its directory and scan `.cs` files within. -- If a `.sln`, parse it, find all project directories, and scan `.cs` files across all projects. - -Always exclude `obj/`, `bin/`, and any user-specified exclusion patterns. - -### Step 2: Search for static dependency patterns - -Scan each file for calls matching these categories: - -| Category | Patterns to search for | Recommended replacement | -|----------|----------------------|------------------------| -| **Time** | `DateTime.Now`, `DateTime.UtcNow`, `DateTime.Today`, `DateTimeOffset.Now`, `DateTimeOffset.UtcNow`, `Task.Delay(`, `new CancellationTokenSource(TimeSpan` | `TimeProvider` (.NET 8+) | -| **File System** | `File.ReadAllText(`, `File.WriteAllText(`, `File.Exists(`, `File.Delete(`, `File.Copy(`, `File.Move(`, `Directory.Exists(`, `Directory.CreateDirectory(`, `Directory.GetFiles(`, `Directory.Delete(`, `Path.Combine(`, `Path.GetTempPath(` | `IFileSystem` (System.IO.Abstractions NuGet) | -| **Environment** | `Environment.GetEnvironmentVariable(`, `Environment.SetEnvironmentVariable(`, `Environment.MachineName`, `Environment.UserName`, `Environment.CurrentDirectory`, `Environment.Exit(` | Custom `IEnvironmentProvider` | -| **Network** | `new HttpClient(`, `HttpClient.GetAsync(`, `HttpClient.PostAsync(`, `HttpClient.SendAsync(` | `IHttpClientFactory` (built-in) | -| **Console** | `Console.WriteLine(`, `Console.ReadLine(`, `Console.Write(`, `Console.ReadKey(` | `IConsole` wrapper or `ILogger` | -| **Process** | `Process.Start(`, `Process.GetCurrentProcess(`, `Process.GetProcessesByName(` | Custom `IProcessRunner` | - -### Step 3: Aggregate and rank results - -Count each static call pattern across the entire scan scope. Produce a summary with: - -1. **Category summary** — total call sites per category (time, filesystem, env, etc.) -2. **Top patterns** — the 10 most frequent individual patterns ranked by count -3. **Most affected files** — files with the highest number of static dependencies -4. **Existing abstractions available** — for each category, note the recommended .NET abstraction: - - Time → `TimeProvider` (built-in since .NET 8) - - File system → `System.IO.Abstractions` (NuGet package) - - HTTP → `IHttpClientFactory` (built-in) - - Environment → custom `IEnvironmentProvider` - - Console → custom `IConsole` or `ILogger` - - Process → custom `IProcessRunner` - -### Step 4: Present the report - -Format the output as a structured report: - -``` -## Static Dependency Report - -**Scope**: -**Files scanned**: -**Total static call sites**: - -### Category Summary -| Category | Call Sites | Recommended Abstraction | -|-------------|-----------|------------------------| -| Time | 42 | TimeProvider (.NET 8+) | -| File System | 31 | System.IO.Abstractions | -| Environment | 12 | IEnvironmentProvider | -| ... | ... | ... | - -### Top 10 Patterns -| # | Pattern | Count | Files | -|---|---------------------|-------|-------| -| 1 | DateTime.UtcNow | 28 | 14 | -| 2 | File.ReadAllText | 18 | 9 | -| ... | - -### Most Affected Files -| File | Static Calls | Categories | -|-------------------------------|-------------|---------------------| -| Services/OrderProcessor.cs | 12 | Time, FileSystem | -| ... | - -### Migration Priority -1. **Time** (42 sites) — Use `TimeProvider`, zero NuGet dependencies on .NET 8+ -2. **File System** (31 sites) — Use `System.IO.Abstractions` NuGet package -3. ... -``` - -### Step 5: Suggest next steps - -Based on the report, recommend: -- Which category to tackle first (fewest dependencies, best built-in support) -- Whether to use `generate-testability-wrappers` for custom wrapper generation -- Whether to use `migrate-static-to-wrapper` for mechanical bulk migration - -## Validation - -- [ ] All `.cs` files in scope were scanned (check count) -- [ ] Report includes category totals, top patterns, and affected files -- [ ] Each detected pattern has a recommended replacement listed -- [ ] `obj/` and `bin/` directories were excluded -- [ ] Migration priority is ordered by impact (count × ease of replacement) - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Scanning `obj/` or generated code | Always exclude `obj/`, `bin/`, and `*.Designer.cs` | -| Counting wrapped calls as statics | Check if the call is behind an interface or injected service before counting | -| Missing statics inside lambdas/LINQ | Search covers all code within `.cs` files, including lambdas | -| Recommending `TimeProvider` on < .NET 8 | Check `TargetFramework` in `.csproj` — if < net8.0, recommend `NodaTime.IClock` or custom `ISystemClock` | -| Ignoring test projects | Only scan production code — exclude `*.Tests.csproj` projects from the scan | diff --git a/.agents/skills/dotnet-aot-compat/SKILL.md b/.agents/skills/dotnet-aot-compat/SKILL.md deleted file mode 100644 index bcfeca1..0000000 --- a/.agents/skills/dotnet-aot-compat/SKILL.md +++ /dev/null @@ -1,269 +0,0 @@ ---- -name: dotnet-aot-compat -description: > - Make .NET projects compatible with Native AOT and trimming by systematically - resolving IL trim/AOT analyzer warnings. USE FOR: making projects AOT-compatible, - fixing trimming warnings, resolving IL warnings (IL2026, IL2070, IL2067, IL2072, - IL3050), adding DynamicallyAccessedMembers annotations, enabling IsAotCompatible. - DO NOT USE FOR: publishing native AOT binaries, optimizing binary size, replacing - reflection-heavy libraries with alternatives. - INVOKES: no tools — pure knowledge skill. -license: MIT ---- - -# dotnet-aot-compat - -Make .NET projects compatible with Native AOT and trimming by systematically resolving all IL trim/AOT analyzer warnings. - -## When to Use This Skill - -- **"Make this project AOT-compatible"** -- **"Fix trimming warnings"** or **"fix IL warnings"** -- **"Resolve IL2070 / IL2067 / IL2072 / IL2026 / IL3050 warnings"** -- **"Add DynamicallyAccessedMembers annotations"** -- **"Enable IsAotCompatible in my .csproj"** -- **"My project has trim analyzer warnings after upgrading to net8.0"** -- **"Annotate reflection code for the trimmer"** - -## When Not to Use This Skill - -Do not use this skill when the project exclusively targets .NET Framework (net4x), which does not support the trim/AOT analyzers. - -## Prerequisites - -An existing .NET project targeting net8.0 or later (or multi-targeting with at least one net8.0+ TFM) and the corresponding .NET SDK installed. - -## Background: What AOT Compatibility Means - -Native AOT and the IL trimmer perform static analysis to determine what code is reachable. Reflection can break this analysis because the trimmer can't see what types/members are accessed at runtime. The `IsAotCompatible` property enables analyzers that flag these issues as build warnings (ILXXXX codes). - -## Critical Rules - -### ❌ Never suppress warnings incorrectly - -- **NEVER** use `#pragma warning disable` for IL warnings. It hides warnings from the Roslyn analyzer at build time, but the IL linker and AOT compiler still see the issue. The code will fail at trim/publish time. -- **NEVER** use `[UnconditionalSuppressMessage]`. It tells both the analyzer AND the linker to ignore the warning, meaning the trimmer cannot verify safety. Raising an error at build time is always preferable to hiding the issue and having it silently break at runtime. - -### 💡 Preferred approaches - -- **Prefer** `[DynamicallyAccessedMembers]` annotations to flow type information through the call chain. -- **Prefer** refactoring to eliminate patterns that break annotation flow (e.g., boxing `Type` through `object[]`). -- **Use** `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` / `[RequiresAssemblyFiles]` to mark methods as fundamentally incompatible with trimming, propagating the requirement to callers. This surfaces the issue clearly rather than hiding it — callers must explicitly acknowledge the incompatibility. - -### Annotation flow is key - -The trimmer tracks `[DynamicallyAccessedMembers]` annotations through assignments, parameter passing, and return values. If this flow is broken (e.g., by boxing a `Type` into `object`, storing in an untyped collection, or casting through interfaces), the trimmer loses track and warns. The fix is to preserve the flow, not suppress the warning. - -## Step-by-Step Procedure - -> **Do not explore the codebase up-front.** The build warnings tell you exactly which files and lines need changes. Follow a tight loop: **build → pick a warning → open that file at that line → apply the fix recipe → rebuild**. Reading or analyzing source files beyond what a specific warning points you to is wasted effort and leads to timeouts. Let the compiler guide you. -> -> ❌ Do NOT run `find`, `ls`, or `grep` to understand the project structure before building. Do NOT read README, docs, or architecture files. Your first action should be Step 1 (enable AOT analysis), then build. - -### Step 1: Enable AOT analysis in the .csproj - -Add `IsAotCompatible`. If the project doesn't exclusively target net8.0+, add a TFM condition (AOT analysis requires net8.0+): - -```xml - - true - -``` - -This automatically sets `EnableTrimAnalyzer=true` and `EnableAotAnalyzer=true` for compatible TFMs. For multi-targeting projects (e.g., `netstandard2.0;net8.0`), the condition ensures no `NETSDK1210` warnings on older TFMs. - -### Step 2: Build and collect warnings - -```bash -dotnet build -f --no-incremental 2>&1 | grep 'IL[0-9]\{4\}' -``` - -Sort and deduplicate. Common warning codes: -- **IL2070**: Reflection call on a `Type` parameter missing `[DynamicallyAccessedMembers]` -- **IL2067**: Passing an unannotated `Type` to a method expecting `[DynamicallyAccessedMembers]` -- **IL2072**: Return value or extracted value missing annotation (often from unboxing) -- **IL2057**: `Type.GetType(string)` with a non-constant argument -- **IL2026**: Calling a method marked `[RequiresUnreferencedCode]` -- **IL2050**: P/invoke method with COM marshalling parameters -- **IL2075**: Return value flows into reflection without annotation -- **IL2091**: Generic argument missing `[DynamicallyAccessedMembers]` required by constraint -- **IL3000**: `Assembly.Location` returns empty string in single-file/AOT apps -- **IL3050**: Calling a method marked `[RequiresDynamicCode]` - -### Step 3: Triage warnings by code (do NOT read every file) - -Group the warnings from Step 2 by warning code and count them. **Do not open individual files yet.** Identify the top 1-2 patterns by count — these drive your fix strategy: - -| Pattern | Typical fix | -|---------|-------------| -| Many IL2026 + IL3050 from `JsonSerializer` | **Go to Strategy C immediately** — create a `JsonSerializerContext`, then batch-update all call sites | -| IL2070/IL2087 on `Type` parameters | Add `[DynamicallyAccessedMembers]` to the innermost method, then cascade outward | -| IL2067 passing unannotated `Type` | Annotate the parameter at the source | - -**In most real projects, IL2026/IL3050 from JsonSerializer dominate.** Start with Strategy C unless the warning breakdown clearly shows otherwise. After the batch JSON fix, handle remaining warnings with Strategies A–B. Only use Strategy D as a last resort. - -### Step 4: Fix warnings iteratively (innermost first) - -Work from the **innermost** reflection call outward. Each fix may cascade new warnings to callers. - -**Stay warning-driven.** For each warning, open only the file and line the compiler reported, identify the pattern, apply the matching fix recipe below, and move on. Do not scan the codebase for similar patterns or try to understand the full architecture — fix what the compiler tells you, rebuild, and let new warnings guide the next change. Fix a small batch of warnings (5-10), then rebuild immediately to check progress. - -**Use sub-agents when available.** If you can launch sub-agents (e.g., via a `task` tool), dispatch **multiple sub-agents in parallel** to edit different files simultaneously. Keep the main loop focused on building, parsing warnings, and dispatching — delegate actual file edits to sub-agents. For batch JSON updates, give each sub-agent 5-10 files to update in one prompt. **After 2 build-fix cycles, dispatch all remaining file edits to sub-agents in parallel — do not continue fixing files sequentially.** Example: - -> Update these files to use source-generated JSON: `src/Models/Resource.Serialization.cs`, `src/Models/Identity.Serialization.cs`, `src/Models/Plan.Serialization.cs`. In each file, replace `JsonSerializer.Serialize(writer, value)` with `JsonSerializer.Serialize(writer, value, MyProjectJsonContext.Default.TypeName)` and `JsonSerializer.Deserialize(ref reader)` with `JsonSerializer.Deserialize(ref reader, MyProjectJsonContext.Default.TypeName)`. Only edit the JsonSerializer call sites. - -#### Strategy A: Add `[DynamicallyAccessedMembers]` (preferred) - -When a method uses reflection on a `Type` parameter, annotate the parameter to tell the trimmer what members are needed: - -```csharp -using System.Diagnostics.CodeAnalysis; - -// Before (warns IL2070): -void Process(Type t) { - var method = t.GetMethod("Foo"); // trimmer can't verify -} - -// After (clean): -void Process([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) { - var method = t.GetMethod("Foo"); // trimmer preserves public methods -} -``` - -When you annotate a parameter, **all callers** must now pass properly annotated types. This cascades outward — follow each caller and annotate or refactor as needed. **The caller's annotation must include at least the same member types as the callee's.** If the callee requires `PublicConstructors | NonPublicConstructors`, the caller must specify the same or a superset — using only `NonPublicConstructors` will produce IL2091. - -#### Strategy B: Refactor to preserve annotation flow - -When annotation flow is broken by boxing (storing `Type` in `object`, `object[]`, or untyped collections), **refactor** to pass the `Type` directly: - -```csharp -// BROKEN: Type boxed into object[], annotation lost -void Process(object[] args) { - Type t = (Type)args[0]; // IL2072: annotation lost through boxing - Evaluate(t, ...); -} - -// FIXED: Pass Type as a separate, annotated parameter -void Process( - object[] args, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type calleeType, - ...) { - Evaluate(calleeType, ...); // annotation flows cleanly -} -``` - -Common patterns that break flow and how to fix them: -- **`object[]` parameter bags**: Extract the `Type` into a dedicated annotated parameter -- **Dictionary/List storage**: Use a typed field with annotation instead -- **Interface indirection**: Add annotation to the interface method's parameter -- **Property with boxing getter**: Annotate the property's return type - -#### Strategy C: Source-generated JSON serialization (batch fix) - -When most warnings are IL2026/IL3050 from `JsonSerializer.Serialize`/`Deserialize`, this is a single mechanical fix applied in bulk: - -1. **Collect affected types** — grep for all `JsonSerializer.Serialize` and `JsonSerializer.Deserialize` call sites. Extract the type being serialized (the `` in `Deserialize`, or the runtime type of the object in `Serialize`). - -2. **Create one `JsonSerializerContext`** with `[JsonSerializable]` for every type found. **Skip types from external packages** (e.g., `ResponseError` from `Azure.Core`) — they won't source-generate for types you don't own. Handle external types separately via Gotcha #1 below. - -```csharp -[JsonSerializerContext] -[JsonSerializable(typeof(ManagedServiceIdentity))] -[JsonSerializable(typeof(SystemData))] -// ... one attribute per type YOU OWN -// Do NOT add types from external packages (e.g., ResponseError) -internal partial class MyProjectJsonContext : JsonSerializerContext { } -``` - -3. **Batch-update all call sites** — do not read each file individually. Apply the pattern mechanically: - - `JsonSerializer.Serialize(obj)` → `JsonSerializer.Serialize(obj, MyProjectJsonContext.Default.TypeName)` - - `JsonSerializer.Deserialize(json)` → `JsonSerializer.Deserialize(json, MyProjectJsonContext.Default.TypeName)` - - Find and update all call sites in one pass: - ```bash - # Find all files with JsonSerializer calls - grep -rl 'JsonSerializer\.\(Serialize\|Deserialize\)' src/ --include='*.cs' - ``` - Then use sequential `edit` calls to apply the same transformation to every matching file. **Do not use `sed` for C# code** — generics like `Deserialize()` have angle brackets and nested parentheses that sed will mangle. - -4. **Build once** to verify. Remaining warnings will be non-serialization issues — handle those with Strategies A–B or D. - -#### Strategy D: `[RequiresUnreferencedCode]` (last resort) - -When a method fundamentally requires arbitrary reflection that cannot be statically described: - -```csharp -[RequiresUnreferencedCode("Loads plugins by name using Assembly.Load")] -public void LoadPlugin(string assemblyName) { - var asm = Assembly.Load(assemblyName); - // ... -} -``` - -This propagates to callers — they must also be annotated with `[RequiresUnreferencedCode]`. Use sparingly; it marks the entire call chain as trim-incompatible. - -### Step 5: Rebuild and repeat - -After each small batch of fixes (5-10 warnings), rebuild with `--no-incremental` and check for new warnings. **Do not attempt to fix all warnings before rebuilding** — frequent rebuilds catch mistakes early and reveal cascading warnings. Fixes cascade — annotating an inner method may surface warnings in its callers. Repeat until `0 Warning(s)`. - -### Step 6: Validate all TFMs - -Build all target frameworks to ensure: -- **0 IL warnings** on net8.0+ TFMs -- **No NETSDK1210 warnings** (the `IsAotCompatible` condition handles this) -- **Clean builds** on older TFMs (netstandard2.0, net472, etc.) - -```bash -dotnet build # builds all TFMs -``` - -## Stop Signals - -- **Do not analyze more than 2-3 representative files per warning pattern.** After identifying the fix for a pattern, apply it to all matching files without reading each one first. -- **Start fixing after one build.** Do not do a second analysis pass — begin implementing fixes for the most common warning pattern immediately after Step 3 triage. -- Stop after achieving **0 IL warnings** for net8.0+ TFMs. Don't optimize or refactor already-clean annotations. -- If a warning requires **architectural refactoring** beyond annotation flow fixes (e.g., replacing an entire serialization layer), document it and stop — don't rewrite large subsystems. -- Limit to **3 build-fix iterations** per warning. If annotation flow doesn't resolve it after 3 attempts, escalate to `[RequiresUnreferencedCode]`. -- Don't chase warnings in **third-party dependencies** you can't modify. Note them and move on. -- If the user asked a scoped question (e.g., "fix warnings in this file"), don't expand to the entire project. - -## Polyfills for Older TFMs - -For multi-targeting projects that include netstandard2.0 or net472, you need polyfills for `DynamicallyAccessedMembersAttribute` and related types. See [references/polyfills.md](references/polyfills.md). - -## Common Gotchas - -1. **External types without AOT-safe serialization**: When a type comes from a dependency you can't modify (e.g., `ResponseError` from `Azure.Core`) and it lacks a source-generated serializer, `Options.GetConverter()` is reflection-based and will produce IL warnings. First check if the type implements `IJsonModel` (common in Azure SDK) — if so, bypass `JsonSerializer` entirely: - -```csharp -// Before (IL2026 — JsonSerializer uses reflection): -JsonSerializer.Serialize(writer, errorValue); - -// After (AOT-safe — uses IJsonModel directly): -((IJsonModel)errorValue).Write(writer, ModelReaderWriterOptions.Json); - -// For deserialization: -var error = ((IJsonModel)new ResponseError()).Create(ref reader, ModelReaderWriterOptions.Json); -``` - -Do **not** add the external type to your `JsonSerializerContext` — it won't source-generate for types you don't own. If the type doesn't implement `IJsonModel`, write a custom `JsonConverter` with manual `Utf8JsonReader`/`Utf8JsonWriter` logic and register it via `[JsonSourceGenerationOptions]` on your context. - -2. **Serialization libraries**: Most reflection-based serializers (e.g., `Newtonsoft.Json`, `XmlSerializer`) are not AOT-compatible. Migrate to a source-generation-based serializer such as `System.Text.Json` with a `JsonSerializerContext`. If migration is not feasible, mark the serialization call site with `[RequiresUnreferencedCode]`. - -3. **Shared projects / projitems**: When source is shared between multiple projects via ``, annotations added to shared code affect ALL consuming projects. Verify that all consumers still build cleanly. - -## References - -[Limitations](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows%2Cnet8#limitations-of-native-aot-deployment) -[Conceptual: Understanding trimming](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-concepts) -[How-to: trim compat](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/fixing-warnings) - -## Checklist - -- [ ] Added `` with TFM condition to .csproj -- [ ] Built with AOT analyzers enabled (net8.0+ TFM) -- [ ] Fixed all IL warnings via annotations or refactoring -- [ ] No `#pragma warning disable` or `[UnconditionalSuppressMessage]` used for any IL warning -- [ ] Polyfills present for older TFMs if needed -- [ ] All target frameworks build with 0 warnings -- [ ] Verified shared/linked source doesn't break sibling projects diff --git a/.agents/skills/dotnet-aot-compat/references/polyfills.md b/.agents/skills/dotnet-aot-compat/references/polyfills.md deleted file mode 100644 index a577f2e..0000000 --- a/.agents/skills/dotnet-aot-compat/references/polyfills.md +++ /dev/null @@ -1,43 +0,0 @@ -# Polyfills for Older TFMs - -`DynamicallyAccessedMembersAttribute` shipped in .NET 5. For projects targeting netstandard2.0 or net472, you need a polyfill. The trimmer recognizes the attribute by name, so a local copy works: - -```csharp -#if !NET -namespace System.Diagnostics.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Field | AttributeTargets.ReturnValue | - AttributeTargets.GenericParameter | AttributeTargets.Parameter | - AttributeTargets.Property, Inherited = false)] - internal sealed class DynamicallyAccessedMembersAttribute : Attribute - { - public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) - => MemberTypes = memberTypes; - public DynamicallyAccessedMemberTypes MemberTypes { get; } - } - - [Flags] - internal enum DynamicallyAccessedMemberTypes - { - None = 0, - PublicParameterlessConstructor = 0x0001, - PublicConstructors = 0x0002 | PublicParameterlessConstructor, - NonPublicConstructors = 0x0004, - PublicMethods = 0x0008, - NonPublicMethods = 0x0010, - PublicFields = 0x0020, - NonPublicFields = 0x0040, - PublicNestedTypes = 0x0080, - NonPublicNestedTypes = 0x0100, - PublicProperties = 0x0200, - NonPublicProperties = 0x0400, - PublicEvents = 0x0800, - NonPublicEvents = 0x1000, - Interfaces = 0x2000, - All = ~None // Discouraged — prefer specific flags - } -} -#endif -``` - -Similarly for `RequiresUnreferencedCodeAttribute` and `UnconditionalSuppressMessageAttribute` if needed on older TFMs. diff --git a/.agents/skills/migrate-nullable-references/SKILL.md b/.agents/skills/migrate-nullable-references/SKILL.md deleted file mode 100644 index e7e77af..0000000 --- a/.agents/skills/migrate-nullable-references/SKILL.md +++ /dev/null @@ -1,291 +0,0 @@ ---- -name: migrate-nullable-references -description: > - Enable nullable reference types in a C# project and systematically resolve all warnings. - USE FOR: adopting NRTs in existing codebases, file-by-file or project-wide migration, - fixing CS8602/CS8618/CS86xx warnings, annotating APIs for nullability, cleaning up - null-forgiving operators, upgrading dependencies with new nullable annotations. - DO NOT USE FOR: projects already fully migrated with zero warnings (unless auditing - suppressions), fixing a handful of nullable warnings in code that already has NRTs enabled, - suppressing warnings without fixing them, C# 7.3 or earlier projects. - INVOKES: Get-NullableReadiness.ps1 scanner script. -license: MIT ---- - -# Nullable Reference Migration - -Enable C# nullable reference types (NRTs) in an existing codebase and systematically resolve all warnings. The outcome is a project (or solution) with `enable`, zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information. - -## When to Use - -- Enabling nullable reference types in an existing C# project or solution -- Systematically resolving CS86xx nullable warnings after enabling the feature -- Annotating a library's public API surface so consumers get accurate nullability information -- Upgrading a dependency that has added nullable annotations and new warnings appear -- Analyzing suppressions in a code base that has already enabled NRTs to determine whether they can be removed - -## When Not to Use - -- The project already has `enable` and zero warnings — the migration is done unless the user wants to re-examine suppressions with a view to removing unnecessary ones (see Step 6) -- The user only wants to suppress warnings without fixing them (recommend against this) -- The code targets C# 7.3 or earlier, which does not support nullable reference types - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Project or solution path | Yes | The `.csproj`, `.sln`, or build entry point to migrate | -| Migration scope | No | `project-wide` (default) or `file-by-file` — controls the rollout strategy | -| Build command | No | How to build the project (e.g., `dotnet build`, `msbuild`, or a repo-specific build script). Detect from the repo if not provided | -| Test command | No | How to run tests (e.g., `dotnet test`, or a repo-specific test script). Detect from the repo if not provided | - -## Workflow - -> 🛑 **Zero runtime behavior changes.** NRT migration is strictly a metadata and annotation exercise. The generated IL must not change — no new branches, no new null checks, no changed control flow, no added or removed method calls. The only acceptable changes are nullable annotations (`?`), nullable attributes (`[NotNullWhen]`, etc.), `!` operators (metadata-only), and `#nullable` directives. If you discover a missing runtime null guard or a latent bug during migration, **do not fix it inline**. Instead, offer to insert a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit. - -> **Commit strategy:** Commit at each logical boundary — after enabling `` (Step 2), after fixing dereference warnings (Step 3), after annotating declarations (Step 4), after applying nullable attributes (Step 5), and after cleaning up suppressions (Step 6). This keeps each commit focused and reviewable, and prevents losing work if a later step reveals a design issue that requires rethinking. For file-by-file migrations, commit each file or batch of related files individually. - -### Step 1: Evaluate readiness - -> **Optional:** Run `scripts/Get-NullableReadiness.ps1 -Path ` to automate the checks below. The script reports ``, ``, ``, `` settings and counts `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions. Use `-Json` for machine-readable output. - -1. Identify how the project is built and tested. Look for build scripts (e.g., `build.cmd`, `build.sh`, `Makefile`), a `.sln` file, or individual `.csproj` files. If the repo uses a custom build script, use it instead of `dotnet build` throughout this workflow. -2. Run `dotnet --version` to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (`.NET Core 3.0` / `.NET Standard 2.1` or later). -3. Open the `.csproj` (or `Directory.Build.props` if properties are set at the repo level) and check the `` and ``. If the project multi-targets, note all TFMs. - -> **Stop if the language version or target framework is insufficient.** If `` is below 8.0, or the project targets a framework that defaults to C# 7.x (e.g., `.NET Framework 4.x` without an explicit ``), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set `8.0` or higher, or retarget to `.NET Core 3.0+` / `.NET 5+`), and ask whether they want to make that update and continue, or abort the migration. Do not silently proceed or assume the update is acceptable. -4. Check whether `` is already set. If it is set to `enable`, skip to Step 5 to audit remaining warnings. -5. Determine the project type — this shapes annotation priorities throughout the migration: - - **Library**: Focus on public API contracts first. Every `?` on a public parameter or return type is a contract change that consumers depend on. Be precise and conservative. - - **Application (web, console, desktop)**: Focus on null safety at boundaries — deserialization, database queries, user input, external API responses. Internal plumbing can be annotated more liberally. - - **Test project**: Lower priority for annotation precision. Use `!` more freely on test setup and assertions where null is never expected. Focus on ensuring test code compiles cleanly. - -### Step 2: Choose a rollout strategy - -Pick one of the following strategies based on codebase size and activity level. Recommend the strategy to the user and confirm before proceeding. - -> **Multi-project solutions:** Migrate in dependency order — shared libraries and core projects first, then projects that consume them. Annotating a dependency first eliminates cascading warnings in its consumers and prevents doing work twice. - -Regardless of strategy, **start at the center and work outward**:begin with core domain models, DTOs, and shared utility types that have few dependencies but are used widely. Annotating these first eliminates cascading warnings across the codebase and gives the biggest return on effort. Then move on to higher-level services, controllers, and UI code that depend on the core types. This approach minimizes the number of warnings at each step and prevents getting overwhelmed by a flood of warnings from a large project-wide enable. Prefer to create at least one PR per project, or per layer, to keep changesets reviewable and focused. If there are relatively few annotations needed, a single project-wide enable and single PR may be appropriate. - -#### Strategy A — Project-wide enable (small to medium projects) - -Best when the project has fewer than roughly 50 source files or the team wants to finish in one pass. - -1. Add `enable` to the `` in the `.csproj`. -2. Build and address all warnings at once. - -#### Strategy B — Warnings-first, then annotations (large or active projects) - -Best when the codebase is large or under active development by multiple contributors. - -1. Add `warnings` to the `.csproj`. This enables warnings without changing type semantics. -2. Build, fix all warnings from Step 3 onward. -3. Change to `enable` to activate annotations — this triggers a second wave of warnings. -4. Resolve the annotation-phase warnings from Step 4 onward. - -#### Strategy C — File-by-file (very large projects) - -Best for large legacy codebases where enabling project-wide would produce an unmanageable number of warnings. - -1. Set `disable` (or omit it) at the project level. -2. Add `#nullable enable` at the top of each file as it is migrated. -3. Prioritize files in dependency order: shared utilities and models first, then higher-level consumers. - -> **Build checkpoint:** After enabling `` (or adding `#nullable enable` to the first batch of files), do a **clean build** (e.g., `dotnet build --no-incremental`, or delete `bin`/`obj` first). Incremental builds only recompile changed files and will hide warnings in untouched files. Record the initial warning count — this is the baseline to work down from. Do not proceed to fixing warnings without first confirming the project still compiles. Use clean builds for all subsequent build checkpoints in this workflow. - -### Step 3: Fix dereference warnings - -> **Prioritization:** Work through files in dependency order — start with core models and shared utilities that other code depends on, then move to higher-level consumers. Within each file, fix public and protected members first (these define the contract), then internal and private members. This order minimizes cascading warnings: fixing a core type's annotations often resolves warnings in its consumers automatically. - -Build the project and work through dereference warnings. These are the most common: - -| Warning | Meaning | Typical fix | -|---------|---------|-------------| -| CS8602 | Dereference of a possibly null reference | Prefer annotation-only fixes: make the upstream type nullable (`T?`) if null is valid, or use `!` if you can verify the value is never null at this point. Adding a null check or `?.` changes runtime behavior — reserve those for a separate commit (see zero-behavior-change rule above) | -| CS8600 | Converting possible null to non-nullable type | Add `?` to the target type if null is valid, or use `!` if you can verify the value is never null. Adding a null guard changes runtime behavior | -| CS8603 | Possible null reference return | Change the return type to nullable (`T?`) if the method can genuinely return null. **Do not suppress with `!` if the method can genuinely return null** — fix the return type instead. This is the single most important rule in NRT migration: a non-nullable return type is a promise to every caller that null will never be returned | -| CS8604 | Possible null reference argument | Mark the parameter as nullable if null is valid, or use `!` if the argument is verifiably non-null. Adding a null check before passing changes runtime behavior | - -> ❌ **Do not use `?.` as a quick fix for dereference warnings.** Replacing `obj.Method()` with `obj?.Method()` silently changes runtime behavior — the call is skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null. - -> ❌ **Do not sprinkle `!` to silence warnings.** Each `!` is a claim that the value is never null. If that claim is wrong, you have hidden a `NullReferenceException`. Add a null check or make the type nullable instead. - -> ❌ **Never use `return null!` to keep a return type non-nullable.** If a method returns `null`, the return type must be `T?`. Writing `return null!` hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and get `NullReferenceException` at runtime. This applies to `null!`, `default!`, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use of `!` on a return value is when the value is **provably never null** but the compiler cannot see why. - -> ⚠️ **Do not add `?` to value types unless you intend to change the runtime type.** For reference types, `?` is metadata-only. For value types (`int`, enums, structs), `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. - -**Decision flowchart for each warning:** - -1. **Is null a valid value here by design?** - - **Yes** → add `?` to the declaration (make it nullable). - - **No** → go to step 2. - - **Unsure** → ask the user before proceeding. -2. **Can you prove the value is never null at this point?** - - **Yes, with a code path the compiler can't see** → add `!` with a comment explaining why. - - **Yes, by adding a guard** → add a null check (`if`, `??`, `is not null`). - - **No** → the type should be nullable (go back to step 1 — the answer is "Yes"). - -Guidance: - -- Prefer explicit null checks (`if`, `is not null`, `??`) over the null-forgiving operator (`!`). -- Use the null-forgiving operator only when you can prove the value is never null but the compiler cannot, and add a comment explaining why. -- Guard clause libraries (e.g., Ardalis.GuardClauses, Dawn.Guard) often decorate parameters with `[NotNull]`, which narrows null state after the guard call. After `Guard.Against.NullOrEmpty(value, nameof(value))`, the compiler already narrows `string?` to `string` — do not add a redundant `!` at the subsequent assignment. Check whether the guard method uses `[NotNull]` before assuming the compiler needs help. -- When a method legitimately returns null, change the return type to `T?` — do not hide nulls behind a non-nullable signature. -- `Debug.Assert(x != null)` acts as a null-state hint to the compiler just like an `if` check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequent `!` operators in that scope. Note: `Debug.Assert` informs the compiler but is stripped from Release builds — it does not protect against null at runtime. For public API boundaries, prefer an explicit null check or `ArgumentNullException`. -- If you find yourself adding `!` at every call site of an internal method, consider making that parameter nullable instead. Reserve `!` for cases where the compiler genuinely cannot prove non-nullness. -- When a boolean-returning helper method's result guarantees a nullable parameter is non-null (e.g., `if (IsValid(x))` implies `x != null`), prefer adding `[NotNullWhen(true)]` to the helper's parameter over using `!` at every call site. This is a metadata-only change (no behavior change) that eliminates `!` operators downstream while giving the compiler real flow information. -- For fields that are always set after construction (e.g., by a framework, an `Init()` method, or a builder pattern), prefer `= null!` on the field declaration over adding `!` at every use site. A field accessed 50 times should have one `= null!`, not fifty `field!` assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair with `[MemberNotNull]` on the initializing method when possible. -- For generic methods returning `default` on an unconstrained type parameter (e.g., `FirstOrDefault`), use `[return: MaybeNull] T` rather than `T?`. Writing `T?` on an unconstrained generic changes value-type signatures to `Nullable`, altering the method signature and binary layout. `[return: MaybeNull]` preserves the original signature while communicating that the return may be null for reference types. -- LINQ's `Where(x => x != null)` does not narrow `T?` to `T` — the compiler cannot track nullability through lambdas passed to generic methods. Use `source.OfType()` to filter nulls with correct type narrowing. - -> **Build checkpoint:** After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings. - -### Step 4: Annotate declarations - -Start by deciding the **intended nullability** of each member based on its design purpose — should this parameter accept null? Can this return value ever be null? Annotate accordingly, then address any resulting warnings. Do not let warnings drive your annotations; that leads to over-annotating with `?` or scattering `!` to silence the compiler. - -> **When to ask the user:** Do not guess API contracts. Never infer nullability intent from usage frequency or naming conventions alone — if intent is not explicit in code or documentation, ask the user. Specifically, ask before: (1) changing a public method's return type to nullable or adding `?` to a public parameter — this changes the API contract consumers depend on; (2) deciding whether a property should be nullable vs. required when the design intent is unclear; (3) choosing between a null check and `!` when you cannot determine from context whether null is a valid state. For internal/private members where the answer is obvious from usage, proceed without asking. - -> ❌ **Do not let warnings drive annotations.** Decide the intended nullability of each member first, then annotate. Adding `?` everywhere to make warnings disappear defeats the purpose — callers must then add unnecessary null checks. Adding `!` everywhere hides bugs. - -> ⚠️ **Return types must reflect semantic nullability, not just compiler satisfaction.** A common mistake is removing `?` from a return type because the implementation uses `default!` or a cast that satisfies the compiler. If the method can return null by design, its return type must be nullable — regardless of whether the compiler warns. Key patterns: -> - Methods named `*OrDefault` (`FirstOrDefault`, `SingleOrDefault`, `FindOrDefault`) → return type must be nullable (`T?`, `object?`, `dynamic?`) because "or default" means "or null" for reference types. -> - `ExecuteScalar` and similar database methods → return type must be `object?` because the result can be `DBNull.Value` or null when no rows match. -> - `Find`, `TryGet*` (out parameter), and lookup methods → return type should be nullable when the item may not exist. -> - Any method documented or designed to return null on failure, not-found, or empty-input → nullable return type. -> -> The compiler cannot catch a *missing* `?` on a return type when the implementation hides null behind `!` or `default!`. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading to `NullReferenceException` at runtime. - -> ⚠️ **Do not remove existing `ArgumentNullException` checks.** A non-nullable parameter annotation is a compile-time hint only — it does not prevent null at runtime. Callers using older C# versions, other .NET languages, reflection, or `!` can still pass null. - -> ⚠️ **Flag public API methods missing runtime null validation — but do not add checks.** While annotating, check each `public` and `protected` method: if a parameter is non-nullable (`T`, not `T?`), there should be a runtime null check (e.g., `ArgumentNullException.ThrowIfNull(param)` or `if (param is null) throw new ArgumentNullException(...)`). Without one, a null passed at runtime causes a `NullReferenceException` deep in the method body instead of a clear `ArgumentNullException` at the entry point. Adding a null guard is a runtime behavior change and must not be part of the NRT migration. Instead, ask the user whether they want a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled. - -> **Methods with defined behavior for null should accept nullable parameters.** If a method handles null input gracefully — returning null, returning a default, or returning a failure result instead of throwing — the parameter should be `T?`, not `T`. The BCL follows this convention: `Path.GetPathRoot(string?)` returns null for null input, while `Path.GetFullPath(string)` throws. Only use a non-nullable parameter when null causes an exception. Marking a parameter as non-nullable when the method actually tolerates null forces callers to add unnecessary null checks before calling. -> -> **Gray areas:** When a parameter is neither validated, sanitized, nor documented for null, consider: (1) Is null ever passed in your own codebase? If yes → nullable. (2) Is null likely used as a "default" or no-op placeholder by callers? If yes → nullable. (3) Do similar methods in the same area accept null? If yes → nullable for consistency. (4) If the method is largely oblivious to null and just happens to work, but null makes no semantic sense for the API's purpose → non-nullable. When in doubt between nullable and non-nullable for a parameter, prefer nullable — it is safer and can be tightened later. - -After dereference warnings are resolved, address annotation warnings: - -| Warning | Meaning | Typical fix | -|---------|---------|-------------| -| CS8618 | Non-nullable field/property not initialized in constructor | Initialize the member, make it nullable (`?`), or use `required` (C# 11+). For fields that are always set after construction but outside the constructor (e.g., by a framework lifecycle method, an `Init()` call, or a builder pattern), use `= null!` to declare intent while keeping the field non-nullable at every use site. If a helper method initializes fields, decorate it with `[MemberNotNull(nameof(field))]` so the compiler knows the field is non-null after the call | -| CS8625 | Cannot convert null literal to non-nullable type | Make the target nullable or provide a non-null value | -| CS8601 | Possible null reference assignment | Same techniques as CS8600 | - -For each type, decide: **should this member ever be null?** - -- **Yes** → add `?` to its declaration. -- **No** → ensure it is initialized in every constructor path, or mark it `required` (C# 11+). -- **No, but it is set after the constructor** (e.g., by a framework method, a builder, or a two-phase init pattern) → use `= null!` on the field declaration. This keeps the field's type non-nullable everywhere it is used, while telling the compiler "I guarantee this will be set before access." This is far preferable to adding `!` at every use site — a field accessed 50 times would need 50 `!` operators instead of one `= null!`. If the initialization is done by a specific method, also consider `[MemberNotNull(nameof(field))]` on that method. - -Focus annotation effort on public and protected APIs first — these define the contract that consumers depend on. Internal and private code can tolerate `!` more liberally since it does not affect external callers. - -> **Public libraries: track breaking changes.** If the project is a library consumed by others, create a `nullable-breaking-changes.md` file (or equivalent) and record every public API change that could affect consumers. While adding `?` to a reference type is metadata-only and not binary-breaking, it IS source-breaking for consumers who have NRTs enabled — they will get new warnings or errors. Key changes to document: -> - Return types changed from `T` to `T?` (consumers must now handle null) -> - Parameters changed from `T?` to `T` (consumers can no longer pass null) -> - Parameters changed from `T` to `T?` (existing null checks in callers become unnecessary — low impact but worth noting) -> - `?` added to a value type parameter or return (changes `T` to `Nullable` — binary-breaking) -> - New `ArgumentNullException` guards added where none existed -> - Any behavioral changes discovered and fixed during annotation (e.g., a method that silently accepted null now throws) -> -> Present this file to the user for review. It may also serve as the basis for release notes. - -Pay special attention to: - -- **DTOs vs domain models**: Apply different nullability strategies depending on the role of the class. **DTOs and serialization models** cross trust boundaries (JSON, forms, external APIs) — their properties should be nullable by default unless enforced by the serializer, because deserialized data can always be null regardless of the declared type. Use `required` (C# 11+), `[JsonRequired]` (.NET 7+), or runtime validation to enforce non-null constraints. **Domain models** represent internal invariants — prefer non-nullable properties with constructor enforcement, making invalid state unrepresentable. This distinction is where migrations most often go wrong: treating a DTO as a domain model leads to runtime `NullReferenceException`; treating a domain model as a DTO leads to unnecessary null checks everywhere. -- **Event handlers and delegates**: The pattern `EventHandler? handler = SomeEvent; handler?.Invoke(...)` is idiomatic. -- **Struct reference-type fields**: Reference-type fields in structs are null when using `default(T)`. If `default` is valid usage for the struct, those fields must be nullable. If `default` is never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks. -- **Post-Dispose state**: If a field or property is non-null for the entire useful lifetime of the object but may become null after `Dispose`, keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case. -- **Overrides and interface implementations**: An override can return a stricter (non-nullable) type than the base method declares. If your implementation never returns null but the base/interface returns `T?`, you can declare the override as returning `T`. Parameter types must match the base exactly. -- **Widely-overridden virtual return types**: For virtual/abstract methods that many classes override, consider whether existing overrides actually return null. If they commonly do (like `Object.ToString()`), annotate the return as `T?` — callers need to know. If null overrides are vanishingly rare (like `Exception.Message`), annotate as `T`. When in doubt for broadly overridden virtuals, prefer `T?`. -- **`IEquatable` and `IComparable`**: Reference types should implement `IEquatable` and `IComparable` (with nullable `T`), because callers commonly pass null to `Equals` and `CompareTo`. -- **`Equals(object?)` overrides**: Add `[NotNullWhen(true)]` to the parameter of `Equals(object? obj)` overrides — if `Equals` returns `true`, the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test. - -> **Build checkpoint:** After annotating declarations, build and confirm zero CS8618/CS8625/CS8601 warnings remain before moving to nullable attributes. - -### Step 5: Apply nullable attributes for advanced scenarios - -When a simple `?` annotation cannot express the null contract, apply attributes from `System.Diagnostics.CodeAnalysis` — see [references/nullable-attributes.md](references/nullable-attributes.md) for the full attribute table (`[NotNullWhen]`, `[MaybeNullWhen]`, `[MemberNotNull]`, `[AllowNull]`, `[DisallowNull]`, `[DoesNotReturn]`, etc.) with usage guidance for each. - -> **Build checkpoint:** After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones. - -### Step 6: Clean up suppressions - -> **Optional:** Re-run `scripts/Get-NullableReadiness.ps1` to get current counts of `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions across the project. - -1. Search for any `#nullable disable` directives or `!` operators that were added as temporary workarounds. -2. For each one, determine whether the suppression is still needed. -3. Remove suppressions that are no longer necessary. For any that remain, add a comment explaining why. -4. Search for `#pragma warning disable CS86` to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead. - -> **Build checkpoint:** After removing suppressions, build again — removing a `#nullable disable` or `!` may surface new warnings that need fixing. - -### Step 7: Validate - -1. Build the project and confirm zero nullable warnings. -2. Add `nullable` to the project file (or `Directory.Build.props` for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent of `dotnet build /warnaserror:nullable`. -3. Run existing tests to confirm no regressions. -4. If the project is a library, inspect the public API surface to verify that nullable annotations match the intended contracts (parameters that accept null are `T?`, parameters that reject null are `T`). - -> **Verify before claiming the migration is complete.** Zero warnings alone does not mean the migration is correct. Before reporting success: (1) spot-check public API signatures — confirm `?` annotations match actual design intent, not just compiler silence; (2) verify no `?.` operators were added that change runtime behavior (search for `?.` in the diff); (3) confirm no `ArgumentNullException` checks were removed; (4) check that `!` operators are rare and each has a justifying comment. - -## Validation - -- [ ] Project file(s) contain `enable` (or `#nullable enable` per-file for file-by-file strategy) -- [ ] Build produces zero CS86xx warnings -- [ ] `nullable` added to project file to prevent regressions -- [ ] Tests pass with no regressions -- [ ] No `#nullable disable` directives remain unless justified with a comment -- [ ] Null-forgiving operators (`!`) are rare, each with a justifying comment -- [ ] Public API signatures accurately reflect null contracts -- [ ] For public libraries: breaking changes documented in `nullable-breaking-changes.md` and reviewed by the user - -### Code review checklist - -Nullable migration changes require broader review than a typical diff: - -1. **Verify no behavior changes**: confirm that `?` and `!` are the only additions — no accidental `?.`, no removed null checks, no new branches. The generated IL should be unchanged except for nullable metadata. -2. **Review explicit annotation changes**: for every `?` added to a parameter or return type, confirm it matches the intended design. Does the method really accept null? Can it really return null? -3. **Review unchanged APIs in scope**: enabling `enable` implicitly makes every unannotated reference type in that scope non-nullable. Scan unchanged public members for parameters that actually do accept null but were not annotated. - -## Breaking Changes from NRT Annotations (Libraries) - -For libraries, see [references/breaking-changes.md](references/breaking-changes.md) — NRT annotations are part of the public API contract and incorrect annotations are source-breaking changes for consumers. - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Sprinkling `!` everywhere to silence warnings | The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead | -| Marking everything `T?` to eliminate warnings quickly | Over-annotating with `?` defeats the purpose — callers must add unnecessary null checks. Only use `?` when null is a valid value | -| Constructor does not initialize all non-nullable members | Initialize fields and properties in every constructor, use `required` (C# 11+), or make the member nullable | -| Serialization bypasses constructors — non-nullable ≠ runtime safety | Serializers create objects without calling constructors, so non-nullable DTO properties can still be null at runtime. See "DTOs vs domain models" in Step 4 for detailed guidance | -| Generated code produces warnings | Generated files are excluded from nullable analysis automatically if they contain `` comments. If warnings persist, add `#nullable disable` at the top of the generated file or configure `.editorconfig` with `generated_code = true` | -| Multi-target projects and older TFMs | NRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like `[NotNullWhen]` may not exist. Use a polyfill package such as `Nullable` from NuGet, or define the attributes internally | -| Warnings reappear after upgrading a dependency | The dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5 | -| Accidentally changing behavior while annotating | Adding `?` to a type or `!` to an expression is metadata-only and does not change generated IL. But replacing `obj.Method()` with `obj?.Method()` (null-conditional) changes runtime behavior — the call is silently skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null, not as a quick fix for a warning | -| Adding `?` to a value type (enum, struct) | For reference types, `?` is a metadata annotation with no runtime effect. For value types like `int` or an enum, `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. Double-check that you are only adding `?` to reference types unless you truly intend to make a value type nullable | -| Removing existing null argument validation | Non-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing `ArgumentNullException` checks. See Step 4 for details | -| `var` infers nullability from the assigned expression | When using `var`, the inferred type includes nullability from the assigned expression, which can be surprising compared to explicitly declaring `T` vs `T?`. Flow analysis determines the actual null-state from that point forward, but the inferred declaration type may carry nullability you did not expect. If precise nullability at the declaration matters, use an explicit type instead of `var` | -| Consuming unannotated (nullable-oblivious) libraries | When a dependency has not opted into nullable annotations, the compiler treats all its types as "oblivious" — you get no warnings for dereferencing or assigning null. This gives a false sense of safety. Treat return values from oblivious APIs as potentially null, especially for methods that could conceptually return null (dictionary lookups, `FirstOrDefault`-style calls). Upgrade dependencies or wrap calls when possible | - -## Entity Framework Core Considerations - -If the project uses EF Core, see [references/ef-core.md](references/ef-core.md) — enabling NRTs can change database schema inference and migration output. - -## ASP.NET Core Considerations - -If the project uses ASP.NET Core, see [references/aspnet-core.md](references/aspnet-core.md) — enabling NRTs can change MVC model validation and JSON serialization behavior. - -## More Info - -- [Nullable reference types](https://learn.microsoft.com/dotnet/csharp/nullable-references) — overview of the feature, nullable contexts, and compiler analysis -- [Nullable reference types (C# reference)](https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/nullable-reference-types) — language reference for nullable annotation and warning contexts -- [Nullable migration strategies](https://learn.microsoft.com/dotnet/csharp/nullable-migration-strategies) -- [Embracing Nullable Reference Types](https://devblogs.microsoft.com/dotnet/embracing-nullable-reference-types/) — Mads Torgersen's guidance on adoption timing and ecosystem considerations -- [Resolve nullable warnings](https://learn.microsoft.com/dotnet/csharp/language-reference/compiler-messages/nullable-warnings) -- [Attributes for nullable static analysis](https://learn.microsoft.com/dotnet/csharp/language-reference/attributes/nullable-analysis) -- [! (null-forgiving) operator](https://learn.microsoft.com/dotnet/csharp/language-reference/operators/null-forgiving) — language reference for the operator and when to use it -- [EF Core and nullable reference types](https://learn.microsoft.com/ef/core/miscellaneous/nullable-reference-types) -- [.NET Runtime nullable annotation guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/api-guidelines/nullability.md) — the annotation principles used when annotating the .NET libraries themselves diff --git a/.agents/skills/migrate-nullable-references/references/aspnet-core.md b/.agents/skills/migrate-nullable-references/references/aspnet-core.md deleted file mode 100644 index ebedcb7..0000000 --- a/.agents/skills/migrate-nullable-references/references/aspnet-core.md +++ /dev/null @@ -1,17 +0,0 @@ -# ASP.NET Core Considerations - -ASP.NET Core reads nullable annotations at runtime to drive model validation and serialization behavior. Enabling NRTs in an ASP.NET Core project can change request validation outcomes, not just compiler warnings: - -- **MVC model validation treats non-nullable properties as `[Required]`**: When NRTs are enabled, ASP.NET Core MVC and Web API implicitly add `[Required(AllowEmptyStrings = true)]` to every non-nullable reference type property in DTOs and view models. A `string Name` property that previously accepted null from JSON or form posts will now return a 400 Bad Request. Review all model classes when enabling NRTs. To disable this behavior during gradual migration, set `SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true` in `AddControllers` options. -- **Minimal API parameter optionality changes with NRTs**: When NRTs are enabled, minimal API parameter binding uses nullable annotations to determine whether a parameter is required or optional. A `string name` parameter that was previously treated as optional (accepting null) becomes required and returns a 400 Bad Request if missing. To preserve the previous behavior, explicitly mark the parameter as nullable (`string? name`). Review all minimal API endpoint parameters when enabling NRTs. See [optional parameters](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/parameter-binding#optional-parameters). -- **Enable `JsonSerializerOptions.RespectNullableAnnotations = true` (.NET 9+)**: For .NET 9+ projects, always enable `RespectNullableAnnotations` (along with `RespectRequiredConstructorParameters`) to align runtime serialization behavior with your NRT annotations. Without this, `System.Text.Json` silently assigns `null` to non-nullable properties, undermining compile-time null safety. When enabled, the serializer throws `JsonException` when a non-nullable property receives an explicit `null` during deserialization, or emits `null` for a non-nullable property during serialization. Be aware this enforcement has hard limitations rooted in how NRTs are represented in IL. It does **not** cover: - - Collection element types (`List` and `List` are indistinguishable via reflection) - - Dictionary value types (`Dictionary` vs `Dictionary`) - - Top-level types passed directly to `Deserialize` - - Generic type parameter nullability - - For these gaps, use manual validation or custom converters. Do not rely on `RespectNullableAnnotations` alone for complete null safety in your JSON layer. -- **Use `#nullable disable`, not `#nullable disable warnings` on model files**: Just as with EF Core, `#nullable disable warnings` only suppresses compiler diagnostics — the annotations remain active and MVC still reads them via reflection to infer `[Required]`. Use `#nullable disable` to fully opt out for files not yet migrated. -- **Razor Pages `[BindProperty]` properties**: Properties decorated with `[BindProperty]` (e.g., `public InputModel Input { get; set; }`) are populated by model binding during POST requests — similar to how EF Core initializes `DbSet` properties. Initialize with `= default!` or suppress CS8618 with a pragma. After a `ModelState.IsValid` check succeeds, sub-properties with `[Required]` can be accessed with the null-forgiving operator (`!`), since validation guarantees they are non-null. -- **Collection properties in ViewModels and DTOs**: Prefer non-nullable with an empty initializer (`= new List()`) over nullable. An empty collection means "no items"; null means "unknown/not loaded." This avoids forcing every consumer to null-check before iterating and matches the EF Core convention for collection navigations. -- **Avoid `?.` followed by `!`**: The pattern `obj?.Property!` is contradictory — `?.` handles the null case by producing null, then `!` immediately asserts the result is non-null. Use either `obj!.Property` (assert non-null, then access) or `obj?.Property` (conditionally access and handle null downstream). The `?.` + `!` combination often appears in Razor Page code-behind when accessing `[BindProperty]` model sub-properties; prefer `obj!.Property` after validation confirms the model is bound. diff --git a/.agents/skills/migrate-nullable-references/references/breaking-changes.md b/.agents/skills/migrate-nullable-references/references/breaking-changes.md deleted file mode 100644 index a8790eb..0000000 --- a/.agents/skills/migrate-nullable-references/references/breaking-changes.md +++ /dev/null @@ -1,8 +0,0 @@ -# Breaking Changes from NRT Annotations (Libraries) - -For libraries consumed by other projects, NRT annotations are part of the public API contract. Incorrect annotations are source-breaking changes for consumers: - -- **Making a parameter non-nullable when it should be nullable**: If consumers previously passed null to a parameter and the method handled it gracefully, marking that parameter as `T` (non-nullable) causes compile warnings or errors for those callers. For example, annotating a logging enricher's `value` parameter as `object` instead of `object?` when the method has always accepted null values would break every caller that passes null. -- **Implicit non-nullability of unannotated types**: Enabling `enable` implicitly makes every unannotated reference-type parameter non-nullable. If the method previously accepted null without throwing, this is a silent contract change. Scan all public methods for parameters that tolerate null. -- **Return types that can be null**: If a method can return null, the return type must be `T?`. Marking it as `T` hides a potential `NullReferenceException` from callers who trust the annotation. -- **Ship annotations in a minor version, not a patch**: Because annotations can cause new warnings for consumers (especially those using `TreatWarningsAsErrors`), treat the NRT migration as a minor version bump, not a patch. Document the change in release notes. diff --git a/.agents/skills/migrate-nullable-references/references/ef-core.md b/.agents/skills/migrate-nullable-references/references/ef-core.md deleted file mode 100644 index 7b04bd8..0000000 --- a/.agents/skills/migrate-nullable-references/references/ef-core.md +++ /dev/null @@ -1,18 +0,0 @@ -# Entity Framework Core Considerations - -EF Core uses nullable annotations to infer database schema. Enabling NRTs in a project that uses EF Core has effects beyond compiler warnings: - -- **Schema changes from annotations**: When NRTs are enabled, EF Core treats `string` properties as required (NOT NULL) columns and `string?` as optional (NULL) columns. If you enable NRTs on an existing model without reviewing every entity property, running `Add-Migration` can generate migrations that make previously nullable columns required — potentially causing data loss if those columns already store nulls. -- **Always review generated migrations**: After enabling NRTs on entity classes, run `Add-Migration` and carefully inspect the output before applying it. Look for unexpected `AlterColumn` calls that change column nullability. -- **Navigation properties**: Required navigation properties present a design choice because they are null until loaded. The official EF Core docs describe three approaches: **(a)** Non-nullable with `= null!` — appropriate when accessing an unloaded navigation is a programmer error; **(b)** Nullable (`public Order? Order { get; set; }`) — appropriate when code legitimately checks whether the navigation is loaded; **(c)** Non-nullable property wrapping a nullable backing field that throws `InvalidOperationException` on uninitialized access — the strictest pattern. Collection navigations should always be non-nullable (initialize to an empty collection, e.g., `= new List()`; an empty collection means no related entities exist, but the list itself should never be null). -- **Migrate entity classes carefully**: Consider annotating entity model classes one at a time rather than enabling NRTs project-wide, to control the scope of schema impact. -- **Use `#nullable disable`, not `#nullable disable warnings` on entity files**: `#nullable disable warnings` only suppresses compiler warnings — the nullable annotations remain active and EF Core still reads them via reflection. This means properties without `?` are still treated as required, potentially altering schema. To fully opt entity files out of NRT effects, use `#nullable disable` which disables both warnings and the annotation context. -- **Private parameterless constructors — always pair `#pragma` disable with restore**: When suppressing CS8618 for a private parameterless constructor required by EF Core, always pair `#pragma warning disable CS8618` with `#pragma warning restore CS8618` immediately after the constructor. Without `restore`, the suppression leaks to all subsequent members in the file — any new property or constructor added later will silently skip the CS8618 check. Example: - ```csharp - #pragma warning disable CS8618 // Required by Entity Framework - private Order() { } - #pragma warning restore CS8618 - ``` - As an alternative, use `= null!` on each non-nullable property instead of a pragma — this is more explicit and does not risk suppression leakage, but is more verbose for entities with many properties. -- **DbSet properties**: Keep `DbSet` properties non-nullable — EF Core always initializes them. EF Core 7.0+ (`.NET 7`) automatically suppresses CS8618 for DbSet properties. On older versions, initialize with `= null!` or use a read-only expression body: `public DbSet Customers => Set();`. -- **LINQ queries with optional navigations**: EF Core translates LINQ queries to SQL, so navigating through an optional relationship in `Where` or `Include` won't cause a `NullReferenceException` at runtime — EF handles the null case server-side. However, the compiler doesn't know this and will warn. Use the null-forgiving operator in these expressions: `.Where(o => o.OptionalNav!.Prop == "foo")` and `.Include(o => o.OptionalNav!).ThenInclude(n => n.Child)`. diff --git a/.agents/skills/migrate-nullable-references/references/nullable-attributes.md b/.agents/skills/migrate-nullable-references/references/nullable-attributes.md deleted file mode 100644 index 3a2e00e..0000000 --- a/.agents/skills/migrate-nullable-references/references/nullable-attributes.md +++ /dev/null @@ -1,19 +0,0 @@ -# Nullable Attributes Reference - -When a simple `?` annotation cannot express the null contract, use attributes from `System.Diagnostics.CodeAnalysis`: - -| Attribute | Use case | -|-----------|----------| -| `[NotNullWhen(true/false)]` | `TryGet` or `IsNullOrEmpty` patterns — the argument is not null when the method returns the specified bool. For `Try` methods with a **non-generic** out parameter, declare the parameter nullable and use `[NotNullWhen(true)] out MyType? result` — it is `null` on failure and non-null on success. Also add to `Equals(object? obj)` overrides to indicate the argument is non-null when returning `true` | -| `[MaybeNullWhen(true/false)]` | For `Try` methods with a **generic** out parameter, keep the parameter non-nullable and use `[MaybeNullWhen(false)] out T result` — the value may be `default` (null for reference types) on failure. Using `[NotNullWhen]` with `T?` here would change value-type signatures to `Nullable` | -| `[NotNull]` | A nullable parameter is guaranteed non-null when the method returns (e.g., a `ThrowIfNull` helper) | -| `[MaybeNull]` | A non-nullable generic return might be `default` (null). Rare in practice — prefer `T?` when possible. Reserve for cases like `AsyncLocal.Value` where `T?` is wrong because setting to null is invalid when `T` is non-nullable | -| `[AllowNull]` | A non-nullable property setter accepts null (e.g., falls back to a default value) | -| `[DisallowNull]` | A nullable property should never be explicitly set to null | -| `[MemberNotNull(nameof(...))]` | A helper method guarantees that specific members are non-null after it returns. When initializing multiple fields, prefer multiple `[MemberNotNull("field1")]` `[MemberNotNull("field2")]` attributes over one `[MemberNotNull("field1", "field2")]` — the `params` overload is not CLS-compliant | -| `[NotNullIfNotNull("paramName")]` | The return is non-null if the named parameter is non-null | -| `[DoesNotReturn]` | The method always throws — code after the call is unreachable | - -Add `using System.Diagnostics.CodeAnalysis;` where needed. - -> **Caution:** The compiler does not warn when nullable attributes are misapplied — for example, `[DisallowNull]` on an already non-nullable parameter or `[MaybeNull]` on a by-value input parameter (not `ref`/`out`) are silently ignored. Verify each attribute is placed where it has an effect. diff --git a/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 b/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 deleted file mode 100644 index ac912f5..0000000 --- a/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 +++ /dev/null @@ -1,487 +0,0 @@ -<# -.SYNOPSIS - Scans a C# project or solution for nullable reference type (NRT) readiness. - -.DESCRIPTION - Reports project-level NRT settings (, , , - ) and source-level counts (#nullable directives, null-forgiving - operators, #pragma warning disable CS86xx) to help assess migration status. - - Automates the manual checks in Steps 1 and 6 of the migrate-nullable-references skill. - -.PARAMETER Path - Path to a .csproj, .sln, or directory. Defaults to the current directory. - -.PARAMETER Json - Output as JSON instead of a human-readable summary. - -.PARAMETER Recurse - When Path is a directory (not a .sln), scan recursively for all .csproj files. - -.EXAMPLE - ./Get-NullableReadiness.ps1 - Scans the current directory for a .sln or .csproj and reports NRT readiness. - -.EXAMPLE - ./Get-NullableReadiness.ps1 -Path ./src/MyLib/MyLib.csproj - Scans a single project. - -.EXAMPLE - ./Get-NullableReadiness.ps1 -Path ./src -Recurse -Json - Scans all projects under ./src and outputs JSON. - -.NOTES - Example output BEFORE NRT migration: - - === NRT Readiness Report === - Project: System.Text.RegularExpressions - Path: src\System.Text.RegularExpressions.csproj - : (not set) - : latest (inherited) - : (not set) - Warning enforcement: all warnings as errors - Source files: 39 - #nullable enable: 1 - #nullable disable: 0 - #pragma CS86xx: 0 - ! operators (approx): 0 - Uninit ref fields: ~322 (estimated CS8618 warnings) - Migration progress: 1/39 files (2.6%) - Migration work needed: - CaptureCollection.cs: ~6 uninit fields - GroupCollection.cs: ~9 uninit fields - .... - === Summary === - Projects scanned: 1 - NRT enabled: 0/1 - Total .cs files: 39 - Total #nullable disable: 0 - Total #pragma CS86xx: 0 - Total ! operators: 0 - Total uninit ref fields: ~322 (estimated CS8618 warnings) - - Example output AFTER NRT migration (same project, all 502 CS86xx warnings resolved): - - === NRT Readiness Report === - Project: System.Text.RegularExpressions - Path: src\System.Text.RegularExpressions.csproj - : enable - : latest (inherited) - : (not set) - Warning enforcement: all warnings as errors - Source files: 39 - #nullable enable: 1 - #nullable disable: 0 - #pragma CS86xx: 0 - ! operators (approx): 192 - null!/default!: 47 - assertions: 145 - Suppression audit (review ! operators for possible removal): - Match.cs: 7 ! - Regex.Cache.cs: 22 ! - Regex.cs: 11 ! - RegexCompiler.cs: 31 ! (24 null!/default!, 7 assertions) - ... - === Summary === - Projects scanned: 1 - NRT enabled: 1/1 - Total .cs files: 39 - Total #nullable disable: 0 - Total #pragma CS86xx: 0 - Total ! operators: 192 - null!/default!: 47 - assertions: 145 -#> - -[CmdletBinding()] -param( - [string]$Path = ".", - [switch]$Json, - [switch]$Recurse -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -#region Helpers - -function Get-ProjectFiles { - param([string]$InputPath, [switch]$Recurse) - - $resolved = Resolve-Path $InputPath -ErrorAction Stop - - if (Test-Path $resolved -PathType Leaf) { - $ext = [System.IO.Path]::GetExtension($resolved) - if ($ext -eq ".csproj") { - return @($resolved.Path) - } - if ($ext -eq ".sln") { - return Get-ProjectsFromSolution $resolved.Path - } - Write-Error "Unsupported file type: $ext. Provide a .csproj, .sln, or directory." - } - - # Directory - if ($Recurse) { - $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -Recurse | Select-Object -ExpandProperty FullName - } else { - # Look for .sln first, then .csproj in the directory - $sln = Get-ChildItem -Path $resolved -Filter "*.sln" -File | Select-Object -First 1 - if ($sln) { - return Get-ProjectsFromSolution $sln.FullName - } - $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -File | Select-Object -ExpandProperty FullName - } - - if (-not $projects -or $projects.Count -eq 0) { - Write-Error "No .csproj files found in '$resolved'." - } - return $projects -} - -function Get-ProjectsFromSolution { - param([string]$SlnPath) - - $slnDir = Split-Path $SlnPath -Parent - $projects = @() - foreach ($line in Get-Content $SlnPath) { - if ($line -match 'Project\("[^"]*"\)\s*=\s*"[^"]*"\s*,\s*"([^"]*\.csproj)"') { - $relPath = $Matches[1] -replace '\\', [System.IO.Path]::DirectorySeparatorChar - $fullPath = Join-Path $slnDir $relPath - if (Test-Path $fullPath) { - $projects += (Resolve-Path $fullPath).Path - } - } - } - return $projects -} - -function Read-ProjectSettings { - param([string]$CsprojPath) - - $xml = [xml](Get-Content $CsprojPath -Raw) - $ns = $xml.DocumentElement.NamespaceURI - - # Check for Directory.Build.props in parent directories - $propsSettings = Find-DirectoryBuildProps (Split-Path $CsprojPath -Parent) - - $nullable = Select-XmlValue $xml "//Nullable" $ns - $langVersion = Select-XmlValue $xml "//LangVersion" $ns - $tfm = Select-XmlValue $xml "//TargetFramework" $ns - $tfms = Select-XmlValue $xml "//TargetFrameworks" $ns - $warningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns - $treatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns - - # Fall back to Directory.Build.props values - if (-not $nullable -and $propsSettings.Nullable) { $nullable = $propsSettings.Nullable + " (inherited)" } - if (-not $langVersion -and $propsSettings.LangVersion) { $langVersion = $propsSettings.LangVersion + " (inherited)" } - if (-not $warningsAsErrors -and $propsSettings.WarningsAsErrors) { $warningsAsErrors = $propsSettings.WarningsAsErrors + " (inherited)" } - if (-not $treatWarningsAsErrors -and $propsSettings.TreatWarningsAsErrors) { $treatWarningsAsErrors = $propsSettings.TreatWarningsAsErrors + " (inherited)" } - - $framework = if ($tfms) { $tfms } elseif ($tfm) { $tfm } else { "(not set)" } - - $warningEnforcement = "none" - if ($treatWarningsAsErrors -and $treatWarningsAsErrors -match "true") { - $warningEnforcement = "all warnings as errors" - } elseif ($warningsAsErrors -and $warningsAsErrors -match "nullable") { - $warningEnforcement = "nullable warnings as errors" - } - - return [PSCustomObject]@{ - Nullable = if ($nullable) { $nullable } else { "(not set)" } - LangVersion = if ($langVersion) { $langVersion } else { "(not set)" } - TargetFramework = $framework - WarningEnforcement = $warningEnforcement - } -} - -function Select-XmlValue { - param($Xml, [string]$XPath, [string]$Namespace) - - if ($Namespace) { - $nsmgr = New-Object System.Xml.XmlNamespaceManager($Xml.NameTable) - $nsmgr.AddNamespace("ns", $Namespace) - $nsXPath = $XPath -replace '//', '//ns:' -replace '/ns:ns:', '/ns:' - $node = $Xml.SelectSingleNode($nsXPath, $nsmgr) - } else { - $node = $Xml.SelectSingleNode($XPath) - } - - if ($node) { return $node.InnerText.Trim() } - return $null -} - -function Find-DirectoryBuildProps { - param([string]$StartDir) - - $result = [PSCustomObject]@{ - Nullable = $null - LangVersion = $null - WarningsAsErrors = $null - TreatWarningsAsErrors = $null - } - - $dir = $StartDir - while ($dir) { - $propsPath = Join-Path $dir "Directory.Build.props" - if (Test-Path $propsPath) { - $xml = [xml](Get-Content $propsPath -Raw) - $ns = $xml.DocumentElement.NamespaceURI - if (-not $result.Nullable) { $result.Nullable = Select-XmlValue $xml "//Nullable" $ns } - if (-not $result.LangVersion) { $result.LangVersion = Select-XmlValue $xml "//LangVersion" $ns } - if (-not $result.WarningsAsErrors) { $result.WarningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns } - if (-not $result.TreatWarningsAsErrors) { $result.TreatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns } - } - $parent = Split-Path $dir -Parent - if ($parent -eq $dir) { break } - $dir = $parent - } - - return $result -} - -function Scan-SourceFiles { - param([string]$CsprojPath) - - $projectDir = Split-Path $CsprojPath -Parent - $csFiles = @(Get-ChildItem -Path $projectDir -Filter "*.cs" -Recurse -File | - Where-Object { $_.FullName -notmatch '[\\/](obj|bin)[\\/]' }) - - $totalFiles = $csFiles.Count - $filesWithNullableEnable = 0 - $totalNullableDisable = 0 - $totalNullableEnable = 0 - $totalPragmaDisable = 0 - $totalBangOperator = 0 - $totalBangNullInit = 0 - $totalBangAssertions = 0 - $totalUninitFields = 0 - $fileDetails = @() - - foreach ($file in $csFiles) { - $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue - if (-not $content) { continue } - - $lines = $content -split '\r?\n' - - $nullableDisable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+disable' }).Count - $nullableEnable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+enable' }).Count - $pragmaDisable = @($lines | Where-Object { $_ -match '#pragma\s+warning\s+disable\s+CS86' }).Count - - # Count null-forgiving operators (approximate). - # Strip string literals before comments to avoid false positives — a string - # like "http://..." contains // that would otherwise be mis-parsed as a comment. - # Then match ! preceded by ), ], >, or a word character, not followed by =. - $strippedContent = $content - $strippedContent = [regex]::Replace($strippedContent, '(?\w])!(?!=)') - $bangCount = $bangMatches.Count - - # Categorize: null! initializers (= null!, => null!, default!) vs other assertions - $nullInitCount = ([regex]::Matches($strippedContent, '(?:=\s*null!|=>\s*null!|default!)')).Count - $bangAssertionCount = $bangCount - $nullInitCount - - # Estimate uninitialised reference-type fields and auto-properties (approximate CS8618 predictor). - # Matches field declarations ending in ; without an = initializer, and auto-properties - # without initializers, excluding value types and events. - $valueTypes = 'bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|nint|nuint|void|IntPtr|UIntPtr|Guid|DateTime|DateTimeOffset|TimeSpan|CancellationToken' - $uninitFields = @($lines | Where-Object { - ( - # Field declarations: type name; - ($_ -match '^\s*(private|protected|internal|public|static|readonly|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*;') -or - # Auto-properties: type Name { get; set; } or { get; } - ($_ -match '^\s*(private|protected|internal|public|static|virtual|override|abstract|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*\{\s*get;') - ) -and - $_ -notmatch '=' -and - $_ -notmatch '\brequired\b' -and - $_ -notmatch "^\s*(private|protected|internal|public|static|readonly|virtual|override|abstract|\s)+\s+($valueTypes)\b" -and - $_ -notmatch '^\s*(private|protected|internal|public|static|readonly|\s)+\s*(const|event)\b' - }).Count - - if ($nullableEnable -gt 0) { $filesWithNullableEnable++ } - $totalNullableDisable += $nullableDisable - $totalNullableEnable += $nullableEnable - $totalPragmaDisable += $pragmaDisable - $totalBangOperator += $bangCount - $totalBangNullInit += $nullInitCount - $totalBangAssertions += $bangAssertionCount - $totalUninitFields += $uninitFields - - $relativePath = $file.FullName.Substring($projectDir.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar) - - if ($nullableDisable -gt 0 -or $pragmaDisable -gt 0 -or $bangCount -gt 5 -or $uninitFields -gt 5) { - $fileDetails += [PSCustomObject]@{ - File = $relativePath - NullableDisable = $nullableDisable - PragmaDisable = $pragmaDisable - BangOperators = $bangCount - BangNullInit = $nullInitCount - BangAssertions = $bangAssertionCount - UninitFields = $uninitFields - } - } - } - - return [PSCustomObject]@{ - TotalFiles = $totalFiles - FilesWithEnable = $filesWithNullableEnable - NullableDisableCount = $totalNullableDisable - NullableEnableCount = $totalNullableEnable - PragmaDisableCount = $totalPragmaDisable - BangOperatorCount = $totalBangOperator - BangNullInitCount = $totalBangNullInit - BangAssertionCount = $totalBangAssertions - UninitFieldCount = $totalUninitFields - FilesOfInterest = $fileDetails - } -} - -#endregion - -#region Main - -$projectFiles = Get-ProjectFiles -InputPath $Path -Recurse:$Recurse - -$results = @() - -foreach ($proj in $projectFiles) { - $projName = [System.IO.Path]::GetFileNameWithoutExtension($proj) - - Write-Verbose "Scanning $projName..." - - $settings = Read-ProjectSettings $proj - $sourceStats = Scan-SourceFiles $proj - - $results += [PSCustomObject]@{ - Project = $projName - Path = $proj - Nullable = $settings.Nullable - LangVersion = $settings.LangVersion - TargetFramework = $settings.TargetFramework - WarningEnforcement = $settings.WarningEnforcement - TotalCsFiles = $sourceStats.TotalFiles - FilesWithEnable = $sourceStats.FilesWithEnable - NullableDisable = $sourceStats.NullableDisableCount - NullableEnable = $sourceStats.NullableEnableCount - PragmaDisableCS86 = $sourceStats.PragmaDisableCount - BangOperators = $sourceStats.BangOperatorCount - BangNullInit = $sourceStats.BangNullInitCount - BangAssertions = $sourceStats.BangAssertionCount - UninitFields = $sourceStats.UninitFieldCount - FilesOfInterest = $sourceStats.FilesOfInterest - } -} - -if ($Json) { - $results | ConvertTo-Json -Depth 4 - return -} - -# Human-readable output -Write-Host "" -Write-Host "=== NRT Readiness Report ===" -ForegroundColor Cyan -Write-Host "" - -foreach ($r in $results) { - Write-Host "Project: $($r.Project)" -ForegroundColor Yellow - Write-Host " Path: $($r.Path)" - Write-Host " : $($r.Nullable)" - Write-Host " : $($r.LangVersion)" - Write-Host " : $($r.TargetFramework)" - Write-Host " Warning enforcement: $($r.WarningEnforcement)" - Write-Host "" - Write-Host " Source files: $($r.TotalCsFiles)" - Write-Host " #nullable enable: $($r.NullableEnable)" - Write-Host " #nullable disable: $($r.NullableDisable)" - Write-Host " #pragma CS86xx: $($r.PragmaDisableCS86)" - Write-Host " ! operators (approx): $($r.BangOperators)" - if ($r.BangOperators -gt 0) { - Write-Host " null!/default!: $($r.BangNullInit)" - Write-Host " assertions: $($r.BangAssertions)" - } - - if ($r.UninitFields -gt 0 -and $r.Nullable -notmatch "enable") { - Write-Host " Uninit ref fields: ~$($r.UninitFields) (estimated CS8618 warnings)" -ForegroundColor DarkYellow - } - - if ($r.FilesWithEnable -gt 0 -and $r.Nullable -notmatch "enable") { - $pct = [math]::Round(($r.FilesWithEnable / $r.TotalCsFiles) * 100, 1) - Write-Host " Migration progress: $($r.FilesWithEnable)/$($r.TotalCsFiles) files ($pct%)" -ForegroundColor Green - } - - # Per-file details — context-dependent heading and content - $nrtEnabled = $r.Nullable -match "enable" - $interestFiles = @($r.FilesOfInterest) - - # Filter to files with displayable parts - $displayFiles = @() - foreach ($f in $interestFiles) { - $parts = @() - if ($f.NullableDisable -gt 0) { $parts += "$($f.NullableDisable) #nullable disable" } - if ($f.PragmaDisable -gt 0) { $parts += "$($f.PragmaDisable) #pragma" } - if ($f.BangOperators -gt 5) { - $bangDetail = "$($f.BangOperators) !" - if ($f.BangNullInit -gt 0) { - $bangDetail += " ($($f.BangNullInit) null!/default!, $($f.BangAssertions) assertions)" - } - $parts += $bangDetail - } - if (-not $nrtEnabled -and $f.UninitFields -gt 5) { $parts += "~$($f.UninitFields) uninit fields" } - if ($parts.Count -gt 0) { - $displayFiles += [PSCustomObject]@{ File = $f.File; Detail = ($parts -join ', ') } - } - } - - if ($displayFiles.Count -gt 0) { - Write-Host "" - if (-not $nrtEnabled) { - Write-Host " Migration work needed:" -ForegroundColor Magenta - } elseif ($r.NullableDisable -gt 0 -or $r.PragmaDisableCS86 -gt 0) { - Write-Host " Remaining cleanup:" -ForegroundColor Magenta - } else { - Write-Host " Suppression audit (review ! operators for possible removal):" -ForegroundColor DarkYellow - } - foreach ($df in $displayFiles) { - Write-Host " $($df.File): $($df.Detail)" - } - } - - Write-Host "" -} - -# Summary -if (@($results).Count -gt 1) { - $total = [PSCustomObject]@{ - Projects = @($results).Count - CsFiles = ($results | Measure-Object -Property TotalCsFiles -Sum).Sum - NullDisable = ($results | Measure-Object -Property NullableDisable -Sum).Sum - PragmaCS86 = ($results | Measure-Object -Property PragmaDisableCS86 -Sum).Sum - BangOps = ($results | Measure-Object -Property BangOperators -Sum).Sum - BangNullInit = ($results | Measure-Object -Property BangNullInit -Sum).Sum - BangAssert = ($results | Measure-Object -Property BangAssertions -Sum).Sum - UninitFields = ($results | Measure-Object -Property UninitFields -Sum).Sum - NrtEnabled = @($results | Where-Object { $_.Nullable -match "enable" }).Count - } - - Write-Host "=== Summary ===" -ForegroundColor Cyan - Write-Host " Projects scanned: $($total.Projects)" - Write-Host " NRT enabled: $($total.NrtEnabled)/$($total.Projects)" - Write-Host " Total .cs files: $($total.CsFiles)" - Write-Host " Total #nullable disable: $($total.NullDisable)" - Write-Host " Total #pragma CS86xx: $($total.PragmaCS86)" - Write-Host " Total ! operators: $($total.BangOps)" - if ($total.BangOps -gt 0) { - Write-Host " null!/default!: $($total.BangNullInit)" - Write-Host " assertions: $($total.BangAssert)" - } - if ($total.UninitFields -gt 0) { - Write-Host " Total uninit ref fields: ~$($total.UninitFields) (estimated CS8618 warnings)" - } - Write-Host "" -} - -#endregion diff --git a/.agents/skills/msbuild-antipatterns/SKILL.md b/.agents/skills/msbuild-antipatterns/SKILL.md deleted file mode 100644 index 3daa077..0000000 --- a/.agents/skills/msbuild-antipatterns/SKILL.md +++ /dev/null @@ -1,409 +0,0 @@ ---- -name: msbuild-antipatterns -description: "Detect and fix MSBuild anti-patterns in project and build files. USE WHEN asked to review, audit, lint, clean up, or code-review a .csproj/.vbproj/.fsproj/.props/.targets/.proj (or Directory.Build.props/.targets) file, when asked 'is this project file correct?' or 'what's wrong with my build file?', or when hunting subtle build bugs caused by how a project is authored. Each anti-pattern has a symptom and a concrete BAD→GOOD fix. DO NOT USE FOR: non-MSBuild build systems (npm, Maven, CMake), or migrating a project to SDK-style (use msbuild-modernization)." -license: MIT ---- - -# MSBuild Anti-Pattern Catalog - -A numbered catalog of common MSBuild anti-patterns. Each entry follows the format: - -- **Smell**: What to look for -- **Why it's bad**: Impact on builds, maintainability, or correctness -- **Fix**: Concrete transformation - -Use this catalog when scanning project files for improvements. - ---- - -## AP-01: `` for Operations That Have Built-in Tasks - -**Smell**: ``, ``, `` - -**Why it's bad**: Built-in tasks are cross-platform, support incremental build, emit structured logging, and handle errors consistently. `` is opaque to MSBuild. - -```xml - - - - - - - - - - - - - -``` - -**Built-in task alternatives:** - -| Shell Command | MSBuild Task | -|--------------|--------------| -| `mkdir` | `` | -| `copy` / `cp` | `` | -| `del` / `rm` | `` | -| `move` / `mv` | `` | -| `echo text > file` | `` | -| `touch` | `` | -| `xcopy /s` | `` with item globs | - ---- - -## AP-02: Unquoted Condition Expressions - -**Smell**: `Condition="$(Foo) == Bar"` — either side of a comparison is unquoted. - -**Why it's bad**: If the property is empty or contains spaces/special characters, the condition evaluates incorrectly or throws a parse error. MSBuild requires single-quoted strings for reliable comparisons. - -```xml - - - true - - - - - true - -``` - -**Rule**: Always quote **both** sides of `==` and `!=` comparisons with single quotes. - ---- - -## AP-03: Hardcoded Absolute Paths - -**Smell**: Paths like `C:\tools\`, `D:\packages\`, `/usr/local/bin/` in project files. - -**Why it's bad**: Breaks on other machines, CI environments, and other operating systems. Not relocatable. - -```xml - - - C:\tools\mytool\mytool.exe - - - - - - $(MSBuildThisFileDirectory)tools\mytool\mytool.exe - - -``` - -**Preferred path properties:** - -| Property | Meaning | -|----------|---------| -| `$(MSBuildThisFileDirectory)` | Directory of the current .props/.targets file | -| `$(MSBuildProjectDirectory)` | Directory of the .csproj | -| `$([MSBuild]::GetDirectoryNameOfFileAbove(...))` | Walk up to find a marker file | -| `$([MSBuild]::NormalizePath(...))` | Combine and normalize path segments | - ---- - -## AP-04: Restating SDK Defaults - -**Smell**: Properties set to values that the .NET SDK already provides by default. - -**Why it's bad**: Adds noise, hides intentional overrides, and makes it harder to identify what's actually customized. When defaults change in newer SDKs, the redundant properties may silently pin old behavior. - -```xml - - - Library - true - true - MyLib - MyLib - true - - - - - net8.0 - -``` - ---- - -## AP-05: Manual File Listing in SDK-Style Projects - -**Smell**: ``, `` in SDK-style projects. - -**Why it's bad**: SDK-style projects automatically glob `**/*.cs` (and other file types). Explicit listing is redundant, creates merge conflicts, and new files may be accidentally missed if not added to the list. - -```xml - - - - - - - - - - - -``` - -**Exception**: Non-SDK-style (legacy) projects require explicit file includes. If migrating, see `msbuild-modernization` skill. - -**Exception (F# / `.fsproj`)**: F# compilation is order-dependent — the compiler processes `` items sequentially and a file can only reference types/modules declared in files listed above it. `.fsproj` files must therefore list every source file explicitly, in dependency order (utility/leaf modules at the top, the entry point such as `Program.fs` at the bottom). If a `.fsi` signature file is used, it must appear **immediately before** its companion `.fs` implementation file. - ---- - -## AP-06: Using `` with HintPath for NuGet Packages - -**Smell**: `` - -**Why it's bad**: This is the legacy `packages.config` pattern. It doesn't support transitive dependencies, version conflict resolution, or automatic restore. The `packages/` folder must be committed or restored separately. - -```xml - - - - ..\packages\Newtonsoft.Json.13.0.3\lib\netstandard2.0\Newtonsoft.Json.dll - - - - - - - -``` - -**Note**: `` without HintPath is still valid for .NET Framework GAC assemblies like `WindowsBase`, `PresentationCore`, etc. - ---- - -## AP-07: Missing `PrivateAssets="all"` on Analyzer/Tool Packages - -**Smell**: `` without `PrivateAssets="all"`. - -**Why it's bad**: Without `PrivateAssets="all"`, analyzer and build-tool packages flow as transitive dependencies to consumers of your library. Consumers get unwanted analyzers or build-time tools they didn't ask for. - -See [`references/private-assets.md`](references/private-assets.md) for BAD/GOOD examples and the full list of packages that need this. - ---- - -## AP-08: Copy-Pasted Properties Across Multiple .csproj Files - -**Smell**: The same `` block appears in 3+ project files. - -**Why it's bad**: Maintenance burden — a change must be made in every file. Inconsistencies creep in over time. - -```xml - - - - enable - true - enable - - - - - - - enable - true - enable - - -``` - -See `directory-build-organization` skill for full guidance on structuring `Directory.Build.props` / `Directory.Build.targets`. - ---- - -## AP-09: Scattered Package Versions Without Central Package Management - -**Smell**: `` with different versions of the same package across projects. - -**Why it's bad**: Version drift — different projects use different versions of the same package, leading to runtime mismatches, unexpected behavior, or diamond dependency conflicts. - -```xml - - - - - -``` - -**Fix:** Use Central Package Management. See [https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) for details. - ---- - -## AP-10: Monolithic Targets (Too Much in One Target) - -**Smell**: A single `` with 50+ lines doing multiple unrelated things. - -**Why it's bad**: Can't skip individual steps via incremental build, hard to debug, hard to extend, and the target name becomes meaningless. - -```xml - - - - - - - - - - - - - - - - - - - - - - -``` - ---- - -## AP-11: Custom Targets Missing `Inputs` and `Outputs` - -**Smell**: `` with no `Inputs` / `Outputs` attributes. - -**Why it's bad**: The target runs on every build, even when nothing changed. This defeats incremental build and slows down no-op builds. - -See [`references/incremental-build-inputs-outputs.md`](references/incremental-build-inputs-outputs.md) for BAD/GOOD examples and the full pattern including FileWrites registration. - -See `incremental-build` skill for deep guidance on Inputs/Outputs, FileWrites, and up-to-date checks. - ---- - -## AP-12: Setting Defaults in .targets Instead of .props - -**Smell**: `` with default values inside a `.targets` file. - -**Why it's bad**: `.targets` files are imported late (after project files). By the time they set defaults, other `.targets` files may have already used the empty/undefined value. `.props` files are imported early and are the correct place for defaults. - -```xml - - - 2.0 - - - - - - - - - 2.0 - - - - - - -``` - -**Rule**: `.props` = defaults and settings (evaluated early). `.targets` = build logic and targets (evaluated late). - ---- - -## AP-13: Import Without `Exists()` Guard - -**Smell**: `` without a `Condition="Exists('...')"` check. - -**Why it's bad**: If the file doesn't exist (not yet created, wrong path, deleted), the build fails with a confusing error. Optional imports should always be guarded. - -```xml - - - - - - - - -``` - -**Exception — required imports**: Imports that are *required* for the build to work correctly should fail fast — don't guard those. Guard imports that are optional or environment-specific (e.g., local developer overrides, CI-specific settings). - -**Exception — NuGet package forwarders**: `.props`/`.targets` files inside a NuGet package's per-TFM `build/` or `buildTransitive/` folder routinely import a sibling file under `buildTransitive//…` without an `Exists()` guard. These are a **package contract**: the target file is guaranteed to be present in the restored package, even if it doesn't appear in the source tree at that relative path. The package layout is typically produced by: - -- A custom `.nuspec` with per-TFM `` entries — e.g. `` — that copy files from a single source folder (such as `buildTransitive/common/`) into per-TFM subfolders at pack time, or -- `` / `` items in the `.csproj` with a per-TFM `` (e.g. `buildTransitive/net8.0/`), declared once per target TFM, or -- SDK conventions (e.g. `IncludeBuildOutput`, `BuildOutputTargetFolder`) that place built outputs under `build//`. - -Before flagging an unguarded `` inside a `build/` or `buildTransitive/` folder, **resolve it against the packed layout** — read every `*.nuspec` in the project directory **and its immediate parent directory** (shared nuspecs are common in mono-repos; do not walk further up), and any `` metadata on ``/`` items in the `.csproj`. Only flag if the target path is missing from **both** the source tree *and* the projected package layout. The `dotnet-msbuild/extension-points` skill — *Source tree vs packed layout* — documents the full cross-check procedure. - -**Forwarding `buildTransitive/` → `build/`:** forward through the sibling `build/*.props` / `build/*.targets` file (not directly to `buildMultiTargeting/`); when `build/` is per-TFM (`build//`), include the TFM segment derived from the file's own folder (not `$(TargetFramework)`), or transitive consumers hit `MSB4019`. See the `extension-points` skill — *Forwarding chain* — for the rule and derivation expression. - ---- - -## AP-14: Backslashes in Paths — Where It Matters - -**Smell**: Backslash path separators in `.props`/`.targets` files meant to run cross-platform. - -**Where this is a real bug (🔴 Error)** — paths that MSBuild does **not** route through its path normalizer: - -- Raw shell strings inside `` — passed verbatim to `bash`/`sh` on Unix, which treats `\` as an escape. -- Backslash-delimited paths inside CDATA blocks, embedded in source files written by ``, or constructed for non-MSBuild consumers (custom scripts, response files, environment variables). -- Paths handed to custom tasks that call OS file APIs directly without going through MSBuild path utilities. - -**Where this is only a style preference (🔵 Style)** — paths that go through MSBuild's evaluator (``, file-path properties consumed by built-in tasks like ``/``/``, item `Include=`/`Exclude=` globs): - -MSBuild's evaluator normalizes `\` → `/` on Unix-like systems before resolving the path. See `FileUtilities.MaybeAdjustFilePath` and `ConvertToUnixSlashes` in [`microsoft/msbuild` `src/Framework/FileUtilities.cs`](https://github.com/dotnet/msbuild/blob/main/src/Framework/FileUtilities.cs). So `` resolves correctly on Linux/macOS today. Forward slashes are still **preferred for consistency**, but the import will not break and existing backslash-style imports should not be flagged as 🔴 **Error**. - -```xml - - - - - - - - -``` - -**Verification rule**: Before flagging a backslash path as 🔴 **Error**, ask *"does this string flow through MSBuild's evaluator, or is it handed verbatim to a non-MSBuild consumer?"* Only the second case is a correctness defect. - -**Note**: `$(MSBuildThisFileDirectory)` already ends with a platform-appropriate separator, so `$(MSBuildThisFileDirectory)tools/mytool` works on both platforms. - ---- - -## AP-15: Unconditional Property Override in Multiple Scopes - -**Smell**: A property set unconditionally in both `Directory.Build.props` and a `.csproj` — last write wins silently. - -**Why it's bad**: Hard to trace which value is actually used. Makes the build fragile and confusing for anyone reading the project files. - -```xml - - - - bin\custom\ - - - - bin\other\ - - - - - - bin\custom\ - - -``` - ---- - -For additional anti-patterns (AP-16 through AP-23) and a quick-reference checklist, see [additional-antipatterns.md](references/additional-antipatterns.md). diff --git a/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md b/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md deleted file mode 100644 index 9ad2e8e..0000000 --- a/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md +++ /dev/null @@ -1,315 +0,0 @@ -## AP-16: Using `` for String/Path Operations - -**Smell**: `` or `` for simple string manipulation. - -**Why it's bad**: Shell-dependent, not cross-platform, slower than property functions, and the result is hard to capture back into MSBuild properties. - -```xml - - - - - - - - $(Version.Replace('-preview', '')) - $(Version.Contains('-')) - $(AssemblyName.ToLowerInvariant()) - - - - - $([MSBuild]::NormalizeDirectory($(OutputPath))) - $([System.IO.Path]::Combine($(MSBuildThisFileDirectory), 'tools', 'mytool.exe')) - -``` - ---- - -## AP-17: Mixing `Include` and `Update` for the Same Item Type in One ItemGroup - -**Smell**: Same `` has both `` and ``. - -**Why it's bad**: `Update` acts on items already in the set. If `Include` hasn't been processed yet (evaluation order), `Update` may not find the item. Separating them avoids subtle ordering bugs. - -```xml - - - - - - - - - - - - - -``` - ---- - -## AP-18: Redundant `` to Transitively-Referenced Projects - -**Smell**: A project references both `Core` and `Utils`, but `Core` already depends on `Utils`. - -**Why it's bad**: Adds unnecessary coupling, makes the dependency graph harder to understand, and can cause ordering issues in large builds. MSBuild resolves transitive references automatically. - -```xml - - - - - - - - - - -``` - -**Caveat**: If you need to use types from `Utils` directly (not just transitively), the explicit reference is appropriate. But verify whether the direct dependency is actually needed. - ---- - -## AP-19: Side Effects During Property Evaluation - -**Smell**: Property functions that write files, make network calls, or modify state during `` evaluation. - -**Why it's bad**: Property evaluation happens during the evaluation phase, which can run multiple times (e.g., during design-time builds in Visual Studio). Side effects are unpredictable and can corrupt state. - -```xml - - - $([System.IO.File]::WriteAllText('stamp.txt', 'built')) - - - - - - -``` - ---- - -## AP-20: Platform-Specific Exec Without OS Condition - -**Smell**: `` or `` without an OS condition. - -**Why it's bad**: Fails on the wrong platform. If the project is cross-platform, guard platform-specific commands. - -```xml - - - - - - - - - -``` - ---- - -## AP-21: Property Conditioned on TargetFramework in .props Files - -**Smell**: `` or `` in `Directory.Build.props` or any `.props` file imported before the project body. - -**Why it's bad**: `$(TargetFramework)` is NOT reliably available in `Directory.Build.props` or any `.props` file imported before the project body. It is only set that early for multi-targeting projects, which receive `TargetFramework` as a global property from the outer build. Single-targeting projects (using singular ``) set it in the project body, which is evaluated *after* `.props`. This means property conditions on `$(TargetFramework)` in `.props` files silently fail for single-targeting projects — the condition never matches because the property is empty. This applies to both `` and individual `` elements. - -For a detailed explanation of MSBuild's evaluation and execution phases, see [Build process overview](https://learn.microsoft.com/en-us/visualstudio/msbuild/build-process-overview). - -```xml - - - $(DefineConstants);MY_FEATURE - - - - - $(DefineConstants);MY_FEATURE - - - - - $(DefineConstants);MY_FEATURE - - - - - - $(DefineConstants);MY_FEATURE - -``` - -**⚠️ Item and Target conditions are NOT affected.** This restriction applies ONLY to property conditions (`` and ``). Item conditions (``) and Target conditions in `.props` files are SAFE because items and targets evaluate after all properties (including those set in the project body) have been evaluated. This includes `PackageVersion` items in `Directory.Packages.props`, `PackageReference` items in `Directory.Build.props`, and any other item types. - -**Do NOT flag the following patterns — they are correct:** - -```xml - - - - - - - - - - - - - - - - - -``` - ---- - -## AP-22: Forking a Project Instance via `` with Path-Neutral Global Properties - -**Smell**: A target uses the `` task to build or publish a project, passing extra `Properties` that don't change that project's output path. Two common shapes: - -```xml - - - - - -``` - -**Why it's bad**: An MSBuild project instance is identified by its path **plus its global properties**. Passing an extra global property creates a *distinct* instance of the target project — `(project, {_IsPublishing=true})` — that still resolves to the same `OutputPath`/`IntermediateOutputPath` as the instance the solution/graph already builds, `(project, {})`. That project is then built twice, and in a parallel/graph build the two instances can write the same files concurrently (PDBs, `*.sourcelink` and other NativeAOT intermediates, `project.assets.json`), producing `The process cannot access the file because it is being used by another process` or intermittent file-lock failures. This applies whether the offending `` call is in the target project itself or in some other project in the same build. Use the `check-bin-obj-clash` skill to confirm two evaluations of that project differ only by a path-neutral property while sharing an output path. - -```xml - - - - -``` - -```xml - - - - <_PublishWasInvokedDirectly Condition="'$(_IsPublishing)' == 'true'">true - <_IsPublishing>true - - - -``` - -For (a), the static property keeps everything in one instance (one output path, nothing to race); running `Publish` via `DependsOnTargets` (or `CallTarget`) reuses that instance instead of forking. The `_PublishWasInvokedDirectly` guard breaks the target cycle when publish is the entry point (e.g. `dotnet publish`, which sets `_IsPublishing=true` as a global property and would otherwise re-trigger `PublishOnBuild`). - -```xml - - - - - - - - -``` - -For (b), the consumer must not fork the producer with path-neutral global properties. Let the producer publish itself (one instance), reference it only to sequence the build, and read its output. - -**When extra global properties ARE fine**: only when the output path encodes the discriminator (`RuntimeIdentifier`, `TargetFramework`, `Configuration`, `Platform`) so each instance writes to a distinct directory. If you must invoke a project with a path-neutral property, give that build its own `BaseIntermediateOutputPath`/output path so it can't collide. - ---- - -## AP-23: `SetTargetFramework` Metadata on a `ProjectReference` to a Non-Multi-Targeting Project - -**Smell**: A `` carries `SetTargetFramework="TargetFramework=net8.0"` (or similar) metadata, the referenced project is **single-targeting** (uses singular ``, not ``), **and the injected TFM equals the TFM the project already targets**. - -```xml - - - - -``` - -**Why it's bad**: `SetTargetFramework` injects `TargetFramework` as a **global property** on the referenced project's build. That mechanism exists so a consumer can pick *one specific TFM* of a **multi-targeting** project — different TFM values produce different output paths, so each build is distinct and safe. - -For a **single-targeting** project, injecting the TFM it **already targets** is **path-neutral**: the project already resolves to `bin\\net8.0\` and `obj\\net8.0\` on its own, so the extra global property doesn't change the output path — it only creates a *distinct* MSBuild project instance `(project, {TargetFramework=net8.0})`. Meanwhile the solution/graph builds that same project as `(project, {})` with no global properties. Both instances resolve to the **same** `OutputPath`/`IntermediateOutputPath`, so the project is **built twice** and the two instances write the same files (assemblies, PDBs, `project.assets.json`, etc.). Under a parallel build this is a classic bin/obj clash — `The process cannot access the file because it is being used by another process` or intermittent, retry-flaky failures. (Injecting a *different* TFM changes the output path and is a legitimate override — see below.) - -Note the healthy contrast: the P2P protocol itself does **not** inject `TargetFramework` when it sees a non-multi-targeting reference — it correctly omits the global property. `SetTargetFramework` overrides that safe default and is what reintroduces the clash. Use the `check-bin-obj-clash` skill to confirm two evaluations of the referenced project differ only by a path-neutral `TargetFramework` global property while sharing an output path. - -```xml - - - - -``` - -**When `SetTargetFramework` IS appropriate**: - -1. **Multi-targeting reference** — the referenced project is multi-targeting (``) and you deliberately need to consume a specific TFM. Each TFM has its own output path, so the forked instance doesn't collide. - -2. **Deliberately overriding a single-targeting project's TFM to a *different* value** — you can use `SetTargetFramework` on a single-targeting reference to build it under a TFM *other than* the one it declares. This is only valid when the passed-in TFM **differs** from what the project single-targets: because the injected `TargetFramework` then changes the output path (`obj\\\`), the instance no longer collides with the `(project, {})` build. It is **only** the redundant case — passing the *same* TFM the project already targets (path-neutral) — that causes the clash. - -**Related: referencing a framework-incompatible project.** Independently of the clash above, whenever the referencing and referenced projects target **incompatible frameworks** (e.g. a `.NETFramework` project referencing a `.NETCoreApp` project, or vice-versa) — **regardless of whether either side is single- or multi-targeting** — you must set both: -- `SkipGetTargetFrameworkProperties="true"` — bypass the P2P `GetTargetFrameworkProperties` negotiation, which would otherwise fail because the frameworks aren't compatible, and -- `ReferenceOutputAssembly="false"` — because an assembly built for an incompatible framework can't be consumed as a reference; you only want to trigger/sequence the build, not reference its output. - -```xml - - -``` - -**⚠️ Prevent the referencing project's `TargetFramework` from leaking.** When `SkipGetTargetFrameworkProperties="true"` bypasses the negotiation, nothing stops the referencing project's own `TargetFramework` **global property** (present whenever the referencing project is being built for a specific TFM — e.g. it is multi-targeting) from flowing down into the referenced project. If it flows into a **single-targeting** referenced project, that project builds under the *wrong* TFM (and to a different, wrong output path). Guard against it one of two ways: -- set `SetTargetFramework="TargetFramework="` to explicitly pin the referenced build's TFM (also required for multi-targeting references), **or** -- for a single-targeting referenced project you want to build as-declared, set `UndefineProperties="TargetFramework"` to strip the inherited global property so the project uses its own ``. - -```xml - - -``` - -Add `SetTargetFramework` on top of these **only** if you also need to pin the referenced build to a specific TFM (a multi-targeting project, or a single-targeting project you're overriding to a *different* TFM per case 2 above). Use `SetTargetFramework` **or** `UndefineProperties="TargetFramework"`, not both — the former sets the property, the latter removes it. - ---- - -## Quick-Reference Checklist - -When reviewing an MSBuild file, scan for these in order: - -| # | Check | Severity | -|---|-------|----------| -| AP-02 | Unquoted conditions | 🔴 Error-prone | -| AP-19 | Side effects in evaluation | 🔴 Dangerous | -| AP-21 | Property conditioned on TargetFramework in .props | 🔴 Silent failure | -| AP-22 | Forking a project instance via `` with path-neutral global properties (self or cross-project) | 🔴 Race/duplicate build | -| AP-23 | `SetTargetFramework` re-injecting a single-targeting project's own TFM on a `ProjectReference` | 🔴 Race/duplicate build | -| AP-03 | Hardcoded absolute paths | 🔴 Broken on other machines | -| AP-06 | `` with HintPath for NuGet | 🟡 Legacy | -| AP-07 | Missing `PrivateAssets="all"` on tools | 🟡 Leaks to consumers | -| AP-11 | Missing Inputs/Outputs on targets | 🟡 Perf regression | -| AP-13 | Import without Exists guard | 🟡 Fragile | -| AP-05 | Manual file listing in SDK-style | 🔵 Noise | -| AP-04 | Restating SDK defaults | 🔵 Noise | -| AP-08 | Copy-paste across csproj files | 🔵 Maintainability | -| AP-09 | Scattered package versions | 🔵 Version drift | -| AP-01 | `` for built-in tasks | 🔵 Cross-platform | -| AP-14 | Backslashes in cross-platform paths | 🔵 Cross-platform | -| AP-10 | Monolithic targets | 🔵 Maintainability | -| AP-12 | Defaults in .targets instead of .props | 🔵 Ordering issue | -| AP-15 | Unconditional property override | 🔵 Confusing | -| AP-16 | `` for string operations | 🔵 Preference | -| AP-17 | Mixed Include/Update in one ItemGroup | 🔵 Subtle bugs | -| AP-18 | Redundant transitive ProjectReferences | 🔵 Graph noise | -| AP-20 | Platform-specific Exec without guard | 🔵 Cross-platform | diff --git a/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md b/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md deleted file mode 100644 index 7c54447..0000000 --- a/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md +++ /dev/null @@ -1,30 +0,0 @@ -# Incremental Build: Inputs and Outputs on Custom Targets - -Custom targets **must** specify `Inputs` and `Outputs` attributes so MSBuild can skip them when up-to-date. Without both attributes, the target runs on every build. - -```xml - - - - - - - - - - - - - -``` - -**Key points:** -- **`Inputs`** should include `$(MSBuildProjectFile)` plus any source files that drive generation -- **`Outputs`** should use `$(IntermediateOutputPath)` so generated files go in `obj/` and are managed by MSBuild -- **`FileWrites`** registration ensures `dotnet clean` removes the generated file -- **`Compile` inclusion** adds the generated file to compilation without requiring it at evaluation time - -See the `incremental-build` skill for deep guidance on diagnosing broken incremental builds, FileWrites tracking, and Visual Studio's Fast Up-to-Date Check. diff --git a/.agents/skills/msbuild-antipatterns/references/private-assets.md b/.agents/skills/msbuild-antipatterns/references/private-assets.md deleted file mode 100644 index e9414eb..0000000 --- a/.agents/skills/msbuild-antipatterns/references/private-assets.md +++ /dev/null @@ -1,22 +0,0 @@ -# PrivateAssets for Analyzers and Build Tools - -Analyzer and build-tool packages should always use `PrivateAssets="all"` to prevent them from flowing as transitive dependencies to consumers of your library. - -```xml - - - - - - - - - -``` - -**Packages that almost always need `PrivateAssets="all"`:** -- Roslyn analyzers (`*.Analyzers`, `*.CodeFixes`) -- Source generators -- SourceLink packages (`Microsoft.SourceLink.*`) -- Versioning tools (`MinVer`, `Nerdbank.GitVersioning`) -- Build-only tools (`Microsoft.DotNet.ApiCompat`, etc.) diff --git a/.agents/skills/msbuild-modernization/SKILL.md b/.agents/skills/msbuild-modernization/SKILL.md deleted file mode 100644 index d8c52ab..0000000 --- a/.agents/skills/msbuild-modernization/SKILL.md +++ /dev/null @@ -1,501 +0,0 @@ ---- -name: msbuild-modernization -description: "Guide for modernizing and migrating MSBuild project files to SDK-style format. USE FOR: converting legacy .csproj/.vbproj with verbose XML to SDK-style, migrating packages.config to PackageReference, removing Properties/AssemblyInfo.cs in favor of auto-generation, eliminating explicit lists via implicit globbing, consolidating shared settings into Directory.Build.props. Indicators of legacy projects: ToolsVersion attribute, , .csproj files > 50 lines for simple projects. DO NOT USE FOR: projects already in SDK-style format, non-.NET build systems (npm, Maven, CMake), .NET Framework projects that cannot move to SDK-style." -license: MIT ---- - -# MSBuild Modernization: Legacy to SDK-style Migration - -## Identifying Legacy vs SDK-style Projects - -**Legacy indicators:** - -- `` -- Explicit file lists (`` for every `.cs` file) -- `ToolsVersion` attribute on `` element -- `packages.config` file present -- `Properties\AssemblyInfo.cs` with assembly-level attributes - -**SDK-style indicators:** - -- `` attribute on root element -- Minimal content — a simple project may be 10–15 lines -- No explicit file includes (implicit globbing) -- `` items instead of `packages.config` - -**Quick check:** if a `.csproj` is more than 50 lines for a simple class library or console app, it is likely legacy format. - -```xml - - - - - - Debug - AnyCPU - Library - MyLibrary - MyLibrary - v4.7.2 - 512 - true - - - - -``` - -```xml - - - - net472 - - -``` - -## Migration Checklist: Legacy → SDK-style - -### Step 1: Replace Project Root Element - -**BEFORE:** - -```xml - - - - - - -``` - -**AFTER:** - -```xml - - - -``` - -Remove the XML declaration, `ToolsVersion`, `xmlns`, and both `` lines. The `Sdk` attribute replaces all of them. - -### Step 2: Set TargetFramework - -**BEFORE:** - -```xml - - v4.7.2 - -``` - -**AFTER:** - -```xml - - net472 - -``` - -**TFM mapping table:** - -| Legacy `TargetFrameworkVersion` | SDK-style `TargetFramework` | -|---------------------------------|-----------------------------| -| `v4.6.1` | `net461` | -| `v4.7.2` | `net472` | -| `v4.8` | `net48` | -| (migrating to .NET 6) | `net6.0` | -| (migrating to .NET 8) | `net8.0` | - -### Step 3: Remove Explicit File Includes - -**BEFORE:** - -```xml - - - - - - - - - - - - - - -``` - -**AFTER:** - -Delete all of these `` and `` item groups entirely. SDK-style projects include them automatically via implicit globbing. - -**Exception:** keep explicit entries only for files that need special metadata or reside outside the project directory: - -```xml - - - -``` - -### Step 4: Remove AssemblyInfo.cs - -**BEFORE** (`Properties\AssemblyInfo.cs`): - -```csharp -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("MyLibrary")] -[assembly: AssemblyDescription("A useful library")] -[assembly: AssemblyCompany("Contoso")] -[assembly: AssemblyProduct("MyLibrary")] -[assembly: AssemblyCopyright("Copyright © Contoso 2024")] -[assembly: ComVisible(false)] -[assembly: Guid("...")] -[assembly: AssemblyVersion("1.2.0.0")] -[assembly: AssemblyFileVersion("1.2.0.0")] -``` - -**AFTER** (in `.csproj`): - -```xml - - MyLibrary - A useful library - Contoso - MyLibrary - Copyright © Contoso 2024 - 1.2.0 - -``` - -Delete `Properties\AssemblyInfo.cs` — the SDK auto-generates assembly attributes from these properties. - -**Alternative:** if you prefer to keep `AssemblyInfo.cs`, disable auto-generation: - -```xml - - false - -``` - -### Step 5: Migrate packages.config → PackageReference - -**BEFORE** (`packages.config`): - -```xml - - - - - - -``` - -**AFTER** (in `.csproj`): - -```xml - - - - - -``` - -Delete `packages.config` after migration. - -**Migration options:** - -- **Visual Studio:** right-click `packages.config` → *Migrate packages.config to PackageReference* -- **CLI:** `dotnet migrate-packages-config` or manual conversion -- **Binding redirects:** SDK-style projects auto-generate binding redirects — remove the `` section from `app.config` if present - -### Step 6: Remove Unnecessary Boilerplate - -Delete all of the following — the SDK provides sensible defaults: - -```xml - - - - - - - Debug - AnyCPU - {...} - Library - Properties - 512 - true - true - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - -``` - -**Keep** only properties that differ from SDK defaults (e.g., `Exe`, `` if it differs from the assembly name, custom ``). - -### Step 7: Enable Modern Features - -After migration, consider enabling modern C# features: - -```xml - - net8.0 - enable - enable - -``` - -- `enable` — enables nullable reference type analysis -- `enable` — auto-imports common namespaces (.NET 6+) -- **Avoid `latest`** — the effective language version is determined by the SDK/compiler defaults, not just the TFM, so builds can silently vary across machines with different SDKs installed. Omit `` unless you need to pin a specific version. For reproducible builds, pin the SDK version repo-wide with `global.json` (which indirectly fixes the default language version), or set an explicit numeric `` (e.g. `12`) per project to directly control the language version. - -## Complete Before/After Example - -**BEFORE** (legacy — 65 lines): - -```xml - - - - - Debug - AnyCPU - {12345678-1234-1234-1234-123456789ABC} - Library - Properties - MyLibrary - MyLibrary - v4.7.2 - 512 - true - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - -``` - -**AFTER** (SDK-style — 11 lines): - -```xml - - - net472 - - - - - - -``` - -## Common Migration Issues - -**Embedded resources:** files not in a standard location may need explicit includes: - -```xml - - - -``` - -**Content files with CopyToOutputDirectory:** these still need explicit entries: - -```xml - - - - -``` - -**Multi-targeting:** change the element name from singular to plural: - -```xml - -net8.0 - - -net472;net8.0 -``` - -**WPF/WinForms projects:** use the appropriate SDK or properties: - -```xml - - - - - - - true - - true - - -``` - -**Test projects:** use the standard SDK with test framework packages: - -```xml - - - net8.0 - false - - - - - - - -``` - -## Central Package Management Migration - -Centralizes NuGet version management across a multi-project solution. See [https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) for details. - -**Step 1:** Create `Directory.Packages.props` at the repository root with `true` and `` items for all packages. - -**Step 2:** Remove `Version` from each project's `PackageReference`: - -```xml - - - - - -``` - -## Directory.Build Consolidation - -Identify properties repeated across multiple `.csproj` files and move them to shared files. - -**`Directory.Build.props`** (for properties — placed at repo or src root): - -```xml - - - net8.0 - enable - enable - true - Contoso - Copyright © Contoso 2024 - - -``` - -**`Directory.Build.targets`** (for targets/tasks — placed at repo or src root): - -```xml - - - - - -``` - -**Keep in individual `.csproj` files** only what is project-specific: - -```xml - - - Exe - MyApp - - - - - - -``` - -## Tools and Automation - -| Tool | Usage | -|------|-------| -| `dotnet try-convert` | Automated legacy-to-SDK conversion. Install: `dotnet tool install -g try-convert` | -| .NET Upgrade Assistant | Full migration including API changes. Install: `dotnet tool install -g upgrade-assistant` | -| Visual Studio | Right-click `packages.config` → *Migrate packages.config to PackageReference* | -| Manual migration | Often cleanest for simple projects — follow the checklist above | - -**Recommended approach:** - -1. Run `try-convert` for a first pass -2. Review and clean up the output manually -3. Build and fix any issues -4. Enable modern features (nullable, implicit usings) -5. Consolidate shared settings into `Directory.Build.props` diff --git a/.agents/skills/run-tests/SKILL.md b/.agents/skills/run-tests/SKILL.md deleted file mode 100644 index f1e3226..0000000 --- a/.agents/skills/run-tests/SKILL.md +++ /dev/null @@ -1,288 +0,0 @@ ---- -name: run-tests -description: > - Recommend or run the exact `dotnet test` command. ALWAYS use when the - user asks to run, filter, or troubleshoot .NET tests or wants the precise - command, flags, or argument order — the right syntax depends on the test - platform (VSTest vs Microsoft.Testing.Platform) and SDK version and is - easy to get wrong from memory. USE FOR: running all tests or a subset (a - specific class, category, or trait) via filters; a single framework in a - multi-TFM project (`--framework`); TRX reports; crash or hang dumps; - whether MTP args need the `--` separator (SDK 8/9) or pass directly - (SDK 10+); diagnosing why `dotnet test` fails or uses wrong argument - syntax. Detects the platform (VSTest vs MTP) and framework - (MSTest/xUnit/NUnit/TUnit), then picks the matching command and filter - flag (--filter, --filter-class, --filter-trait, --filter-query, - --treenode-filter). DO NOT USE FOR: writing test code (use - code-testing-agent), iterating on failing tests without rebuilding (use - mtp-hot-reload), CI/CD config, or debugging test logic. -license: MIT ---- - -# Run .NET Tests - -Detect the test platform and framework, run tests, and apply filters using `dotnet test`. - -## When to Use - -- User wants to run tests in a .NET project -- User needs to run a subset of tests using filters -- User needs help detecting which test platform (VSTest vs MTP) or framework is in use -- User wants to understand the correct filter syntax for their setup - -## When Not to Use - -- User needs to write or generate test code (use `writing-mstest-tests` for MSTest, or general coding assistance for other frameworks) -- User needs to migrate from VSTest to MTP (use `migrate-vstest-to-mtp`) -- User wants to iterate on failing tests without rebuilding (use `mtp-hot-reload`) -- User needs CI/CD pipeline configuration (use CI-specific skills) -- User needs to debug a test (use debugging skills) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Project or solution path | No | Path to the test project (.csproj) or solution (.sln, .slnf, .slnx). Defaults to current directory. | -| Filter expression | No | Filter expression to select specific tests | -| Target framework | No | Target framework moniker to run against (e.g., `net8.0`) | - -## Critical Rules — Avoid Cross-Platform Mistakes - -These are the most common agent mistakes. Internalize before proceeding: - -| Rule | Why | -|------|-----| -| **Do NOT use `--logger trx`** for MTP projects | MTP uses `--report-trx` (requires the TrxReport extension package) | -| **Do NOT use `--report-trx`** for VSTest projects | VSTest uses `--logger trx` | -| **Do NOT use `-- --arg`** on .NET SDK 10+ | SDK 10+ passes MTP args directly: `dotnet test --project . --report-trx` | -| **Do NOT omit `--`** on .NET SDK 8/9 with MTP | SDK 8/9 requires the separator: `dotnet test -- --report-trx` | -| **Do NOT use `--filter "ClassName=..."`** with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, `--filter-trait` | -| **Do NOT use bare positional path** on SDK 10+ | Use `--project ` or `--solution ` instead | -| **Do NOT use `--blame`** for MTP projects | MTP uses `--blame-crash` and `--blame-hang-timeout` separately (each requires its extension package) | -| **Do NOT use `--collect "Code Coverage"`** for MTP | MTP uses `--coverage` (requires the CodeCoverage extension package) | - -## Workflow - -### Quick Reference - -| Platform | SDK | Command pattern | -|----------|-----|----------------| -| VSTest | Any | `dotnet test [] [--filter ] [--logger trx]` | -| MTP | 8 or 9 | `dotnet test [] -- ` | -| MTP | 10+ | `dotnet test --project ` | - -**Detection files to always check** (in order): `global.json` -> `.csproj` -> `Directory.Build.props` -> `Directory.Packages.props` - -**If the prompt names a subset of tests** (e.g., "integration tests", "smoke tests", a specific class, a specific TFM), plan to apply the matching filter / `--framework` in [Step 3](#step-3-run-filtered-tests) — do not run the whole suite. - -### Step 1: Detect the test platform and framework - -1. Run `dotnet --version` in the project directory to determine the SDK version. This accounts for `global.json` SDK pinning. -2. Read `global.json` — on .NET SDK 10+, `"test": { "runner": "Microsoft.Testing.Platform" }` is the **authoritative MTP signal**. If present, the project uses MTP and SDK 10+ syntax (no `--` separator). -3. Read `.csproj`, `Directory.Build.props`, **and** `Directory.Packages.props` for framework packages and MTP properties. **Always check all three files** — MTP properties are frequently set in `Directory.Build.props` rather than individual `.csproj` files. -4. For full detection logic (SDK 8/9 signals, framework identification), see the `platform-detection` skill. - -**What to look for in each file:** - -| File | Look for | Indicates | -|------|----------|-----------| -| `global.json` | `"test": { "runner": "Microsoft.Testing.Platform" }` | MTP on SDK 10+ | -| `global.json` | `"sdk": { "version": "..." }` | SDK version (determines `--` separator behavior) | -| `.csproj` | `true` | MTP on SDK 8/9 | -| `.csproj` | `MSTest`, `xunit.v3`, `NUnit`, `TUnit` packages | Framework identity | -| `.csproj` | `Microsoft.NET.Test.Sdk` + test adapter | VSTest (unless overridden by MTP signals above) | -| `.csproj` | `` (plural) | Multi-TFM — may need `--framework` | -| `Directory.Build.props` | `true` | MTP on SDK 8/9 (often set here, not in .csproj) | -| `Directory.Packages.props` | Centrally managed test package versions | Framework identity for CPM repos | - -**Quick detection summary:** - -| Signal | Means | -|--------|-------| -| `global.json` has `"test": { "runner": "Microsoft.Testing.Platform" }` | **MTP on SDK 10+** — pass args directly, no `--` | -| `true` in csproj or Directory.Build.props | **MTP on SDK 8/9** — pass args after `--` | -| Neither signal present | **VSTest** | - -### Step 2: Run tests - -#### VSTest (any .NET SDK version) - -```bash -dotnet test [ | | | | ] -``` - -Common flags: - -| Flag | Description | -|------|-------------| -| `--framework ` | Target a specific framework in multi-TFM projects (e.g., `net8.0`) | -| `--no-build` | Skip build, use previously built output | -| `--filter ` | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | -| `--logger trx` | Generate TRX results file | -| `--collect "Code Coverage"` | Collect code coverage using Microsoft Code Coverage (built-in, always available) | -| `--blame` | Enable blame mode to detect tests that crash the host | -| `--blame-crash` | Collect a crash dump when the test host crashes | -| `--blame-hang-timeout ` | Abort test if it hangs longer than duration (e.g., `5min`) | -| `-v ` | Verbosity: `quiet`, `minimal`, `normal`, `detailed`, `diagnostic` | - -#### MTP with .NET SDK 8 or 9 - -With `true`, `dotnet test` bridges to MTP but uses VSTest-style argument parsing. MTP-specific arguments must be passed after `--`: - -```bash -dotnet test [ | | | | ] -- -``` - -#### MTP with .NET SDK 10+ - -With the `global.json` runner set to `Microsoft.Testing.Platform`, `dotnet test` natively understands MTP arguments without `--`: - -```bash -dotnet test - [--project ] - [--solution ] - [--test-modules ] - [] -``` - -Examples: - -```bash -# Run all tests in a project -dotnet test --project path/to/MyTests.csproj - -# Run all tests in a directory containing a project -dotnet test --project path/to/ - -# Run all tests in a solution (sln, slnf, slnx) -dotnet test --solution path/to/MySolution.sln -dotnet test --solution path/to/MySolution.slnf -dotnet test --solution path/to/MySolution.slnx - -# Run all tests in a directory containing a solution -dotnet test --solution path/to/ - -# Run with MTP flags -dotnet test --project path/to/MyTests.csproj --report-trx --blame-hang-timeout 5min -``` - -> **Note**: The .NET 10+ `dotnet test` syntax does **not** accept a bare positional argument like the VSTest syntax. Use `--project`, `--solution`, or `--test-modules` to specify the target. - -#### Common MTP flags - -These flags apply to MTP on both SDK versions. On SDK 8/9, pass after `--`; on SDK 10+, pass directly. - -> **Important:** `dotnet test`/MSBuild flags such as `--framework`, `--no-build`, `--configuration`, and `--verbosity` are consumed by `dotnet test` itself (they drive restore/build/host selection) and **always go BEFORE `--`**, regardless of platform or SDK. Only MTP test-platform arguments go after `--` on SDK 8/9. For example: `dotnet test --framework net9.0 -- --report-trx` (built-in flag before `--`, MTP extension flag after). - -**Built-in flags (always available):** - -| Flag | Description | -|------|-------------| -| `--results-directory ` | Directory for test result output | -| `--diagnostic` | Enable diagnostic logging for the test platform | -| `--diagnostic-output-directory ` | Directory for diagnostic log output | - -**Extension-dependent flags (require the corresponding extension package to be registered):** - -| Flag | Requires | Description | -|------|----------|-------------| -| `--filter ` | Framework-specific (not all frameworks support this) | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | -| `--report-trx` | `Microsoft.Testing.Extensions.TrxReport` | Generate TRX results file | -| `--report-trx-filename ` | `Microsoft.Testing.Extensions.TrxReport` | Set TRX output filename | -| `--blame-hang-timeout ` | `Microsoft.Testing.Extensions.HangDump` | Abort test if it hangs longer than duration (e.g., `5min`) | -| `--blame-crash` | `Microsoft.Testing.Extensions.CrashDump` | Collect a crash dump when the test host crashes | -| `--coverage` | `Microsoft.Testing.Extensions.CodeCoverage` | Collect code coverage using Microsoft Code Coverage | - -> Some frameworks (e.g., MSTest) bundle common extensions by default. Others may require explicit package references. If a flag is not recognized, check that the corresponding extension package is referenced in the project. - -#### Alternative MTP invocations - -MTP test projects are standalone executables. Beyond `dotnet test`, they can be run directly: - -```bash -# Build and run -dotnet run --project - -# Run a previously built DLL -dotnet exec - -# Run the executable directly (Windows) - -``` - -These alternative invocations accept MTP command line arguments directly (no `--` separator needed). - -### Step 3: Run filtered tests - -See the `filter-syntax` skill for the complete filter syntax for each platform and framework combination. Key points: - -- **VSTest** (MSTest, xUnit v2, NUnit): `dotnet test --filter ` with `=`, `!=`, `~`, `!~` operators -- **MTP -- MSTest and NUnit**: Same `--filter` syntax as VSTest; pass after `--` on SDK 8/9, directly on SDK 10+ -- **MTP -- xUnit v3**: Uses `--filter-class`, `--filter-method`, `--filter-trait` (not VSTest expression syntax). For a **single combined expression** (e.g., a class-name pattern AND a trait), use `--filter-query` with the xUnit v3 query filter language: path segments `////` with `*` wildcards and a `[Trait=Value]` qualifier — for example `dotnet test -- --filter-query "/*/*/*IntegrationTests*/*[Category=Smoke]"`. See the `filter-syntax` skill for the full query language. -- **MTP -- TUnit**: Uses `--treenode-filter` with path-based syntax - -#### When the user names a test category, trait, or group - -When the prompt names a subset of tests by category (e.g., "integration tests", "unit tests", "smoke tests", "fast tests"), **do not run all tests** — translate the user's vocabulary into the platform-appropriate filter: - -1. **Inspect the test source files** for filter-attribute annotations that match the named group: - - | Framework | Attribute | Filter property | - |-----------|-----------|-----------------| - | MSTest | `[TestCategory("Integration")]` | `TestCategory` | - | NUnit | `[Category("Integration")]` | `TestCategory` (mapped) | - | xUnit v2 | `[Trait("Category", "Integration")]` | `Category` | - | xUnit v3 | `[Trait("Category", "Integration")]` | `Category` (use `--filter-trait`) | - | TUnit | `[Category("Integration")]` | `Category` | - -2. **Build the filter expression** and combine it with the platform-correct invocation. For "run the integration tests" against an MSTest project: - - | Platform | SDK | Command | - |----------|-----|---------| - | VSTest (MSTest) | any | `dotnet test --filter "TestCategory=Integration"` | - | MTP (MSTest) | 8 or 9 | `dotnet test -- --filter "TestCategory=Integration"` | - | MTP (MSTest) | 10+ | `dotnet test --filter "TestCategory=Integration"` | - | MTP (xUnit v3) | 8 or 9 | `dotnet test -- --filter-trait "Category=Integration"` | - | MTP (xUnit v3) | 10+ | `dotnet test --filter-trait "Category=Integration"` | - | MTP (TUnit) | 8 or 9 | `dotnet test -- --treenode-filter "/*/*/*/*[Category=Integration]"` | - -3. If you cannot find a matching attribute, ask the user to confirm the category name or fall back to a name-pattern filter (e.g., `--filter "FullyQualifiedName~Integration"`). - -## Validation - -- [ ] Test platform (VSTest or MTP) was correctly identified -- [ ] Test framework (MSTest, xUnit, NUnit, TUnit) was correctly identified -- [ ] Correct `dotnet test` invocation was used for the detected platform and SDK version -- [ ] When the user named a test category/trait/group, the appropriate filter was applied (not "run all tests") -- [ ] Filter expressions used the syntax appropriate for the platform and framework -- [ ] Test results were clearly reported to the user - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Missing `Microsoft.NET.Test.Sdk` in a VSTest project | Tests won't be discovered. Add `` | -| Using VSTest `--filter` syntax with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, etc. -- not the VSTest expression syntax | -| Passing MTP args without `--` on .NET SDK 8/9 | Before .NET 10, MTP args must go after `--`: `dotnet test -- --report-trx` | -| Using `-- --arg` separator on .NET SDK 10+ | SDK 10+ passes MTP args directly — do NOT use `--` separator | -| Using `--logger trx` for MTP or `--report-trx` for VSTest | Each platform has its own TRX flag — check the Critical Rules table | -| Only checking `.csproj` for MTP signals | Always check `Directory.Build.props` and `Directory.Packages.props` too — MTP properties are frequently set there | -| Using bare positional path argument on SDK 10+ | SDK 10+ requires named flags: `--project ` or `--solution ` | - -## Troubleshooting - -Common error messages and how to resolve them: - -| Error | Cause | Fix | -|-------|-------|-----| -| `No test is available` or `No test matches the given testcase filter` | Wrong filter syntax for the platform/framework, or tests not discovered | Verify filter syntax matches the platform (see `filter-syntax` skill). For discovery issues, check that the test SDK and adapter packages are installed | -| `The --report-trx option is unrecognized` | MTP extension package not referenced, or using MTP flag on a VSTest project | Add `` for MTP, or use `--logger trx` for VSTest | -| `The --blame-hang-timeout option is unrecognized` | Missing HangDump extension on MTP | Add `` | -| `error NETSDK1045: The current .NET SDK does not support targeting .NET X.0` | SDK version in `global.json` doesn't match the project's target framework | Update `global.json` SDK version or install the required SDK | -| `The test runner process exited with non-zero exit code` | MTP test host crashed or test failure | Run with `--blame-crash` (MTP) or `--blame` (VSTest) to collect a crash dump for diagnosis | -| `No test source files were found` / `No test project found` | `dotnet test` can't find a test project in the given path | Specify the path explicitly: `dotnet test ` (VSTest) or `dotnet test --project ` (SDK 10+) | -| Tests discovered but 0 executed | Filter expression matches no tests | Double-check filter property names and values. Common typo: `TestCategory` (MSTest) vs `Category` (NUnit) vs trait syntax (xUnit) | -| Using `--` for MTP args on .NET SDK 10+ | On .NET 10+, MTP args are passed directly: `dotnet test --project . --blame-hang-timeout 5min` — do NOT use `-- --blame-hang-timeout` | -| Multi-TFM project runs tests for all frameworks | Use `--framework ` to target a specific framework | -| `global.json` runner setting ignored | Requires .NET 10+ SDK. On older SDKs, use `` MSBuild property instead | -| TUnit `--treenode-filter` not recognized | TUnit is MTP-only. On .NET SDK 10+ use `dotnet test`; on older SDKs use `dotnet run` since VSTest-mode `dotnet test` does not support TUnit | diff --git a/.agents/skills/test-anti-patterns/SKILL.md b/.agents/skills/test-anti-patterns/SKILL.md deleted file mode 100644 index cdcabca..0000000 --- a/.agents/skills/test-anti-patterns/SKILL.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -name: test-anti-patterns -description: > - Audits an existing test file or suite in any language for anti-patterns - and quality issues — produces a severity-ranked report - (Critical/Warning/Info). INVOKE whenever asked to audit or review tests, - find what's wrong with a suite, judge whether tests are any good, or - check for: tests that pass but verify nothing, missing assertions, - swallowed exceptions, self-comparing / tautological assertions, - coverage-touching tests, broad exceptions, flaky or order-dependent tests - (Thread.Sleep, DateTime.Now, shared state), duplicated tests, or magic - values — in .NET, Python/pytest, TS/Jest, Java, Go, Ruby or C++. DO NOT - USE FOR: writing new tests (use code-testing-agent, or writing-mstest-tests - for MSTest); running tests (use - run-tests); migration; assertion-diversity metrics (use assertion-quality); - coverage/CRAP metrics (use coverage-analysis); the testsmells.org academic - catalog (use test-smell-detection); fixing or modernizing MSTest tests, - assertions, attributes, or lifecycle (use writing-mstest-tests). -license: MIT ---- - -# Test Anti-Pattern Detection - -Quick, pragmatic analysis of test code in any supported language for anti-patterns and quality issues that undermine test reliability, maintainability, and diagnostic value. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase (e.g., `extensions/dotnet.md`, `extensions/python.md`, `extensions/typescript.md`, `extensions/go.md`). The extension file tells you which sleep / time / random / skip / setup-teardown / mystery-guest APIs to look for in that language. - -## When to Use - -- User asks to review test quality or find test smells -- User wants to know why tests are flaky or unreliable -- User asks "are my tests good?" or "what's wrong with my tests?" -- User requests a test audit or test code review -- User wants to improve existing test code - -## When Not to Use - -- User wants to write new tests from scratch (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants direct implementation fixes rather than a diagnostic review (use the relevant write/edit skill) -- User asks to fix swapped `Assert.AreEqual` argument order in MSTest (use `writing-mstest-tests`) -- User asks to convert MSTest `DynamicData` from `IEnumerable` to `ValueTuple` (use `writing-mstest-tests`) -- User wants to run or execute tests (use `run-tests` for .NET) -- User wants to migrate between test frameworks or versions (use migration skills) -- User wants to measure code coverage (out of scope) -- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `test-smell-detection`) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Test code | Yes | One or more test files or classes to analyze | -| Production code | No | The code under test, for context on what tests should verify | -| Specific concern | No | A focused area like "flakiness" or "naming" to narrow the review | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file. The extension file documents framework-specific anti-pattern markers — what counts as a sleep/wait, a test marker, a skip, a setup/teardown, a shared-state hot spot, and an integration boundary — so this skill stays language-neutral. - -### Step 2: Gather the test code - -Read the test files the user wants reviewed. If the user points to a directory or project, scan for all test files using the discovery markers in the loaded language extension file (e.g., `[TestClass]`/`[Fact]`/`[Test]` for .NET, `test_*.py` / `def test_*` for pytest, `*.test.ts` / `it()` for Jest, `*Test.java` / `@Test` for JUnit, `*_test.go` / `func TestXxx` for Go, `*_spec.rb` for RSpec, `#[test]` for Rust, `*.Tests.ps1` / `Describe` for Pester, `TEST(...)` for GoogleTest, `TEST_CASE(...)` for Catch2/doctest). - -If production code is available, read it too -- this is critical for detecting tests that are coupled to implementation details rather than behavior. - -### Step 3: Scan for anti-patterns - -Check each test file against the anti-pattern catalog below. Report findings grouped by severity. The examples are .NET-centric but the patterns generalize — use the loaded language extension file to map each pattern to the framework you are auditing. - -#### Critical -- Tests that give false confidence - -| Anti-Pattern | What to Look For | -|---|---| -| **No assertions** | Test methods that execute code but never assert anything. A passing test without assertions proves nothing. In .NET look for missing `Assert.*`; in pytest a function with no `assert` and no `pytest.raises`; in Jest no `expect(...)`; in JUnit no `assert*`/`assertThat`; in Go a test that never calls `t.Error*`, `t.Fatal*`, or testify; in RSpec a block with no `expect`; in Pester no `Should`. Mock-call verifications (`verify(mock)`, `expect(mock).toHaveBeenCalled`, `Should -Invoke`) are real assertions. | -| **Missing await on async assertions (JS/TS, .NET, Python, Kotlin, Swift)** | `expect(promise).resolves.toBe(x)` without `await`/`return`, `pytest-asyncio` test with un-awaited coroutine, `async Task` xUnit test calling `Assert.ThrowsAsync` without `await`, Kotest suspending test without `runTest`, Swift Testing async test without `await`. These tests silently pass even when the underlying assertion would have failed. | -| **Coverage touching** | Test class that methodically calls every public member on a type — often in alphabetical or declaration order — without asserting meaningful outcomes. Each test typically does `var result = sut.MethodName(...)` (or `result = sut.method_name(...)`, `sut.methodName()`, `sut.MethodName(t)`) with no assertion, or only a trivial null/None/nil check. The intent is to inflate code-coverage metrics rather than verify behavior. Distinct from a single assertion-free test: the pattern is *systematic* coverage of the surface area with no real verification. | -| **Self-referential assertion** | Asserts that the output of an operation equals its input when the operation is expected to be an identity or no-op, e.g. `Assert.AreEqual(input, Parse(input.ToString()))`, `assert input == parse(str(input))`, `expect(parse(input.toString())).toBe(input)`, `assert.Equal(t, input, parse(input))`. Also flags `Assert.AreEqual(dto.Name, dto.Name)` / `assert dto.name == dto.name` / `expect(dto.name).toBe(dto.name)` (asserting a field against itself). The test is tautological — it can only fail if the round-trip is broken, but never verifies that a *transformation* actually happened. | -| **Swallowed exceptions** | `try { ... } catch { }`, `catch (Exception)` without rethrowing or asserting (.NET); bare `except:` or `except Exception:` with `pass` (Python); `try { ... } catch (e) {}` (JS/TS/Java); `defer recover()` without re-panic and no assertion (Go); `rescue StandardError` with no assertion (Ruby); `Result::unwrap_or(...)` swallowing errors in a test (Rust); empty `catch` block (Kotlin/Swift). | -| **Assert in catch block only** | `try { Act(); } catch (Exception ex) { Assert.Fail(ex.Message); }` (and equivalents in other languages) -- use `Assert.ThrowsException` / `pytest.raises` / `expect(fn).toThrow` / `assertThrows` / `assert.Error(t, err)` / `#[should_panic]` / `Should -Throw` / `EXPECT_THROW` instead. The test passes when no exception is thrown even if the result is wrong. | -| **Always-true assertions** | `Assert.IsTrue(true)`, `Assert.AreEqual(x, x)`, `assert True`, `expect(true).toBe(true)`, `assert.True(t, true)`, `assert!(true)`, or conditions that can never fail. | -| **Commented-out assertions** | Assertions that were disabled but the test still runs, giving the illusion of coverage. | - -#### High -- Tests likely to cause pain - -| Anti-Pattern | What to Look For | -|---|---| -| **Flakiness indicators** | Wall-clock sleeps/waits used for synchronization: `Thread.Sleep` / `Task.Delay` (.NET), `time.sleep` (Python), `setTimeout` / `await new Promise(r => setTimeout(...))` (JS/TS), `Thread.sleep` (Java/Kotlin), `time.Sleep` (Go), `sleep` (Ruby/Bash), `std::thread::sleep` (Rust), `Start-Sleep` (Pester), `std::this_thread::sleep_for` (C++). Wall-clock reads without abstraction: `DateTime.Now`/`UtcNow`, `datetime.now()`/`datetime.utcnow()`, `Date.now()` / `new Date()`, `System.currentTimeMillis()`, `time.Now()`, `Time.now`, `Instant::now()`, `Date()`/`Date.now`, `Get-Date`, `std::chrono::system_clock::now`. Unseeded randomness: `new Random()`, `random.random()`/`random.randint()`, `Math.random()`, `new Random()` (Java/Kotlin), `rand.Int()` without seed, `rand` (Ruby), `rand::random()` (Rust). Environment-dependent paths (hard-coded `C:\...`, `/tmp/...`, network hosts). | -| **Test ordering dependency** | Static/global mutable state modified across tests; setup that doesn't fully reset state (`[TestInitialize]`, `setUp`, `beforeEach`, `before(:each)`, `BeforeEach`, `t.Cleanup`); tests that fail when run individually but pass in suite (or vice versa). Examples per language: `static` fields (.NET/Java), module-level globals (Python), top-level `let`/`const` in test file (JS/TS), `var` package globals (Go), class variables (Ruby), `static mut`/`lazy_static!`/`OnceCell` (Rust), `$script:` variables (PowerShell). | -| **Over-mocking** | More mock setup lines than actual test logic. Verifying exact call sequences on mocks rather than outcomes. Mocking types the test owns. Per language: Moq/NSubstitute/FakeItEasy (.NET), `unittest.mock` / `pytest-mock` (Python), Jest auto-mocks / Sinon (JS/TS), Mockito/PowerMock (Java), gomock/testify mock (Go), RSpec mocks/mocha (Ruby), `mockall` (Rust), MockK (Kotlin), `Mock` cmdlet (Pester), gmock (C++). For a deep mock audit in .NET, use `exp-mock-usage-analysis`. | -| **Implementation coupling** | Testing private methods via reflection (`MethodInfo.Invoke`, `getattr` in Python, `(thing as any)` in TS, `Field.setAccessible(true)` in Java, `Object#send` in Ruby, internal `pub(crate)` access in Rust). Asserting on internal state instead of observable behavior. Verifying exact method call counts on collaborators instead of business outcomes. | -| **Broad exception assertions** | `Assert.ThrowsException(...)` (.NET) / `pytest.raises(Exception)` / `expect(fn).toThrow(Error)` without a message matcher / `assertThrows(Exception.class, ...)` (Java) / `assert.Error(t, err)` without checking the kind / `expect { ... }.to raise_error` without class (RSpec) / `#[should_panic]` without `expected = "..."` / `Should -Throw` without `-ExpectedMessage` / `EXPECT_ANY_THROW` instead of `EXPECT_THROW(stmt, SpecificType)`. | - -#### Medium -- Maintainability and clarity issues - -| Anti-Pattern | What to Look For | -|---|---| -| **Poor naming** | Test names like `Test1`, `TestMethod`, `test`, names that don't describe the scenario or expected outcome. Good naming differs by language convention — see the loaded language extension file (e.g., `Add_NegativeNumber_ThrowsArgumentException` for .NET, `test_add_negative_number_raises_value_error` for pytest, `addNegativeNumber_throwsArgumentException` for Java, `'adds negative number throws'` for Jest descriptions, `TestAdd_NegativeNumber_ReturnsError` for Go). | -| **Magic values** | Unexplained numbers or strings in arrange/assert: `Assert.AreEqual(42, result)` / `assert result == 42` / `expect(result).toBe(42)` -- what does 42 mean? | -| **Duplicate tests** | Three or more test methods with near-identical bodies that differ only in a single input value. Should be parametrized: `[DataRow]`/`[Theory]`/`[TestCase]` (.NET), `@pytest.mark.parametrize` (pytest), `test.each` / `it.each` (Jest/Vitest), `@ParameterizedTest` + `@ValueSource` (JUnit 5), `@DataProvider` (TestNG), Go table-driven tests, `where` / shared examples (RSpec), `#[rstest]` (Rust), `@ParameterizedTest` + `@MethodSource` (Kotlin), `-ForEach` / `-TestCases` (Pester), `INSTANTIATE_TEST_SUITE_P` (GoogleTest), `SECTION` / `GENERATE` (Catch2), `TEST_CASE_TEMPLATE` (doctest). For a detailed duplication analysis in .NET, use `exp-test-maintainability`. Note: Two tests covering distinct boundary conditions (e.g., zero vs. negative) are NOT duplicates -- separate tests for different edge cases provide clearer failure diagnostics and are a valid practice. | -| **Giant tests** | Test methods exceeding ~30 lines or testing multiple behaviors at once. Hard to diagnose when they fail. | -| **Assertion messages that repeat the assertion** | `Assert.AreEqual(expected, actual, "Expected and actual are not equal")` / `assert x == y, "x is not equal to y"` / `assertEquals(x, y, "values not equal")` add no information. Messages should describe the business meaning. | -| **Missing AAA / Given-When-Then separation** | Arrange/Act/Assert (or Given/When/Then for BDD frameworks like RSpec, Kotest behavior specs, Pester) phases are interleaved or indistinguishable. | - -#### Low -- Style and hygiene - -| Anti-Pattern | What to Look For | -|---|---| -| **Unused test infrastructure** | Setup/teardown hooks that do nothing — `[TestInitialize]`/`[SetUp]`/`[BeforeEach]`, `setUp`/`@BeforeEach`/`@BeforeAll`, `beforeEach`/`beforeAll`, `before(:each)`/`before(:all)`, `BeforeEach`/`BeforeAll` (Pester), `setUpWithError` (XCTest) — and test helper methods that are never called. | -| **Unmanaged resources** | Test creates disposable/closeable resources without cleanup: `HttpClient`/`Stream` without `using` (.NET), file/connection without `with` block or `try/finally` (Python), `FileInputStream` without `try-with-resources` (Java), `defer file.Close()` missing (Go), connection without `ensure` (Ruby), `Drop` not relied on / forgotten `close` (Rust), missing teardown for temp files / DBs in any language. | -| **Print debugging** | Leftover `Console.WriteLine` / `Debug.WriteLine` / `print()` / `console.log` / `System.out.println` / `fmt.Println` / `puts` / `dbg!` / `Write-Host` / `std::cout` statements used during test development. | -| **Inconsistent naming convention** | Mix of naming styles in the same test class/module/file (e.g., some use `Method_Scenario_Expected`, others use `ShouldDoSomething`). | - -### Step 4: Calibrate severity honestly - -Before reporting, re-check each finding against these severity rules: - -- **Critical/High**: Only for issues that cause tests to give false confidence or be unreliable. A test that always passes regardless of correctness is Critical. Flaky shared state is High. Missing-await on async assertions is Critical (silent pass). -- **Medium**: Only for issues that actively harm maintainability -- 5+ nearly-identical tests, truly meaningless names like `Test1` / `test` / `it1`. -- **Low**: Cosmetic naming mismatches, minor style preferences, assertion messages that could be better. When in doubt, rate Low. -- **Not an issue** (per-language nuance): - - Go and Rust **table-driven loops** with sub-tests (`t.Run` / `for case in cases { ... }`) are *idiomatic*, not "Conditional Test Logic". Do NOT flag. - - pytest **bare `assert`** is the canonical assertion form, not a missing assertion library. Do NOT flag. - - Go tests use `if got != want { t.Errorf(...) }` as canonical equality. Do NOT flag as ad-hoc. - - Separate tests for distinct boundary conditions (zero vs. negative vs. null). Do NOT flag as duplicates. - - Explicit per-test setup instead of `[TestInitialize]` / `beforeEach` (this *improves* isolation). - - Tests that are short and clear but could theoretically be consolidated. - -IMPORTANT: If the tests are well-written, say so clearly up front. Do not inflate severity to justify the review. A review that finds zero Critical/High issues and only minor Low suggestions is a valid and valuable outcome. Lead with what the tests do well. - -### Step 5: Report findings - -Present findings in this structure: - -1. **Summary** -- Total issues found, broken down by severity (Critical / High / Medium / Low). If tests are well-written, lead with that assessment. -2. **Critical and High findings** -- List each with: - - The anti-pattern name - - The specific location (file, method name, line) - - A brief explanation of why it's a problem - - A concrete fix (show before/after code when helpful) -3. **Medium and Low findings** -- Summarize in a table unless the user wants full detail -4. **Positive observations** -- Call out things the tests do well (sealed class, specific exception types, data-driven tests, clear AAA structure, proper use of fakes, good naming). Don't only report negatives. - -### Step 6: Prioritize recommendations - -If there are many findings, recommend which to fix first: - -1. **Critical** -- Fix immediately, these tests may be giving false confidence -2. **High** -- Fix soon, these cause flakiness or maintenance burden -3. **Medium/Low** -- Fix opportunistically during related edits - -## Validation - -- [ ] Every finding includes a specific location (not just a general warning) -- [ ] Every Critical/High finding includes a concrete fix -- [ ] Report covers all categories (assertions, isolation, naming, structure) -- [ ] Positive observations are included alongside problems -- [ ] Recommendations are prioritized by severity - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Reporting style issues as critical | Naming and formatting are Medium/Low, never Critical | -| Suggesting rewrites instead of targeted fixes | Show minimal diffs -- change the assertion, not the whole test | -| Flagging intentional design choices | If `Thread.Sleep` / `time.sleep` / `time.Sleep` is in an integration test testing actual timing, that's not an anti-pattern. Consider context. | -| Inventing false positives on clean code | If tests follow best practices, say so. A review finding "0 Critical, 0 High, 1 Low" is perfectly valid. Don't inflate findings to justify the review. | -| Flagging separate boundary tests as duplicates | Two tests for zero and negative inputs test different edge cases. Only flag as duplicates when 3+ tests have truly identical bodies differing by a single value. | -| Rating cosmetic issues as Medium | Naming mismatches (e.g., method name says `ArgumentException` but asserts `ArgumentOutOfRangeException`) are Low, not Medium -- the test still works correctly. | -| Ignoring the test framework | Use correct terminology per the loaded language extension: xUnit `[Fact]`/`[Theory]`, NUnit `[Test]`/`[TestCase]`, MSTest `[TestMethod]`/`[DataRow]`, pytest `def test_*` / `@pytest.mark.parametrize`, Jest `it.each` / `describe`, JUnit `@Test` / `@ParameterizedTest`, Go `func TestXxx(t *testing.T)` + table-driven, RSpec `describe`/`it`, Pester `Describe`/`It`, Rust `#[test]` / `#[rstest]`, Catch2 `TEST_CASE`/`SECTION`. | -| Treating idiomatic patterns as smells | Go/Rust **table-driven loops** are idiomatic. Pytest **bare `assert`** is canonical. Go's `if got != want { t.Errorf(...) }` is canonical. JS/TS `expect(mock).toHaveBeenCalledWith(...)` is a real assertion, not an over-mock. Do NOT flag these. | -| Missing async-test pitfalls | A Jest test that calls `expect(promise).resolves.toBe(x)` without returning/awaiting the promise silently passes; a TUnit/xUnit `async Task` test calling `Assert.ThrowsAsync` without `await` silently passes; pytest-asyncio tests with un-awaited coroutines silently pass. Always flag as Critical. | -| Missing the forest for the trees | If 80% of tests have no assertions, lead with that systemic issue rather than listing every instance | diff --git a/.agents/skills/test-gap-analysis/SKILL.md b/.agents/skills/test-gap-analysis/SKILL.md deleted file mode 100644 index 1879675..0000000 --- a/.agents/skills/test-gap-analysis/SKILL.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -name: test-gap-analysis -description: "Performs pseudo-mutation analysis on production code in any language to find gaps in existing tests. Use when the user asks to find weak or shallow tests, discover untested edge cases, or check whether tests would catch a bug — e.g. \"would my tests catch it if someone changed the code\", \"would a subtle logic or boundary change slip past the current tests\", \"are my tests strong enough to catch a subtle bug\". Evaluates test effectiveness through mutation-style reasoning: analyzes mutation points (boundaries, boolean flips, null returns, exception removal, arithmetic changes) and checks whether tests would detect each. Polyglot: .NET, Python, TS/JS, Java, Go, Ruby, Rust, Swift, Kotlin, PowerShell, C++. DO NOT USE FOR: writing new tests (use code-testing-agent, or writing-mstest-tests for MSTest), detecting anti-patterns (use test-anti-patterns), measuring assertion diversity (use assertion-quality), or running actual mutation testing tools (Stryker, mutmut, PIT, cargo-mutants)." -license: MIT ---- - -# Test Gap Analysis via Pseudo-Mutation - -Analyze production code in any supported language by reasoning about hypothetical mutations and checking whether existing tests would catch them. This reveals blind spots where tests pass but would continue to pass even if the code were broken. - -> **Language-specific guidance**: Call the `test-analysis-extensions` skill to discover available extension files, then read the file matching the target codebase (e.g., `extensions/dotnet.md`, `extensions/python.md`, `extensions/typescript.md`). The extension file helps you find test files, recognize framework-specific assertion APIs, and identify language-specific null/None/nil patterns and error-handling idioms that map to the mutation catalog below. - -## Why Pseudo-Mutation Matters - -Code coverage tells you what code ran during tests. It does **not** tell you whether tests would fail if that code were wrong. A method can have 100% line coverage but zero tests that would catch a sign flip, an off-by-one error, or a removed null check. - -Pseudo-mutation analysis asks: _"If I changed this line, would any test fail?"_ When the answer is "no," you've found a test gap. - -| Coverage Metric | What It Measures | What It Misses | -|----------------|-----------------|----------------| -| Line coverage | Which lines executed | Whether assertions verify those lines' behavior | -| Branch coverage | Which branches taken | Whether both branches produce different asserted outcomes | -| **Mutation score** | Whether tests detect code changes | Nothing — this is the gold standard | - -This skill performs **static pseudo-mutation** — reasoning about mutations without actually running them — to approximate mutation testing at the speed of code review. - -## When to Use - -- User asks "would my tests catch a bug in this code?" -- User wants to find weak or shallow tests -- User wants to evaluate test effectiveness beyond coverage -- User asks for mutation testing or mutation analysis -- User asks "where are my tests blind?" -- User wants to prioritize which tests to strengthen -- The `code-testing-generator` agent (or any test-generation workflow) calls this skill as a pre-completion self-review step on freshly generated tests, before declaring the run finished - -## When Not to Use - -- User wants to write new tests from scratch (use `code-testing-agent` for any language, or `writing-mstest-tests` for MSTest specifically) -- User wants to detect test anti-patterns like flakiness or poor naming (use `test-anti-patterns`) -- User wants to measure assertion variety (use `assertion-quality`) -- User wants to run an actual mutation testing framework (Stryker for .NET/JS/TS, mutmut for Python, PIT for Java, go-mutesting for Go, cargo-mutants for Rust, mutant for Ruby) — help them directly with the tool -- User only wants code coverage numbers (out of scope) - -## Inputs - -| Input | Required | Description | -|-------|----------|-------------| -| Production code | Yes | The source files to analyze for mutation points | -| Test code | Yes | The test files that cover the production code | -| Focus area | No | A specific mutation category or code region to focus on | - -## Workflow - -### Step 1: Detect language and load extension - -Identify the target codebase's language and test framework. Call the `test-analysis-extensions` skill and read the matching extension file. The mutation catalog below uses language-neutral concepts; the extension file tells you how each concept maps in the language you are analyzing (e.g., `null` vs `None` vs `nil` vs `undefined`, `throw` vs `raise` vs `panic!` vs `return err`). - -### Step 2: Gather production and test code - -Read both the production code and its corresponding test files. If the user points to a directory, identify production/test pairs by convention — defaults differ by language: `.cs` ↔ `*Tests.cs`/`*.Tests.cs` (.NET), `foo.py` ↔ `test_foo.py`/`foo_test.py` (Python), `foo.ts` ↔ `foo.test.ts`/`foo.spec.ts` (JS/TS), `Foo.java` ↔ `FooTest.java`/`FooTests.java` (Java), `foo.go` ↔ `foo_test.go` (Go), `foo.rb` ↔ `foo_spec.rb`/`test_foo.rb` (Ruby), `lib.rs` ↔ inline `#[cfg(test)] mod tests` or `tests/foo.rs` (Rust), `Foo.swift` ↔ `FooTests.swift` (Swift), `Foo.kt` ↔ `FooTest.kt`/`FooSpec.kt` (Kotlin), `Foo.ps1` ↔ `Foo.Tests.ps1` (Pester), `foo.cpp` ↔ `foo_test.cpp`/`test_foo.cpp` (C++). - -Establish which production methods are exercised by which test methods — trace this through method calls in test code, setup, helper methods, and shared examples. - -### Step 3: Identify mutation points - -Scan the production code and annotate every location where a mutation could reveal a test gap. Use the mutation catalog below. - -#### Boundary Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `<` | `<=` | Off-by-one at upper bound | -| `>` | `>=` | Off-by-one at lower bound | -| `<=` | `<` | Boundary inclusion | -| `>=` | `>` | Boundary inclusion | -| `== 0` | `== 1` or `<= 0` | Zero-boundary handling | -| `i < length` | `i < length - 1` or `i <= length` | Loop boundary | -| `index + 1` | `index` or `index + 2` | Index arithmetic | - -#### Boolean and Logic Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `&&` | `\|\|` | Condition independence | -| `\|\|` | `&&` | Condition necessity | -| `!condition` | `condition` | Negation correctness | -| `if (x)` | `if (!x)` | Branch selection | -| `true` (constant) | `false` | Hardcoded assumption | -| `flag \|\| other` | `other` | Short-circuit first operand | - -#### Return Value Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `return result` | `return null` / `return None` / `return nil` / `return undefined` | Null/None/nil handling downstream | -| `return result` | `return default(T)` / `return T()` / `return ""` / `return 0` | Default value handling | -| `return true` | `return false` | Boolean return verification | -| `return list` | `return new List()` / `return []` / `return Array.Empty()` / `return make([]T, 0)` / `return Vec::new()` / `return @[]` | Empty collection handling | -| `return count` | `return 0` or `return count + 1` | Numeric return verification | -| `return string` | `return ""` or `return null`/`None`/`nil` | String return verification | -| `return Ok(x)` | `return Err(...)` (Rust) | Result/error variant | -| `return value, nil` | `return zero, err` (Go) | Error tuple | - -#### Exception / Error Removal Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `throw new ArgumentNullException(...)` (.NET) / `raise ValueError(...)` (Python) / `throw new Error(...)` (JS) / `throw new IllegalArgumentException(...)` (Java) / `panic!(...)` (Rust) / `panic(...)` (Go) / `raise ArgumentError` (Ruby) / `throw RuntimeException(...)` (Kotlin) / `throw FooError.bar` (Swift) / `throw "..."` (Pester) / `throw std::invalid_argument(...)` (C++) | _(remove entire throw/raise/panic)_ | Guard clause verification | -| `if (x == null) throw ...` / `if x is None: raise ...` / `if (!x) throw ...` / `if x == nil { return err }` (Go) / `assert!(x.is_some())` (Rust) | _(remove entire guard)_ | Null/None/nil guard testing | -| `if (!IsValid()) throw ...` / `if not is_valid(): raise ...` / etc. | _(remove entire check)_ | Validation testing | -| `return err` after error check (Go) | _(remove or swallow error)_ | Error propagation | -| `?` operator (Rust) | `.unwrap()` or `.expect(...)` | Error short-circuit | - -#### Arithmetic Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `a + b` | `a - b` | Addition correctness | -| `a - b` | `a + b` | Subtraction correctness | -| `a * b` | `a / b` | Multiplication correctness | -| `a / b` | `a * b` | Division correctness | -| `a % b` | `a / b` | Modulo correctness | -| `x++` | `x--` | Increment direction | -| `-value` | `value` | Sign flip | - -#### Null / None / Nil-Check Removal Mutations - -| Original | Mutation | What it tests | -|----------|----------|---------------| -| `if (x == null) return ...` / `if x is None: return ...` / `if (!x) return ...` / `if x == nil { return ... }` / `unless x; return; end` (Ruby) / `if x.is_none() { return ... }` (Rust) | _(remove null/None/nil check)_ | Null path coverage | -| `if (x != null) { ... }` / `if x is not None: ...` / `if x: ...` / `if x != nil { ... }` / `x?.let { ... }` (Kotlin) / `if let Some(x) = ... { ... }` (Rust) | _(always enter block)_ | Null/None/nil guard necessity | -| `x ?? defaultValue` (.NET/JS/Swift) / `x or defaultValue` (Python) / `x \|\| defaultValue` (JS) / `x.unwrap_or(defaultValue)` (Rust) / `x \|\| defaultValue` (Kotlin: `x ?: defaultValue`) | `x` (drop coalescing) | Null coalescing coverage | -| `x?.Method()` (.NET/Swift/Kotlin) / `x && x.method()` (JS) / `x and x.method()` (Python) | `x.Method()` | Null-conditional coverage | -| `x!` (.NET/TS/Swift) / `x!!` (Kotlin) / `.unwrap()` (Rust) | `x` | Null-forgiving / unwrap necessity | - -### Step 4: Evaluate each mutation against tests - -For each identified mutation point, reason about whether existing tests would detect the change: - -1. **Find covering tests** — Which test methods exercise the mutated line? Follow call chains through helpers and setup methods. -2. **Check assertion relevance** — Do those tests assert something that would change if the mutation were applied? A test that calls the method but only asserts an unrelated property would NOT catch the mutation. -3. **Classify the mutation** as: - -| Verdict | Meaning | Action | -|---------|---------|--------| -| **Killed** | At least one test would fail if this mutation were applied | No action needed — tests are effective here | -| **Survived** | No test would fail — the mutation would go undetected | This is a test gap — recommend a test improvement | -| **No coverage** | No test exercises this code path at all | Worse than survived — the code is untested | -| **Equivalent** | The mutation produces identical behavior (e.g., `x * 1` → `x / 1`) | Skip — not a real mutation | - -### Step 5: Calibrate findings - -Before reporting, apply these calibration rules: - -- **Don't flag trivial code.** Simple property getters (`return _name;`), auto-properties, and boilerplate don't need mutation analysis. Focus on logic, conditions, calculations, and error handling. -- **Consider defensive depth.** If a null guard has a survived mutation but the caller also checks for null, note the redundancy but rate it lower priority. -- **Equivalent mutations are not gaps.** If changing `>=` to `>` doesn't alter behavior because the `==` case is impossible given the domain, mark it Equivalent and skip. -- **Private methods reached through public API are valid targets.** Trace through the call chain — a private method called from a tested public method may still have survived mutations if the test doesn't assert the specific behavior affected. -- **Rate by risk, not count.** A single survived mutation in payment calculation logic is more important than five survived mutations in logging code. - -### Step 6: Report findings - -Present the analysis in this structure: - -1. **Summary** — Overall mutation score and key findings: - ``` - | Metric | Value | - |---------------------|----------| - | Mutation points | 42 | - | Killed | 28 (67%) | - | Survived | 10 (24%) | - | No coverage | 2 (5%) | - | Equivalent (skipped) | 2 (5%) | - ``` - -2. **Survived Mutations (Test Gaps)** — For each survived mutation, report: - - **Location**: File, method, line - - **Mutation category**: Boundary / Boolean / Return value / Exception / Arithmetic / Null-check - - **Original code**: The current code - - **Hypothetical mutation**: What would change - - **Why it survives**: Which tests cover this code and why their assertions miss it - - **Recommended fix**: A concrete test assertion or new test case that would kill this mutation - - Group by priority: high-risk survived mutations first (business logic, calculations, security checks), lower-risk last (logging, formatting). - -3. **No-Coverage Zones** — Code paths that no test reaches at all. These are worse than survived mutations. - -4. **Killed Mutations (Strengths)** — Briefly note areas where tests are effective. Highlight well-tested methods and strong assertion patterns. Don't enumerate every killed mutation — summarize. - -5. **Recommendations** — Prioritized list: - - Which survived mutations to address first (by risk) - - Specific test methods to add or strengthen - - Patterns the team can adopt to prevent future gaps (e.g., always test boundary values, always assert exception types) - -## Validation - -- [ ] Every mutation point was classified (Killed / Survived / No coverage / Equivalent) -- [ ] Every survived mutation includes the original code, the hypothetical change, and why tests miss it -- [ ] Every survived mutation includes a concrete recommended fix (a test assertion or test case) -- [ ] Equivalent mutations are correctly identified and excluded from the score -- [ ] Trivial code (simple getters, auto-properties) is excluded from analysis -- [ ] Findings are prioritized by risk, not just listed in source order -- [ ] Report includes strengths (killed mutations) alongside gaps -- [ ] Mutation categories are correctly labeled - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| Analyzing trivial code | Skip auto-properties, simple getters, `@dataclass`/`record`/`data class` accessors, `#[derive]` impls — focus on logic | -| Reporting equivalent mutations as gaps | If the mutation doesn't change behavior, it's not a gap — mark Equivalent | -| Ignoring call chains | A private/internal/unexported helper called from a tested public method is reachable — trace the chain | -| Over-counting mutations in generated code | Skip auto-generated code (`*.g.cs`, `*.designer.cs`, `*_pb.go`, `*.pb.dart`), designer files, migration files, generated mocks/stubs | -| Recommending a new test for every survived mutation | Multiple survived mutations in the same method often share a single missing test — recommend one test that kills several | -| Ignoring production context | A survived mutation in `ToString()` / `__repr__` / `toString()` formatting is less important than one in `CalculateTotal()` — prioritize by business risk | -| Claiming 100% kill rate is required | Some mutations in low-risk code are acceptable to leave — acknowledge this in the report | -| Not considering integration with other skills | If gaps are found, mention that `code-testing-agent` (any language) or `writing-mstest-tests` (MSTest-specific) can help write the missing tests, and `test-anti-patterns` can audit existing test quality | -| Forgetting Go's error idiom | Removing `if err != nil { return err }` is a valid mutation target only when the function actually does something else with `err` (e.g., wrap, log, branch). Bare passthroughs in idiomatic Go are not meaningful gaps. | -| Forgetting Rust's `?` operator | `?` propagates `Err`/`None` short-circuits. Mutating `expr?` → `expr.unwrap()` panics instead of returning — flag as Exception/Panic mutation when tests should observe the propagated error. | diff --git a/.gitignore b/.gitignore index ed459d6..136dc75 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ node_modules **/[Cc]ompiler/[Rr]esources/**/*.js deploy/ /.build +/.agents/ +/docs/ +/.tmp/ diff --git a/Dapper.FluentMap.sln b/Dapper.FluentMap.sln index 635b223..915e4e9 100644 --- a/Dapper.FluentMap.sln +++ b/Dapper.FluentMap.sln @@ -19,6 +19,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dapper.FluentMap.Dommel", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Dommel.Tests", "test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj", "{DFB62D87-9A74-40DF-A930-8F61A53E0F1B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers", "src\Dapper.FluentMap.Analyzers\Dapper.FluentMap.Analyzers.csproj", "{424B90AD-406E-4CC1-B0F4-917F47A06E4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Analyzers.Tests", "test\Dapper.FluentMap.Analyzers.Tests\Dapper.FluentMap.Analyzers.Tests.csproj", "{F5059D11-D45B-4793-B6E0-7758F57AC0E1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dapper.FluentMap.AotSmoke", "test\Dapper.FluentMap.AotSmoke\Dapper.FluentMap.AotSmoke.csproj", "{2E23213D-A547-4FF6-BB58-8793860C18FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Generators", "src\Dapper.FluentMap.Generators\Dapper.FluentMap.Generators.csproj", "{25768DB1-489F-4544-BDD4-8B0D0E88C6E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.Generators.Tests", "test\Dapper.FluentMap.Generators.Tests\Dapper.FluentMap.Generators.Tests.csproj", "{BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapper.FluentMap.GeneratedRegistration.Tests", "test\Dapper.FluentMap.GeneratedRegistration.Tests\Dapper.FluentMap.GeneratedRegistration.Tests.csproj", "{87E09F49-F805-44EB-BA59-87C93C68497D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +53,30 @@ Global {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|Any CPU.ActiveCfg = Release|Any CPU {DFB62D87-9A74-40DF-A930-8F61A53E0F1B}.Release|Any CPU.Build.0 = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {424B90AD-406E-4CC1-B0F4-917F47A06E4D}.Release|Any CPU.Build.0 = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5059D11-D45B-4793-B6E0-7758F57AC0E1}.Release|Any CPU.Build.0 = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E23213D-A547-4FF6-BB58-8793860C18FE}.Release|Any CPU.Build.0 = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5}.Release|Any CPU.Build.0 = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB}.Release|Any CPU.Build.0 = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87E09F49-F805-44EB-BA59-87C93C68497D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -50,6 +86,12 @@ Global {8901F2FD-F98B-484B-A20A-7844A39C7458} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} {E60B79F6-FE71-44E0-BE88-BFA269378EDB} = {580E3446-6579-4414-9875-970849E635E5} {DFB62D87-9A74-40DF-A930-8F61A53E0F1B} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {424B90AD-406E-4CC1-B0F4-917F47A06E4D} = {580E3446-6579-4414-9875-970849E635E5} + {F5059D11-D45B-4793-B6E0-7758F57AC0E1} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {2E23213D-A547-4FF6-BB58-8793860C18FE} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {25768DB1-489F-4544-BDD4-8B0D0E88C6E5} = {580E3446-6579-4414-9875-970849E635E5} + {BA72BEA0-BB6E-41EE-ABFE-215FF0A1E9BB} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} + {87E09F49-F805-44EB-BA59-87C93C68497D} = {742442F2-CAE7-4DC8-BD73-8C54C0005A53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {10834736-59FD-47FF-9344-096247DC48CD} diff --git a/README.md b/README.md index 4f6f0b7..68dee29 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,839 @@ -## 📦 Archived -This repository is archived as I'm not using this library myself anymore and have no time maintaining it. Thanks for using it. +# FluentMap -
+[Português (Brasil)](#português-brasil) +FluentMap provides a fluent API for mapping .NET object properties to database columns used by [Dapper](https://github.com/DapperLib/Dapper), keeping persistence attributes out of your POCOs. -# Dapper.FluentMap -Provides a simple API to fluently map POCO properties to database columns when using Dapper. +> This repository originated from the archived `Dapper.FluentMap` project and is being evolved in this fork. Some legacy project metadata still reflects the original package history. -
+## Why FluentMap? -| Windows | Linux/OSX | NuGet | -| --- | --- | --- | -| [![Windows Build status](https://ci.appveyor.com/api/projects/status/x6grw3cjuyud9c76?svg=true)](https://ci.appveyor.com/project/henkmollema/dapper-fluentmap) | [![Linux Build Status](https://travis-ci.org/henkmollema/Dapper-FluentMap.svg?branch=master)](https://travis-ci.org/henkmollema/Dapper-FluentMap) | [![NuGet Version](http://img.shields.io/nuget/v/Dapper.FluentMap.svg)](https://www.nuget.org/packages/Dapper.FluentMap/ "NuGet version") | +Dapper maps columns to members by name. FluentMap is useful when your database shape does not match your domain model, or when you want the mapping rules to live outside the model classes. -### Introduction +Use FluentMap to: -This [Dapper](https://github.com/StackExchange/dapper-dot-net) extension allows you to fluently configure the mapping between POCO properties and database columns. This keeps your POCO's clean of mapping attributes. The functionality is similar to [Entity Framework Fluent API](http://msdn.microsoft.com/nl-nl/data/jj591617.aspx). If you have any questions, suggestions or bugs, please don't hesitate to [contact me](mailto:henkmollema@gmail.com) or create an issue. +- map properties to columns explicitly; +- ignore mapped properties; +- apply naming conventions or naming policies; +- compose explicit maps, inherited maps and conventions; +- inspect and validate configuration; +- opt into FluentMap-controlled materialization for nested objects, immutable types, value objects and mapping profiles. -
+## Installation -### Download -[![Download Dapper.FluentMap on NuGet](http://i.imgur.com/Rs483do.png "Download Dapper.FluentMap on NuGet")](https://www.nuget.org/packages/Dapper.FluentMap) +Install the package that matches the functionality you need: -
+| Package | Purpose | +|---|---| +| `Dapper.FluentMap` | Core mapping API and Dapper integration. | +| `Dapper.FluentMap.Dommel` | Optional Dommel integration for table, key and generated-column mapping. | +| `Dapper.FluentMap.Analyzers` | Roslyn analyzers for statically provable configuration mistakes. | +| `Dapper.FluentMap.Generators` | Source generator for build-time map registration. | + +```powershell +Install-Package Dapper.FluentMap +``` + +or: + +```bash +dotnet add package Dapper.FluentMap +``` + +The core package targets `netstandard2.0` and depends on Dapper. + +## Quick Start -### Usage -#### Manual mapping -You can map property names manually using the [`EntityMap`](https://github.com/henkmollema/Dapper-FluentMap/blob/master/src/Dapper.FluentMap/Mapping/EntityMap.cs) class. When creating a derived class, the constructor gives you access to the `Map` method, allowing you to specify to which database column name a certain property of `TEntity` should map to. ```csharp -public class ProductMap : EntityMap +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + +FluentMapper.Initialize(config => +{ + config.AddMap(new CustomerMap()); +}); + +var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); +``` + +Call `FluentMapper.Initialize(...)` during application startup and treat the effective configuration as read-only once queries begin. + +## Mapping + +Create a map by deriving from `EntityMap`: + +```csharp +public sealed class ProductMap : EntityMap { public ProductMap() { - // Map property 'Name' to column 'strName'. - Map(p => p.Name) - .ToColumn("strName"); + Map(product => product.Id).ToColumn("product_id"); + Map(product => product.Name).ToColumn("product_name", caseSensitive: false); + Map(product => product.LastModified).Ignore(); + } +} +``` + +Explicit mappings take precedence over convention mappings. Unmapped members fall back to Dapper's normal behavior. - // Ignore the 'LastModified' property when mapping. - Map(p => p.LastModified) - .Ignore(); +Inherited explicit mappings can be included when the derived entity should reuse a base entity map: + +```csharp +public sealed class PreferredCustomerMap : EntityMap +{ + public PreferredCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); } } ``` -Column names are mapped case sensitive by default. You can change this by specifying the `caseSensitive` parameter in the `ToColumn()` method: `Map(p => p.Name).ToColumn("strName", caseSensitive: false)`. +Register the base map before the derived map. + +## Configuration + +Register maps explicitly: -**Initialization:** ```csharp FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddMap(); +}); +``` + +Assembly scanning is available for normal runtime scenarios: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMapsFromAssemblyContaining(); + config.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); +}); +``` + +Use explicit registration for trimmed or Native AOT applications. + +You can validate the current configuration after registration: + +```csharp +FluentMapper.Initialize(config => config.AddMap()); +FluentMapper.Validate(); +``` + +For read-only inspection, use `FluentMapper.GetEntityMaps()` and `FluentMapper.GetTypeConventions()`. The public mutable dictionaries `FluentMapper.EntityMaps` and `FluentMapper.TypeConventions` remain for compatibility, but new code should prefer the registration APIs. + +## Conventions and Naming Policies + +Conventions let you map repeated column patterns: + +```csharp +using Dapper.FluentMap.Conventions; + +public sealed class PrefixConvention : Convention +{ + public PrefixConvention() { - config.AddMap(new ProductMap()); - }); + Properties() + .Configure(property => property.HasPrefix("col")); + } +} + +FluentMapper.Initialize(config => +{ + config.AddConvention() + .ForEntity(); +}); ``` -#### Convention based mapping -When you have a lot of entity types, creating manual mapping classes can become plumbing. If your column names adhere to some kind of naming convention, you might be better off by configuring a mapping convention. +Naming policies cover common name transformations: + +```csharp +using Dapper.FluentMap.Naming; + +FluentMapper.Initialize(config => +{ + config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); +}); +``` + +Available policies include `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` and composition with `Then(...)`, `WithPrefix(...)` and `WithSuffix(...)`. + +## Immutable Types and Constructor Mapping + +FluentMap participates in Dapper constructor mapping for root-level explicit mappings: -You can create a convention by creating a class which derives from the [`Convention`](https://github.com/henkmollema/Dapper-FluentMap/blob/master/src/Dapper.FluentMap/Conventions/Convention.cs) class. In the contructor you can configure the property conventions: ```csharp -public class TypePrefixConvention : Convention +public sealed class Customer { - public TypePrefixConvention() + public Customer(int id, string fullName) { - // Map all properties of type int and with the name 'id' to column 'autID'. - Properties() - .Where(c => c.Name.ToLower() == "id") - .Configure(c => c.HasColumnName("autID")); + Id = id; + FullName = fullName; + } + + public int Id { get; } - // Prefix all properties of type string with 'str' when mapping to column names. - Properties() - .Configure(c => c.HasPrefix("str")); + public string FullName { get; } +} - // Prefix all properties of type int with 'int' when mapping to column names. - Properties() - .Configure(c => c.HasPrefix("int")); +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); } } ``` -When initializing Dapper.FluentMap with conventions, the entities on which a convention applies must be configured. You can choose to either configure the entities explicitly or use assembly scanning. +When you need FluentMap to build nested immutable objects or value objects, use `QueryMapped*`. + +## Nested Object Mapping + +Nested member paths can be configured with the same `Map(...)` API: ```csharp -FluentMapper.Initialize(config => +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() { - // Configure entities explicitly. - config.AddConvention() - .ForEntity() - .ForEntity; + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } +} +``` - // Configure all entities in a certain assembly with an optional namespaces filter. - config.AddConvention() - .ForEntitiesInAssembly(typeof(Product).Assembly, "App.Domain.Model"); +Use FluentMap's opt-in query helpers to materialize nested object graphs: - // Configure all entities in the current assembly with an optional namespaces filter. - config.AddConvention() - .ForEntitiesInCurrentAssembly("App.Domain.Model.Catalog", "App.Domain.Model.Order"); - }); +```csharp +var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); ``` -##### Transformations -The convention API allows you to configure transformation of property names to database column names. An implementation would look like this: +`QueryMapped*` creates supported intermediate objects, preserves null semantics for nested subtrees and rejects unsupported paths with `FluentMapConfigurationException`. + +## Value Objects + +For scalar value objects mapped as a whole property, prefer a Dapper `TypeHandler`: + ```csharp -public class PropertyTransformConvention : Convention +Map(customer => customer.Cpf).ToColumn("cpf"); +``` + +For value objects mapped through their components, `QueryMapped*` can construct them through matching public constructors: + +```csharp +public sealed class CustomerMap : EntityMap { - public PropertyTransformConvention() + public CustomerMap() { - Properties() - .Configure(c => c.Transform(s => Regex.Replace(input: s, pattern: "([A-Z])([A-Z][a-z])|([a-z0-9])([A-Z])", replacement: "$1$3_$2$4"))); + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); } } + +var customer = connection.QueryMappedSingle( + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); ``` -This configuration will map camel case property names to underscore seperated database column names (`UrlOptimizedName` -> `Url_Optimized_Name`). +Factory methods are not used by the current runtime materializer. -
+## Mapping Profiles -### [Dommel](https://github.com/henkmollema/Dommel) -Dommel contains a set of extensions methods providing easy CRUD operations using Dapper. One of the goals was to provide extension points for resolving table and column names. [Dapper.FluentMap.Dommel](https://github.com/henkmollema/Dapper-FluentMap/tree/master/src/Dapper.FluentMap.Dommel) implements certain interfaces of Dommel and uses the configured mapping. It also provides more mapping functionality. +Profiles are opt-in mappings for the same entity under different SQL shapes: -#### [`PM> Install-Package Dapper.FluentMap.Dommel`](https://www.nuget.org/packages/Dapper.FluentMap.Dommel) +```csharp +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } +} + +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddProfile(); +}); + +var legacy = connection.QueryMappedSingle( + "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); +``` + +Profiles are selected per `QueryMapped()` operation. They do not replace the global Dapper type map for the entity. + +## Diagnostics + +Use runtime validation to fail fast after configuration: + +```csharp +FluentMapper.Validate(); +``` + +Use `Explain()` or `Explain()` to inspect the effective mapping: + +```csharp +var explanation = FluentMapper.Explain(); + +foreach (var member in explanation.Members) +{ + Console.WriteLine($"{member.MemberPath} -> {member.ColumnName} ({member.Source})"); +} +``` + +## Source Generator and Analyzers + +`Dapper.FluentMap.Analyzers` reports configuration mistakes that can be proven at compile time, such as invalid map expressions, duplicate member paths, duplicate columns and invalid profile registrations. It complements runtime validation and does not execute map constructors or scan assemblies. + +`Dapper.FluentMap.Generators` discovers eligible `IEntityMap` implementations in the current compilation and emits `AddGeneratedMappings()`: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +Generated registration calls the existing `AddMap()` / `AddProfile()` paths. It does not generate database materializers, scan referenced assemblies or replace `FluentMapper.Validate()`. + +## Trimming / Native AOT + +FluentMap has different levels of support depending on the API: + +| API area | Trimming / Native AOT status | +|---|---| +| Explicit `AddMap()` registration | Preferred path for trimmed and Native AOT applications. | +| Generated registration | Useful alternative to assembly scanning for maps in the current compilation. | +| Assembly scanning APIs | Reflection-discovery based and annotated as trimming-sensitive. | +| `QueryMapped*` | Runtime reflection and dynamic-code based; annotated with trimming and dynamic-code warnings. | + +Do not treat the package as fully Native AOT safe just because explicit registration works. Prefer explicit or generated registration and avoid reflection scanning in trimmed applications. + +## Dapper Integration + +FluentMap installs Dapper type maps for configured entities. The normal Dapper APIs continue to be the default path for root-level mapping: + +```csharp +connection.Query(sql); +connection.QuerySingle(sql); +``` + +Use FluentMap query helpers when you need FluentMap-controlled advanced materialization: -#### Usage -##### `DommelEntityMap` -This class derives from `EntityMap` and allows you to map an entity to a database table using the `ToTable()` method: +```csharp +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +connection.QueryMappedSingle(sql); +``` + +`QueryMapped*` returns buffered results and is the path that supports nested object materialization, constructor-built value objects and profile-specific mapping. + +## Dommel + +Install `Dapper.FluentMap.Dommel` when using [Dommel](https://github.com/henkmollema/Dommel): + +```bash +dotnet add package Dapper.FluentMap.Dommel +``` + +Create maps with `DommelEntityMap` when you need Dommel-specific table and key metadata: ```csharp -public class ProductMap : DommelEntityMap +using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Dommel; + +public sealed class ProductMap : DommelEntityMap { public ProductMap() { - ToTable("tblProduct"); + ToTable("products"); + Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); + } +} +``` + +Enable Dommel integration during FluentMap configuration: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(new ProductMap()); + config.ForDommel(); +}); +``` + +## Current Limitations + +- FluentMap configuration is process-wide. Configure at startup and avoid changing mappings while queries are running. +- Assembly scanning depends on reflection discovery and is not the recommended path for trimmed or Native AOT applications. +- `QueryMapped*` uses runtime metadata and dynamic code; it is not the Native AOT-safe materialization path. +- Mapping profiles are selected only through `QueryMapped()` APIs. +- `QueryMapped*` is buffered; it does not expose unbuffered streaming. +- Value object construction uses matching public constructors, not factory methods. + +## Contributing + +Keep changes small, compatible with the public API and covered by focused tests. The core library should remain a FluentMap layer for Dapper, not an ORM, SQL generator or CRUD abstraction. + +Typical validation: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build +``` + +## License + +FluentMap is licensed under the [MIT License](LICENSE). + +--- + +# Português (Brasil) + +[Back to English](#fluentmap) + +FluentMap fornece uma API fluente para mapear propriedades de objetos .NET para colunas de banco de dados usadas pelo [Dapper](https://github.com/DapperLib/Dapper), mantendo atributos de persistência fora dos seus POCOs. + +> Este repositório se originou do projeto arquivado `Dapper.FluentMap` e está sendo evoluído neste fork. Alguns metadados legados ainda refletem o histórico do pacote original. + +## Por Que FluentMap? + +O Dapper mapeia colunas para membros pelo nome. FluentMap é útil quando o formato do banco não combina com o modelo de domínio, ou quando você quer manter as regras de mapeamento fora das classes do modelo. + +Use FluentMap para: + +- mapear propriedades para colunas explicitamente; +- ignorar propriedades mapeadas; +- aplicar convenções ou políticas de nomenclatura; +- compor mapas explícitos, mapas herdados e convenções; +- inspecionar e validar a configuração; +- optar por materialização controlada pelo FluentMap para objetos aninhados, tipos imutáveis, Value Objects e profiles de mapeamento. + +## Instalação + +Instale o pacote conforme a funcionalidade necessária: + +| Pacote | Finalidade | +|---|---| +| `Dapper.FluentMap` | API principal de mapeamento e integração com Dapper. | +| `Dapper.FluentMap.Dommel` | Integração opcional com Dommel para tabela, chave e colunas geradas. | +| `Dapper.FluentMap.Analyzers` | Analyzers Roslyn para erros de configuração detectáveis estaticamente. | +| `Dapper.FluentMap.Generators` | Source generator para registro de maps em tempo de build. | + +```powershell +Install-Package Dapper.FluentMap +``` + +ou: + +```bash +dotnet add package Dapper.FluentMap +``` + +O pacote principal tem target `netstandard2.0` e depende do Dapper. + +## Início Rápido - // ... +```csharp +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); } } + +FluentMapper.Initialize(config => +{ + config.AddMap(new CustomerMap()); +}); + +var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); ``` -##### `DommelPropertyMap` -This class derives `PropertyMap` and allows you to specify the key property of an entity using the `IsKey` method: +Chame `FluentMapper.Initialize(...)` durante o startup da aplicação e trate a configuração efetiva como somente leitura depois que as consultas começarem. + +## Mapeamento + +Crie um map herdando de `EntityMap`: ```csharp -public class ProductMap : DommelEntityMap +public sealed class ProductMap : EntityMap { public ProductMap() { - Map(p => p.Id).IsKey(); + Map(product => product.Id).ToColumn("product_id"); + Map(product => product.Name).ToColumn("product_name", caseSensitive: false); + Map(product => product.LastModified).Ignore(); } } ``` -You can configure Dapper.FluentMap.Dommel in the `FluentMapper.Initialize()` method: +Mapeamentos explícitos têm precedência sobre convenções. Membros não mapeados usam o comportamento normal do Dapper. + +Mapeamentos explícitos herdados podem ser incluídos quando a entidade derivada deve reutilizar um map da entidade base: + +```csharp +public sealed class PreferredCustomerMap : EntityMap +{ + public PreferredCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } +} +``` + +Registre o map da base antes do map derivado. + +## Configuração + +Registre maps explicitamente: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddMap(); +}); +``` + +Assembly scanning está disponível para cenários normais de runtime: ```csharp FluentMapper.Initialize(config => +{ + config.AddMapsFromAssemblyContaining(); + config.AddMapsFromAssembly(typeof(CustomerMap).Assembly, "App.Domain.Maps"); +}); +``` + +Use registro explícito em aplicações com trimming ou Native AOT. + +Você pode validar a configuração atual depois do registro: + +```csharp +FluentMapper.Initialize(config => config.AddMap()); +FluentMapper.Validate(); +``` + +Para inspeção somente leitura, use `FluentMapper.GetEntityMaps()` e `FluentMapper.GetTypeConventions()`. Os dicionários públicos mutáveis `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem por compatibilidade, mas código novo deve preferir as APIs de registro. + +## Convenções e Políticas de Nomenclatura + +Convenções permitem mapear padrões repetidos de colunas: + +```csharp +using Dapper.FluentMap.Conventions; + +public sealed class PrefixConvention : Convention +{ + public PrefixConvention() { - config.AddMap(new ProductMap()); - config.ForDommel(); - }); + Properties() + .Configure(property => property.HasPrefix("col")); + } +} + +FluentMapper.Initialize(config => +{ + config.AddConvention() + .ForEntity(); +}); +``` + +Políticas de nomenclatura cobrem transformações comuns: + +```csharp +using Dapper.FluentMap.Naming; + +FluentMapper.Initialize(config => +{ + config.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false) + .ForEntity(); +}); +``` + +As políticas disponíveis incluem `Identity`, `SnakeCase`, `Prefix(...)`, `Suffix(...)`, `Custom(...)` e composição com `Then(...)`, `WithPrefix(...)` e `WithSuffix(...)`. + +## Tipos Imutáveis e Constructor Mapping + +FluentMap participa do constructor mapping do Dapper para mapeamentos explícitos no nível raiz: + +```csharp +public sealed class Customer +{ + public Customer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } +} +``` + +Quando você precisa que o FluentMap construa objetos aninhados imutáveis ou Value Objects, use `QueryMapped*`. + +## Mapeamento de Objetos Aninhados + +Caminhos aninhados usam a mesma API `Map(...)`: + +```csharp +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } +} +``` + +Use os helpers opt-in do FluentMap para materializar o grafo de objetos: + +```csharp +var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); +``` + +`QueryMapped*` cria objetos intermediários suportados, preserva semântica de null em subárvores aninhadas e rejeita caminhos não suportados com `FluentMapConfigurationException`. + +## Value Objects + +Para Value Objects escalares mapeados como uma propriedade inteira, prefira um `TypeHandler` do Dapper: + +```csharp +Map(customer => customer.Cpf).ToColumn("cpf"); +``` + +Para Value Objects mapeados pelos seus componentes, `QueryMapped*` pode construí-los por construtores públicos compatíveis: + +```csharp +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} + +var customer = connection.QueryMappedSingle( + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); +``` + +Factory methods não são usadas pelo materializador de runtime atual. + +## Mapping Profiles + +Profiles são mapeamentos opt-in para a mesma entidade em formatos SQL diferentes: + +```csharp +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : + EntityMap, + IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } +} + +FluentMapper.Initialize(config => +{ + config.AddMap(); + config.AddProfile(); +}); + +var legacy = connection.QueryMappedSingle( + "SELECT 7 AS id, 'Legacy Ltd.' AS legal_name;"); +``` + +Profiles são selecionados por operação com `QueryMapped()`. Eles não substituem o type map global do Dapper para a entidade. + +## Diagnósticos + +Use validação em runtime para falhar cedo depois da configuração: + +```csharp +FluentMapper.Validate(); +``` + +Use `Explain()` ou `Explain()` para inspecionar o mapeamento efetivo: + +```csharp +var explanation = FluentMapper.Explain(); + +foreach (var member in explanation.Members) +{ + Console.WriteLine($"{member.MemberPath} -> {member.ColumnName} ({member.Source})"); +} +``` + +## Source Generator e Analyzers + +`Dapper.FluentMap.Analyzers` reporta erros de configuração que podem ser provados em tempo de compilação, como expressões de map inválidas, caminhos de membros duplicados, colunas duplicadas e registros de profile inválidos. Ele complementa a validação de runtime e não executa construtores de maps nem faz scan de assemblies. + +`Dapper.FluentMap.Generators` descobre implementações elegíveis de `IEntityMap` na compilação atual e emite `AddGeneratedMappings()`: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +O registro gerado chama os caminhos existentes `AddMap()` / `AddProfile()`. Ele não gera materializadores de banco, não escaneia assemblies referenciados e não substitui `FluentMapper.Validate()`. + +## Trimming / Native AOT + +FluentMap tem níveis diferentes de suporte conforme a API: + +| Área da API | Status para trimming / Native AOT | +|---|---| +| Registro explícito `AddMap()` | Caminho preferencial para aplicações com trimming e Native AOT. | +| Registro gerado | Alternativa útil ao assembly scanning para maps da compilação atual. | +| APIs de assembly scanning | Baseadas em descoberta por reflection e anotadas como sensíveis a trimming. | +| `QueryMapped*` | Baseado em reflection e código dinâmico em runtime; anotado com warnings de trimming e dynamic code. | + +Não trate o pacote como totalmente seguro para Native AOT apenas porque o registro explícito funciona. Prefira registro explícito ou gerado e evite scanning por reflection em aplicações com trimming. + +## Integração com Dapper + +FluentMap instala type maps do Dapper para entidades configuradas. As APIs normais do Dapper continuam sendo o caminho padrão para mapeamento no nível raiz: + +```csharp +connection.Query(sql); +connection.QuerySingle(sql); +``` + +Use os helpers de consulta do FluentMap quando precisar de materialização avançada controlada pelo FluentMap: + +```csharp +connection.QueryMapped(sql); +connection.QueryMappedSingle(sql); +connection.QueryMappedSingle(sql); +``` + +`QueryMapped*` retorna resultados bufferizados e é o caminho que suporta materialização de objetos aninhados, Value Objects construídos por construtor e mapeamento específico por profile. + +## Dommel + +Instale `Dapper.FluentMap.Dommel` ao usar [Dommel](https://github.com/henkmollema/Dommel): + +```bash +dotnet add package Dapper.FluentMap.Dommel +``` + +Crie maps com `DommelEntityMap` quando precisar de metadados específicos do Dommel para tabela e chave: + +```csharp +using Dapper.FluentMap.Dommel.Mapping; +using Dapper.FluentMap.Dommel; + +public sealed class ProductMap : DommelEntityMap +{ + public ProductMap() + { + ToTable("products"); + Map(product => product.Id).ToColumn("product_id").IsKey().IsIdentity(); + } +} +``` + +Ative a integração com Dommel durante a configuração do FluentMap: + +```csharp +FluentMapper.Initialize(config => +{ + config.AddMap(new ProductMap()); + config.ForDommel(); +}); +``` + +## Limitações Atuais + +- A configuração do FluentMap é global no processo. Configure no startup e evite alterar mappings enquanto consultas estão em execução. +- Assembly scanning depende de descoberta por reflection e não é o caminho recomendado para aplicações com trimming ou Native AOT. +- `QueryMapped*` usa metadados de runtime e código dinâmico; ele não é o caminho de materialização seguro para Native AOT. +- Mapping profiles são selecionados apenas pelas APIs `QueryMapped()`. +- `QueryMapped*` é bufferizado; ele não expõe streaming unbuffered. +- A construção de Value Objects usa construtores públicos compatíveis, não factory methods. + +## Contribuição + +Mantenha mudanças pequenas, compatíveis com a API pública e cobertas por testes focados. A biblioteca principal deve continuar sendo uma camada de FluentMap para Dapper, não um ORM, gerador de SQL ou abstração de CRUD. + +Validação típica: + +```bash +dotnet restore ./Dapper.FluentMap.sln +dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore +dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build ``` -## Resultado da Etapa 1 +## Licença -- Capacidades estabilizadas: parsing de expressoes por membro real, composicao mapping explicito/convention/fallback do Dapper, testes de integracao com materializacao real e cache interno estruturado. -- Principais decisoes: `FluentMapper` permanece como fachada publica; `MappingRegistry` e o dono interno de mappings/cache; `SqlMapper.SetTypeMap` continua como integracao global necessaria com o Dapper. -- Dividas transferidas: dicionarios publicos mutaveis preservados por compatibilidade, consumo direto pelo Dommel, paralelismo da suite ainda desabilitado, MemberPath/nested objects/Value Objects fora desta etapa. -- Relatorios: `docs/sdd/etapa-1/01-reflection-helper.md`, `docs/sdd/etapa-1/02-mapping-composition.md`, `docs/sdd/etapa-1/03-dapper-integration-tests.md`, `docs/sdd/etapa-1/04-mapping-registry-cache.md`. +FluentMap é licenciado sob a [MIT License](LICENSE). diff --git a/docs/sdd/etapa-1/01-reflection-helper.md b/docs/sdd/etapa-1/01-reflection-helper.md deleted file mode 100644 index a0b2d86..0000000 --- a/docs/sdd/etapa-1/01-reflection-helper.md +++ /dev/null @@ -1,123 +0,0 @@ -# 01 - ReflectionHelper - -## Specification - -Corrigir a resolucao de propriedades a partir de `Expression>` para usar o membro representado pela propria expression tree, preservando API publica e rejeitando expressoes nao suportadas com erro claro. - -Fora do escopo: MemberPath completo, objetos aninhados, Value Objects, composicao de conventions, MappingRegistry, redesign de cache, records, constructor mapping, mudancas em Dommel e atualizacoes de frameworks ou dependencias. - -## Discovery - -Arquivos analisados: - -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/TestEntity.cs` -- `README.md` - -Consumidores de `ReflectionHelper.GetMemberInfo`: - -- `EntityMapBase.Map(Expression> expression)`, que converte o retorno para `PropertyInfo`. -- Testes unitarios em `ReflectionHelperTests`. - -Formatos aceitos atualmente: - -- `LambdaExpression` cujo corpo seja `MemberExpression`. -- `UnaryExpression` com `ExpressionType.Convert`, usado por propriedades value type em `Expression>`. -- Acesso aninhado simples, como `x => x.Email.Address`, retornando a propriedade final. - -Comportamentos ja cobertos: - -- propriedade comum (`Id`); -- propriedade herdada em entidade derivada; -- nullable/value type com `Convert`; -- propriedade aninhada em value object; -- propriedade aninhada cujo nome coincide com membro de tipo do sistema (`String.Length`). - -Lacunas encontradas: - -- propriedade final cujo nome coincide com outro membro publico do tipo da propria propriedade, como `string.Format` ou `TimeSpan.Duration`; -- expression invalida sem `MemberExpression`, que atualmente retorna `null` e tende a falhar depois com erro indireto. - -Causa raiz: no caminho de `MemberAccess`, o helper obtem `memberExpression.Member`, mas depois procura novamente membros por nome em tipos relacionados (`GetMembers().FirstOrDefault(...)` e `GetMember(member.Name)[0]`). Essa nova busca depende da ordem de reflection e pode retornar `MethodInfo` ou outro membro homonimo em vez do `PropertyInfo` que a expression tree ja identificou. - -## Decision - -Causa raiz confirmada: a resolucao por nome e por primeiro resultado de reflection e ambigua. - -Estrategia escolhida: - -- Desembrulhar `Lambda` e `Convert`. -- Em `MemberAccess`, retornar diretamente o `PropertyInfo` presente em `MemberExpression.Member`. -- Rejeitar `MemberExpression` que nao represente propriedade com `ArgumentException`. -- Rejeitar expressoes nao suportadas com `ArgumentException` clara. -- Manter a assinatura publica de `ReflectionHelper.GetMemberInfo(LambdaExpression)`. - -Alternativas descartadas: - -- Filtrar `GetMember(...)` por `PropertyInfo`: ainda reexecuta uma busca desnecessaria por nome e pode introduzir ambiguidades futuras. -- Criar uma nova abstracao de parsing ou MemberPath: fora do escopo desta entrega. -- Alterar `EntityMap.Map(...)` para nova API publica: desnecessario para corrigir a falha e aumentaria a superficie publica. - -Impacto esperado: - -- Expressoes validas passam a resolver exatamente a propriedade representada pela expression tree. -- Colisoes de nome com membros de `string`, `TimeSpan` ou outros tipos deixam de produzir `InvalidCastException` indireta. -- Expressoes invalidas passam a falhar mais cedo com erro explicito. - -Compatibilidade preservada: - -- API publica e assinaturas existentes. -- Suporte a propriedade simples, propriedade herdada, value types com `Convert` e acesso aninhado ja existente. -- Sem mudancas em Dommel, build, targets ou dependencias. - -## Delivery - -- `ReflectionHelper.GetMemberInfo` passou a: - - validar `lambda == null` com `ArgumentNullException`; - - desembrulhar `Lambda` e `Convert`; - - retornar diretamente o `PropertyInfo` de `MemberExpression.Member`; - - rejeitar membros que nao sejam propriedades com `ArgumentException`; - - rejeitar expressoes nao suportadas com `ArgumentException`. -- Testes de regressao adicionados em `ReflectionHelperTests` para: - - propriedade comum com nome que colide com membro de `string` (`Format`); - - propriedade value type com `Convert` e nome que colide com membro de `TimeSpan` (`Duration`); - - expression invalida (`e.Id.ToString()`). - -Arquivos alterados: - -- `src/Dapper.FluentMap/Utils/ReflectionHelper.cs` -- `test/Dapper.FluentMap.Tests/ReflectionHelperTests.cs` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` - -## Validation - -- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~ReflectionHelperTests"` - - Resultado inicial: falhou antes de executar por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Reexecutado com `NUGET_PACKAGES` temporario: - - restore e build dos testes concluiram; - - execucao abortou porque o runtime `Microsoft.NETCore.App 3.1.0` nao esta instalado na maquina. -- `dotnet build src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - Resultado: sucesso, 0 warnings, 0 erros. -- Harness temporario `net8.0` referenciando o projeto atual: - - Resultado: sucesso; propriedade comum, value type com `Convert`, colisoes `Format`/`Duration` e expression invalida se comportaram como esperado. -- Harness temporario `net8.0` compilando `ReflectionHelper.cs` de `HEAD` antes da alteracao: - - Resultado: falhou como esperado em `Format`, retornando `RuntimeMethodInfo` em vez de `PropertyInfo`. -- `dotnet restore .\Dapper.FluentMap.sln` - - Resultado: sucesso com cache NuGet temporario. -- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - Resultado: sucesso, 0 warnings, 0 erros. -- `dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` - - Resultado: abortou porque os projetos de teste miram `netcoreapp3.1` e o runtime `Microsoft.NETCore.App 3.1.0` nao esta instalado. - -Riscos e limitacoes: - -- A suite oficial nao foi executada ate o fim neste ambiente por ausencia do runtime `netcoreapp3.1`. -- A mudanca torna expressoes invalidas mais explicitas via `ArgumentException`; isso substitui falhas indiretas anteriores como `null` ou `InvalidCastException`. -- Dommel nao recebeu alteracao funcional. diff --git a/docs/sdd/etapa-1/02-mapping-composition.md b/docs/sdd/etapa-1/02-mapping-composition.md deleted file mode 100644 index 890eb74..0000000 --- a/docs/sdd/etapa-1/02-mapping-composition.md +++ /dev/null @@ -1,198 +0,0 @@ -## Specification - -Corrigir a composicao entre mappings explicitos, conventions e fallback padrao do Dapper para que a resolucao de membros siga uma cadeia previsivel: - -1. mapping explicito; -2. convention; -3. `DefaultTypeMap` do Dapper. - -Objetivos: - -- permitir coexistencia de `AddMap(...)` e `AddConvention(...).ForEntity(...)` para o mesmo tipo; -- permitir que mapping explicito sobrescreva convention para a mesma propriedade; -- preservar o fallback do Dapper quando nem mapping explicito nem convention resolvem a coluna; -- eliminar o comportamento em que a ultima chamada a `SqlMapper.SetTypeMap(...)` determina sozinha a estrategia ativa; -- preservar a API publica e evitar o `MappingRegistry` completo previsto para entrega posterior. - -Fora do escopo: - -- MappingRegistry definitivo; -- redesign amplo de cache; -- MemberPath; -- materializacao aninhada; -- Value Objects; -- profiles por tipo; -- constructor mapping; -- alteracoes funcionais no Dommel. - -## Discovery - -Arquivos analisados: - -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs` -- `src/Dapper.FluentMap/Mapping/EntityMap.cs` -- `src/Dapper.FluentMap/Mapping/PropertyMap.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/status.md` - -`status.md` confirmou que `01 - ReflectionHelper` esta `Concluido`. - -Pontos que chamavam `SqlMapper.SetTypeMap(...)`: - -- `FluentMapper.AddTypeMap()` - - instalava `new FluentMapTypeMap()`; - - chamado por `FluentMapConfiguration.AddMap(...)`. -- `FluentMapper.AddTypeMap(Type entityType)` - - instalava `FluentMapTypeMap<>` via reflection; - - chamado por assembly scanning de maps via `ApplyMapsFromAssemblies(...)`. -- `FluentMapper.AddConventionTypeMap()` - - instalava `new FluentConventionTypeMap()`; - - chamado por `FluentConventionConfiguration.ForEntity()`. -- `FluentMapper.AddConventionTypeMap(Type entityType)` - - instalava `FluentConventionTypeMap<>` via reflection; - - chamado por `ForEntitiesInCurrentAssembly(...)` e `ForEntitiesInAssembly(...)`. - -Fluxo atual antes da mudanca: - -- `AddMap(...)` registra o `IEntityMap` em `FluentMapper.EntityMaps` e instala `FluentMapTypeMap`. -- `ApplyMapsFromAssemblies(...)` encontra classes que implementam `IEntityMap<>` e chama `AddMap(...)` por reflection. -- `AddConvention()` cria um `FluentConventionConfiguration`. -- `ForEntity()` materializa `PropertyMap`s da convention, registra a convention em `FluentMapper.TypeConventions` e instala `FluentConventionTypeMap`. -- `ForEntitiesInCurrentAssembly(...)` e `ForEntitiesInAssembly(...)` repetem o mesmo processo para cada tipo exportado filtrado. -- `FluentMapTypeMap` consultava mappings explicitos e depois caia para `DefaultTypeMap`. -- `FluentConventionTypeMap` consultava conventions e depois caia para `DefaultTypeMap`. - -Causa raiz: - -- mappings explicitos e conventions eram estrategias separadas instaladas diretamente no registro global do Dapper; -- para o mesmo tipo de entidade, a chamada mais recente a `SqlMapper.SetTypeMap(...)` substituia a anterior; -- portanto `AddMap(...); AddConvention(...).ForEntity()` deixava apenas convention + default ativa; -- e `AddConvention(...).ForEntity(); AddMap(...)` deixava apenas explicito + default ativo; -- cada type map ja tinha fallback proprio para `DefaultTypeMap`, mas nao havia um type map unico que compusesse explicito e convention antes do fallback. - -Observacoes sobre cache: - -- `MultiTypeMap.TypePropertyMapCache` e compartilhado entre type maps; -- a chave antiga usava apenas `type.FullName` e `columnName`; -- em uma composicao ingênua com dois `CustomPropertyTypeMap`s, um miss do resolver explicito poderia ser cacheado e impedir a convention de ser consultada para a mesma coluna; -- a Entrega 4 continua sendo o local apropriado para redesenhar registry/cache de forma completa. - -## Decision - -Design escolhido: - -- usar `FluentMapTypeMap` como estrategia composta instalada tanto por mappings explicitos quanto por conventions; -- alterar `FluentMapTypeMap` para resolver em uma unica funcao: - - primeiro mappings explicitos em `FluentMapper.EntityMaps`; - - depois conventions em `FluentMapper.TypeConventions`; - - por fim o `DefaultTypeMap` ja presente no `MultiTypeMap`; -- manter `FluentConventionTypeMap` publico e funcional para compatibilidade, mas deixar de instala-lo nos fluxos internos de `AddConventionTypeMap(...)`; -- mover a comparacao de coluna para `MultiTypeMap.MatchColumnNames(...)`, evitando duplicar a regra case-sensitive/case-insensitive entre os type maps. - -Precedencia final: - -1. se a coluna casa com um mapping explicito, ele vence; -2. se o mapping explicito e `Ignore()`, a resolucao para aquela coluna para sem cair no default; -3. se a coluna nao casa com mapping explicito, conventions podem resolver; -4. conventions nao resolvem propriedades que tenham mapping explicito, permitindo override explicito da convention para a mesma propriedade; -5. se nenhuma regra especial resolver, `DefaultTypeMap` permanece disponivel. - -Comportamento em conflito: - -- mapping explicito para a mesma coluna vence por ser consultado antes; -- mapping explicito para a mesma propriedade remove essa propriedade dos candidatos por convention; -- ambiguidades dentro de uma convention continuam usando a excecao existente quando mais de um `PropertyMap` casa com a mesma coluna. - -Compatibilidade: - -- nenhuma API publica foi removida ou alterada; -- `FluentConventionTypeMap` permanece publico; -- `AddConventionTypeMap(...)` passa a instalar `FluentMapTypeMap` para obter a composicao; -- consumidores que observam diretamente `SqlMapper.GetTypeMap(typeof(T))` apos configurar apenas convention podem notar o tipo concreto diferente, mas o comportamento funcional esperado de convention + default permanece. - -Alternativas descartadas: - -- apenas trocar a ordem de chamadas de `SetTypeMap`: manteria o comportamento dependente de ordem e nao comporia as estrategias; -- criar agora um `MappingRegistry`: resolveria parte do problema, mas antecipa a Entrega 4 e ampliaria o escopo; -- empilhar dois `CustomPropertyTypeMap`s independentes: conflitaria com o cache compartilhado quando o primeiro resolver cacheasse misses antes do segundo ser consultado; -- remover `FluentConventionTypeMap`: seria uma quebra desnecessaria de superficie publica. - -## Delivery - -Implementacao: - -- `FluentMapper.AddConventionTypeMap(...)` agora delega para `AddTypeMap(...)`, instalando o type map composto. -- `FluentMapTypeMap` agora consulta mappings explicitos e conventions antes do fallback default. -- `FluentMapTypeMap` ignora candidates de convention para propriedades ja mapeadas explicitamente. -- `MultiTypeMap` recebeu `MatchColumnNames(...)` protegido para compartilhar a regra de comparacao. -- `FluentConventionTypeMap` passou a usar uma chave de cache distinta da chave do type map composto. - -Testes adicionados em `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs`: - -- somente mapping explicito resolve coluna explicita; -- somente convention resolve coluna por prefixo; -- `DefaultTypeMap` resolve coluna quando nenhuma regra especial casa; -- mapping explicito e convention resolvem propriedades diferentes no mesmo tipo; -- mapping explicito sobrescreve convention para a mesma propriedade; -- ordem `AddMap(...)` antes de `AddConvention(...)` nao impede composicao; -- ordem `AddConvention(...)` antes de `AddMap(...)` nao impede composicao; -- case sensitivity de mapping explicito e convention case-insensitive permanecem independentes. - -Implicacoes para MappingRegistry: - -- a entrega cria uma cadeia de resolucao observavel, mas ainda consulta os dicionarios globais existentes; -- a Entrega 4 deve substituir essa consulta direta por descritores/registry mais explicitos e lidar com invalidacao de cache; -- a chave de cache continua deliberadamente simples e nao resolve colisoes por assembly, reinicializacao tardia ou todas as dimensoes de configuracao. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner detectado: VSTest com xUnit v2 (`Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio`) -- projetos de teste: `netcoreapp3.1` - -Comandos executados: - -- `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: falhou antes de executar por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Com `NUGET_PACKAGES` temporario no workspace: - - `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: restore e build passaram; execucao abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado. -- Harness temporario `net8.0` referenciando o projeto atual: - - resultado: passou todos os cenarios de composicao, override, ordem, fallback e case sensitivity. -- Com `NUGET_PACKAGES` temporario: - - `dotnet build src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- Com `NUGET_PACKAGES` temporario: - - `dotnet build Dapper.FluentMap.sln --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `dotnet restore` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet restore` - - resultado: sucesso. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet build --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet test --configuration Release --no-build` - - resultado: abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado para os projetos de teste core e Dommel. -- Com `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-composition`: - - `dotnet test test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` - - resultado: abortou porque `Microsoft.NETCore.App 3.1.0` nao esta instalado. - -Limitacoes: - -- a suite oficial compilou, mas nao executou ate o fim neste ambiente por ausencia do runtime `netcoreapp3.1`; -- Dommel nao recebeu alteracao funcional; a solution completa compilou em Release; -- pack nao foi executado porque a entrega nao altera metadados ou empacotamento NuGet. diff --git a/docs/sdd/etapa-1/03-dapper-integration-tests.md b/docs/sdd/etapa-1/03-dapper-integration-tests.md deleted file mode 100644 index 2abbefa..0000000 --- a/docs/sdd/etapa-1/03-dapper-integration-tests.md +++ /dev/null @@ -1,202 +0,0 @@ -# 03 - Testes De Integracao Com Dapper - -## Specification - -Criar uma baseline pequena e deterministica de testes de integracao que exercite o comportamento publico do `Dapper.FluentMap` atraves do proprio Dapper materializando objetos a partir de SQL. - -O fluxo protegido e: - -```text -SQL -| -Dapper Query -| -ITypeMap do FluentMap -| -Objeto materializado -| -Assert sobre comportamento observavel -``` - -Fora do escopo: - -- Docker, Testcontainers, PostgreSQL, SQL Server ou servicos externos; -- redesign de estado global, registry ou cache; -- alteracoes funcionais no core; -- cobertura exaustiva de todos os testes unitarios existentes; -- mudanca de target dos projetos de teste. - -## Discovery - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/02-mapping-composition.md` -- `Dapper.FluentMap.sln` -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/TestEntity.cs` - -Entregas anteriores: - -- `01 - ReflectionHelper` esta marcada como `Concluido` em `status.md`. -- `02 - Composicao de mappings` esta marcada como `Concluido` em `status.md`. - -Suite atual: - -- framework de testes: xUnit v2; -- runner: VSTest (`Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`); -- target real dos projetos de teste: `netcoreapp3.1`; -- SDK local: `10.0.302`; -- nao ha `global.json`, `Directory.Build.props` ou `Directory.Packages.props` relevantes; -- a suite principal nao tinha testes com conexao real ou provider SQL; -- os testes existentes exercitavam `SqlMapper.GetTypeMap(...).GetMember(...)`, mas nao `Query`. - -Dependencias existentes: - -- `Dapper.FluentMap` referencia `Dapper` `2.0.35`; -- `Dapper.FluentMap.Tests` referenciava apenas o projeto core, xUnit e VSTest. - -Estado global e isolamento: - -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` sao dicionarios estaticos globais; -- `FluentMapper.Initialize(...)` reutiliza uma instancia estatica de `FluentMapConfiguration`; -- `SqlMapper.SetTypeMap(...)` altera o registro global de type maps do Dapper por tipo; -- `MultiTypeMap.TypePropertyMapCache` e um cache estatico compartilhado, sem reset publico; -- `ManualMappingTests.cs` desabilita paralelismo no assembly com `CollectionBehavior(DisableTestParallelization = true)`; -- testes existentes limpam `EntityMaps` e `TypeConventions`; `MappingCompositionTests` tambem chama `SqlMapper.SetTypeMap(type, null)` para os tipos afetados. - -Riscos identificados: - -- testes que reutilizam o mesmo tipo com configuracoes diferentes podem sofrer interferencia por `SqlMapper.SetTypeMap`; -- o cache estatico pode reter misses ou hits por chave `type.FullName + columnName`; -- nao ha mecanismo publico ou interno dedicado para reset atomico do estado global; -- reabilitar paralelismo sem resolver o estado global seria inseguro. - -## Decision - -Provider escolhido: `Microsoft.Data.Sqlite` com SQLite in-memory. - -Motivos: - -- roda localmente e sem rede; -- nao exige Docker, servico externo ou processo separado; -- permite exercitar `IDbConnection`, SQL real e `Dapper.QuerySingle`; -- adiciona somente uma dependencia de teste; -- a versao `3.1.32` e compativel com o target atual `netcoreapp3.1`, evitando misturar modernizacao de runtime nesta entrega. - -Estrategia de banco: - -- cada teste abre uma nova `SqliteConnection` com `Data Source=:memory:`; -- os testes usam `SELECT` direto para projetar uma linha deterministica; -- a conexao e descartada ao final do teste; -- nenhum arquivo temporario de banco e criado. - -Estrategia de isolamento: - -- cada teste usa um tipo de entidade especifico, evitando colisao no cache por tipo e coluna; -- antes e depois de cada teste, `EntityMaps` e `TypeConventions` sao limpos; -- antes e depois de cada teste, `SqlMapper.SetTypeMap(type, null)` remove o type map do Dapper para os tipos tocados; -- o cache interno de `MultiTypeMap` nao e limpo porque nao ha API para isso e a Entrega 4 deve tratar registry/cache. - -Estrategia de paralelismo: - -- o assembly ja tem paralelismo desabilitado; -- isso continua necessario por causa do estado global do FluentMap e do registro global do Dapper; -- a entrega nao tenta resolver essa restricao arquitetural. - -Cenarios selecionados: - -- mapping padrao do Dapper; -- mapping explicito de nome de coluna; -- convention por prefixo; -- composicao explicit + convention; -- override explicito sobre convention; -- correcao da Entrega 1 exercitada via materializacao real com propriedade `Format`; -- mapping explicito case-insensitive. - -## Delivery - -Implementacao: - -- adicionada dependencia `Microsoft.Data.Sqlite` `3.1.32` ao projeto `Dapper.FluentMap.Tests`; -- adicionada a classe `DapperIntegrationTests`; -- cada teste usa `Dapper.QuerySingle` contra SQLite in-memory; -- os asserts validam propriedades materializadas, nao detalhes internos de `ITypeMap`; -- nenhum codigo de producao foi alterado. - -Testes adicionados: - -- `DefaultDapperMappingShouldMaterializeProperties` -- `ExplicitMappingShouldMaterializeConfiguredColumn` -- `ConventionShouldMaterializeConfiguredColumns` -- `ExplicitMappingAndConventionShouldMaterializeTogether` -- `ExplicitMappingShouldOverrideConventionDuringMaterialization` -- `ExpressionResolvedPropertyShouldMaterializeWhenNameCollidesWithStringMember` -- `CaseInsensitiveExplicitMappingShouldMaterializeColumnWithDifferentCase` - -Arquivos alterados: - -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/03-dapper-integration-tests.md` - -## Validation - -Comandos executados: - -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets`). -- `NUGET_PACKAGES=.\.nuget-temp dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - resultado: sucesso antes da alteracao. -- `DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingCompositionTests"` - - resultado: sucesso antes da alteracao, 8 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp dotnet restore` - - resultado: sucesso. -- `NUGET_PACKAGES=.\.nuget-temp dotnet build --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~DapperIntegrationTests"` - - resultado: sucesso, 7 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test --configuration Release --no-build` - - resultado: sucesso, 38 testes aprovados no projeto core e 7 testes aprovados no projeto Dommel. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build` - - resultado: sucesso, 38 testes aprovados. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test --configuration Release` - - resultado: sucesso, 38 testes aprovados no projeto core e 7 testes aprovados no projeto Dommel. -- `NUGET_PACKAGES=.\.nuget-temp DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release` - - resultado: sucesso, 38 testes aprovados. - -Observacoes: - -- `DOTNET_ROLL_FORWARD=Major` foi necessario apenas para executar os testes `netcoreapp3.1` neste ambiente, que possui runtimes 8.0 e 10.0, mas nao o runtime 3.1. -- Os testes de integracao usam apenas SQLite in-memory e nao persistem arquivos temporarios de banco. -- A primeira tentativa de build apos adicionar a dependencia falhou porque restore e build foram executados em paralelo; apos restore sequencial, o build passou. - -## Follow-Up Para Entrega 4 - -- Criar uma estrategia explicita para reset ou substituicao segura do estado global em testes. -- Definir invalidacao do cache estatico quando mapas ou conventions forem alterados. -- Avaliar chave de cache estruturada que considere tipo, coluna, comparacao e estrategia instalada. -- Avaliar encapsulamento dos dicionarios publicos globais antes de qualquer tentativa de reabilitar paralelismo. -- Considerar se `FluentMapper.Initialize(...)` deve continuar reutilizando uma configuracao estatica mutavel. - -## Achado De Baseline - -- A execucao completa da suite revelou que `ReflectionHelperTests.GetMemberInfo_ReturnsProperty_OfDerivedType` ainda esperava o `PropertyInfo` retornado por `typeof(DerivedTestEntity).GetProperty("Id")`. -- Essa expectativa conflitava com a decisao da Entrega 1 de retornar diretamente o `MemberExpression.Member`, que para propriedade herdada aponta para `TestEntity.Id`. -- O teste foi ajustado para validar a decisao ja documentada; nenhum codigo de producao foi alterado. diff --git a/docs/sdd/etapa-1/04-mapping-registry-cache.md b/docs/sdd/etapa-1/04-mapping-registry-cache.md deleted file mode 100644 index 89e1818..0000000 --- a/docs/sdd/etapa-1/04-mapping-registry-cache.md +++ /dev/null @@ -1,225 +0,0 @@ -# 04 - MappingRegistry E Cache - -## Specification - -Introduzir uma estrutura interna de registry para centralizar a configuracao de mappings do core e substituir o cache ativo de propriedades, antes baseado em chaves de string concatenadas, por chaves estruturadas. - -Objetivos: - -- preservar a API publica existente; -- manter a composicao definida na Entrega 2: mapping explicito, convention e fallback do Dapper; -- manter a baseline de integracao da Entrega 3; -- reduzir o espalhamento de estado entre `FluentMapper`, type maps e caches; -- definir invalidacao explicita do cache nas reconfiguracoes feitas pela API; -- preparar terreno para melhorias futuras sem redesenhar a API publica. - -Fora do escopo: - -- MemberPath; -- nested object materialization; -- Value Objects; -- inheritance mappings; -- records; -- constructor mapping; -- Roslyn analyzers; -- source generators; -- AOT; -- multiplos profiles; -- redesign completo da API publica. - -## Discovery - -Entregas anteriores confirmadas em `status.md`: - -- `01 - ReflectionHelper`: Concluído. -- `02 - Composicao de mappings`: Concluído. -- `03 - Testes de integracao`: Concluído. - -Arquivos analisados: - -- `AGENTS.md` -- `docs/sdd/etapa-1/README.md` -- `docs/sdd/etapa-1/status.md` -- `docs/sdd/etapa-1/decisions.md` -- `docs/sdd/etapa-1/01-reflection-helper.md` -- `docs/sdd/etapa-1/02-mapping-composition.md` -- `docs/sdd/etapa-1/03-dapper-integration-tests.md` -- `src/Dapper.FluentMap/FluentMapper.cs` -- `src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs` -- `src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs` -- `src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs` -- `test/Dapper.FluentMap.Tests/ManualMappingTests.cs` -- `test/Dapper.FluentMap.Tests/ConventionTests.cs` -- `test/Dapper.FluentMap.Tests/MappingCompositionTests.cs` -- `test/Dapper.FluentMap.Tests/DapperIntegrationTests.cs` - -Estado relacionado a mapping antes da mudanca: - -| Estado | Escrita | Leitura | Lifetime | Thread safety | Invalidacao | -|---|---|---|---|---|---| -| `FluentMapper.EntityMaps` | `FluentMapConfiguration.AddMap` e testes por `Clear()` | `FluentTypeMap`, Dommel e testes | estatico por processo | `ConcurrentDictionary`, mas valores continuam mutaveis | manual em testes; sem cache reset | -| `FluentMapper.TypeConventions` | `FluentConventionConfiguration` e testes por `Clear()` | `FluentTypeMap`, `FluentConventionTypeMap`, Dommel e testes | estatico por processo | `ConcurrentDictionary`, mas lista era atualizada por helper nao atomico | manual em testes; sem cache reset | -| `_configuration` | `FluentMapper.Initialize` reutiliza a mesma instancia | callbacks de configuracao | estatico por processo | sem sincronizacao propria | nao aplicavel | -| `SqlMapper.SetTypeMap` | `FluentMapper.AddTypeMap` e `AddConventionTypeMap` | Dapper durante materializacao | global no Dapper por tipo | responsabilidade do Dapper | testes removiam por tipo | -| `MultiTypeMap.TypePropertyMapCache` | `FluentTypeMap` e `FluentConventionTypeMap` | `FluentTypeMap` e `FluentConventionTypeMap` | estatico por processo | `ConcurrentDictionary` | sem reset definido | - -Problemas resolviveis nesta entrega: - -- remover o cache ativo baseado em strings como `FluentMapTypeMap;{type.FullName};{columnName}`; -- centralizar escrita, leitura, instalacao de type map e invalidacao em um componente interno; -- tornar o reset de testes atomico para dicionarios, cache e type maps do Dapper dos tipos tocados; -- atualizar conventions com `ConcurrentDictionary.AddOrUpdate` e copia de lista, evitando mutacao in-place do valor compartilhado; -- manter os campos publicos existentes como visoes do storage interno por compatibilidade. - -Problemas deliberadamente nao resolvidos: - -- consumidores ainda podem mutar diretamente `EntityMaps` e `TypeConventions`, pois esses campos publicos fazem parte da compatibilidade existente; -- o registro global do Dapper continua necessario porque a extensibilidade de materializacao passa por `SqlMapper.SetTypeMap`; -- o paralelismo da suite continua desabilitado por causa de estado global historico e por Dommel ainda consumir os dicionarios publicos diretamente; -- `MultiTypeMap.TypePropertyMapCache` permanece como membro protegido para evitar quebra de compatibilidade, mas deixou de ser usado pelo core. - -## Decision - -Design adotado: - -```text -FluentMapper public facade - | - v -internal MappingRegistry - | - +-- EntityMaps / TypeConventions public-compatible storage - +-- structured mapping cache - +-- SqlMapper.SetTypeMap integration - | - v -FluentMapTypeMap / FluentConventionTypeMap - | - v -Dapper DefaultTypeMap fallback -``` - -Dono do estado: - -- `MappingRegistry` e o dono interno do storage de `EntityMaps`, `TypeConventions` e cache. -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` continuam publicos e apontam para os mesmos dicionarios do registry. -- `FluentMapConfiguration` e `FluentConventionConfiguration` passam a escrever via `FluentMapper.Registry`. -- `FluentMapTypeMap` e `FluentConventionTypeMap` passam a delegar resolucao ao registry. - -Chave estruturada: - -```csharp -MappingCacheKey -{ - Type Type; - string ColumnName; - MappingCacheOptions Options; -} -``` - -`MappingCacheOptions` diferencia: - -- `FluentMap`: mapping explicito, convention e fallback posterior do Dapper; -- `ConventionOnly`: compatibilidade de `FluentConventionTypeMap`. - -Comparacao de coluna e case sensitivity: - -- a chave usa `ColumnName` com igualdade ordinal para diferenciar chamadas como `case_id` e `CASE_ID`; -- a decisao de match continua por `IPropertyMap.CaseSensitive`, preservando o comportamento atual; -- mudancas de configuracao invalidam as entradas do tipo afetado, entao alteracoes de case sensitivity por API nao reaproveitam resultados antigos. - -Invalidacao: - -- `AddEntityMap` invalida todas as entradas de cache do tipo e reinstala o type map composto no Dapper; -- `AddConvention(Type, Convention)` atualiza a lista de conventions, invalida o tipo e reinstala o type map composto; -- `Reset(params Type[])` limpa entity maps, conventions, cache e remove os type maps do Dapper para os tipos informados. - -Thread safety: - -- dicionarios globais continuam `ConcurrentDictionary`; -- o cache estruturado usa `ConcurrentDictionary`; -- misses sao cacheados como `MappingCacheEntry` com `PropertyInfo` nulo, evitando valor nulo direto no dicionario; -- conventions sao adicionadas por `AddOrUpdate` com copia da lista atual. - -Compatibilidade: - -- nenhuma API publica foi removida ou renomeada; -- `EntityMaps` e `TypeConventions` continuam campos publicos do mesmo tipo; -- `FluentConventionTypeMap` continua publico; -- `SqlMapper.GetTypeMap(typeof(T))` continua recebendo `FluentMapTypeMap` nos fluxos internos de configuracao; -- foi adicionado `InternalsVisibleTo("Dapper.FluentMap.Tests")` para validar registry e cache sem tornar membros publicos. - -## Delivery - -Implementacao: - -- adicionado `MappingRegistry` interno; -- adicionados `MappingCacheKey`, `MappingCacheOptions` e `MappingCacheStrategy`; -- `FluentMapper` passou a manter um registry interno e expor os dicionarios publicos como storage compatibilizado; -- `FluentMapConfiguration.AddMap` passou a registrar mappings pelo registry; -- `FluentConventionConfiguration` passou a registrar conventions pelo registry; -- `FluentMapTypeMap` passou a delegar resolucao composta ao registry; -- `FluentConventionTypeMap` passou a delegar resolucao convention-only ao registry; -- testes do core passaram a usar `FluentMapper.Reset(...)` interno; -- adicionado acesso interno ao assembly de testes. - -Testes adicionados em `MappingRegistryTests`: - -- cache hit para mesma chave estruturada; -- chaves distintas para tipos distintos; -- chaves distintas para nomes de coluna distintos; -- comportamento case-sensitive atual; -- reset/invalidacao de mapping cacheado; -- invalidacao de miss cacheado quando um mapping e registrado depois; -- leitura concorrente basica via type map do Dapper. - -## Validation - -Ambiente: - -- SDK: `10.0.302` -- test runner: VSTest com xUnit v2 -- projetos de teste: `netcoreapp3.1` -- `DOTNET_ROLL_FORWARD=Major` usado para executar testes `netcoreapp3.1` neste ambiente. - -Comandos executados: - -- `dotnet restore .\Dapper.FluentMap.sln` - - resultado: falhou por metadado corrompido no cache NuGet global (`microsoft.netcore.targets` / `.nupkg.metadata` com byte `0x00`). -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet restore .\Dapper.FluentMap.sln` - - resultado: sucesso. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet build .\Dapper.FluentMap.sln --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\Dapper.FluentMap.sln --no-build` - - resultado: sucesso, 45 testes aprovados no core e 7 testes aprovados no Dommel. - -- `dotnet build .\src\Dapper.FluentMap\Dapper.FluentMap.csproj --configuration Release` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry dotnet build .\Dapper.FluentMap.sln --configuration Release --no-restore` - - resultado: sucesso, 0 warnings, 0 erros. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingRegistryTests"` - - resultado: sucesso, 7 testes aprovados. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~MappingCompositionTests|FullyQualifiedName~DapperIntegrationTests"` - - resultado: sucesso, 15 testes aprovados. -- `NUGET_PACKAGES=%TEMP%\dfm-nuget-packages-registry DOTNET_ROLL_FORWARD=Major dotnet test .\Dapper.FluentMap.sln --configuration Release --no-build` - - resultado: sucesso, 45 testes aprovados no core e 7 testes aprovados no Dommel. - -Pack nao foi executado porque nao houve mudanca de empacotamento, metadados NuGet ou targets. - -## Encerramento Da Etapa 1 - -Capacidades estabilizadas: - -- parsing de expressoes por membro real da expression tree; -- composicao deterministica entre mappings explicitos, conventions e fallback do Dapper; -- baseline de integracao com materializacao real via Dapper; -- dono interno de mappings e cache estruturado com invalidacao definida. - -Dividas transferidas: - -- os campos publicos mutaveis permanecem por compatibilidade; -- Dommel ainda consome os dicionarios publicos diretamente; -- paralelismo da suite continua desabilitado; -- suporte a MemberPath, objetos aninhados e Value Objects permanece fora do escopo. diff --git a/docs/sdd/etapa-1/README.md b/docs/sdd/etapa-1/README.md deleted file mode 100644 index 5e26cdb..0000000 --- a/docs/sdd/etapa-1/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Etapa 1 - -## Objetivo - -Corrigir e fortalecer pontos centrais da resolucao de mapeamentos do `Dapper.FluentMap`, preservando a API publica e o comportamento existente sempre que possivel. - -## Entregas Previstas - -1. ReflectionHelper -2. Composicao de mappings -3. Testes de integracao com Dapper -4. MappingRegistry e cache - -## Ordem Das Entregas - -As entregas devem ser executadas na ordem acima, pois cada uma produz contexto e decisoes que podem afetar a proxima. - -## Leitura Obrigatoria - -Antes de iniciar proximas tarefas desta etapa, leia todos os arquivos `.md` diretamente relacionados em `docs/sdd/etapa-1/`, principalmente este `README.md`, `status.md`, `decisions.md` e relatorios de entregas anteriores. - -## Escopo - -O escopo atual esta concentrado no projeto principal `Dapper.FluentMap`. - -`Dapper.FluentMap.Dommel` esta fora do escopo funcional desta etapa, salvo se uma mudanca comprovada no core exigir adaptacao explicita. diff --git a/docs/sdd/etapa-1/decisions.md b/docs/sdd/etapa-1/decisions.md deleted file mode 100644 index c75b050..0000000 --- a/docs/sdd/etapa-1/decisions.md +++ /dev/null @@ -1,36 +0,0 @@ -# Decisoes Da Etapa 1 - -Registre aqui apenas decisoes que afetem entregas posteriores. - -## ReflectionHelper - -- Expressoes de propriedade devem ser resolvidas pelo `MemberExpression.Member` produzido pela expression tree, sem nova busca por nome via reflection. -- APIs que recebem `Expression>` para mapeamento devem aceitar `Convert` gerado por boxing de value types. -- Expressoes que nao resolvem para propriedade devem falhar cedo com `ArgumentException`, em vez de produzir `null`, `InvalidCastException` ou depender de falhas indiretas. - -## Composicao De Mappings - -- A estrategia instalada pelo FluentMap deve resolver mappings explicitos antes de conventions e usar `DefaultTypeMap` do Dapper como fallback final. -- `AddMap(...)` e `AddConvention(...).ForEntity(...)` nao devem depender da ordem de registro para coexistirem no mesmo tipo. -- Mapping explicito para uma propriedade impede que conventions resolvam essa mesma propriedade, permitindo override explicito da convention. -- `FluentConventionTypeMap` permanece publico para compatibilidade, mas os fluxos internos de convention passam a instalar o type map composto. -- Registry e invalidacao completa de cache continuam deliberadamente adiados para a Entrega 4. - -## Testes De Integracao Com Dapper - -- A baseline de integracao usa SQLite in-memory via `Microsoft.Data.Sqlite` apenas no projeto de testes principal. -- Os testes de integracao devem validar materializacao observavel por `Dapper.Query`, nao detalhes internos de `ITypeMap`. -- Testes que alteram `FluentMapper` devem usar o reset interno definido na Entrega 4 para limpar registry, cache e type maps do Dapper dos tipos tocados. -- O paralelismo da suite permanece desabilitado porque `FluentMapper`, `SqlMapper.SetTypeMap` e os dicionarios publicos mutaveis ainda compartilham estado global. - -## MappingRegistry E Cache - -- `MappingRegistry` passa a ser o dono interno de entity maps, conventions, cache de propriedades e instalacao de type maps no Dapper. -- `FluentMapper.EntityMaps` e `FluentMapper.TypeConventions` permanecem publicos por compatibilidade, mas apontam para o storage do registry. -- O cache ativo de resolucao usa chave estruturada com tipo, nome de coluna ordinal e opcoes de estrategia (`FluentMap` ou `ConventionOnly`), substituindo as chaves por concatenacao de strings. -- Case sensitivity continua sendo propriedade de cada `IPropertyMap`; a chave diferencia o nome de coluna recebido e a invalidacao por tipo cobre mudancas de configuracao. -- Reconfiguracoes feitas pela API do FluentMap invalidam o cache do tipo afetado e reinstalam o type map composto no Dapper. -- O reset interno de testes limpa entity maps, conventions, cache e type maps do Dapper para os tipos informados. -- `SqlMapper.SetTypeMap` continua como estado global necessario porque e o contrato publico de extensibilidade do Dapper. -- O membro protegido legado `MultiTypeMap.TypePropertyMapCache` nao e mais usado pelo core, mas foi preservado para evitar quebra de compatibilidade. -- Etapa 2 deve tratar qualquer tentativa de reduzir a mutabilidade publica dos dicionarios como mudanca de compatibilidade planejada. diff --git a/docs/sdd/etapa-1/status.md b/docs/sdd/etapa-1/status.md deleted file mode 100644 index f2ab082..0000000 --- a/docs/sdd/etapa-1/status.md +++ /dev/null @@ -1,8 +0,0 @@ -# Status Da Etapa 1 - -| Entrega | Status | Commit | -|---|---|---| -| 01 - ReflectionHelper | Concluído | fix: resolve ambiguous property expressions | -| 02 - Composicao de mappings | Concluído | fix: compose explicit mappings and conventions | -| 03 - Testes de integracao | Concluído | test: add Dapper integration coverage | -| 04 - MappingRegistry e cache | Concluído | refactor: introduce mapping registry and structured cache keys | diff --git a/docs/sdd/net10-migration/01-inventory-baseline.md b/docs/sdd/net10-migration/01-inventory-baseline.md deleted file mode 100644 index 1ebb331..0000000 --- a/docs/sdd/net10-migration/01-inventory-baseline.md +++ /dev/null @@ -1,288 +0,0 @@ -# 01 - Inventory and Baseline - -## Specification - -This delivery inventories the current state before any migration or dependency update. - -Target final state: - -```text -src/Dapper.FluentMap -> netstandard2.0 -src/Dapper.FluentMap.Dommel -> netstandard2.0 -test/Dapper.FluentMap.Tests -> net10.0 -test/Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -Rules for this delivery: - -- Do not change `TargetFramework` or `TargetFrameworks`. -- Do not update packages. -- Do not change C# code, solution files, CI, pack files, or workflows. -- Create only SDD handoff documentation under `docs/sdd/net10-migration/`. -- Preserve `netstandard2.0` for all `src/` projects. - -## Discovery - -### Branch - -Current shared branch: - -- `chore/net10-migration` - -This branch was created locally for the migration because the starting branch was not `main`/`master` and `chore/net10-migration` did not exist locally. - -### Skills Used - -Local skills available under `.agents/skills/`: - -- `assertion-quality` -- `coverage-analysis` -- `detect-static-dependencies` -- `dotnet-aot-compat` -- `migrate-nullable-references` -- `msbuild-antipatterns` -- `msbuild-modernization` -- `run-tests` -- `test-anti-patterns` -- `test-gap-analysis` - -Skills used for this delivery: - -- `msbuild-modernization`: identify project style and migration-relevant MSBuild concerns. -- `msbuild-antipatterns`: classify current project-file risks without changing them. -- `run-tests`: select and document the correct baseline test approach for the current VSTest + xUnit 2 setup. - -### Repository Build Files - -Found: - -- `Dapper.FluentMap.sln` -- `NuGet.Config` -- `.appveyor.yml` -- `.travis.yml` -- Four SDK-style `.csproj` files. - -Not found: - -- `global.json` -- `Directory.Build.props` -- `Directory.Build.targets` -- `Directory.Packages.props` -- `.editorconfig` -- `.github/workflows/` -- `*.props`, `*.targets`, `*.ps1`, `*.sh`, `*.cake`, `*.cmd`, or `*.bat` build scripts beyond the files listed above. - -### Solution Projects - -| Project | Path | Current TFM | Desired TFM | Notes | -|---|---|---|---|---| -| Dapper.FluentMap | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `netstandard2.0` | `netstandard2.0` | Published core library. Uses `` with a single TFM. | -| Dapper.FluentMap.Dommel | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `netstandard2.0` | `netstandard2.0` | Published Dommel integration. Uses `` with a single TFM. | -| Dapper.FluentMap.Tests | `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `netcoreapp3.1` | `net10.0` | xUnit 2 / VSTest test project. Uses SQLite integration tests. | -| Dapper.FluentMap.Dommel.Tests | `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `netcoreapp3.1` | `net10.0` | xUnit 2 / VSTest test project. Includes `coverlet.collector`. | - -### Direct Dependencies - -| Project | Direct packages | -|---|---| -| `src/Dapper.FluentMap` | `Dapper 2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dapper 2.0.35`, `Dommel 2.0.0` | -| `test/Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk 16.7.1`, `Microsoft.Data.Sqlite 3.1.32`, `xunit 2.4.1`, `xunit.runner.visualstudio 2.4.3` | -| `test/Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk 16.7.1`, `xunit 2.4.1`, `xunit.runner.visualstudio 2.4.3`, `coverlet.collector 1.3.0` | - -See `dependency-matrix.md` for latest stable versions, compatibility notes, vulnerabilities, and planned actions. - -### Tests - -Detected test platform: - -- VSTest through `Microsoft.NET.Test.Sdk`. -- xUnit 2 through `xunit` and `xunit.runner.visualstudio`. -- No Microsoft Testing Platform signal found in `global.json`, project files, or shared props. - -Detected test count by source attributes: - -- `test/Dapper.FluentMap.Tests`: 45 `[Fact]` tests. -- `test/Dapper.FluentMap.Dommel.Tests`: 7 `[Fact]` tests. -- Total: 52 `[Fact]` tests. - -Coverage: - -- `coverlet.collector 1.3.0` is referenced only by `test/Dapper.FluentMap.Dommel.Tests`. -- No `runsettings` or custom coverage configuration was found. - -### CI - -| File | Current behavior | Migration risk | -|---|---|---| -| `.appveyor.yml` | Visual Studio 2019 image, runs `dotnet test`. | Image may not contain .NET 10 SDK/runtime. Review in Delivery 04. | -| `.travis.yml` | `dotnet: 3.1`, runs `dotnet test`. | Explicitly obsolete for `net10.0`; review in Delivery 04. | - -No GitHub Actions workflows were found. - -### Environment Baseline - -Sanitized `dotnet --info` summary: - -- Active SDK: `10.0.302` -- MSBuild: `18.6.11` -- Host runtime: `10.0.10` -- OS: Windows x64 -- Installed SDKs: `8.0.423`, `10.0.110`, `10.0.204`, `10.0.302` -- Installed `Microsoft.NETCore.App` runtimes: `8.0.29`, `10.0.8`, `10.0.10` -- `global.json`: not found -- .NET Core 3.1 runtime: not installed - -### Baseline Commands - -Commands requested by this delivery: - -| Command | Result | Cause | Classification | -|---|---|---|---| -| `dotnet --info` | Succeeded | SDK available. | Environment info. | -| `dotnet --list-sdks` | Succeeded | SDK available. | Environment info. | -| `dotnet --list-runtimes` | Succeeded | SDK available. | Environment info. | -| `dotnet restore ./Dapper.FluentMap.sln` | Failed | Global NuGet cache metadata for `microsoft.netcore.targets/1.1.0` is corrupted: invalid JSON start byte in `.nupkg.metadata`. | Environmental; not a code failure. | -| `dotnet build ./Dapper.FluentMap.sln` | Failed | Build performs restore first and hit the same global NuGet cache corruption. | Environmental; not a code failure. | -| `dotnet test ./Dapper.FluentMap.sln` | Failed | Test command performs restore first and hit the same global NuGet cache corruption. | Environmental; not a code failure. | - -Additional diagnostic commands using an isolated local package cache: - -| Command | Result | Cause | Classification | -|---|---|---|---| -| `dotnet restore ./Dapper.FluentMap.sln --packages ./.nuget/packages` | Succeeded | Avoided corrupted global NuGet package cache. | Confirms restore is viable. | -| `dotnet build ./Dapper.FluentMap.sln --no-restore` | Succeeded | Used assets from isolated restore. | Code compiles in Debug: 0 warnings, 0 errors. | -| `dotnet test ./Dapper.FluentMap.sln --no-build` | Failed | Testhost requires `Microsoft.NETCore.App 3.1.0`, which is not installed. Installed runtimes start at 8.0 and 10.0. | Environmental/runtime baseline failure caused by current `netcoreapp3.1` test TFM. | - -### Package Diagnostics - -Commands: - -- `dotnet list ./Dapper.FluentMap.sln package --include-transitive --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --deprecated --no-restore` -- `dotnet list ./Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` - -Findings: - -- Direct production packages have no reported vulnerabilities or deprecation in the current graph. -- Current `xunit 2.4.1` is reported as deprecated/legacy with `xunit.v3` as suggested alternative. -- xUnit 3 migration is intentionally deferred to Delivery 05. -- Test graphs contain vulnerable transitives through old test/runtime packages: - - `Newtonsoft.Json 9.0.1` - - `System.Net.Http 4.3.0` - - `System.Text.RegularExpressions 4.3.0` - - `SQLitePCLRaw.lib.e_sqlite3 2.1.2` in `Dapper.FluentMap.Tests` - -## Decision - -### Safe Update Order - -1. Delivery 02: migrate test projects from `netcoreapp3.1` to `net10.0` and update test-only packages required for a supported test runtime. -2. Delivery 03: update `src/` project dependencies while preserving `netstandard2.0`. -3. Delivery 04: run full validation, package inspection, and CI review. -4. Delivery 05: migrate from xUnit 2 to xUnit 3 as a separate compatibility and syntax change. - -### Packages Planned for Delivery 02 - -Update only test project packages: - -- `Microsoft.NET.Test.Sdk`: `16.7.1` -> latest stable identified `18.8.1` -- `Microsoft.Data.Sqlite`: `3.1.32` -> latest stable identified `10.0.10` -- `xunit`: `2.4.1` -> latest stable xUnit 2 identified `2.9.3` -- `xunit.runner.visualstudio`: `2.4.3` -> latest stable identified `3.1.5` -- `coverlet.collector`: `1.3.0` -> latest stable identified `10.0.1`, if coverage collector remains referenced - -Do not introduce `xunit.v3` in Delivery 02. - -### Packages Planned for Delivery 03 - -Update only direct `src/` dependencies after tests can run on `net10.0`: - -- `Dapper`: `2.0.35` -> latest stable identified `2.1.79` -- `Dommel`: `2.0.0` -> latest stable identified `3.5.3` - -The Dommel update is a major-version jump and must be validated against the existing Dommel resolver behavior. - -### Packages Blocked by `netstandard2.0` - -No direct production dependency planned for Delivery 03 is currently blocked by `netstandard2.0`: - -- `Dapper 2.1.79` declares `netstandard2.0` compatibility. -- `Dommel 3.5.3` declares `netstandard2.0` compatibility. - -Test-only packages that do not declare `netstandard2.0` are not blockers because they are not published dependencies of the `src/` projects. - -### xUnit Strategy - -Delivery 02 keeps xUnit 2: - -- Keep test source syntax unchanged. -- Update `xunit` only to the latest stable xUnit 2 line. -- Use `xunit.runner.visualstudio` that can run xUnit 2 tests on modern VSTest. - -Delivery 05 handles xUnit 3: - -- Introduce `xunit.v3` packages only there. -- Re-check runner/platform syntax there. -- Treat xUnit 3 as an independent migration because package IDs, runner behavior, analyzers, and discovery can change. - -### `netstandard2.0` Consumption Validation - -Use the `net10.0` test projects as consumers of the `netstandard2.0` `src/` projects: - -1. Restore the solution. -2. Build `src/Dapper.FluentMap` and `src/Dapper.FluentMap.Dommel` in Release. -3. Run both test projects on `net10.0`. -4. Keep Dapper integration tests active so materialization/type-map behavior is exercised by a real `net10.0` testhost. -5. In Delivery 04, run `dotnet pack` and inspect package dependency groups to confirm published outputs remain `netstandard2.0`. - -### Known Risks - -- Current default restore is blocked by a corrupted global NuGet cache entry. Later deliveries may need an isolated package cache or a user-performed cache cleanup. -- Current tests cannot run on this machine until the test TFM moves off `netcoreapp3.1` or the obsolete runtime is installed. Do not install .NET Core 3.1 automatically. -- `Dommel 2.0.0` -> `3.5.3` is a major update; validate integration behavior carefully. -- `coverlet.collector 10.0.1` requires modern SDK/test SDK support; update it together with `Microsoft.NET.Test.Sdk`. -- CI files are legacy and likely incompatible with `net10.0`; review after local migration succeeds. -- Project files use `` for a single target. This is an existing MSBuild style issue, but changing it should be done only in the delivery that edits project files. - -## Delivery - -Created SDD handoff files only: - -- `docs/sdd/net10-migration/README.md` -- `docs/sdd/net10-migration/status.md` -- `docs/sdd/net10-migration/decisions.md` -- `docs/sdd/net10-migration/dependency-matrix.md` -- `docs/sdd/net10-migration/01-inventory-baseline.md` - -No `.csproj`, C# source, solution, dependency, CI, or packaging files were changed. - -No `.gitignore` change was needed because generated restore/build/test outputs are already ignored: - -- `.nuget/` -- `artifacts/` -- `bin/` -- `obj/` -- `TestResults/` - -## Validation - -Validation checklist: - -- All solution projects inventoried: yes. -- All direct dependencies registered: yes. -- Latest stable versions identified: yes, using NuGet.org source and `dotnet list package --outdated`. -- Baseline commands documented: yes. -- Shared branch documented: yes. -- Functional files unchanged: yes; documentation-only delivery. -- Absolute local paths omitted from documentation: yes. -- Sensitive data documented: no. -- Handoff folder sufficient for next chats: yes. - -Commands to run before committing: - -```bash -git diff -git status -``` diff --git a/docs/sdd/net10-migration/02-test-projects-net10.md b/docs/sdd/net10-migration/02-test-projects-net10.md deleted file mode 100644 index 77af812..0000000 --- a/docs/sdd/net10-migration/02-test-projects-net10.md +++ /dev/null @@ -1,182 +0,0 @@ -# 02 - Test Projects on net10.0 - -## Specification - -Migrate all projects under `test/` from `netcoreapp3.1` to `net10.0`, preserving the published `src/` projects on `netstandard2.0`. - -Expected consumption shape: - -```text -test net10.0 - -> ProjectReference -src netstandard2.0 -``` - -Scope limits for this delivery: - -- Update only test project TFMs and test-only dependencies needed for `net10.0`. -- Keep xUnit 2; do not introduce `xunit.v3`. -- Do not update `Dapper`, `Dommel`, public APIs, production behavior, package metadata, or CI. -- Do not skip, remove, or weaken tests. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Required migration handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Delivery 01 is concluded in `status.md` and the latest commit is `docs: document .NET 10 migration baseline`. - -### Skills Used - -- `msbuild-modernization`: selected for TargetFramework/PackageReference migration guidance. -- `run-tests`: selected to detect test runner/platform and use the correct `dotnet test` commands. - -### Projects Under `test/` - -| Project | Current target element | Current TFM | Project reference | -|---|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFrameworks` | `netcoreapp3.1` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFrameworks` | `netcoreapp3.1` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | - -### Shared Build Configuration - -No shared build/test configuration files were found: - -- no `global.json` -- no `Directory.Build.props` -- no `Directory.Build.targets` -- no `Directory.Packages.props` -- no `packages.lock.json` -- no `*.runsettings` -- no `.editorconfig` - -Test runner detection: - -- VSTest through `Microsoft.NET.Test.Sdk`. -- xUnit 2 through `xunit` and `xunit.runner.visualstudio`. -- No Microsoft Testing Platform signal was found. - -### Existing Test Dependencies - -| Project | Package | Current version | -|---|---|---:| -| `Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk` | `16.7.1` | -| `Dapper.FluentMap.Tests` | `Microsoft.Data.Sqlite` | `3.1.32` | -| `Dapper.FluentMap.Tests` | `xunit` | `2.4.1` | -| `Dapper.FluentMap.Tests` | `xunit.runner.visualstudio` | `2.4.3` | -| `Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk` | `16.7.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit` | `2.4.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.runner.visualstudio` | `2.4.3` | -| `Dapper.FluentMap.Dommel.Tests` | `coverlet.collector` | `1.3.0` | - -`dotnet list package --outdated --include-transitive --no-restore` and NuGet.org package pages confirmed the Delivery 01 matrix still matches the latest stable versions on 2026-07-25. - -### Test Code Compatibility Scan - -Searches under `test/` found: - -- `[assembly: CollectionBehavior(DisableTestParallelization = true)]` in both test assemblies. -- Shared global state use through `FluentMapper`, `FluentMapper.Reset`, `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, and Dapper type-map integration. -- xUnit 2 `[Fact]` and `[Trait]` usage. -- No `Thread.Sleep`, `Task.Delay`, remoting, `BinaryFormatter`, broad warning suppression, `async void`, blocking async waits, or obvious .NET 10 removed API usage in tests. -- Existing `Assert.Throws` remains unchanged because it is unrelated to this runtime migration. - -## Decision - -### Final Test Targets - -| Project | Final target element | Final TFM | -|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | - -Use singular `TargetFramework` because each test project has one target. Do not normalize the `src/` projects in this delivery even though they currently use `TargetFrameworks` with one target. - -### Test Package Updates - -| Package | Old version | New version | Justification | -|---|---:|---:|---| -| `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Required to run tests reliably on modern SDK/VSTest and removes old vulnerable test-platform transitives from the test graph. | -| `Microsoft.Data.Sqlite` | `3.1.32` | `10.0.10` | Test-only SQLite provider used by integration tests; aligns native/runtime assets with `net10.0` while leaving production Dapper dependencies untouched. | -| `xunit` | `2.4.1` | `2.9.3` | Latest stable xUnit 2 line; preserves xUnit 2 API and defers `xunit.v3` to Delivery 05. | -| `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Modern VSTest adapter that supports .NET 8+ and can run xUnit 2 tests. Keep `PrivateAssets="all"`. | -| `coverlet.collector` | `1.3.0` | `10.0.1` | Coverage collector version compatible with modern SDK/test SDK. Keep `PrivateAssets="all"`. | - -### Dependencies Left Temporarily Old - -- `Dapper 2.0.35` in `src/` remains for Delivery 03. -- `Dommel 2.0.0` in `src/Dapper.FluentMap.Dommel` remains for Delivery 03. -- Production `src/` targets remain `netstandard2.0`. -- xUnit 3 remains deferred to Delivery 05. - -### Behavior and Risk Controls - -- Preserve test source behavior; no test code changes are planned unless build/test exposes a direct `net10.0` incompatibility. -- Preserve disabled parallel execution because the suites share global FluentMapper/Dapper state. -- Keep VSTest runner model because no MTP signal exists. -- Do not alter public API, mapping behavior, package metadata, or CI in this delivery. - -## Delivery - -- Migrated `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` from `TargetFrameworks netcoreapp3.1` to `TargetFramework net10.0`. -- Migrated `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` from `TargetFrameworks netcoreapp3.1` to `TargetFramework net10.0`. -- Updated test-only packages: - - `Microsoft.NET.Test.Sdk` `16.7.1` -> `18.8.1` - - `Microsoft.Data.Sqlite` `3.1.32` -> `10.0.10` - - `xunit` `2.4.1` -> `2.9.3` - - `xunit.runner.visualstudio` `2.4.3` -> `3.1.5` - - `coverlet.collector` `1.3.0` -> `10.0.1` -- No C# test code changes were required. -- No tests were skipped, removed, or weakened. -- No `src/` project files were changed; both published projects remain `netstandard2.0`. -- No production dependencies were updated. -- No CI, package metadata, or public API was changed. - -## Validation - -Environment: - -- Active SDK: `10.0.302` -- Test runner: VSTest -- Test framework: xUnit 2 - -Commands executed: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Passed; 45 passed, 0 failed, 0 skipped. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Passed; 7 passed, 0 failed, 0 skipped. | -| `dotnet build --configuration Release` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test --configuration Release` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed; confirmed test projects resolve as `net10.0` and `src` projects as `netstandard2.0`. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed; direct test packages are current, while production `Dapper`/`Dommel`, xUnit analyzer transitives, and SQLitePCLRaw transitives remain visible. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed; only `Dapper.FluentMap.Tests` reports transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` high severity. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed; `xunit 2.9.3` remains marked legacy with `xunit.v3` alternative, intentionally deferred to Delivery 05. | - -Explicit confirmations: - -- Test projects compile and execute for `net10.0`. -- `src/Dapper.FluentMap` remains `netstandard2.0`. -- `src/Dapper.FluentMap.Dommel` remains `netstandard2.0`. -- `net10.0` test projects consume `netstandard2.0` production projects through `ProjectReference`. -- No test was ignored to hide a migration issue. -- No unrelated functional behavior was changed. -- Repeated Debug/Release test runs were deterministic in result counts. - -Residual risks: - -- `Microsoft.Data.Sqlite 10.0.10` still resolves vulnerable transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11`. This did not block restore/build/test, but should be reviewed in Delivery 04 or in a dedicated dependency-hardening task before release. -- `Dapper 2.0.35` and `Dommel 2.0.0` remain intentionally pending for Delivery 03. -- xUnit 3 remains intentionally pending for Delivery 05. -- `dotnet pack` was not run because this delivery did not alter published package projects or package metadata. diff --git a/docs/sdd/net10-migration/03-src-dependencies.md b/docs/sdd/net10-migration/03-src-dependencies.md deleted file mode 100644 index 005f1f7..0000000 --- a/docs/sdd/net10-migration/03-src-dependencies.md +++ /dev/null @@ -1,206 +0,0 @@ -# 03 - Source Project Dependencies - -## Specification - -Update direct production dependencies in `src/` to the newest stable versions that preserve: - -- published source projects on `netstandard2.0`; -- `net10.0` test projects consuming the libraries through `ProjectReference`; -- public API and behavior unless a dependency incompatibility requires a minimal adjustment. - -Do not migrate the source projects to multi-targeting, do not change package metadata, and do not update test-only packages in this delivery. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Required migration handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Delivery 01 is concluded in `status.md`. -- Delivery 02 is concluded in `status.md`. - -### Project Targets - -| Project | Target element | Current TFM | Required final TFM | -|---|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks` | `netstandard2.0` | `netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks` | `netstandard2.0` | `netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | `net10.0` | - -The `src/` projects still use `TargetFrameworks` with a single target. This delivery preserves that shape to avoid unrelated published-project churn. - -### Skills Used - -- `msbuild-modernization`: selected for TFM and PackageReference guardrails. -- `msbuild-antipatterns`: selected for project-file dependency review. -- `run-tests`: selected to run the correct VSTest/xUnit 2 validation commands. - -### Package Metadata Sources - -Version discovery and compatibility were checked using: - -- `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive` -- `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` -- `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` -- NuGet flat container metadata: - - `https://api.nuget.org/v3-flatcontainer/dapper/index.json` - - `https://api.nuget.org/v3-flatcontainer/dapper/2.1.79/dapper.nuspec` - - `https://api.nuget.org/v3-flatcontainer/dommel/index.json` - - `https://api.nuget.org/v3-flatcontainer/dommel/3.5.3/dommel.nuspec` -- NuGet Gallery package pages: - - `https://www.nuget.org/packages/Dapper` - - `https://www.nuget.org/packages/Dommel` -- Official source/release pages where available: - - `https://github.com/DapperLib/Dapper/releases` - - `https://github.com/henkmollema/Dommel/releases` - -### Direct Production Dependencies Before Update - -| Project | Package | Current version | -|---|---|---:| -| `src/Dapper.FluentMap` | `Dapper` | `2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.0.35` | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `2.0.0` | - -### Relevant API Usage - -Core Dapper integration uses public Dapper APIs: - -- `SqlMapper.ITypeMap` -- `SqlMapper.IMemberMap` -- `SqlMapper.SetTypeMap` -- `CustomPropertyTypeMap` -- `DefaultTypeMap` - -Dommel integration uses public Dommel APIs: - -- `DommelMapper.SetColumnNameResolver` -- `DommelMapper.SetKeyPropertyResolver` -- `DommelMapper.SetTableNameResolver` -- `DommelMapper.SetPropertyResolver` -- `IColumnNameResolver` -- `IKeyPropertyResolver` -- `ITableNameResolver` -- `IPropertyResolver` -- `Default*Resolver` -- `ColumnPropertyInfo` - -### Relevant Transitives Before Update - -| Area | Package | Resolved version | Finding | -|---|---|---:|---| -| `src` via Dapper/netstandard graph | `Microsoft.NETCore.Platforms` | `1.1.0` | Old transitive from the netstandard restore graph; not a direct dependency to force. | -| Dommel integration | `System.ComponentModel.Annotations` | `4.7.0` | Transitive dependency of `Dommel 2.0.0`; latest Dommel updates this to `5.0.0` for `netstandard2.0`. | -| Core tests | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.11` | Known NU1903 high severity warning remains from Delivery 02; test-only transitive and outside this source-dependency delivery. | - -## Decision - -### Dependency Selection Table - -| Project | Package | Previous version | Chosen version | Latest stable available | Reason for choice | `netstandard2.0` compatibility | Breaking changes evaluated | Code correction expected | -|---|---|---:|---:|---:|---|---|---|---| -| `src/Dapper.FluentMap` | `Dapper` | `2.0.35` | `2.1.79` | `2.1.79` | Latest stable from NuGet; package includes `netstandard2.0` assets and keeps the public type-map APIs used by FluentMap. | Compatible. NuGet metadata declares `.NETStandard2.0` with dependencies on `Microsoft.Bcl.AsyncInterfaces`, `System.Reflection.Emit.Lightweight`, and `System.Threading.Tasks.Extensions`. | Dapper release notes from the 2.1 line include TFM changes, async API normalization, DateOnly/TimeOnly support disablement after unlisted releases, type-handler fixes, and dependency updates. FluentMap does not use DateOnly/TimeOnly support or obsolete internal type-handler APIs. | None expected; compile and Dapper integration tests must confirm. | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.0.35` | `2.1.79` | `2.1.79` | Keep the integration aligned with the core Dapper version and avoid a lower direct version than Dommel transitively requires. | Compatible, same as core. | Same Dapper review as the core project. | None expected; compile and Dommel tests must confirm. | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `2.0.0` | `3.5.3` | `3.5.3` | Latest stable from NuGet; package includes `netstandard2.0` assets and preserves Dommel's resolver extension model according to package metadata and source surface to be validated by compile/tests. | Compatible. NuGet metadata declares `.NETStandard2.0` with dependencies on `Dapper 2.1.72`, `Microsoft.Bcl.HashCode 6.0.0`, and `System.ComponentModel.Annotations 5.0.0`. | Major-version update. Release page has tags through the 3.5 line but no detailed migration notes for 3.5.3 were found; resolver API compatibility will be verified by compilation and existing Dommel resolver tests. | Possible minimal resolver signature adjustment if the public Dommel interfaces changed. | - -### Update Categories - -Safe updates: - -- `Dapper 2.0.35` -> `2.1.79` in both source projects, pending build/test confirmation. - -Updates that require focused validation: - -- `Dommel 2.0.0` -> `3.5.3` because it is a major-version jump and the integration implements Dommel resolver interfaces. - -Blocked updates: - -- None for direct production dependencies. No selected latest stable direct production package is blocked by `netstandard2.0`. - -Deferred updates: - -- `xunit` / `xunit.v3`: deferred to Delivery 05. -- `SQLitePCLRaw.*` test transitives: defer to Delivery 04 or a dedicated dependency-hardening task unless source dependency updates naturally change the graph. -- `xunit.analyzers`: transitive of `xunit`; do not force as a direct dependency in this delivery. -- `Microsoft.NETCore.Platforms`: transitive netstandard graph package; do not force as a direct dependency. - -## Delivery - -- Updated `src/Dapper.FluentMap/Dapper.FluentMap.csproj`: - - `Dapper` `2.0.35` -> `2.1.79` -- Updated `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj`: - - `Dapper` `2.0.35` -> `2.1.79` - - `Dommel` `2.0.0` -> `3.5.3` -- Preserved `TargetFrameworks netstandard2.0` in both `src/` projects. -- Preserved `TargetFramework net10.0` in both test projects. -- No C# code changes were required. -- No public API, package metadata, CI, xUnit packages, or test code was changed. -- No tests were skipped, removed, or weakened. - -### Direct Production Dependencies After Update - -| Project | Package | Final version | Status | -|---|---|---:|---| -| `src/Dapper.FluentMap` | `Dapper` | `2.1.79` | Updated to latest stable. | -| `src/Dapper.FluentMap.Dommel` | `Dapper` | `2.1.79` | Updated to latest stable and aligned with core. | -| `src/Dapper.FluentMap.Dommel` | `Dommel` | `3.5.3` | Updated to latest stable. | - -### Post-Update Dependency Findings - -| Area | Package | Resolved version | Latest stable identified | Handling | -|---|---|---:|---:|---| -| `src` via Dapper `netstandard2.0` graph | `Microsoft.Bcl.AsyncInterfaces` | `10.0.8` | `10.0.10` | Do not force as direct dependency; Dapper declares `>= 10.0.8` and restore chose the dependency floor. | -| `src` via `NETStandard.Library` graph | `Microsoft.NETCore.Platforms` | `1.1.0` | `7.0.4` | Do not force as direct dependency. Existing netstandard graph behavior. | -| Test transitives | `SQLitePCLRaw.*` | `2.1.11` | `3.0.4` / `3.53.3` | Test-only SQLite transitives remain deferred to Delivery 04 or dependency hardening. | -| Test transitives | `xunit.analyzers` | `1.18.0` | `1.27.0` | Transitive of xUnit 2; defer to Delivery 05. | - -## Validation - -Environment: - -- Active SDK: `10.0.302` -- Test runner: VSTest -- Test framework: xUnit 2 - -Commands executed: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with the existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet test .\test\Dapper.FluentMap.Tests\Dapper.FluentMap.Tests.csproj` | Passed; 45 passed, 0 failed, 0 skipped. | -| `dotnet test .\test\Dapper.FluentMap.Dommel.Tests\Dapper.FluentMap.Dommel.Tests.csproj` | Passed; 7 passed, 0 failed, 0 skipped. | -| `dotnet build --configuration Release` | Passed; `src` outputs built under `netstandard2.0`, test outputs under `net10.0`. | -| `dotnet test --configuration Release` | Passed; `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped; `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed; direct production dependencies resolve to `Dapper 2.1.79` and `Dommel 3.5.3`. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed; no outdated direct production package remains. Only deferred transitives were reported. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed; no vulnerable packages in `src/`; existing vulnerable test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains in `Dapper.FluentMap.Tests`. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed; no deprecated packages in `src/`; `xunit 2.9.3` remains legacy in test projects and is deferred to Delivery 05. | - -Explicit confirmations: - -- Shared branch is `chore/net10-migration`. -- Deliveries 01 and 02 are concluded in `status.md`. -- Test projects remain `net10.0`. -- Source projects remain `netstandard2.0`. -- All direct production package references restored. -- No package downgrade or conflict was reported by restore/build. -- Dapper integration behavior is covered by the existing core integration tests on `net10.0`. -- Dommel resolver behavior is covered by the existing Dommel tests on `net10.0`. -- No unnecessary public breaking change was introduced. - -Residual risks and Delivery 04 handoff: - -- `dotnet pack` and package content/dependency-group inspection remain for Delivery 04. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` still reports NU1903 in the core test project and should be reviewed in Delivery 04 or separately. -- `Dommel 3.5.3` had no detailed 3.5.3 migration notes found on the release page; compile/tests validate the resolver surface used here, but Delivery 04 should keep package inspection focused. diff --git a/docs/sdd/net10-migration/04-validation-pack-ci.md b/docs/sdd/net10-migration/04-validation-pack-ci.md deleted file mode 100644 index a3d6578..0000000 --- a/docs/sdd/net10-migration/04-validation-pack-ci.md +++ /dev/null @@ -1,329 +0,0 @@ -# 04 - Validation, Pack and CI - -## Specification - -This delivery consolidates the .NET 10 migration completed by Deliveries 01, 02 and 03. - -Expected final matrix: - -```text -src/ -|-- Dapper.FluentMap -> netstandard2.0 -`-- Dapper.FluentMap.Dommel -> netstandard2.0 - -test/ -|-- Dapper.FluentMap.Tests -> net10.0 -`-- Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -The validation must prove: - -- `src/` projects still compile for `netstandard2.0`. -- `test/` projects compile and execute for `net10.0`. -- `net10.0` test projects consume the `netstandard2.0` libraries through `ProjectReference`. -- dependencies restore without downgrade or target compatibility errors. -- Debug and Release builds work. -- NuGet packages can be generated and inspected. -- package contents include expected `lib/netstandard2.0` assemblies and exclude test/local artifacts. -- CI installs or selects a .NET 10 compatible SDK. -- CI does not publish NuGet packages. - -Out of scope: - -- xUnit 3 migration. -- functional library changes. -- public API changes. -- moving `src/` projects to `net10.0`. -- source multi-targeting. -- publishing packages. -- pushing the branch or opening a pull request. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Local skills under `.agents/skills/` were checked. -- Skills used: - - `run-tests` for VSTest/xUnit 2 command selection. - - `msbuild-modernization` for TargetFramework and SDK guardrails. - - `msbuild-antipatterns` for project-file review. -- Required handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Deliveries 01, 02 and 03 are concluded in `status.md`. - -### Local State - -- Active SDK: `10.0.302`. -- Host runtime: `10.0.10`. -- `global.json`: not found. -- `Directory.Build.props`: not found. -- `Directory.Build.targets`: not found. -- `Directory.Packages.props`: not found. -- `.editorconfig`: not found. -- Build scripts (`*.ps1`, `*.sh`, `*.cmd`, `*.bat`, `*.cake`): not found. -- `.gitignore` already excludes local restore/build/package outputs: - - `.nuget/` - - `bin/` - - `obj/` - - `TestResults/` - - `artifacts/` - -### Project Targets - -| Project | Target element | Effective target | -|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks` | `netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks` | `netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `net10.0` | - -The `src/` projects still use `TargetFrameworks` with a single TFM. This is existing shape from earlier deliveries and is preserved to avoid published-project churn. - -### Package and Pack Configuration - -- Published projects: - - `src/Dapper.FluentMap` - - `src/Dapper.FluentMap.Dommel` -- Test projects have `false`. -- Source package metadata is unchanged from earlier deliveries: - - `VersionPrefix` is `2.0.0`. - - authors and copyright remain Henk Mollema. - - `PackageProjectUrl` points to the original repository. - - `PackageLicenseUrl` is present; no metadata modernization is introduced here. -- No `.nuspec`, SourceLink, symbol package, package README, or repository metadata file was found. -- `NuGet.Config` uses only `https://api.nuget.org/v3/index.json` after clearing inherited sources. - -### Test Consumption Evidence - -- `test/Dapper.FluentMap.Tests` references `src/Dapper.FluentMap`. -- `test/Dapper.FluentMap.Dommel.Tests` references `src/Dapper.FluentMap.Dommel`. -- Existing tests exercise real library behavior through: - - `FluentMapper.Initialize` - - Dapper `SqlMapper` type-map resolution. - - SQLite-backed Dapper integration tests. - - Dommel resolver integration tests. -- No additional compatibility project is required because the `net10.0` test projects already consume the `netstandard2.0` source projects. - -### CI State - -Found CI files: - -| File | Current state | Risk | -|---|---|---| -| `.appveyor.yml` | Visual Studio 2019 image, runs only `dotnet test`. | Does not explicitly install/select .NET 10 and does not validate pack. | -| `.travis.yml` | `dotnet: 3.1`, `dist: xenial`, runs only `dotnet test`. | Incompatible with `net10.0` test projects and obsolete distro/runtime. | - -No `.github/workflows/` directory exists. - -No CI file currently runs `dotnet nuget push`, publishes packages, uses NuGet tokens, uses `continue-on-error`, references `poc-arquitetura`, or creates a fake test framework matrix. - -### Dependency State - -- Direct production dependencies are already updated by Delivery 03: - - `Dapper 2.1.79` - - `Dommel 3.5.3` -- Test dependencies are already updated by Delivery 02: - - `Microsoft.NET.Test.Sdk 18.8.1` - - `Microsoft.Data.Sqlite 10.0.10` - - `xunit 2.9.3` - - `xunit.runner.visualstudio 3.1.5` - - `coverlet.collector 10.0.1` -- Known deferred items: - - xUnit 3 migration is Delivery 05. - - `xunit.analyzers` remains transitive to xUnit 2 and is deferred with Delivery 05. - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable test transitive reported by NuGet; handle as separate dependency-hardening work unless Delivery 05 changes it naturally. - -## Decision - -### Commands - -Official local and CI commands for this delivery: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./Dapper.FluentMap.sln --no-restore -dotnet test ./Dapper.FluentMap.sln --no-build -dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build -dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release -dotnet test ./test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release -dotnet pack ./Dapper.FluentMap.sln --configuration Release --no-build --output ./artifacts/packages -``` - -`dotnet pack --no-build` is retained because Release build runs first and test projects are marked non-packable. - -### SDK and `global.json` - -Do not create `global.json` in this delivery. - -Reasoning: - -- The repo currently has no `global.json`. -- Local validation already uses a stable .NET 10 SDK (`10.0.302`). -- CI can explicitly install the stable .NET 10 channel through setup steps. -- Pinning an exact SDK file now would add maintenance and could be more brittle than the current small-library setup. - -CI should use the .NET 10 SDK channel with GA quality where supported. - -### CI Plan - -Add GitHub Actions CI because no GitHub workflow exists and the project is hosted as a GitHub repository. - -Update legacy CI files so they no longer keep obsolete SDK assumptions: - -- `.appveyor.yml`: use a newer Windows image and install .NET 10 explicitly before restore/build/test/pack. -- `.travis.yml`: move from `dotnet: 3.1`/`xenial` to a .NET 10 compatible configuration and run the same restore/build/test/pack sequence. - -GitHub Actions choices: - -- `ubuntu-latest`. -- `actions/checkout` current major from the official action README. -- `actions/setup-dotnet` current major from the official action README. -- `dotnet-version: 10.0.x`. -- `dotnet-quality: ga`. -- no NuGet cache because there is no lock file and setup-dotnet cache requires lock files. -- upload `artifacts/packages/*.nupkg` as a CI artifact. -- no matrix, because tests target only `net10.0`. -- no package publishing or NuGet token configuration. - -### Validation Criteria - -Migration is valid when: - -- required local restore/build/test/pack commands pass, allowing documented NuGet vulnerability warnings. -- both test projects pass directly in Release. -- package inspection confirms only expected package contents and dependency groups. -- CI YAML parses as YAML and contains no publish/token/obsolete-framework commands. -- final git diff contains only CI/config/docs changes required by this delivery. - -## Delivery - -- Added `.github/workflows/ci.yml`: - - installs .NET SDK `10.0.x` with GA quality. - - runs `dotnet --info`. - - restores `Dapper.FluentMap.sln`. - - builds Release with `--no-restore`. - - tests Release with `--no-build`. - - packs Release with `--no-build`. - - uploads generated `.nupkg` files as workflow artifacts. - - uses no NuGet publish command, token, secret or package source mutation. -- Updated `.appveyor.yml`: - - moved from `Visual Studio 2019` to `Visual Studio 2022`. - - installs .NET SDK 10 GA through `dotnet-install.ps1`. - - runs restore, Release build, Release tests and Release pack. - - stores generated `.nupkg` files as AppVeyor artifacts. - - keeps `test: off` because tests are executed explicitly in the build script. -- Updated `.travis.yml`: - - moved from `dotnet: 3.1` and `dist: xenial` to `dotnet: 10.0` and `dist: jammy`. - - runs restore, Release build, Release tests and Release pack. -- No `global.json` was created. -- No project file, C# source file, public API, package metadata, dependency version, or Dommel behavior was changed. -- No package was published. - -## Validation - -### Commands Executed - -| Command | Result | -|---|---| -| `dotnet --info` | Passed. Active SDK `10.0.302`; host runtime `10.0.10`; no `global.json`. | -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --no-restore` | Passed. `src` outputs under `Debug/netstandard2.0`; tests under `Debug/net10.0`. | -| `dotnet test --no-build` | Passed. `Dapper.FluentMap.Tests`: 45 passed; `Dapper.FluentMap.Dommel.Tests`: 7 passed. | -| `dotnet build --configuration Release --no-restore` | Passed. `src` outputs under `Release/netstandard2.0`; tests under `Release/net10.0`. | -| `dotnet test --configuration Release --no-build` | Passed. `Dapper.FluentMap.Tests`: 45 passed; `Dapper.FluentMap.Dommel.Tests`: 7 passed. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. Restored/built the core library as `netstandard2.0`, ran 45 `net10.0` tests. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. Restored/built core and Dommel libraries as `netstandard2.0`, ran 7 `net10.0` tests. | -| `dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages` | Passed. Generated both expected `.nupkg` files. Warnings: NU5125 for deprecated `licenseUrl`; package README recommendation. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed. Confirmed final dependency graph and TFMs. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed. No outdated direct packages; deferred transitives remain. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed. Only known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` is vulnerable. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed. Only `xunit 2.9.3` is reported as Legacy with `xunit.v3` alternative, deferred to Delivery 05. | -| PyYAML parse of `.github/workflows/ci.yml`, `.appveyor.yml`, `.travis.yml` | Passed. YAML syntax parsed locally. | -| `rg` for publish commands, secrets, `continue-on-error`, `poc-arquitetura`, `.NET Core 3.1`, and VS 2019 in CI files | Passed. No matches. | - -### TargetFramework Confirmation - -| Project | Confirmed target | -|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks=netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks=netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework=net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework=net10.0` | - -### Generated Packages - -Generated under `artifacts/packages/`: - -- `Dapper.FluentMap.2.0.0.nupkg` -- `Dapper.FluentMap.Dommel.2.0.0.nupkg` - -Package contents inspected: - -| Package | Expected contents | Dependency group | -|---|---|---| -| `Dapper.FluentMap.2.0.0.nupkg` | `lib/netstandard2.0/Dapper.FluentMap.dll`, `lib/netstandard2.0/Dapper.FluentMap.xml`, `.nuspec`, package metadata files. | `.NETStandard2.0`: `Dapper 2.1.79`. | -| `Dapper.FluentMap.Dommel.2.0.0.nupkg` | `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll`, `lib/netstandard2.0/Dapper.FluentMap.Dommel.xml`, `.nuspec`, package metadata files. | `.NETStandard2.0`: `Dapper.FluentMap 2.0.0`, `Dapper 2.1.79`, `Dommel 3.5.3`. | - -Package inspection confirmed: - -- `lib/netstandard2.0` exists in both packages. -- expected assemblies and XML documentation files are present. -- no test assemblies are present. -- no `bin/`, `obj/`, local cache, local path, source tree artifact, or secret file is present. -- package version remains `2.0.0`. -- package metadata remains consistent with existing project files. -- no symbols or SourceLink files are included; none were configured before this delivery. -- license is represented by existing `licenseUrl`, which now produces NU5125 but was not modernized to avoid unrelated package metadata churn. -- package README is not included; NuGet reports a recommendation, not a packaging failure. - -### CI Validation - -Local validation of CI files: - -- YAML syntax parses for GitHub Actions, AppVeyor and Travis files. -- paths reference the real solution and artifact directory. -- CI commands match the locally validated Release sequence. -- no CI file publishes to NuGet. -- no CI file adds tokens or secrets. -- no CI file uses `continue-on-error`. -- no CI file references `poc-arquitetura`. -- no CI file references `netcoreapp3.1`, `.NET Core 3.1`, or `Visual Studio 2019`. -- tests for both core and Dommel are run through the solution. -- package generation is controlled and artifacts are stored, not published. - -GitHub Actions was not executed remotely in this delivery. AppVeyor and Travis were also not executed remotely. The validation here is local YAML parsing plus command equivalence to the local successful build/test/pack sequence. - -### Dependency Review - -No direct dependency changes were required in this delivery. - -Confirmed: - -- no direct package downgrade was reported. -- no vulnerable packages are reported in `src/`. -- `Dapper 2.1.79` and `Dommel 3.5.3` remain compatible with `netstandard2.0` package outputs. -- test packages restore and run on `net10.0`. -- xUnit remains on `2.9.3` for Delivery 05. - -Deferred: - -- `xunit` -> `xunit.v3` migration remains Delivery 05. -- `xunit.analyzers 1.18.0` remains a transitive package to xUnit 2 and is deferred with xUnit 3 migration. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable test transitive from `Microsoft.Data.Sqlite 10.0.10`; this should be handled by a dedicated dependency-hardening task unless Delivery 05 changes the graph naturally. - -### Limitations - -- CI was not run on GitHub/AppVeyor/Travis from this environment. -- Travis availability and image contents were not proven remotely. -- AppVeyor installation of .NET 10 depends on network access to `https://dot.net/v1/dotnet-install.ps1`. -- NuGet package metadata modernization (`PackageLicenseExpression`, README, SourceLink/repository URL metadata) was intentionally not performed because it is outside the migration validation scope. diff --git a/docs/sdd/net10-migration/05-xunit3-migration.md b/docs/sdd/net10-migration/05-xunit3-migration.md deleted file mode 100644 index 874490a..0000000 --- a/docs/sdd/net10-migration/05-xunit3-migration.md +++ /dev/null @@ -1,363 +0,0 @@ -# 05 - xUnit 3 Migration - -## Specification - -Migrate the test projects from xUnit 2 to xUnit 3 as the final, isolated delivery of the .NET 10 migration. - -Scope: - -- test projects remain on `net10.0`; -- source projects remain on `netstandard2.0`; -- replace xUnit 2 infrastructure with xUnit 3 infrastructure; -- preserve the existing test scenarios, assertions, traits, and observable behavior; -- keep local `dotnet test`, CI, Test Explorer, and coverage behavior working; -- do not change production code, production dependencies, package metadata, target frameworks, or public API. - -Out of scope: - -- Dapper or Dommel updates; -- broad test refactoring; -- assertion rewrites; -- removing or skipping tests; -- Microsoft Testing Platform adoption unless required; -- publishing packages, pushing the branch, or opening a pull request. - -## Discovery - -### Recovered Context - -- `AGENTS.md` was read before changes. -- Local skills under `.agents/skills/` were checked. -- The requested official `migrate-xunit-to-xunit-v3` skill is referenced by `AGENTS.md`, but is not available in this session and is not present under `.agents/skills/`. -- Skills used: - - `run-tests` for VSTest/xUnit command selection. - - `msbuild-antipatterns` for focused project-file review. -- Required handoff files were read: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/01-inventory-baseline.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` - - `docs/sdd/net10-migration/04-validation-pack-ci.md` -- Shared branch recorded in `README.md`: `chore/net10-migration`. -- Current branch: `chore/net10-migration`. -- Deliveries 01 through 04 are concluded in `status.md`. - -### Baseline Before Migration - -Environment: - -- Active SDK: `10.0.302`. -- Test platform: VSTest. -- Test framework: xUnit 2. -- No `global.json`. -- No `Directory.Build.props`, `Directory.Build.targets`, or `Directory.Packages.props`. -- No `xunit.runner.json` or `*.runsettings`. -- No `.vscode/` configuration. - -Commands executed before any edit: - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --configuration Release` | Passed. `src` projects built for `netstandard2.0`; test projects built for `net10.0`. | -| `dotnet test --configuration Release` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 271 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 103 ms. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. 45 passed, 0 failed, 0 skipped, about 281 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. 7 passed, 0 failed, 0 skipped, about 87 ms. | - -Baseline result files: - -- No TRX, coverage, or custom result file was generated by the baseline commands because no logger or collector was requested. - -Baseline test count: - -| Metric | Before | -|---|---:| -| Tests discovered | 52 | -| Tests passed | 52 | -| Tests failed | 0 | -| Tests skipped | 0 | - -### Test Project Inventory - -| Project | Target | Current direct test packages | -|---|---|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `net10.0` | `Microsoft.NET.Test.Sdk 18.8.1`, `Microsoft.Data.Sqlite 10.0.10`, `xunit 2.9.3`, `xunit.runner.visualstudio 3.1.5` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `net10.0` | `Microsoft.NET.Test.Sdk 18.8.1`, `xunit 2.9.3`, `xunit.runner.visualstudio 3.1.5`, `coverlet.collector 10.0.1` | - -Source projects: - -- `src/Dapper.FluentMap`: `TargetFrameworks=netstandard2.0`. -- `src/Dapper.FluentMap.Dommel`: `TargetFrameworks=netstandard2.0`. - -### xUnit Usage Scan - -Patterns found under `test/`: - -- `[Fact]`: 52 tests. -- `[Trait("Category", "Integration")]`: 7 tests in `DapperIntegrationTests`. -- `Assert.Throws`: present and semantically preserved. -- `[assembly: CollectionBehavior(DisableTestParallelization = true)]`: present in both test assemblies. -- `FluentMapper`, `FluentMapper.Reset`, `FluentMapper.EntityMaps`, `FluentMapper.TypeConventions`, `SqlMapper.GetTypeMap`, and Dapper/SQLite integration tests. - -Patterns not found: - -- `[Theory]`, `[InlineData]`, `[MemberData]`, `[ClassData]`; -- `IClassFixture<>`, `ICollectionFixture<>`, `CollectionDefinition`; -- `ITestOutputHelper`; -- custom traits or custom discoverers; -- `Assert.ThrowsAsync`; -- `Skip` attributes; -- `async` test methods; -- reflection over xUnit implementation types. - -### CI and Tooling - -Found: - -- `.github/workflows/ci.yml` restores, builds, tests, packs, and uploads `.nupkg` artifacts with .NET SDK `10.0.x`. -- `.appveyor.yml` installs .NET SDK 10 GA, then restores, builds, tests, packs, and stores artifacts. -- `.travis.yml` uses `dotnet: 10.0` on `jammy`, then restores, builds, tests, and packs. - -Not found: - -- `.vscode/`; -- VS Code tasks or settings; -- runner-specific `xunit.runner.json`; -- MTP configuration through `global.json` or ``. - -### Package Discovery - -Latest stable versions were checked against the NuGet flat container feed on 2026-07-25: - -| Package | Latest stable identified | -|---|---:| -| `xunit.v3` | `3.2.2` | -| `xunit.runner.visualstudio` | `3.1.5` | -| `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `coverlet.collector` | `10.0.1` | - -Additional package diagnostics before migration: - -- `dotnet list package --deprecated --no-restore` reports `xunit 2.9.3` as legacy with `xunit.v3` as the alternative. -- `dotnet list package --outdated --include-transitive --no-restore` reports deferred transitives: - - `SQLitePCLRaw.*` from `Microsoft.Data.Sqlite`; - - `xunit.analyzers 1.18.0`; - - source netstandard transitives already documented in previous deliveries. -- `dotnet list package --vulnerable --include-transitive --no-restore` reports only the known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` high severity warning. - -## Decision - -### Package Plan - -Remove from both test projects: - -- `xunit 2.9.3`. - -Add to both test projects: - -- `xunit.v3 3.2.2`. - -Keep: - -- `Microsoft.NET.Test.Sdk 18.8.1`; -- `xunit.runner.visualstudio 3.1.5`; -- `coverlet.collector 10.0.1` in the Dommel test project; -- `Microsoft.Data.Sqlite 10.0.10` in the core test project. - -### Runner Strategy - -Use VSTest through `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio`. - -Rationale: - -- The repository already uses VSTest successfully with `dotnet test`. -- xUnit.net v3 supports VSTest through the 3.x Visual Studio runner. -- Keeping VSTest is the smallest change and preserves existing CI and Test Explorer behavior. -- Do not introduce Microsoft Testing Platform, `global.json`, or `` in this delivery because they are not required for the migration. - -### Coverage Strategy - -Keep the existing coverlet collector package in `Dapper.FluentMap.Dommel.Tests`. - -The repository has no official runsettings or coverage command beyond collector availability. Validation will run the repository's available coverage path with `dotnet test --collect:"XPlat Code Coverage"` on the project that references `coverlet.collector`. - -### Parallelism Strategy - -Preserve `[assembly: CollectionBehavior(DisableTestParallelization = true)]` in both test assemblies. - -Technical reason: - -- Tests use global mutable state from `FluentMapper`, Dapper type maps, caches, and Dommel resolver state. -- The existing suite is green with parallelization disabled. -- Re-enabling parallelization would be a behavior and isolation change outside this migration. - -### Expected Code Adaptations - -No source or test-code adaptation is expected because the suite uses xUnit surface area that remains compatible: - -- `[Fact]`; -- `[Trait]`; -- `Assert.Equal`, `Assert.Null`, `Assert.NotNull`, `Assert.IsType`, `Assert.Single`, `Assert.Throws`; -- `CollectionBehavior`. - -If compilation or discovery exposes a real xUnit 3 API incompatibility, only the minimal semantic equivalent will be applied. - -### Risks - -- Test discovery could change if the VSTest adapter resolves xUnit 3 differently from xUnit 2. -- Trait display/filter metadata must remain discoverable for integration tests. -- Coverage execution must still load the testhost with the migrated framework package. -- Known SQLitePCLRaw vulnerability is unrelated to xUnit and remains deferred. - -## Delivery - -- Updated `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj`: - - removed `xunit 2.9.3`; - - added `xunit.v3 3.2.2`; - - preserved `Microsoft.NET.Test.Sdk 18.8.1`; - - preserved `xunit.runner.visualstudio 3.1.5`; - - preserved `Microsoft.Data.Sqlite 10.0.10`; - - preserved `TargetFramework net10.0`. -- Updated `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj`: - - removed `xunit 2.9.3`; - - added `xunit.v3 3.2.2`; - - preserved `Microsoft.NET.Test.Sdk 18.8.1`; - - preserved `xunit.runner.visualstudio 3.1.5`; - - preserved `coverlet.collector 10.0.1`; - - preserved `TargetFramework net10.0`. -- No C# source or test file required changes. -- No test was removed, skipped, renamed, or weakened. -- No production project was changed. -- No `global.json`, Microsoft Testing Platform runner setting, `xunit.runner.json`, `.runsettings`, or VS Code config was added. -- CI files did not require command changes because they already run `dotnet test` through VSTest-compatible infrastructure. - -## Validation - -### Commands Executed - -| Command | Result | -|---|---| -| `dotnet restore` | Passed with existing NU1903 warning for transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` in `Dapper.FluentMap.Tests`. | -| `dotnet build --configuration Release` immediately after package edit | Passed; no C# adaptation required. | -| `dotnet test --configuration Release` immediately after package edit | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 393 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 165 ms. | -| `dotnet build` | Passed. Debug outputs remain `src`=`netstandard2.0`, `test`=`net10.0`. | -| `dotnet test` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 415 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 192 ms. | -| `dotnet build --configuration Release` | Passed. Release outputs remain `src`=`netstandard2.0`, `test`=`net10.0`. | -| `dotnet test --configuration Release` | Passed. `Dapper.FluentMap.Tests`: 45 passed, 0 failed, 0 skipped, about 418 ms. `Dapper.FluentMap.Dommel.Tests`: 7 passed, 0 failed, 0 skipped, about 158 ms. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release` | Passed. 45 passed, 0 failed, 0 skipped, about 537 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release` | Passed. 7 passed, 0 failed, 0 skipped, about 270 ms. | -| `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --collect:"XPlat Code Coverage" --results-directory TestResults\coverage-xunit3` | Passed. 7 passed, 0 failed, 0 skipped; generated `TestResults/coverage-xunit3//coverage.cobertura.xml`. | -| `dotnet pack .\Dapper.FluentMap.sln --configuration Release --no-build --output .\artifacts\packages` | Passed. Generated both expected `.nupkg` files. Existing NU5125 `licenseUrl` warning and package README recommendation remain. | -| `dotnet list .\Dapper.FluentMap.sln package --include-transitive --no-restore` | Passed. Test projects resolve `xunit.v3 3.2.2` and no longer resolve xUnit 2 packages. | -| `dotnet list .\Dapper.FluentMap.sln package --outdated --include-transitive --no-restore` | Passed. No outdated direct packages. New xUnit v3 MTP v1 transitives report newer MTP v2 lines, but are transitive to the selected stable xUnit v3 package and not forced directly. | -| `dotnet list .\Dapper.FluentMap.sln package --deprecated --no-restore` | Passed. No deprecated packages reported. | -| `dotnet list .\Dapper.FluentMap.sln package --vulnerable --include-transitive --no-restore` | Passed. Only known test transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains vulnerable. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --list-tests` | Passed. Listed 45 core tests, confirming discovery under xUnit 3/VSTest. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --filter "Category=Integration"` | Passed. 7 integration trait tests passed. | - -### Before and After Counts - -| Metric | Before | After | -|---|---:|---:| -| Tests discovered | 52 | 52 | -| Tests passed | 52 | 52 | -| Tests failed | 0 | 0 | -| Tests skipped | 0 | 0 | - -No count changed. The migration preserved all existing test scenarios. - -### Package Graph After Migration - -Final direct test packages: - -| Project | Direct package | Final version | -|---|---|---:| -| `Dapper.FluentMap.Tests` | `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `Dapper.FluentMap.Tests` | `Microsoft.Data.Sqlite` | `10.0.10` | -| `Dapper.FluentMap.Tests` | `xunit.runner.visualstudio` | `3.1.5` | -| `Dapper.FluentMap.Tests` | `xunit.v3` | `3.2.2` | -| `Dapper.FluentMap.Dommel.Tests` | `Microsoft.NET.Test.Sdk` | `18.8.1` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.runner.visualstudio` | `3.1.5` | -| `Dapper.FluentMap.Dommel.Tests` | `xunit.v3` | `3.2.2` | -| `Dapper.FluentMap.Dommel.Tests` | `coverlet.collector` | `10.0.1` | - -Removed xUnit 2 packages from the test graph: - -- direct `xunit 2.9.3`; -- transitive `xunit.abstractions 2.0.3`; -- transitive `xunit.assert 2.9.3`; -- transitive `xunit.core 2.9.3`; -- transitive `xunit.extensibility.core 2.9.3`; -- transitive `xunit.extensibility.execution 2.9.3`. - -Added xUnit 3 graph: - -- direct `xunit.v3 3.2.2`; -- transitive `xunit.v3.assert 3.2.2`; -- transitive `xunit.v3.common 3.2.2`; -- transitive `xunit.v3.core.mtp-v1 3.2.2`; -- transitive `xunit.v3.extensibility.core 3.2.2`; -- transitive `xunit.v3.mtp-v1 3.2.2`; -- transitive `xunit.v3.runner.common 3.2.2`; -- transitive `xunit.v3.runner.inproc.console 3.2.2`; -- transitive `xunit.analyzers 1.27.0`. - -### Coverage - -Coverage collector validation passed for the only test project that references `coverlet.collector`: - -- command: `dotnet test test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --configuration Release --collect:"XPlat Code Coverage" --results-directory TestResults\coverage-xunit3`; -- generated: `TestResults/coverage-xunit3//coverage.cobertura.xml`; -- result: 7 passed, 0 failed, 0 skipped. - -The core test project still has no `coverlet.collector` reference or repository runsettings. That preexisting shape was preserved. - -### CI and VS Code - -CI files were reviewed: - -- `.github/workflows/ci.yml` still restores, builds, tests, packs, and uploads artifacts with .NET SDK `10.0.x`. -- `.appveyor.yml` still installs .NET SDK 10 GA and runs restore/build/test/pack. -- `.travis.yml` still uses `dotnet: 10.0` on `jammy` and runs restore/build/test/pack. -- No CI file references `netcoreapp3.1`, `.NET Core 3.1`, `Visual Studio 2019`, `dotnet nuget push`, package publish tokens, or `continue-on-error`. - -VS Code: - -- No `.vscode/` directory exists in the repository. -- Test Explorer was not opened from this environment. -- The VSTest adapter path was validated through `dotnet test`, `--list-tests`, and trait filtering, which is the same runner family used by VS Code C# test discovery. - -### TargetFramework Confirmation - -| Project | Final target | -|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `TargetFrameworks=netstandard2.0` | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `TargetFrameworks=netstandard2.0` | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework=net10.0` | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework=net10.0` | - -### Package Inspection - -Generated packages: - -- `artifacts/packages/Dapper.FluentMap.2.0.0.nupkg` -- `artifacts/packages/Dapper.FluentMap.Dommel.2.0.0.nupkg` - -Package contents remain limited to package metadata plus: - -- `lib/netstandard2.0/Dapper.FluentMap.dll` -- `lib/netstandard2.0/Dapper.FluentMap.xml` -- `lib/netstandard2.0/Dapper.FluentMap.Dommel.dll` -- `lib/netstandard2.0/Dapper.FluentMap.Dommel.xml` - -No test assemblies, local paths, secrets, `bin/`, `obj/`, `TestResults/`, or `.nupkg` source artifacts were found inside the packages. - -### Limitations and Deferred Items - -- Remote GitHub Actions, AppVeyor, and Travis runs were not executed from this environment. -- VS Code Test Explorer was not opened interactively; runner compatibility was validated through VSTest command-line discovery and execution. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains a vulnerable transitive dependency in the core test project. This is unrelated to xUnit and remains deferred to a dependency-hardening task. -- xUnit v3 `3.2.2` resolves Microsoft Testing Platform v1 transitives by default. The repository intentionally remains on VSTest for `dotnet test`; MTP v2 adoption would be a separate runner migration. diff --git a/docs/sdd/net10-migration/README.md b/docs/sdd/net10-migration/README.md deleted file mode 100644 index 3b08e52..0000000 --- a/docs/sdd/net10-migration/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# .NET 10 test migration - -## Objective - -Document and coordinate the migration of the test projects from `netcoreapp3.1` to `net10.0`, with controlled dependency updates for `src/` and `test/` projects. - -The migration must preserve the published library targets: - -- `src/Dapper.FluentMap` stays on `netstandard2.0`. -- `src/Dapper.FluentMap.Dommel` stays on `netstandard2.0`. -- The `src/` projects must remain consumable by `net10.0` applications and tests. - -This folder is the persistent handoff source for the five independent chats. Future deliveries must read these files instead of relying on memory from previous conversations. - -## Shared Branch - -Branch: `chore/net10-migration` - -Do not push this branch unless a later prompt explicitly asks for it. - -## Expected Final Structure - -```text -src/ -|-- Dapper.FluentMap -> netstandard2.0 -`-- Dapper.FluentMap.Dommel -> netstandard2.0 - -test/ -|-- Dapper.FluentMap.Tests -> net10.0 -`-- Dapper.FluentMap.Dommel.Tests -> net10.0 -``` - -## Delivery Order - -1. Inventory and baseline. -2. Migrate test projects to `net10.0`. -3. Update `src/` project dependencies. -4. Complete validation, package inspection, and CI review. -5. Separate migration to xUnit 3. - -## Identified Solution and Projects - -Solution: - -- `Dapper.FluentMap.sln` - -Projects: - -- `src/Dapper.FluentMap/Dapper.FluentMap.csproj` -- `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` -- `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` -- `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` - -## Repository Validation Commands - -Commands documented in `AGENTS.md` for the main library: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-restore -dotnet test ./test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release -``` - -Commands documented in `AGENTS.md` for the full solution: - -```bash -dotnet restore ./Dapper.FluentMap.sln -dotnet build ./Dapper.FluentMap.sln --configuration Release --no-restore -dotnet test ./Dapper.FluentMap.sln --configuration Release --no-build -``` - -Commands documented in `AGENTS.md` for packaging: - -```bash -dotnet pack ./src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build --output ./artifacts/packages -``` - -CI files after Delivery 04: - -- `.github/workflows/ci.yml`: installs .NET SDK `10.0.x` GA, runs restore, Release build, Release tests, Release pack, and uploads `.nupkg` artifacts. -- `.appveyor.yml`: installs .NET SDK 10 GA on Visual Studio 2022 image, runs restore, Release build, Release tests, Release pack, and stores `.nupkg` artifacts. -- `.travis.yml`: uses `dotnet: 10.0` on `jammy`, runs restore, Release build, Release tests, and Release pack. - -No `global.json`, `Directory.Build.props`, `Directory.Packages.props`, or `.editorconfig` files are present after Delivery 04. - -## Resultado final - -TargetFrameworks finais: - -- `src/Dapper.FluentMap`: `netstandard2.0` -- `src/Dapper.FluentMap.Dommel`: `netstandard2.0` -- `test/Dapper.FluentMap.Tests`: `net10.0` -- `test/Dapper.FluentMap.Dommel.Tests`: `net10.0` - -Versoes principais: - -- .NET SDK local validado: `10.0.302` -- `Dapper`: `2.1.79` -- `Dommel`: `3.5.3` -- `Microsoft.NET.Test.Sdk`: `18.8.1` -- `xunit.v3`: `3.2.2` -- `xunit.runner.visualstudio`: `3.1.5` -- `coverlet.collector`: `10.0.1` - -Runner de testes: - -- VSTest via `Microsoft.NET.Test.Sdk` e `xunit.runner.visualstudio`. -- Microsoft Testing Platform nao foi adotado. -- Paralelismo de testes permanece desabilitado por estado global compartilhado. - -Status: - -- build Debug: aprovado. -- build Release: aprovado. -- testes Debug: 52 descobertos, 52 aprovados, 0 falhos, 0 ignorados. -- testes Release: 52 descobertos, 52 aprovados, 0 falhos, 0 ignorados. -- pack Release: aprovado; pacotes emitidos com `lib/netstandard2.0`. -- CI: arquivos GitHub Actions, AppVeyor e Travis revisados localmente; execucoes remotas nao foram realizadas. - -Dependencias bloqueadas, deferidas ou concluidas posteriormente: - -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` foi corrigido em hardening dedicado documentado em `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; `Dapper.FluentMap.Tests` agora pina `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` com `PrivateAssets="all"`. -- Modernizacao de metadados NuGet (`licenseUrl`, README de pacote, SourceLink/repository metadata) permanece fora do escopo. -- Adocao de Microsoft Testing Platform fica deferida para uma migracao de runner separada, se necessaria. - -Referencias dos relatorios: - -- `docs/sdd/net10-migration/01-inventory-baseline.md` -- `docs/sdd/net10-migration/02-test-projects-net10.md` -- `docs/sdd/net10-migration/03-src-dependencies.md` -- `docs/sdd/net10-migration/04-validation-pack-ci.md` -- `docs/sdd/net10-migration/05-xunit3-migration.md` diff --git a/docs/sdd/net10-migration/decisions.md b/docs/sdd/net10-migration/decisions.md deleted file mode 100644 index 4a43bf5..0000000 --- a/docs/sdd/net10-migration/decisions.md +++ /dev/null @@ -1,99 +0,0 @@ -# Cross-Delivery Decisions - -## Preserve `netstandard2.0` for Published Projects - -`src/Dapper.FluentMap` and `src/Dapper.FluentMap.Dommel` must remain on `netstandard2.0` throughout the migration. Dependency updates in Delivery 03 must not force either published project to move to `net8.0`, `net10.0`, or multi-targeting. - -## Isolate xUnit 3 Until Delivery 05 - -Delivery 02 may update xUnit 2 packages to the latest stable xUnit 2 line, but must not migrate test code or project references to `xunit.v3`. The xUnit 3 migration is intentionally isolated in Delivery 05. - -## Separate Test Runtime Migration From Source Dependency Updates - -Delivery 02 should focus on test projects and test-only packages needed for `net10.0`. Delivery 03 should focus on direct dependencies of `src/` projects (`Dapper` and `Dommel`) after the tests can run on a supported runtime. - -## Use `net10.0` Tests as Consumer Validation - -The primary compatibility check for the published `netstandard2.0` projects is to run the `net10.0` test projects while they reference the `src/` projects. Delivery 04 should add package inspection with `dotnet pack` to confirm the published assemblies and dependency groups still target `netstandard2.0`. - -## Delivery 02 Test Runtime Migration - -Delivery 02 migrated only the test projects to `net10.0` and changed their single-target element from `TargetFrameworks` to `TargetFramework`. The `src/` projects were not normalized in this delivery and remain on `TargetFrameworks netstandard2.0` to avoid unrelated published-project churn. - -Test execution remains on VSTest with xUnit 2: - -- no `global.json` Microsoft Testing Platform runner was introduced; -- no `TestingPlatformDotnetTestSupport` property was introduced; -- `xunit` was updated only within the xUnit 2 package line; -- `xunit.runner.visualstudio 3.1.5` was selected because it is a modern VSTest adapter that supports .NET 8+ and can run xUnit 2 tests; -- `xunit.v3` remains deferred to Delivery 05. - -`Microsoft.Data.Sqlite` was updated only in the core test project because it is a direct test-only dependency used by Dapper integration tests. A vulnerable transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` remains after the update and should be reviewed in Delivery 04 or a dedicated dependency-hardening task rather than adding unrelated overrides in this migration step. - -Dedicated security hardening later completed that review in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`: `Dapper.FluentMap.Tests` now pins `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` with `PrivateAssets="all"`, removing vulnerable `SQLitePCLRaw.lib.e_sqlite3 2.1.11` while keeping SQLite test-only. - -## Delivery 03 Production Dependency Updates - -Delivery 03 updated only direct production dependencies in `src/`: - -- `Dapper` was updated from `2.0.35` to `2.1.79` in both published projects. -- `Dommel` was updated from `2.0.0` to `3.5.3` in `src/Dapper.FluentMap.Dommel`. -- Both `src/` projects remain on `TargetFrameworks netstandard2.0`. -- Both test projects remain on `TargetFramework net10.0`. - -No C# code changes were required. Compilation confirmed that the Dapper type-map API surface used by `Dapper.FluentMap` and the Dommel resolver API surface used by `Dapper.FluentMap.Dommel` remain source-compatible for this repository. - -The update was intentionally limited to direct production dependencies. Transitively outdated packages reported after the update were not forced as direct references: - -- `Microsoft.Bcl.AsyncInterfaces 10.0.8` is resolved through Dapper's `netstandard2.0` dependency floor. -- `Microsoft.NETCore.Platforms 1.1.0` remains part of the `NETStandard.Library` restore graph. -- `xunit.analyzers 1.18.0` remains transitive to xUnit 2 and is deferred with the xUnit 3 migration. -- `SQLitePCLRaw.*` remains test-only and was covered by the dedicated dependency-hardening task documented in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`. - -Delivery 04 should pack and inspect the published packages to confirm the final dependency groups and package contents. - -## Delivery 04 Validation and CI - -Delivery 04 validated the final migration state without changing production code, public API, target frameworks, package versions, or package metadata. - -Final target matrix: - -- `src/Dapper.FluentMap`: `netstandard2.0` -- `src/Dapper.FluentMap.Dommel`: `netstandard2.0` -- `test/Dapper.FluentMap.Tests`: `net10.0` -- `test/Dapper.FluentMap.Dommel.Tests`: `net10.0` - -No `global.json` was created. CI installs/selects .NET 10 explicitly, while the repository avoids pinning a short-lived exact SDK in source control. - -CI policy: - -- run restore, Release build, Release tests and Release pack. -- store `.nupkg` files only as CI artifacts. -- do not run `dotnet nuget push`. -- do not configure NuGet API keys, publish tokens, package feeds, release uploads, or `continue-on-error`. -- do not add a framework matrix because tests only target `net10.0`. - -Package inspection confirms both published packages contain only `lib/netstandard2.0` assemblies/XML docs plus package metadata. Existing `licenseUrl` metadata produces NU5125 and package README is recommended by NuGet, but both are deferred because this delivery must avoid unrelated metadata modernization. - -## Delivery 05 xUnit 3 Migration - -Delivery 05 migrated the test projects from xUnit 2 to xUnit 3 without changing test semantics or production code. - -Final test infrastructure: - -- `Microsoft.NET.Test.Sdk 18.8.1` -- `xunit.v3 3.2.2` -- `xunit.runner.visualstudio 3.1.5` -- `coverlet.collector 10.0.1` in `Dapper.FluentMap.Dommel.Tests` -- no Microsoft Testing Platform runner in `global.json` -- no `` property -- test assemblies disable parallel execution with `[assembly: CollectionBehavior(DisableTestParallelization = true)]` because the suite uses global FluentMapper/Dapper state - -Permanent decisions: - -- VSTest remains the official `dotnet test` runner path for this repository. -- `xunit.runner.visualstudio` remains referenced for `dotnet test`, Visual Studio, and VS Code Test Explorer compatibility. -- Microsoft Testing Platform adoption is deferred to a separate runner migration, if ever needed. -- Parallel execution remains disabled until FluentMapper, Dapper type-map, and Dommel resolver global state are isolated. -- Coverage remains based on the existing Coverlet collector setup; no Microsoft Testing Platform coverage extension was introduced. -- Existing Dapper materialization and Dommel resolver tests remain active as compatibility proof. diff --git a/docs/sdd/net10-migration/dependency-matrix.md b/docs/sdd/net10-migration/dependency-matrix.md deleted file mode 100644 index c971ca7..0000000 --- a/docs/sdd/net10-migration/dependency-matrix.md +++ /dev/null @@ -1,128 +0,0 @@ -# Dependency Matrix - -Latest stable versions were identified with `dotnet list package --outdated --include-transitive --no-restore` after an isolated restore, and cross-checked against NuGet.org package pages on 2026-07-25. - -## Projects and Direct Dependencies - -| Project | Type | Current TFM | Desired TFM | Project references | Direct package | Current version | Latest stable identified | Declared `netstandard2.0` compatibility | `net10.0` consumer compatibility | Planned action | Notes / blocks | -|---|---|---|---|---|---|---:|---:|---|---|---|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | Published library | `netstandard2.0` | `netstandard2.0` | - | `Dapper` | `2.1.79` | `2.1.79` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.35`; no code changes required; Dapper type-map integration tests passed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | Published library / integration | `netstandard2.0` | `netstandard2.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dapper` | `2.1.79` | `2.1.79` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.35`; kept aligned with core and Dommel dependency floor. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | Published library / integration | `netstandard2.0` | `netstandard2.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dommel` | `3.5.3` | `3.5.3` | Yes; latest includes `netstandard2.0` assets. | Yes; latest declares `net10.0` compatibility. | Completed in Delivery 03. | Updated from `2.0.0`; major update compiled without resolver code changes and Dommel tests passed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Microsoft.NET.Test.Sdk` | `18.8.1` | `18.8.1` | Test-only; latest declares `netstandard2.0`, `net8.0`, and computed `net10.0` compatibility. | Yes. | Completed in Delivery 02. | Updated from `16.7.1`; VSTest execution passed on `net10.0`. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Microsoft.Data.Sqlite` | `10.0.10` | `10.0.10` | Yes; current includes `netstandard2.0` assets. | Yes; current has computed `net10.0` compatibility. | Completed in Delivery 02. | Updated from `3.1.32`; transitive `SQLitePCLRaw.lib.e_sqlite3 2.1.11` was later corrected by security hardening with an explicit test-only bundle pin. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `SQLitePCLRaw.bundle_e_sqlite3` | `2.1.12` | `3.0.4` | Yes; `2.1.12` declares `netstandard2.0` compatibility. | Yes; `2.1.12` has computed `net10.0` compatibility. | Completed in security hardening. | Direct test-only pin with `PrivateAssets="all"` to force `SQLitePCLRaw.lib.e_sqlite3 2.1.12` and remove `GHSA-2m69-gcr7-jv3q`; major `3.x` avoided as unnecessary. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `xunit.v3` | `3.2.2` | `3.2.2` | Test-only framework package; xUnit v3 supports modern .NET test projects. | Yes. | Completed in Delivery 05. | Replaced `xunit 2.9.3`; no test code changes required. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Test adapter; latest does not declare `netstandard2.0`, but that is not required for published `src` packages. | Yes; latest supports .NET 8+ and computed `net10.0`; can run xUnit v1/v2/v3 tests. | Completed in Delivery 05. | Kept as VSTest runner for `dotnet test` and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Microsoft.NET.Test.Sdk` | `18.8.1` | `18.8.1` | Test-only; latest declares `netstandard2.0`, `net8.0`, and computed `net10.0` compatibility. | Yes. | Completed in Delivery 02. | Updated from `16.7.1`; VSTest execution passed on `net10.0`. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `xunit.v3` | `3.2.2` | `3.2.2` | Test-only framework package; xUnit v3 supports modern .NET test projects. | Yes. | Completed in Delivery 05. | Replaced `xunit 2.9.3`; no test code changes required. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Test adapter; latest does not declare `netstandard2.0`, but that is not required for published `src` packages. | Yes; latest supports .NET 8+ and computed `net10.0`; can run xUnit v1/v2/v3 tests. | Completed in Delivery 05. | Kept as VSTest runner for `dotnet test` and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | Test project | `net10.0` | `net10.0` | `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `coverlet.collector` | `10.0.1` | `10.0.1` | Test/coverage collector; latest does not declare `netstandard2.0`, but it is not a published dependency. | Yes; latest supports .NET 8+ / .NET Framework 4.7.2+ and declares `net10.0` compatibility. | Completed in Delivery 02. | Updated from `1.3.0`; repo still has no runsettings. | - -## Relevant Transitive Findings - -| Area | Package | Resolved version | Latest stable identified | Finding | Planned handling | -|---|---|---:|---:|---|---| -| Test transitives | `Newtonsoft.Json` | Not resolved after Delivery 02 | `13.0.4` | Was vulnerable in old test platform graph. | Resolved by Delivery 02 test package updates. | -| Test transitives | `System.Net.Http` | Not resolved after Delivery 02 | `4.3.4` | Was vulnerable in old `netcoreapp3.1` graph. | Resolved by Delivery 02 test package updates. | -| Test transitives | `System.Text.RegularExpressions` | Not resolved after Delivery 02 | `4.3.1` | Was vulnerable in old `netcoreapp3.1` graph. | Resolved by Delivery 02 test package updates. | -| Core/test transitives | `Microsoft.NETCore.Targets` | `1.1.0` | `5.0.0` | Old transitive package involved in the corrupted global cache error. | Do not edit directly; should disappear from modern test graph where possible. | -| Core/test transitives | `Microsoft.NETCore.Platforms` | `1.1.0` | `7.0.4` | Old transitive from `NETStandard.Library` graph. | Do not edit directly. | -| Source transitives | `Microsoft.Bcl.AsyncInterfaces` | `10.0.8` | `10.0.10` | Transitive dependency resolved through `Dapper 2.1.79` for `netstandard2.0`. | Do not force as a direct source dependency; restore selected Dapper's dependency floor. | -| Dommel transitives | `System.ComponentModel.Annotations` | `5.0.0` | `5.0.0` | Updated naturally through `Dommel 3.5.3`. | Completed by Delivery 03 without direct override. | -| Dommel transitives | `Microsoft.Bcl.HashCode` | `6.0.0` | `6.0.0` | New `Dommel 3.5.3` transitive dependency for `netstandard2.0`. | Accept as package metadata dependency; no direct override. | -| Core tests | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.12` | `3.53.3` | `2.1.11` was vulnerable after `Microsoft.Data.Sqlite 10.0.10`; hardening pins the bundle to resolve `2.1.12` instead. | Completed in `docs/sdd/security-hardening/sqlitepclraw-vulnerability.md`; no vulnerable SQLitePCLRaw package remains in audit. | -| Test transitives | `xunit.analyzers` | `1.27.0` | `1.27.0` | Updated transitively by xUnit 3 in Delivery 05. | No direct override needed. | -| Test transitives | `Microsoft.Testing.Platform` | `1.9.1` | `2.3.2` | Introduced transitively by the default xUnit v3 `3.x` MTP v1 package graph. | Do not force directly; repository uses VSTest for `dotnet test`. | - -## Delivery 02 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `TargetFramework` | `netcoreapp3.1` | `net10.0` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `Microsoft.Data.Sqlite` | `3.1.32` | `10.0.10` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit` | `2.4.1` | `2.9.3` | Completed; xUnit 3 deferred. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `TargetFramework` | `netcoreapp3.1` | `net10.0` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `Microsoft.NET.Test.Sdk` | `16.7.1` | `18.8.1` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit` | `2.4.1` | `2.9.3` | Completed; xUnit 3 deferred. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit.runner.visualstudio` | `2.4.3` | `3.1.5` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `coverlet.collector` | `1.3.0` | `10.0.1` | Completed. | - -## Delivery 03 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `src/Dapper.FluentMap/Dapper.FluentMap.csproj` | `Dapper` | `2.0.35` | `2.1.79` | Completed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Dapper` | `2.0.35` | `2.1.79` | Completed. | -| `src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj` | `Dommel` | `2.0.0` | `3.5.3` | Completed. | - -## Delivery 05 Applied Updates - -| Project | Package | Previous version | New version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit` -> `xunit.v3` | `2.9.3` | `3.2.2` | Completed. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit` -> `xunit.v3` | `2.9.3` | `3.2.2` | Completed. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Kept for VSTest and Test Explorer compatibility. | -| `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj` | `xunit.runner.visualstudio` | `3.1.5` | `3.1.5` | Kept for VSTest and Test Explorer compatibility. | - -## Security Hardening Applied Updates - -| Project | Package | Previous resolved version | New requested/resolved version | Status | -|---|---|---:|---:|---| -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `SQLitePCLRaw.bundle_e_sqlite3` | `2.1.11` transitively through `Microsoft.Data.Sqlite` | `2.1.12` direct test-only pin | Completed; `PrivateAssets="all"` keeps the override private to tests. | -| `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` | `SQLitePCLRaw.lib.e_sqlite3` | `2.1.11` vulnerable transitive | `2.1.12` transitive through the pinned bundle | Completed; NuGet audit no longer reports `GHSA-2m69-gcr7-jv3q`. | - -## Pending Direct Package Work - -| Delivery | Package | Current version | Latest stable identified | Reason deferred | -|---|---|---:|---:|---| -| - | - | - | - | No pending direct package work remains in this .NET 10 migration initiative. | - -## Delivery 04 Validation Findings - -No direct package version changed in Delivery 04. - -Validation commands confirmed: - -- restore succeeds with the current package graph. -- Debug and Release builds succeed. -- Release package generation succeeds. -- no direct package downgrade is reported. -- no vulnerable packages are reported in `src/`. -- both `src` packages are emitted under `lib/netstandard2.0`. -- both `net10.0` test projects consume the `netstandard2.0` source projects through `ProjectReference`. - -NuGet previously reported only deferred items already known from earlier deliveries. Security hardening has since resolved the SQLitePCLRaw item: - -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` was a vulnerable transitive in `Dapper.FluentMap.Tests`; it is now resolved as `2.1.12`. -- xUnit 2 legacy packages are removed by Delivery 05. -- xUnit v3 introduces Microsoft Testing Platform v1 transitives through its default `3.x` package graph. They are not direct dependencies and are not forced because the repository continues to use VSTest for `dotnet test`. -- `Microsoft.Bcl.AsyncInterfaces 10.0.8` remains Dapper's resolved `netstandard2.0` dependency floor. -- `Microsoft.NETCore.Platforms 1.1.0` remains part of the `NETStandard.Library` restore graph. - -Delivery 04 did not force transitive overrides because none are required to validate the .NET 10 migration and package contents. - -## Packages Whose Latest Version Does Not Support `netstandard2.0` - -No direct production dependency updated in Delivery 03 was blocked by `netstandard2.0`. - -Test-only packages that do not need to support the published `netstandard2.0` libraries: - -- `xunit.v3` latest supports the repository's `net10.0` test projects. -- `xunit.runner.visualstudio` latest targets .NET 8+ and .NET Framework 4.7.2+. -- `coverlet.collector` latest supports .NET Core 8+ and .NET Framework 4.7.2+. - -## Package Source References - -- NuGet package source: `https://api.nuget.org/v3/index.json` -- `Dapper`: https://www.nuget.org/packages/Dapper -- `Dommel`: https://www.nuget.org/packages/Dommel/3.5.3 -- `Microsoft.Data.Sqlite`: https://www.nuget.org/packages/Microsoft.Data.Sqlite/10.0.10 -- `SQLitePCLRaw.bundle_e_sqlite3`: https://www.nuget.org/packages/SQLitePCLRaw.bundle_e_sqlite3/2.1.12 -- `SQLitePCLRaw.lib.e_sqlite3`: https://www.nuget.org/packages/SQLitePCLRaw.lib.e_sqlite3/2.1.12 -- `Microsoft.NET.Test.Sdk`: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk/18.8.1 -- `xunit.v3`: https://www.nuget.org/packages/xunit.v3 -- `xunit.runner.visualstudio`: https://www.nuget.org/packages/xunit.runner.visualstudio -- `coverlet.collector`: https://www.nuget.org/packages/coverlet.collector diff --git a/docs/sdd/net10-migration/status.md b/docs/sdd/net10-migration/status.md deleted file mode 100644 index dd505f6..0000000 --- a/docs/sdd/net10-migration/status.md +++ /dev/null @@ -1,9 +0,0 @@ -# Migration Status - -| Entrega | Status | Commit | -|---|---|---| -| 01 - Inventario e baseline | Concluido | docs: document .NET 10 migration baseline | -| 02 - Projetos de teste em net10.0 | Concluido | test: migrate test projects to net10.0 | -| 03 - Dependencias dos projetos de src | Concluido | chore: update production dependencies | -| 04 - Validacao, pack e CI | Concluido | ci: validate .NET 10 build and packaging | -| 05 - Migracao para xUnit 3 | Concluido | test: migrate test suite to xUnit 3 | diff --git a/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md b/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md deleted file mode 100644 index c9523b2..0000000 --- a/docs/sdd/security-hardening/sqlitepclraw-vulnerability.md +++ /dev/null @@ -1,156 +0,0 @@ -## Specification - -Corrigir de forma isolada o alerta de vulnerabilidade de `SQLitePCLRaw.lib.e_sqlite3 2.1.11` resolvido apenas pela infraestrutura de testes SQLite do projeto `Dapper.FluentMap.Tests`. - -A correcao deve: - -- permanecer restrita a projetos de teste; -- nao alterar projetos em `src/`; -- nao alterar API publica ou comportamento funcional do FluentMap; -- nao atualizar dependencias nao relacionadas; -- remover `SQLitePCLRaw.lib.e_sqlite3 2.1.11` do grafo restaurado; -- manter os testes de integracao SQLite funcionando; -- confirmar que dependencias SQLite nao aparecem no pacote NuGet de producao. - -## Discovery - -Contexto recuperado: - -- `AGENTS.md` foi lido antes das alteracoes. -- Skills locais em `.agents/skills/` foram verificadas. -- Skills usadas: - - `msbuild-antipatterns`, para revisar a alteracao de `PackageReference` e uso de `PrivateAssets`; - - `run-tests`, para confirmar o runner VSTest/xUnit v3 e comandos de validacao. -- Documentos de handoff lidos: - - `docs/sdd/net10-migration/README.md` - - `docs/sdd/net10-migration/status.md` - - `docs/sdd/net10-migration/decisions.md` - - `docs/sdd/net10-migration/dependency-matrix.md` - - `docs/sdd/net10-migration/02-test-projects-net10.md` - - `docs/sdd/net10-migration/03-src-dependencies.md` - - `docs/sdd/net10-migration/04-validation-pack-ci.md` -- Branch compartilhada registrada: `chore/net10-migration`. -- Branch atual: `chore/net10-migration`. -- SDK local: `10.0.302`. -- Nao ha `global.json`, `Directory.Build.props` ou `Directory.Packages.props`. -- Runner de testes: VSTest por `Microsoft.NET.Test.Sdk` e `xunit.runner.visualstudio`; Microsoft Testing Platform nao foi adotado como runner do `dotnet test`. - -Cadeia transitiva antes da correcao: - -```text -Dapper.FluentMap.Tests [net10.0] -└── Microsoft.Data.Sqlite 10.0.10 - └── SQLitePCLRaw.bundle_e_sqlite3 2.1.11 - └── SQLitePCLRaw.lib.e_sqlite3 2.1.11 -``` - -Baseline NuGet antes da correcao: - -- `dotnet nuget why test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj SQLitePCLRaw.lib.e_sqlite3` confirmou a cadeia acima. -- `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive` confirmou: - - `SQLitePCLRaw.bundle_e_sqlite3 2.1.11` - - `SQLitePCLRaw.core 2.1.11` - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11` - - `SQLitePCLRaw.provider.e_sqlite3 2.1.11` -- `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive --vulnerable` reportou somente: - - `SQLitePCLRaw.lib.e_sqlite3 2.1.11`, severidade High, `GHSA-2m69-gcr7-jv3q`. -- `Dapper.FluentMap.Dommel.Tests` nao tem dependencia de `SQLitePCLRaw.lib.e_sqlite3` e nao reportou pacotes vulneraveis. - -Metadata oficial consultada: - -- NuGet.org mostra `Microsoft.Data.Sqlite 10.0.10` como versao estavel atual e dependente de `SQLitePCLRaw.bundle_e_sqlite3 >= 2.1.11` e `SQLitePCLRaw.core >= 2.1.11`. -- NuGet.org mostra `SQLitePCLRaw.bundle_e_sqlite3` com versoes estaveis `2.1.12` e `3.0.4`; a linha `2.1.12` preserva compatibilidade declarada com `.NETStandard 2.0`, `.NETFramework 4.6.1` e TFMs modernos computados, incluindo `net10.0`. -- NuGet.org mostra `SQLitePCLRaw.lib.e_sqlite3 2.1.12` como versao estavel da mesma linha 2.1 e sem marcacao de vulnerabilidade na pagina do pacote, enquanto `2.1.11` aparece como vulneravel. -- NuGet.org mostra `SQLitePCLRaw.lib.e_sqlite3 3.53.3` como versao estavel mais recente do pacote nativo, mas essa versao pertence a outra linha maior/familia de dependencias. -- GitHub Advisory `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965` afeta `SQLitePCLRaw.lib.e_sqlite3 <= 2.1.11`, com severidade High, por embutir SQLite anterior a `3.50.2`. - -## Decision - -Causa raiz: - -- Pacote pai direto: `Microsoft.Data.Sqlite 10.0.10`, usado somente por `test/Dapper.FluentMap.Tests`. -- Caminho transitivo: `Microsoft.Data.Sqlite` -> `SQLitePCLRaw.bundle_e_sqlite3` -> `SQLitePCLRaw.lib.e_sqlite3`. -- Versao vulneravel: `SQLitePCLRaw.lib.e_sqlite3 2.1.11`. -- Advisory: `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965`, severidade High. -- Projeto afetado: `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj`. -- Projeto nao afetado: `test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj`. - -Estrategia escolhida: Opcao B, adicionar referencia direta test-only a `SQLitePCLRaw.bundle_e_sqlite3 2.1.12` com `PrivateAssets="all"` no projeto `Dapper.FluentMap.Tests`. - -Motivos: - -- `Microsoft.Data.Sqlite 10.0.10` ja e a versao estavel atual e ainda declara piso transitivo `SQLitePCLRaw.bundle_e_sqlite3 >= 2.1.11`; atualizar o pacote pai nao remove a versao vulneravel. -- Pin do bundle mantem a familia SQLitePCLRaw coerente, atualizando junto `core`, `provider` e `lib` para a linha `2.1.12`. -- `2.1.12` e a menor atualizacao estavel dentro da mesma major/linha que evita `2.1.11`; evita migrar para a familia `3.x` sem necessidade. -- A referencia e exclusivamente para controlar uma dependencia transitiva de testes, portanto `PrivateAssets="all"` e apropriado. -- A mudanca nao adiciona SQLite a projetos em `src/` e nao altera o pacote NuGet de producao. - -Alternativas descartadas: - -- Opcao A, atualizar `Microsoft.Data.Sqlite`: descartada porque `10.0.10` ja e a versao estavel atual e ainda permite o piso vulneravel `2.1.11`. -- Pin direto de `SQLitePCLRaw.lib.e_sqlite3`: descartado porque o bundle e o ponto de composicao usado pelo pacote pai e mantem os pacotes SQLitePCLRaw alinhados. -- Atualizar para `SQLitePCLRaw.bundle_e_sqlite3 3.0.4`: descartado por ser mudanca de major/familia desnecessaria para eliminar a vulnerabilidade test-only. - -## Delivery - -- Atualizado `test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj` com referencia direta: - -```xml - -``` - -- Nenhum projeto em `src/` recebeu dependencia SQLite. -- `test/Dapper.FluentMap.Dommel.Tests` nao foi alterado porque nao possui a cadeia SQLitePCLRaw. -- Nenhum codigo C# de producao ou teste foi alterado. -- Nenhum teste foi enfraquecido, removido ou ignorado. -- Nenhuma dependencia funcional nao relacionada foi atualizada. - -Versoes finais resolvidas em `Dapper.FluentMap.Tests`: - -```text -SQLitePCLRaw.bundle_e_sqlite3 2.1.12 -SQLitePCLRaw.core 2.1.12 -SQLitePCLRaw.lib.e_sqlite3 2.1.12 -SQLitePCLRaw.provider.e_sqlite3 2.1.12 -``` - -## Validation - -Comandos executados: - -| Comando | Resultado | -|---|---| -| `dotnet restore` | Aprovado, sem NU1903 para `SQLitePCLRaw.lib.e_sqlite3`. | -| `dotnet nuget why test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj SQLitePCLRaw.lib.e_sqlite3` | Aprovado; cadeia final resolve `SQLitePCLRaw.lib.e_sqlite3 2.1.12` via `Microsoft.Data.Sqlite 10.0.10` e via pin direto do bundle. | -| `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive` | Aprovado; `SQLitePCLRaw.lib.e_sqlite3 2.1.11` nao aparece; `2.1.12` aparece. | -| `dotnet package list --project test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel reportado. | -| `dotnet package list --project test/Dapper.FluentMap.Dommel.Tests/Dapper.FluentMap.Dommel.Tests.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel reportado. | -| `dotnet package list --project src/Dapper.FluentMap/Dapper.FluentMap.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel em `src/Dapper.FluentMap`. | -| `dotnet package list --project src/Dapper.FluentMap.Dommel/Dapper.FluentMap.Dommel.csproj --include-transitive --vulnerable` | Aprovado; nenhum pacote vulneravel em `src/Dapper.FluentMap.Dommel`. | -| `dotnet build --configuration Release` | Aprovado; 0 avisos, 0 erros. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build --filter FullyQualifiedName~DapperIntegrationTests` | Aprovado; 7 testes SQLite/Dapper, 0 falhas, 0 ignorados. | -| `dotnet test test/Dapper.FluentMap.Tests/Dapper.FluentMap.Tests.csproj --configuration Release --no-build` | Aprovado; 45 testes, 0 falhas, 0 ignorados. | -| `dotnet test --configuration Release --no-build` | Aprovado; `Dapper.FluentMap.Tests` 45/45 e `Dapper.FluentMap.Dommel.Tests` 7/7. | -| `dotnet pack src/Dapper.FluentMap/Dapper.FluentMap.csproj --configuration Release --no-build` | Aprovado; gerou `Dapper.FluentMap.2.0.0.nupkg`. Avisos existentes: NU5125 para `licenseUrl` e recomendacao de README de pacote. | - -Inspecao do pacote: - -- Conteudo do pacote de producao: - - `lib/netstandard2.0/Dapper.FluentMap.dll` - - `lib/netstandard2.0/Dapper.FluentMap.xml` - - `Dapper.FluentMap.nuspec` - - metadados padrao do pacote -- Dependencia unica na `.nuspec`: - - `.NETStandard2.0`: `Dapper 2.1.79` -- Confirmado que a `.nuspec` nao contem: - - `Microsoft.Data.Sqlite` - - `SQLitePCLRaw.*` - -Confirmacoes finais: - -- Advisory eliminado do audit NuGet: `GHSA-2m69-gcr7-jv3q` / `CVE-2025-6965`. -- `SQLitePCLRaw.lib.e_sqlite3 2.1.11` nao aparece mais no grafo restaurado do projeto afetado. -- Dependencia SQLite permanece test-only. -- Projetos em `src/` nao receberam dependencia SQLite. -- Nao houve breaking change de API publica. -- Nao houve push, pull request, publicacao NuGet, tag ou release. diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..7a70022 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 2.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- diff --git a/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..5abf163 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DFM001 | Dapper.FluentMap.Configuration | Error | Map expression must resolve to a property path. +DFM002 | Dapper.FluentMap.Configuration | Error | Property path is mapped more than once. +DFM003 | Dapper.FluentMap.Configuration | Error | Column is mapped by more than one property path. +DFM004 | Dapper.FluentMap.Configuration | Error | Included mapping type must be a base class. +DFM005 | Dapper.FluentMap.Configuration | Error | Generic map registration type is invalid. +DFM009 | Dapper.FluentMap.Configuration | Error | Generic profile registration type is invalid. +DFM010 | Dapper.FluentMap.Configuration | Error | Mapping profile is registered more than once. diff --git a/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj new file mode 100644 index 0000000..127a392 --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/Dapper.FluentMap.Analyzers.csproj @@ -0,0 +1,26 @@ + + + Roslyn analyzers for Dapper.FluentMap configuration. + 2.0.0 + Henk Mollema + netstandard2.0 + true + false + Dapper.FluentMap.Analyzers + c#;dapper;mapping;fluentmap;roslyn;analyzers + https://github.com/henkmollema/Dapper-FluentMap + MIT + README.md + true + + + + + + + + + + + + diff --git a/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs new file mode 100644 index 0000000..349798a --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/FluentMapConfigurationAnalyzer.cs @@ -0,0 +1,836 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapper.FluentMap.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class FluentMapConfigurationAnalyzer : DiagnosticAnalyzer + { + public const string InvalidMapExpressionDiagnosticId = "DFM001"; + public const string DuplicateMemberPathDiagnosticId = "DFM002"; + public const string DuplicateColumnDiagnosticId = "DFM003"; + public const string InvalidIncludeBaseDiagnosticId = "DFM004"; + public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; + public const string InvalidGenericProfileRegistrationDiagnosticId = "DFM009"; + public const string DuplicateProfileRegistrationDiagnosticId = "DFM010"; + + private const string Category = "Dapper.FluentMap.Configuration"; + private const string MappingNamespace = "Dapper.FluentMap.Mapping"; + private const string ConfigurationNamespace = "Dapper.FluentMap.Configuration"; + + private static readonly DiagnosticDescriptor InvalidMapExpressionRule = new DiagnosticDescriptor( + InvalidMapExpressionDiagnosticId, + "Map expression must resolve to a property path", + "Map expression '{0}' is invalid: {1}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Dapper.FluentMap Map expressions must resolve to a property path rooted in the entity parameter."); + + private static readonly DiagnosticDescriptor DuplicateMemberPathRule = new DiagnosticDescriptor( + DuplicateMemberPathDiagnosticId, + "Property path is mapped more than once", + "Property path '{0}' is mapped more than once in this entity map constructor", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Mapping the same property path more than once in the same entity map constructor is an invalid FluentMap configuration.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + + private static readonly DiagnosticDescriptor DuplicateColumnRule = new DiagnosticDescriptor( + DuplicateColumnDiagnosticId, + "Column is mapped by more than one property path", + "Column '{0}' is mapped by more than one property path in this entity map constructor: '{1}' and '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Two explicit FluentMap mappings in the same entity map constructor must not resolve the same column when that conflict is statically known.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + + private static readonly DiagnosticDescriptor InvalidIncludeBaseRule = new DiagnosticDescriptor( + InvalidIncludeBaseDiagnosticId, + "Included mapping type must be a base class", + "Type '{0}' cannot be included as a base mapping for entity '{1}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "IncludeBase() can only include a real base class of the entity mapped by the current EntityMap."); + + private static readonly DiagnosticDescriptor InvalidGenericMapRegistrationRule = new DiagnosticDescriptor( + InvalidGenericMapRegistrationDiagnosticId, + "Generic map registration type is invalid", + "Entity map type '{0}' must implement exactly one closed IEntityMap interface targeting a class type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "AddMap() can only register map types that implement exactly one closed IEntityMap interface whose entity type is a class."); + + private static readonly DiagnosticDescriptor InvalidGenericProfileRegistrationRule = new DiagnosticDescriptor( + InvalidGenericProfileRegistrationDiagnosticId, + "Generic profile registration type is invalid", + "Profile map type '{0}' must implement exactly one closed IEntityMap interface and exactly one closed IProfileMap interface", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "AddProfile() can only register map types that implement one entity map interface and one mapping profile interface."); + + private static readonly DiagnosticDescriptor DuplicateProfileRegistrationRule = new DiagnosticDescriptor( + DuplicateProfileRegistrationDiagnosticId, + "Mapping profile is registered more than once", + "Entity '{0}' registers mapping profile '{1}' more than once in this configuration method", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The same entity/profile pair must not be registered more than once.", + customTags: WellKnownDiagnosticTags.CompilationEnd); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create( + InvalidMapExpressionRule, + DuplicateMemberPathRule, + DuplicateColumnRule, + InvalidIncludeBaseRule, + InvalidGenericMapRegistrationRule, + InvalidGenericProfileRegistrationRule, + DuplicateProfileRegistrationRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(startContext => + { + var constructorMapInvocations = new ConcurrentBag(); + var profileRegistrations = new ConcurrentBag(); + + startContext.RegisterSyntaxNodeAction( + nodeContext => AnalyzeInvocation(nodeContext, constructorMapInvocations, profileRegistrations), + SyntaxKind.InvocationExpression); + + startContext.RegisterCompilationEndAction( + endContext => + { + AnalyzeConstructorMapInvocations(endContext, constructorMapInvocations); + AnalyzeProfileRegistrations(endContext, profileRegistrations); + }); + }); + } + + private static void AnalyzeInvocation( + SyntaxNodeAnalysisContext context, + ConcurrentBag constructorMapInvocations, + ConcurrentBag profileRegistrations) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var method = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol as IMethodSymbol; + + if (method == null) + { + return; + } + + if (IsMapInvocation(method)) + { + AnalyzeMapInvocation(context, invocation, constructorMapInvocations); + return; + } + + if (IsIncludeBaseInvocation(method)) + { + AnalyzeIncludeBaseInvocation(context, invocation, method); + return; + } + + if (IsGenericAddMapInvocation(method)) + { + AnalyzeGenericAddMapInvocation(context, invocation, method); + return; + } + + if (IsGenericAddProfileInvocation(method)) + { + AnalyzeGenericAddProfileInvocation(context, invocation, method, profileRegistrations); + } + } + + private static void AnalyzeMapInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ConcurrentBag constructorMapInvocations) + { + if (invocation.ArgumentList.Arguments.Count != 1) + { + return; + } + + var argument = invocation.ArgumentList.Arguments[0].Expression; + if (!TryGetLambda(argument, out var lambda)) + { + return; + } + + if (!TryCreateMemberPath(lambda.Body, context.SemanticModel, context.CancellationToken, out var memberPath, out var reason)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidMapExpressionRule, + lambda.Body.GetLocation(), + lambda.Body.ToString(), + reason)); + return; + } + + if (!TryCreateDirectConstructorMapInvocation( + invocation, + context.SemanticModel, + memberPath, + context.CancellationToken, + out var mapInvocation)) + { + return; + } + + constructorMapInvocations.Add(mapInvocation); + } + + private static void AnalyzeIncludeBaseInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var containingType = context.ContainingSymbol?.ContainingType; + if (containingType == null) + { + return; + } + + var entityType = FindEntityType(containingType); + var baseType = method.TypeArguments[0] as INamedTypeSymbol; + if (entityType == null || baseType == null) + { + return; + } + + if (baseType.TypeKind == TypeKind.Class && + !SymbolEqualityComparer.Default.Equals(baseType, entityType) && + IsAssignableTo(entityType, baseType)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + InvalidIncludeBaseRule, + invocation.GetLocation(), + FormatSymbol(baseType), + FormatSymbol(entityType))); + } + + private static void AnalyzeGenericAddMapInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var mapType = method.TypeArguments[0] as INamedTypeSymbol; + if (mapType == null) + { + return; + } + + if (TryGetEntityMapInterface(mapType, out _)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericMapRegistrationRule, + invocation.GetLocation(), + FormatSymbol(mapType))); + } + + private static void AnalyzeGenericAddProfileInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + IMethodSymbol method, + ConcurrentBag profileRegistrations) + { + if (method.TypeArguments.Length != 1) + { + return; + } + + var mapType = method.TypeArguments[0] as INamedTypeSymbol; + if (mapType == null) + { + return; + } + + if (!TryGetEntityMapInterface(mapType, out var entityType) || + !TryGetProfileMapInterface(mapType, out var profileType)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericProfileRegistrationRule, + invocation.GetLocation(), + FormatSymbol(mapType))); + return; + } + + if (context.ContainingSymbol != null) + { + profileRegistrations.Add(new ProfileRegistrationInvocation( + context.ContainingSymbol, + entityType, + profileType, + GetInvocationNameLocation(invocation))); + } + } + + private static void AnalyzeConstructorMapInvocations( + CompilationAnalysisContext context, + ConcurrentBag constructorMapInvocations) + { + var groups = constructorMapInvocations + .GroupBy(invocation => invocation.Constructor, SymbolEqualityComparer.Default); + + foreach (var group in groups) + { + var invocations = group + .OrderBy(invocation => invocation.InvocationLocation.SourceSpan.Start) + .ToList(); + + ReportDuplicateMemberPaths(context, invocations); + ReportDuplicateColumns(context, invocations); + } + } + + private static void ReportDuplicateMemberPaths( + CompilationAnalysisContext context, + IList invocations) + { + var seen = new Dictionary(StringComparer.Ordinal); + + foreach (var invocation in invocations) + { + if (seen.ContainsKey(invocation.MemberPath.Key)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateMemberPathRule, + invocation.InvocationLocation, + invocation.MemberPath.Display)); + continue; + } + + seen.Add(invocation.MemberPath.Key, invocation); + } + } + + private static void ReportDuplicateColumns( + CompilationAnalysisContext context, + IList invocations) + { + for (var i = 0; i < invocations.Count; i++) + { + var left = invocations[i]; + if (!left.ColumnKnown || left.Ignored) + { + continue; + } + + for (var j = i + 1; j < invocations.Count; j++) + { + var right = invocations[j]; + if (!right.ColumnKnown || + right.Ignored || + left.MemberPath.Key == right.MemberPath.Key || + !ColumnNamesOverlap(left, right)) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DuplicateColumnRule, + right.ColumnLocation, + right.ColumnName, + left.MemberPath.Display, + right.MemberPath.Display)); + } + } + } + + private static void AnalyzeProfileRegistrations( + CompilationAnalysisContext context, + ConcurrentBag profileRegistrations) + { + var groups = profileRegistrations + .GroupBy( + registration => registration.ContainingSymbol, + SymbolEqualityComparer.Default); + + foreach (var group in groups) + { + var seen = new Dictionary(StringComparer.Ordinal); + foreach (var registration in group.OrderBy(item => item.Location.SourceSpan.Start)) + { + var key = FormatSymbol(registration.EntityType) + "|" + FormatSymbol(registration.ProfileType); + if (seen.ContainsKey(key)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateProfileRegistrationRule, + registration.Location, + FormatSymbol(registration.EntityType), + FormatSymbol(registration.ProfileType))); + continue; + } + + seen.Add(key, registration); + } + } + } + + private static bool ColumnNamesOverlap(MapInvocation left, MapInvocation right) + { + if (string.Equals(left.ColumnName, right.ColumnName, StringComparison.Ordinal)) + { + return true; + } + + return (!left.CaseSensitive || !right.CaseSensitive) && + string.Equals(left.ColumnName, right.ColumnName, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryCreateDirectConstructorMapInvocation( + InvocationExpressionSyntax mapInvocation, + SemanticModel semanticModel, + MemberPathInfo memberPath, + System.Threading.CancellationToken cancellationToken, + out MapInvocation result) + { + result = null; + + var statement = mapInvocation.FirstAncestorOrSelf(); + var block = statement?.Parent as BlockSyntax; + var constructor = block?.Parent as ConstructorDeclarationSyntax; + if (statement == null || constructor == null) + { + return false; + } + + var constructorSymbol = semanticModel.GetDeclaredSymbol(constructor, cancellationToken); + if (constructorSymbol == null) + { + return false; + } + + var column = memberPath.TerminalName; + var columnKnown = true; + var caseSensitive = true; + var ignored = false; + var columnLocation = mapInvocation.GetLocation(); + + SyntaxNode current = mapInvocation; + while (current.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression == current && + memberAccess.Parent is InvocationExpressionSyntax chainedInvocation) + { + var chainedMethod = semanticModel.GetSymbolInfo(chainedInvocation, cancellationToken).Symbol as IMethodSymbol; + if (chainedMethod == null) + { + return false; + } + + if (IsToColumnInvocation(chainedMethod)) + { + columnLocation = chainedInvocation.GetLocation(); + if (!TryGetColumn(chainedInvocation, semanticModel, cancellationToken, out column, out caseSensitive)) + { + columnKnown = false; + } + } + else if (IsIgnoreInvocation(chainedMethod)) + { + ignored = true; + } + + current = chainedInvocation; + } + + if (current != statement.Expression) + { + return false; + } + + result = new MapInvocation( + constructorSymbol, + memberPath, + column, + columnKnown, + caseSensitive, + ignored, + mapInvocation.GetLocation(), + columnLocation); + return true; + } + + private static bool TryGetColumn( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out string column, + out bool caseSensitive) + { + column = null; + caseSensitive = true; + + if (invocation.ArgumentList.Arguments.Count == 0) + { + return false; + } + + var columnConstant = semanticModel.GetConstantValue( + invocation.ArgumentList.Arguments[0].Expression, + cancellationToken); + if (!columnConstant.HasValue || !(columnConstant.Value is string columnValue)) + { + return false; + } + + column = columnValue; + + foreach (var argument in invocation.ArgumentList.Arguments.Skip(1)) + { + var name = argument.NameColon?.Name.Identifier.ValueText; + if (name != null && name != "caseSensitive") + { + continue; + } + + var caseConstant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + if (!caseConstant.HasValue || !(caseConstant.Value is bool caseValue)) + { + return false; + } + + caseSensitive = caseValue; + return true; + } + + return true; + } + + private static bool TryGetLambda(ExpressionSyntax expression, out LambdaExpressionSyntax lambda) + { + expression = StripCastsAndParentheses(expression); + lambda = expression as LambdaExpressionSyntax; + return lambda != null; + } + + private static bool TryCreateMemberPath( + CSharpSyntaxNode body, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out MemberPathInfo memberPath, + out string reason) + { + memberPath = null; + reason = null; + + var expression = StripCastsAndParentheses(body as ExpressionSyntax); + var properties = new Stack(); + + while (expression != null) + { + if (expression is MemberAccessExpressionSyntax memberAccess) + { + var symbol = semanticModel.GetSymbolInfo(memberAccess, cancellationToken).Symbol; + var property = symbol as IPropertySymbol; + if (property == null) + { + reason = symbol == null + ? "the member could not be resolved statically" + : $"member '{symbol.Name}' is not a property"; + return false; + } + + if (property.IsIndexer || property.Parameters.Length > 0) + { + reason = $"indexed property '{property.Name}' is not supported"; + return false; + } + + properties.Push(property); + expression = StripCastsAndParentheses(memberAccess.Expression); + continue; + } + + if (expression is IdentifierNameSyntax identifier) + { + var symbol = semanticModel.GetSymbolInfo(identifier, cancellationToken).Symbol; + if (symbol is IParameterSymbol && properties.Count > 0) + { + memberPath = MemberPathInfo.Create(properties); + return true; + } + + reason = "the expression must resolve to a property path rooted in the entity parameter"; + return false; + } + + reason = "the expression must resolve to a property path"; + return false; + } + + reason = "the expression must resolve to a property path"; + return false; + } + + private static ExpressionSyntax StripCastsAndParentheses(ExpressionSyntax expression) + { + while (true) + { + if (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + continue; + } + + if (expression is CastExpressionSyntax cast) + { + expression = cast.Expression; + continue; + } + + return expression; + } + } + + private static bool IsMapInvocation(IMethodSymbol method) + { + return method.Name == "Map" && + method.Parameters.Length == 1 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsIncludeBaseInvocation(IMethodSymbol method) + { + return method.Name == "IncludeBase" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType.OriginalDefinition, MappingNamespace, "EntityMapBase`2"); + } + + private static bool IsGenericAddMapInvocation(IMethodSymbol method) + { + return method.Name == "AddMap" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType, ConfigurationNamespace, "FluentMapConfiguration"); + } + + private static bool IsGenericAddProfileInvocation(IMethodSymbol method) + { + return method.Name == "AddProfile" && + method.IsGenericMethod && + method.TypeArguments.Length == 1 && + method.Parameters.Length == 0 && + IsType(method.ContainingType, ConfigurationNamespace, "FluentMapConfiguration"); + } + + private static bool IsToColumnInvocation(IMethodSymbol method) + { + return method.Name == "ToColumn" && + method.Parameters.Length >= 1 && + method.Parameters[0].Type.SpecialType == SpecialType.System_String; + } + + private static bool IsIgnoreInvocation(IMethodSymbol method) + { + return method.Name == "Ignore" && method.Parameters.Length == 0; + } + + private static Location GetInvocationNameLocation(InvocationExpressionSyntax invocation) + { + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + return memberAccess == null + ? invocation.GetLocation() + : memberAccess.Name.GetLocation(); + } + + private static INamedTypeSymbol FindEntityType(INamedTypeSymbol mapType) + { + for (var current = mapType; current != null; current = current.BaseType) + { + if (IsType(current.OriginalDefinition, MappingNamespace, "EntityMapBase`2") || + IsType(current.OriginalDefinition, MappingNamespace, "EntityMap`1")) + { + return current.TypeArguments[0] as INamedTypeSymbol; + } + } + + return null; + } + + private static bool IsAssignableTo(INamedTypeSymbol type, INamedTypeSymbol baseType) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + + return false; + } + + private static bool IsType(INamedTypeSymbol type, string namespaceName, string metadataName) + { + return type != null && + type.MetadataName == metadataName && + type.ContainingNamespace.ToDisplayString() == namespaceName; + } + + private static bool TryGetEntityMapInterface(INamedTypeSymbol mapType, out INamedTypeSymbol entityType) + { + entityType = null; + var entityMapInterfaces = mapType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IEntityMap`1")) + .ToList(); + + if (entityMapInterfaces.Count != 1 || + entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class) + { + return false; + } + + entityType = entityMapInterfaces[0].TypeArguments[0] as INamedTypeSymbol; + return entityType != null; + } + + private static bool TryGetProfileMapInterface(INamedTypeSymbol mapType, out INamedTypeSymbol profileType) + { + profileType = null; + var profileMapInterfaces = mapType.AllInterfaces + .Where(type => IsType(type.OriginalDefinition, MappingNamespace, "IProfileMap`1")) + .ToList(); + + if (profileMapInterfaces.Count != 1) + { + return false; + } + + profileType = profileMapInterfaces[0].TypeArguments[0] as INamedTypeSymbol; + return profileType != null; + } + + private static string FormatSymbol(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private sealed class MapInvocation + { + internal MapInvocation( + IMethodSymbol constructor, + MemberPathInfo memberPath, + string columnName, + bool columnKnown, + bool caseSensitive, + bool ignored, + Location invocationLocation, + Location columnLocation) + { + Constructor = constructor; + MemberPath = memberPath; + ColumnName = columnName; + ColumnKnown = columnKnown; + CaseSensitive = caseSensitive; + Ignored = ignored; + InvocationLocation = invocationLocation; + ColumnLocation = columnLocation; + } + + internal IMethodSymbol Constructor { get; } + + internal MemberPathInfo MemberPath { get; } + + internal string ColumnName { get; } + + internal bool ColumnKnown { get; } + + internal bool CaseSensitive { get; } + + internal bool Ignored { get; } + + internal Location InvocationLocation { get; } + + internal Location ColumnLocation { get; } + } + + private sealed class MemberPathInfo + { + private MemberPathInfo(string key, string display, string terminalName) + { + Key = key; + Display = display; + TerminalName = terminalName; + } + + internal string Key { get; } + + internal string Display { get; } + + internal string TerminalName { get; } + + internal static MemberPathInfo Create(IEnumerable properties) + { + var propertyList = properties.ToList(); + var key = string.Join( + ".", + propertyList.Select(property => property.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "." + property.MetadataName)); + var display = string.Join(".", propertyList.Select(property => property.Name)); + return new MemberPathInfo(key, display, propertyList[propertyList.Count - 1].Name); + } + } + + private sealed class ProfileRegistrationInvocation + { + internal ProfileRegistrationInvocation( + ISymbol containingSymbol, + INamedTypeSymbol entityType, + INamedTypeSymbol profileType, + Location location) + { + ContainingSymbol = containingSymbol; + EntityType = entityType; + ProfileType = profileType; + Location = location; + } + + internal ISymbol ContainingSymbol { get; } + + internal INamedTypeSymbol EntityType { get; } + + internal INamedTypeSymbol ProfileType { get; } + + internal Location Location { get; } + } + } +} diff --git a/src/Dapper.FluentMap.Analyzers/README.md b/src/Dapper.FluentMap.Analyzers/README.md new file mode 100644 index 0000000..0acb6fa --- /dev/null +++ b/src/Dapper.FluentMap.Analyzers/README.md @@ -0,0 +1,11 @@ +# Dapper.FluentMap.Analyzers + +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 and invalid generic map/profile registration. + +```bash +dotnet add package Dapper.FluentMap.Analyzers +``` + +The analyzer package complements runtime validation. It does not execute user mapping constructors, scan assemblies, access databases or replace `FluentMapper.Validate()`. diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..9a71430 --- /dev/null +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,10 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Releases.md + +## Release 2.0.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DFM005 | Dapper.FluentMap.Configuration | Error | Generic map registration type is invalid diff --git a/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..a58bc16 --- /dev/null +++ b/src/Dapper.FluentMap.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,10 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Releases.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DFM006 | Dapper.FluentMap.Configuration | Info | Entity map type is skipped by generated registration +DFM007 | Dapper.FluentMap.Configuration | Error | Multiple generated entity maps target the same entity +DFM008 | Dapper.FluentMap.Configuration | Error | Multiple generated profile maps target the same entity and profile diff --git a/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj new file mode 100644 index 0000000..6f8bb5e --- /dev/null +++ b/src/Dapper.FluentMap.Generators/Dapper.FluentMap.Generators.csproj @@ -0,0 +1,26 @@ + + + Source generators for Dapper.FluentMap mapping registration. + 2.0.0 + Henk Mollema + netstandard2.0 + true + false + Dapper.FluentMap.Generators + c#;dapper;mapping;fluentmap;roslyn;source-generator + https://github.com/henkmollema/Dapper-FluentMap + MIT + README.md + true + + + + + + + + + + + + diff --git a/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs new file mode 100644 index 0000000..ab9e87e --- /dev/null +++ b/src/Dapper.FluentMap.Generators/MappingRegistrationGenerator.cs @@ -0,0 +1,478 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Dapper.FluentMap.Generators +{ + [Generator(LanguageNames.CSharp)] + public sealed class MappingRegistrationGenerator : IIncrementalGenerator + { + public const string InvalidGenericMapRegistrationDiagnosticId = "DFM005"; + public const string SkippedGeneratedMapDiagnosticId = "DFM006"; + public const string DuplicateGeneratedEntityMapDiagnosticId = "DFM007"; + public const string DuplicateGeneratedProfileMapDiagnosticId = "DFM008"; + + private const string Category = "Dapper.FluentMap.Configuration"; + private const string MappingNamespace = "Dapper.FluentMap.Mapping"; + private const string GeneratedCodeHintName = "DapperFluentMapGeneratedRegistration.g.cs"; + + private static readonly DiagnosticDescriptor InvalidGenericMapRegistrationRule = new DiagnosticDescriptor( + InvalidGenericMapRegistrationDiagnosticId, + "Generic map registration type is invalid", + "Entity map type '{0}' must implement exactly one closed IEntityMap interface targeting a class type", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration can only register map types that implement exactly one closed IEntityMap interface whose entity type is a class."); + + private static readonly DiagnosticDescriptor SkippedGeneratedMapRule = new DiagnosticDescriptor( + SkippedGeneratedMapDiagnosticId, + "Entity map type is skipped by generated registration", + "Entity map type '{0}' is not included in generated registration: {1}", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Only concrete, closed and accessible entity map types with a public parameterless constructor can be included in generated registration."); + + private static readonly DiagnosticDescriptor DuplicateGeneratedEntityMapRule = new DiagnosticDescriptor( + DuplicateGeneratedEntityMapDiagnosticId, + "Multiple generated entity maps target the same entity", + "Entity '{0}' has multiple generated entity maps: '{1}' and '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration must not register more than one entity map for the same entity."); + + private static readonly DiagnosticDescriptor DuplicateGeneratedProfileMapRule = new DiagnosticDescriptor( + DuplicateGeneratedProfileMapDiagnosticId, + "Multiple generated profile maps target the same entity and profile", + "Entity '{0}' has multiple generated maps for profile '{1}': '{2}' and '{3}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Generated registration must not register more than one map for the same entity and mapping profile."); + + private static readonly SymbolDisplayFormat FullyQualifiedTypeFormat = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var mapCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + (node, _) => IsCandidateClassDeclaration(node), + (syntaxContext, cancellationToken) => CreateMapCandidate(syntaxContext, cancellationToken)) + .Where(candidate => candidate != null) + .Collect(); + + context.RegisterSourceOutput( + mapCandidates, + (sourceProductionContext, candidates) => Execute(sourceProductionContext, candidates)); + } + + private static bool IsCandidateClassDeclaration(SyntaxNode node) + { + var classDeclaration = node as ClassDeclarationSyntax; + return classDeclaration?.BaseList != null; + } + + private static MapCandidate CreateMapCandidate( + GeneratorSyntaxContext context, + System.Threading.CancellationToken cancellationToken) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var mapType = context.SemanticModel.GetDeclaredSymbol(classDeclaration, cancellationToken); + if (mapType == null) + { + return null; + } + + var entityMapInterfaces = mapType.AllInterfaces + .Where(type => IsEntityMapInterface(type)) + .ToList(); + var profileMapInterfaces = mapType.AllInterfaces + .Where(type => IsProfileMapInterface(type)) + .ToList(); + + if (entityMapInterfaces.Count == 0) + { + return null; + } + + var location = classDeclaration.Identifier.GetLocation(); + var mapDisplayName = FormatSymbol(mapType); + var mapTypeName = mapType.ToDisplayString(FullyQualifiedTypeFormat); + + if (entityMapInterfaces.Count != 1 || + entityMapInterfaces[0].TypeArguments[0].TypeKind != TypeKind.Class || + profileMapInterfaces.Count > 1) + { + return MapCandidate.InvalidRegistration(mapDisplayName, location); + } + + var entityType = (INamedTypeSymbol)entityMapInterfaces[0].TypeArguments[0]; + var profileTypeName = profileMapInterfaces.Count == 0 + ? null + : profileMapInterfaces[0].TypeArguments[0].ToDisplayString(FullyQualifiedTypeFormat); + if (mapType.IsAbstract) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is abstract"); + } + + if (mapType.TypeParameters.Length != 0 || ContainsGenericParameters(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is an open generic type"); + } + + if (!IsAccessibleFromGeneratedCode(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type is not accessible from generated code"); + } + + if (!HasPublicParameterlessConstructor(mapType)) + { + return MapCandidate.Skipped(mapDisplayName, location, "the map type does not have a public parameterless constructor"); + } + + return MapCandidate.Valid( + mapDisplayName, + mapTypeName, + entityType.ToDisplayString(FullyQualifiedTypeFormat), + profileTypeName, + GetInheritanceDepth(entityType), + location); + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates) + { + var distinctCandidates = candidates + .GroupBy(candidate => candidate.MapDisplayName, StringComparer.Ordinal) + .Select(group => group.First()) + .ToList(); + + foreach (var candidate in distinctCandidates) + { + ReportCandidateDiagnostic(context, candidate); + } + + var validMaps = distinctCandidates + .Where(candidate => candidate.Kind == MapCandidateKind.Valid) + .OrderBy(candidate => candidate.EntityInheritanceDepth) + .ThenBy(candidate => candidate.EntityTypeName, StringComparer.Ordinal) + .ThenBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + + var duplicateEntityTypeNames = ReportDuplicateEntityMaps(context, validMaps); + var duplicateProfileKeys = ReportDuplicateProfileMaps(context, validMaps); + var generatedMaps = validMaps + .Where(candidate => + candidate.ProfileTypeName == null + ? !duplicateEntityTypeNames.Contains(candidate.EntityTypeName) + : !duplicateProfileKeys.Contains(candidate.ProfileKey)) + .ToList(); + + context.AddSource(GeneratedCodeHintName, SourceText.From(CreateGeneratedSource(generatedMaps), Encoding.UTF8)); + } + + private static void ReportCandidateDiagnostic(SourceProductionContext context, MapCandidate candidate) + { + if (candidate.Kind == MapCandidateKind.InvalidRegistration) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidGenericMapRegistrationRule, + candidate.Location, + candidate.MapDisplayName)); + return; + } + + if (candidate.Kind == MapCandidateKind.Skipped) + { + context.ReportDiagnostic(Diagnostic.Create( + SkippedGeneratedMapRule, + candidate.Location, + candidate.MapDisplayName, + candidate.SkipReason)); + } + } + + private static ISet ReportDuplicateEntityMaps( + SourceProductionContext context, + IList validMaps) + { + var duplicateEntityTypeNames = new HashSet(StringComparer.Ordinal); + var groups = validMaps + .Where(candidate => candidate.ProfileTypeName == null) + .GroupBy(candidate => candidate.EntityTypeName, StringComparer.Ordinal) + .Where(group => group.Count() > 1); + + foreach (var group in groups) + { + var orderedGroup = group + .OrderBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + var first = orderedGroup[0]; + duplicateEntityTypeNames.Add(first.EntityTypeName); + + foreach (var duplicate in orderedGroup.Skip(1)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateGeneratedEntityMapRule, + duplicate.Location, + duplicate.EntityTypeName, + first.MapTypeName, + duplicate.MapTypeName)); + } + } + + return duplicateEntityTypeNames; + } + + private static ISet ReportDuplicateProfileMaps( + SourceProductionContext context, + IList validMaps) + { + var duplicateProfileKeys = new HashSet(StringComparer.Ordinal); + var groups = validMaps + .Where(candidate => candidate.ProfileTypeName != null) + .GroupBy(candidate => candidate.ProfileKey, StringComparer.Ordinal) + .Where(group => group.Count() > 1); + + foreach (var group in groups) + { + var orderedGroup = group + .OrderBy(candidate => candidate.MapTypeName, StringComparer.Ordinal) + .ToList(); + var first = orderedGroup[0]; + duplicateProfileKeys.Add(first.ProfileKey); + + foreach (var duplicate in orderedGroup.Skip(1)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateGeneratedProfileMapRule, + duplicate.Location, + duplicate.EntityTypeName, + duplicate.ProfileTypeName, + first.MapTypeName, + duplicate.MapTypeName)); + } + } + + return duplicateProfileKeys; + } + + private static string CreateGeneratedSource(IList maps) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("namespace Dapper.FluentMap"); + builder.AppendLine("{"); + builder.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"Dapper.FluentMap.Generators\", \"2.0.0\")]"); + builder.AppendLine(" internal static class DapperFluentMapGeneratedRegistration"); + builder.AppendLine(" {"); + builder.AppendLine(" public static global::Dapper.FluentMap.Configuration.FluentMapConfiguration AddGeneratedMappings("); + builder.AppendLine(" this global::Dapper.FluentMap.Configuration.FluentMapConfiguration configuration)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (configuration == null)"); + builder.AppendLine(" {"); + builder.AppendLine(" throw new global::System.ArgumentNullException(nameof(configuration));"); + builder.AppendLine(" }"); + builder.AppendLine(); + + if (maps.Count == 0) + { + builder.AppendLine(" return configuration;"); + } + else + { + builder.AppendLine(" return configuration"); + for (var index = 0; index < maps.Count; index++) + { + var terminator = index == maps.Count - 1 ? ";" : string.Empty; + builder.Append(maps[index].ProfileTypeName == null + ? " .AddMap<" + : " .AddProfile<"); + builder.Append(maps[index].MapTypeName); + builder.Append(">()"); + builder.AppendLine(terminator); + } + } + + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static bool IsEntityMapInterface(INamedTypeSymbol type) + { + return type.OriginalDefinition.MetadataName == "IEntityMap`1" && + type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; + } + + private static bool IsProfileMapInterface(INamedTypeSymbol type) + { + return type.OriginalDefinition.MetadataName == "IProfileMap`1" && + type.OriginalDefinition.ContainingNamespace.ToDisplayString() == MappingNamespace; + } + + private static bool ContainsGenericParameters(INamedTypeSymbol type) + { + if (type.IsGenericType && type.TypeArguments.Any(argument => argument.Kind == SymbolKind.TypeParameter)) + { + return true; + } + + for (var containingType = type.ContainingType; containingType != null; containingType = containingType.ContainingType) + { + if (containingType.TypeParameters.Length != 0) + { + return true; + } + } + + return false; + } + + private static bool IsAccessibleFromGeneratedCode(INamedTypeSymbol type) + { + for (var current = type; current != null; current = current.ContainingType) + { + if (current.DeclaredAccessibility != Accessibility.Public && + current.DeclaredAccessibility != Accessibility.Internal) + { + return false; + } + } + + return true; + } + + private static bool HasPublicParameterlessConstructor(INamedTypeSymbol type) + { + return type.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && + constructor.DeclaredAccessibility == Accessibility.Public); + } + + private static int GetInheritanceDepth(INamedTypeSymbol type) + { + var depth = 0; + for (var current = type.BaseType; current != null; current = current.BaseType) + { + depth++; + } + + return depth; + } + + private static string FormatSymbol(ISymbol symbol) + { + return symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private sealed class MapCandidate + { + private MapCandidate( + MapCandidateKind kind, + string mapDisplayName, + string mapTypeName, + string entityTypeName, + string profileTypeName, + int entityInheritanceDepth, + Location location, + string skipReason) + { + Kind = kind; + MapDisplayName = mapDisplayName; + MapTypeName = mapTypeName; + EntityTypeName = entityTypeName; + ProfileTypeName = profileTypeName; + EntityInheritanceDepth = entityInheritanceDepth; + Location = location; + SkipReason = skipReason; + } + + internal MapCandidateKind Kind { get; } + + internal string MapDisplayName { get; } + + internal string MapTypeName { get; } + + internal string EntityTypeName { get; } + + internal string ProfileTypeName { get; } + + internal string ProfileKey => EntityTypeName + "|" + ProfileTypeName; + + internal int EntityInheritanceDepth { get; } + + internal Location Location { get; } + + internal string SkipReason { get; } + + internal static MapCandidate Valid( + string mapDisplayName, + string mapTypeName, + string entityTypeName, + string profileTypeName, + int entityInheritanceDepth, + Location location) + { + return new MapCandidate( + MapCandidateKind.Valid, + mapDisplayName, + mapTypeName, + entityTypeName, + profileTypeName, + entityInheritanceDepth, + location, + null); + } + + internal static MapCandidate InvalidRegistration(string mapDisplayName, Location location) + { + return new MapCandidate( + MapCandidateKind.InvalidRegistration, + mapDisplayName, + null, + null, + null, + 0, + location, + null); + } + + internal static MapCandidate Skipped(string mapDisplayName, Location location, string reason) + { + return new MapCandidate( + MapCandidateKind.Skipped, + mapDisplayName, + null, + null, + null, + 0, + location, + reason); + } + } + + private enum MapCandidateKind + { + Valid, + InvalidRegistration, + Skipped + } + } +} diff --git a/src/Dapper.FluentMap.Generators/README.md b/src/Dapper.FluentMap.Generators/README.md new file mode 100644 index 0000000..e241cbe --- /dev/null +++ b/src/Dapper.FluentMap.Generators/README.md @@ -0,0 +1,20 @@ +# Dapper.FluentMap.Generators + +Build-time source generator for Dapper.FluentMap mapping registration. + +The generator discovers eligible `IEntityMap` implementations declared in the current compilation and emits an `AddGeneratedMappings()` extension method that registers them through the existing `AddMap()` API. + +```bash +dotnet add package Dapper.FluentMap.Generators +``` + +```csharp +using Dapper.FluentMap; + +FluentMapper.Initialize(config => +{ + config.AddGeneratedMappings(); +}); +``` + +Generated registration avoids reflection-based assembly scanning for maps in the current compilation. It does not scan referenced assemblies, execute map constructors during generation, generate database materializers or replace `FluentMapper.Validate()`. diff --git a/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs new file mode 100644 index 0000000..5e587b1 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/CodeAnalysisAttributes.cs @@ -0,0 +1,78 @@ +using System; + +#if !NET5_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage( + AttributeTargets.Field | + AttributeTargets.ReturnValue | + AttributeTargets.GenericParameter | + AttributeTargets.Parameter | + AttributeTargets.Property, + Inherited = false)] + internal sealed class DynamicallyAccessedMembersAttribute : Attribute + { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) + { + MemberTypes = memberTypes; + } + + public DynamicallyAccessedMemberTypes MemberTypes { get; } + } + + [Flags] + internal enum DynamicallyAccessedMemberTypes + { + None = 0, + PublicParameterlessConstructor = 0x0001, + PublicConstructors = 0x0002 | PublicParameterlessConstructor, + NonPublicConstructors = 0x0004, + PublicMethods = 0x0008, + NonPublicMethods = 0x0010, + PublicFields = 0x0020, + NonPublicFields = 0x0040, + PublicNestedTypes = 0x0080, + NonPublicNestedTypes = 0x0100, + PublicProperties = 0x0200, + NonPublicProperties = 0x0400, + PublicEvents = 0x0800, + NonPublicEvents = 0x1000, + Interfaces = 0x2000, + All = ~None + } + + [AttributeUsage( + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Class, + Inherited = false)] + internal sealed class RequiresUnreferencedCodeAttribute : Attribute + { + public RequiresUnreferencedCodeAttribute(string message) + { + Message = message; + } + + public string Message { get; } + + public string Url { get; set; } + } + + [AttributeUsage( + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Class, + Inherited = false)] + internal sealed class RequiresDynamicCodeAttribute : Attribute + { + public RequiresDynamicCodeAttribute(string message) + { + Message = message; + } + + public string Message { get; } + + public string Url { get; set; } + } +} +#endif diff --git a/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs b/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs new file mode 100644 index 0000000..7b68f15 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperFluentPropertyTypeMap.cs @@ -0,0 +1,50 @@ +using System; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperFluentPropertyTypeMap : SqlMapper.ITypeMap + { + private readonly Type _type; + private readonly Func _propertyMapResolver; + + internal DapperFluentPropertyTypeMap(Type type, Func propertyMapResolver) + { + _type = type ?? throw new ArgumentNullException(nameof(type)); + _propertyMapResolver = propertyMapResolver ?? throw new ArgumentNullException(nameof(propertyMapResolver)); + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + return null; + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + return null; + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + var map = _propertyMapResolver(_type, columnName); + if (map == null) + { + return null; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (map.Ignored || memberPath.IsNested) + { + return new DapperIgnoredMemberMap(columnName); + } + + return new DapperPropertyMemberMap(columnName, map.PropertyInfo); + } + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs b/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs new file mode 100644 index 0000000..946c204 --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperIgnoredMemberMap.cs @@ -0,0 +1,28 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperIgnoredMemberMap : SqlMapper.IMemberMap + { + internal DapperIgnoredMemberMap(string columnName) + { + ColumnName = columnName ?? throw new ArgumentNullException(nameof(columnName)); + } + + public string ColumnName { get; } + + public Type MemberType => typeof(object); + + public PropertyInfo Property => null; + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + + internal static bool IsIgnored(SqlMapper.IMemberMap memberMap) + { + return memberMap is DapperIgnoredMemberMap; + } + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs b/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs new file mode 100644 index 0000000..bdc393e --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperPropertyMemberMap.cs @@ -0,0 +1,24 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal sealed class DapperPropertyMemberMap : SqlMapper.IMemberMap + { + internal DapperPropertyMemberMap(string columnName, PropertyInfo property) + { + ColumnName = columnName ?? throw new ArgumentNullException(nameof(columnName)); + Property = property ?? throw new ArgumentNullException(nameof(property)); + } + + public string ColumnName { get; } + + public Type MemberType => Property.PropertyType; + + public PropertyInfo Property { get; } + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + } +} diff --git a/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs b/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs new file mode 100644 index 0000000..665406e --- /dev/null +++ b/src/Dapper.FluentMap/Compatibility/DapperTypeHandlerAdapter.cs @@ -0,0 +1,116 @@ +using System; +using System.Data; +using System.Linq.Expressions; +using System.Reflection; + +namespace Dapper.FluentMap.Compatibility +{ + internal static class DapperTypeHandlerAdapter + { + private const string TypeHandlerCacheName = "TypeHandlerCache`1"; + + internal static bool HasTypeHandler(Type targetType) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + return SqlMapper.HasTypeHandler(GetHandlerType(targetType)); + } + + internal static Func CreateConverter(Type targetType) + { + return CreateConverter(targetType, ResolveTypeHandlerCacheDefinition); + } + + internal static Func CreateConverter(Type targetType, Func cacheDefinitionResolver) + { + if (targetType == null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + if (cacheDefinitionResolver == null) + { + throw new ArgumentNullException(nameof(cacheDefinitionResolver)); + } + + var handlerType = GetHandlerType(targetType); + var cacheTypeDefinition = cacheDefinitionResolver(); + if (cacheTypeDefinition == null) + { + throw CreateCompatibilityException(targetType, "nested TypeHandlerCache type was not found"); + } + + MethodInfo parse; + try + { + var cacheType = cacheTypeDefinition.MakeGenericType(handlerType); + parse = cacheType.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(object) }, + null); + } + catch (Exception exception) + { + throw CreateCompatibilityException(targetType, "TypeHandlerCache.Parse could not be resolved", exception); + } + + if (parse == null) + { + throw CreateCompatibilityException(targetType, "TypeHandlerCache.Parse(object) was not found"); + } + + return CreateParseDelegate(targetType, parse); + } + + private static Func CreateParseDelegate(Type targetType, MethodInfo parse) + { + var value = Expression.Parameter(typeof(object), "value"); + var nullValue = Expression.Constant(GetDefaultValue(targetType), typeof(object)); + var body = Expression.Condition( + Expression.OrElse( + Expression.Equal(value, Expression.Constant(null, typeof(object))), + Expression.Equal(value, Expression.Constant(DBNull.Value, typeof(object)))), + nullValue, + Expression.Convert(Expression.Call(parse, value), typeof(object))); + + return Expression.Lambda>(body, value).Compile(); + } + + private static Type ResolveTypeHandlerCacheDefinition() + { + return typeof(SqlMapper).GetNestedType(TypeHandlerCacheName, BindingFlags.Public | BindingFlags.NonPublic); + } + + private static Type GetHandlerType(Type targetType) + { + return Nullable.GetUnderlyingType(targetType) ?? targetType; + } + + private static object GetDefaultValue(Type type) + { + if (!type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null) + { + return null; + } + + return Activator.CreateInstance(type); + } + + private static FluentMapConfigurationException CreateCompatibilityException(Type targetType, string reason, Exception innerException = null) + { + var message = + $"Dapper TypeHandler compatibility failed for target type '{targetType.FullName}': {reason}. " + + "This FluentMap version expects Dapper to expose SqlMapper.TypeHandlerCache.Parse(object). " + + "Review the Dapper compatibility boundary before upgrading Dapper."; + + return innerException == null + ? new FluentMapConfigurationException(message) + : new FluentMapConfigurationException(message, innerException); + } + } +} diff --git a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs index d9f0eea..1ccc42e 100644 --- a/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentConventionConfiguration.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Dapper.FluentMap.Conventions; @@ -13,6 +14,9 @@ namespace Dapper.FluentMap.Configuration /// public class FluentConventionConfiguration { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Convention assembly scanning discovers entity types and properties by reflection. Register conventions with ForEntity() when publishing trimmed or Native AOT applications."; + private readonly Convention _convention; /// @@ -22,6 +26,11 @@ public class FluentConventionConfiguration /// The convention. public FluentConventionConfiguration(Convention convention) { + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + _convention = convention; } @@ -30,7 +39,9 @@ public FluentConventionConfiguration(Convention convention) /// /// The type of the entity. /// The current instance of . - public FluentConventionConfiguration ForEntity() + public FluentConventionConfiguration ForEntity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + T>() { var type = typeof(T); MapProperties(type); @@ -48,6 +59,7 @@ public FluentConventionConfiguration ForEntity() /// This parameter is optional. /// /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[] namespaces) { foreach (var type in Assembly.GetCallingAssembly().GetExportedTypes()) @@ -77,6 +89,7 @@ public FluentConventionConfiguration ForEntitiesInCurrentAssembly(params string[ /// This parameter is optional. /// /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, params string[] namespaces) { foreach (var type in assembly.GetExportedTypes()) @@ -96,7 +109,9 @@ public FluentConventionConfiguration ForEntitiesInAssembly(Assembly assembly, pa return this; } - private void MapProperties(Type type) + private void MapProperties( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + Type type) { var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); @@ -107,9 +122,12 @@ private void MapProperties(Type type) .Where(c => c.PropertyPredicates.Count <= 0 || c.PropertyPredicates.All(e => e(property)))) { + MappingConfigurationValidator.ValidateConventionConfiguration(type, _convention, config); + if (!string.IsNullOrEmpty(config.PropertyConfiguration.ColumnName)) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.ColumnName, config.PropertyConfiguration.CaseSensitive); @@ -119,6 +137,7 @@ private void MapProperties(Type type) if (!string.IsNullOrEmpty(config.PropertyConfiguration.Prefix)) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.Prefix + property.Name, config.PropertyConfiguration.CaseSensitive); @@ -128,6 +147,7 @@ private void MapProperties(Type type) if (config.PropertyConfiguration.PropertyTransformer != null) { AddConventionPropertyMap( + type, property, config.PropertyConfiguration.PropertyTransformer(property.Name), config.PropertyConfiguration.CaseSensitive); @@ -136,9 +156,10 @@ private void MapProperties(Type type) } } - private void AddConventionPropertyMap(PropertyInfo property, string columnName, bool caseSensitive) + private void AddConventionPropertyMap(Type entityType, PropertyInfo property, string columnName, bool caseSensitive) { var map = new PropertyMap(property, columnName, caseSensitive); + MappingConfigurationValidator.ValidateConventionColumn(entityType, _convention, map); _convention.PropertyMaps.Add(map); } diff --git a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs index a927eb9..0f0ecf3 100644 --- a/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs +++ b/src/Dapper.FluentMap/Configuration/FluentMapConfiguration.cs @@ -1,8 +1,12 @@ -using System; -using System.Linq; +using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; using Dapper.FluentMap.Conventions; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; namespace Dapper.FluentMap.Configuration { @@ -11,6 +15,9 @@ namespace Dapper.FluentMap.Configuration /// public class FluentMapConfiguration { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Assembly scanning discovers entity maps by reflection. Register maps explicitly with AddMap() when publishing trimmed or Native AOT applications."; + /// /// Adds the specified to the configuration of Dapper.FluentMap. /// @@ -21,9 +28,96 @@ public class FluentMapConfiguration /// public void AddMap(IEntityMap mapper) where TEntity : class { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + FluentMapper.Registry.AddEntityMap(mapper); } + /// + /// Adds a new instance of the specified entity map type to the configuration of Dapper.FluentMap. + /// + /// The type of the entity map to create and register. + /// The current instance of . + public FluentMapConfiguration AddMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() + where TMap : IEntityMap, new() + { + var mapType = typeof(TMap); + var entityType = GetMappedEntityType(mapType); + var mapper = CreateEntityMap(); + + FluentMapper.Registry.AddEntityMap(entityType, mapper); + return this; + } + + /// + /// Adds a new instance of the specified entity map type as an explicitly selected mapping profile. + /// + /// The profile entity map type to create and register. + /// The current instance of . + public FluentMapConfiguration AddProfile< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + TMap>() + where TMap : IEntityMap, new() + { + var mapType = typeof(TMap); + var entityType = GetMappedEntityType(mapType); + var profileType = GetMappedProfileType(mapType); + var mapper = CreateEntityMap(); + + FluentMapper.Registry.AddProfileMap(entityType, profileType, mapper); + return this; + } + + /// + /// Finds exported entity map types in the specified assembly and adds them to the configuration of Dapper.FluentMap. + /// + /// The assembly to scan for entity maps. + /// Optional namespaces used to filter discovered entity map types. + /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + public FluentMapConfiguration AddMapsFromAssembly(Assembly assembly, params string[] namespaces) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + var definitions = FindEntityMapDefinitions(assembly, namespaces).ToList(); + EnsureNoDuplicateEntityMaps(definitions); + + var registrations = definitions + .Select(definition => new EntityMapRegistration( + definition.MapType, + definition.EntityType, + CreateEntityMap(definition.MapType))) + .ToList(); + + foreach (var registration in OrderByIncludedBaseMaps(registrations)) + { + FluentMapper.Registry.AddEntityMap(registration.EntityType, registration.Map); + } + + return this; + } + + /// + /// Finds exported entity map types in the assembly containing + /// and adds them to the configuration of Dapper.FluentMap. + /// + /// A marker type from the assembly to scan. + /// Optional namespaces used to filter discovered entity map types. + /// The current instance of . + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + public FluentMapConfiguration AddMapsFromAssemblyContaining(params string[] namespaces) + { + return AddMapsFromAssembly(typeof(TMarker).GetTypeInfo().Assembly, namespaces); + } + /// /// Adds the specified to the configuration of Dapper.FluentMap. /// @@ -37,6 +131,274 @@ public void AddMap(IEntityMap mapper) where TEntity : class return new FluentConventionConfiguration(new TConvention()); } + /// + /// Adds a naming policy to the configuration of Dapper.FluentMap. + /// + /// The naming policy used to transform member names into column names. + /// A value indicating whether the generated column name mappings should be case sensitive. + /// + /// An instance of + /// which allows configuration of the naming policy for entities. + /// + public FluentConventionConfiguration UseNamingPolicy(NamingPolicy namingPolicy, bool caseSensitive = true) + { + if (namingPolicy == null) + { + throw new ArgumentNullException(nameof(namingPolicy)); + } + + return new FluentConventionConfiguration(new NamingPolicyConvention(namingPolicy, caseSensitive)); + } + + /// + /// Adds a custom naming policy to the configuration of Dapper.FluentMap. + /// + /// A function that receives a member name and returns a column name. + /// A value indicating whether the generated column name mappings should be case sensitive. + /// + /// An instance of + /// which allows configuration of the naming policy for entities. + /// + public FluentConventionConfiguration UseNamingPolicy(Func transformer, bool caseSensitive = true) + { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + + return UseNamingPolicy(NamingPolicy.Custom(transformer), caseSensitive); + } + + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + private static IEnumerable FindEntityMapDefinitions(Assembly assembly, string[] namespaces) + { + return GetExportedTypes(assembly) + .Where(IsConcreteEntityMapType) + .Where(type => IsNamespaceMatch(type, namespaces)) + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ThenBy(type => type.AssemblyQualifiedName, StringComparer.Ordinal) + .Select(type => new EntityMapDefinition(type, GetMappedEntityType(type))); + } + + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + private static IEnumerable GetExportedTypes(Assembly assembly) + { + try + { + return assembly.GetExportedTypes(); + } + catch (ReflectionTypeLoadException ex) + { + throw new FluentMapConfigurationException( + $"Cannot load exported types from assembly '{assembly.FullName}'.", + ex); + } + } + + private static bool IsConcreteEntityMapType(Type type) + { + var typeInfo = type.GetTypeInfo(); + return !typeInfo.IsAbstract && + !typeInfo.IsInterface && + !typeInfo.ContainsGenericParameters && + typeof(IEntityMap).GetTypeInfo().IsAssignableFrom(typeInfo); + } + + private static bool IsNamespaceMatch(Type type, string[] namespaces) + { + return namespaces == null || + namespaces.Length == 0 || + namespaces.Any(ns => string.Equals(ns, type.Namespace, StringComparison.Ordinal)); + } + + private static Type GetMappedEntityType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + Type mapType) + { + var entityMapInterfaces = mapType.GetInterfaces() + .Where(type => type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(IEntityMap<>)) + .ToList(); + + if (entityMapInterfaces.Count != 1) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' must implement exactly one closed IEntityMap interface."); + } + + var entityType = entityMapInterfaces[0].GetGenericArguments()[0]; + if (!entityType.GetTypeInfo().IsClass) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' targets '{entityType.FullName}', but entity maps must target class types."); + } + + return entityType; + } + + private static Type GetMappedProfileType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + Type mapType) + { + var profileInterfaces = mapType.GetInterfaces() + .Where(type => type.GetTypeInfo().IsGenericType && + type.GetGenericTypeDefinition() == typeof(IProfileMap<>)) + .ToList(); + + if (profileInterfaces.Count != 1) + { + throw new FluentMapConfigurationException( + $"Profile entity map type '{mapType.FullName}' must implement exactly one closed IProfileMap interface."); + } + + var profileType = profileInterfaces[0].GetGenericArguments()[0]; + if (!typeof(IMappingProfile).GetTypeInfo().IsAssignableFrom(profileType.GetTypeInfo())) + { + throw new FluentMapConfigurationException( + $"Profile entity map type '{mapType.FullName}' targets '{profileType.FullName}', but mapping profiles must implement IMappingProfile."); + } + + return profileType; + } + + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] + private static IEntityMap CreateEntityMap(Type mapType) + { + try + { + return (IEntityMap)Activator.CreateInstance(mapType); + } + catch (Exception ex) + { + throw new FluentMapConfigurationException( + $"Entity map type '{mapType.FullName}' could not be created. Ensure it has a public parameterless constructor and the constructor completes successfully.", + ex); + } + } + + private static IEntityMap CreateEntityMap() + where TMap : IEntityMap, new() + { + try + { + return new TMap(); + } + catch (Exception ex) + { + throw new FluentMapConfigurationException( + $"Entity map type '{typeof(TMap).FullName}' could not be created. Ensure it has a public parameterless constructor and the constructor completes successfully.", + ex); + } + } + + private static void EnsureNoDuplicateEntityMaps(IList definitions) + { + var duplicates = definitions + .GroupBy(definition => definition.EntityType) + .Where(group => group.Count() > 1) + .ToList(); + + if (duplicates.Count == 0) + { + return; + } + + var duplicateDescriptions = duplicates + .Select(group => + $"entity '{group.Key.FullName}' mapped by {string.Join(", ", group.Select(definition => "'" + definition.MapType.FullName + "'"))}"); + + throw new FluentMapConfigurationException( + "Multiple entity maps were discovered for the same entity: " + + string.Join("; ", duplicateDescriptions) + "."); + } + + private static IList OrderByIncludedBaseMaps(IList registrations) + { + var ordered = new List(); + var remaining = registrations.ToList(); + + while (remaining.Count > 0) + { + var progressed = false; + + foreach (var registration in remaining.ToList()) + { + if (!HasPendingIncludedBaseMap(registration, remaining, ordered)) + { + remaining.Remove(registration); + ordered.Add(registration); + progressed = true; + } + } + + if (!progressed) + { + throw new FluentMapConfigurationException( + "Entity maps discovered from assembly could not be ordered by included base mappings. Check for cyclic or invalid IncludeBase configuration."); + } + } + + return ordered; + } + + private static bool HasPendingIncludedBaseMap( + EntityMapRegistration registration, + IList remaining, + IList ordered) + { + foreach (var includedBaseType in GetIncludedBaseTypes(registration.Map)) + { + if (ordered.Any(map => map.EntityType == includedBaseType)) + { + continue; + } + + if (remaining.Any(map => map.EntityType == includedBaseType)) + { + return true; + } + } + + return false; + } + + private static IList GetIncludedBaseTypes(IEntityMap map) + { + var mapWithIncludedBases = map as IEntityMapWithIncludedBaseTypes; + return mapWithIncludedBases == null + ? new Type[0] + : mapWithIncludedBases.IncludedBaseTypes; + } + + private sealed class EntityMapDefinition + { + internal EntityMapDefinition(Type mapType, Type entityType) + { + MapType = mapType; + EntityType = entityType; + } + + internal Type MapType { get; } + + internal Type EntityType { get; } + } + + private sealed class EntityMapRegistration + { + internal EntityMapRegistration(Type mapType, Type entityType, IEntityMap map) + { + MapType = mapType; + EntityType = entityType; + Map = map; + } + + internal Type MapType { get; } + + internal Type EntityType { get; } + + internal IEntityMap Map { get; } + } + #region EditorBrowsableStates /// [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs b/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs index a0a279c..aa5d5bb 100644 --- a/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs +++ b/src/Dapper.FluentMap/Conventions/ConventionPropertyConfiguration.cs @@ -23,6 +23,11 @@ public ConventionPropertyConfiguration() /// The same instance of . public ConventionPropertyConfiguration HasColumnName(string columnName) { + if (string.IsNullOrEmpty(columnName)) + { + throw new ArgumentException("Column name cannot be null or empty.", nameof(columnName)); + } + ColumnName = columnName; return this; } @@ -34,6 +39,11 @@ public ConventionPropertyConfiguration HasColumnName(string columnName) /// The same instance of . public ConventionPropertyConfiguration HasPrefix(string prefix) { + if (prefix == null) + { + throw new ArgumentNullException(nameof(prefix)); + } + Prefix = prefix; return this; } @@ -55,6 +65,11 @@ public ConventionPropertyConfiguration IsCaseInsensitive() /// The same instance of . public ConventionPropertyConfiguration Transform(Func transformer) { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + PropertyTransformer = transformer; return this; } diff --git a/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs b/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs new file mode 100644 index 0000000..9202dc8 --- /dev/null +++ b/src/Dapper.FluentMap/Conventions/NamingPolicyConvention.cs @@ -0,0 +1,20 @@ +using Dapper.FluentMap.Naming; + +namespace Dapper.FluentMap.Conventions +{ + internal sealed class NamingPolicyConvention : Convention + { + internal NamingPolicyConvention(NamingPolicy namingPolicy, bool caseSensitive) + { + Properties() + .Configure(c => + { + c.Transform(namingPolicy.GetColumnName); + if (!caseSensitive) + { + c.IsCaseInsensitive(); + } + }); + } + } +} diff --git a/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs b/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs index c9dbcfd..967b2c4 100644 --- a/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs +++ b/src/Dapper.FluentMap/Conventions/PropertyConventionConfiguration.cs @@ -30,6 +30,11 @@ public PropertyConventionConfiguration() /// The same instance of . public PropertyConventionConfiguration Where(Func predicate) { + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + PropertyPredicates.Add(predicate); return this; } @@ -43,6 +48,11 @@ public PropertyConventionConfiguration Where(Func predicate) /// public void Configure(Action configure) { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + var config = new ConventionPropertyConfiguration(); PropertyConfiguration = config; configure(config); diff --git a/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs b/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs new file mode 100644 index 0000000..92f3325 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/ConstructorParameterExplanation.cs @@ -0,0 +1,43 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes a constructor parameter that can receive a mapped column. + /// + public sealed class ConstructorParameterExplanation + { + internal ConstructorParameterExplanation(ConstructorInfo constructor, ParameterInfo parameter) + { + if (constructor == null) + { + throw new ArgumentNullException(nameof(constructor)); + } + + if (parameter == null) + { + throw new ArgumentNullException(nameof(parameter)); + } + + Constructor = constructor; + Name = parameter.Name; + ParameterType = parameter.ParameterType; + } + + /// + /// Gets the constructor that declares the parameter. + /// + public ConstructorInfo Constructor { get; } + + /// + /// Gets the constructor parameter name. + /// + public string Name { get; } + + /// + /// Gets the constructor parameter type. + /// + public Type ParameterType { get; } + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs new file mode 100644 index 0000000..cd34319 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingExplanation.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the effective FluentMap diagnostics for an entity type. + /// + public sealed class MappingExplanation + { + internal MappingExplanation( + Type entityType, + Type profileType, + Type entityMapType, + IEnumerable conventionTypes, + IEnumerable members, + IEnumerable diagnostics) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + EntityType = entityType; + ProfileType = profileType; + EntityMapType = entityMapType; + ConventionTypes = new ReadOnlyCollection( + (conventionTypes ?? Enumerable.Empty()).ToList()); + Members = new ReadOnlyCollection( + (members ?? Enumerable.Empty()).ToList()); + Diagnostics = new ReadOnlyCollection( + (diagnostics ?? Enumerable.Empty()).ToList()); + } + + /// + /// Gets the entity type described by this explanation. + /// + public Type EntityType { get; } + + /// + /// Gets the mapping profile marker type, when this explanation targets a profile. + /// + public Type ProfileType { get; } + + /// + /// Gets the registered entity map type, when one exists. + /// + public Type EntityMapType { get; } + + /// + /// Gets the registered convention types for the entity. + /// + public IReadOnlyList ConventionTypes { get; } + + /// + /// Gets the effective member mappings. + /// + public IReadOnlyList Members { get; } + + /// + /// Gets additional diagnostics that are not tied to a single member. + /// + public IReadOnlyList Diagnostics { get; } + + /// + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append("Entity: ").Append(EntityType.FullName); + if (ProfileType != null) + { + builder.AppendLine() + .Append("Profile: ") + .Append(ProfileType.FullName); + } + + foreach (var member in Members) + { + builder.AppendLine() + .Append(member.MemberPath) + .Append(" -> ") + .Append(member.ColumnName) + .Append(" (") + .Append(member.Source) + .Append(")"); + } + + return builder.ToString(); + } + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs new file mode 100644 index 0000000..fb7b590 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingMaterialization.cs @@ -0,0 +1,23 @@ +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes how a mapped member is materialized. + /// + public enum MappingMaterialization + { + /// + /// The member is materialized by Dapper's regular root-object mapping. + /// + Dapper, + + /// + /// The member is materialized by FluentMap's opt-in nested object materializer. + /// + Nested, + + /// + /// The member is materialized by FluentMap's opt-in constructor-based value object materializer. + /// + ValueObject + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MappingSource.cs b/src/Dapper.FluentMap/Diagnostics/MappingSource.cs new file mode 100644 index 0000000..d6e78e7 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MappingSource.cs @@ -0,0 +1,33 @@ +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the source that provides a mapping in the effective FluentMap configuration. + /// + public enum MappingSource + { + /// + /// The mapping was configured directly on the entity map. + /// + Explicit, + + /// + /// The mapping was included from a registered base entity map. + /// + Inherited, + + /// + /// The mapping was produced by a configured convention. + /// + Convention, + + /// + /// The mapping was produced by a naming policy. + /// + NamingPolicy, + + /// + /// The mapping is left to Dapper's default type map. + /// + DapperDefault + } +} diff --git a/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs new file mode 100644 index 0000000..3a5d4d6 --- /dev/null +++ b/src/Dapper.FluentMap/Diagnostics/MemberMappingExplanation.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; + +namespace Dapper.FluentMap.Diagnostics +{ + /// + /// Describes the effective mapping metadata for one entity member path. + /// + public sealed class MemberMappingExplanation + { + internal MemberMappingExplanation( + string memberPath, + PropertyInfo propertyInfo, + string columnName, + MappingSource source, + bool caseSensitive, + bool ignored, + Type inheritedFrom, + Type conventionType, + IEnumerable constructorParameters, + MappingMaterialization materialization) + { + if (string.IsNullOrEmpty(memberPath)) + { + throw new ArgumentException("Member path cannot be null or empty.", nameof(memberPath)); + } + + if (propertyInfo == null) + { + throw new ArgumentNullException(nameof(propertyInfo)); + } + + MemberPath = memberPath; + PropertyInfo = propertyInfo; + ColumnName = columnName; + Source = source; + CaseSensitive = caseSensitive; + Ignored = ignored; + InheritedFrom = inheritedFrom; + ConventionType = conventionType; + ConstructorParameters = new ReadOnlyCollection( + (constructorParameters ?? Enumerable.Empty()).ToList()); + Materialization = materialization; + } + + /// + /// Gets the member path represented by the mapping. + /// + public string MemberPath { get; } + + /// + /// Gets the terminal property represented by the mapping. + /// + public PropertyInfo PropertyInfo { get; } + + /// + /// Gets the configured or default column name. + /// + public string ColumnName { get; } + + /// + /// Gets the source that provides the mapping. + /// + public MappingSource Source { get; } + + /// + /// Gets a value indicating whether the column name comparison is case-sensitive. + /// + public bool CaseSensitive { get; } + + /// + /// Gets a value indicating whether this member is ignored by FluentMap. + /// + public bool Ignored { get; } + + /// + /// Gets the base entity type that declared an inherited mapping, when applicable. + /// + public Type InheritedFrom { get; } + + /// + /// Gets the convention type that produced the mapping, when applicable. + /// + public Type ConventionType { get; } + + /// + /// Gets constructor parameters that can receive this mapped column. + /// + public IReadOnlyList ConstructorParameters { get; } + + /// + /// Gets how this member is materialized. + /// + public MappingMaterialization Materialization { get; } + } +} diff --git a/src/Dapper.FluentMap/FluentMapConfigurationException.cs b/src/Dapper.FluentMap/FluentMapConfigurationException.cs new file mode 100644 index 0000000..626d8c4 --- /dev/null +++ b/src/Dapper.FluentMap/FluentMapConfigurationException.cs @@ -0,0 +1,38 @@ +using System; + +namespace Dapper.FluentMap +{ + /// + /// Represents an invalid Dapper.FluentMap configuration. + /// + public class FluentMapConfigurationException : InvalidOperationException + { + /// + /// Initializes a new instance of the class. + /// + public FluentMapConfigurationException() + { + } + + /// + /// Initializes a new instance of the class + /// with the specified error message. + /// + /// The message that describes the error. + public FluentMapConfigurationException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with the specified error message and a reference to the inner exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public FluentMapConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Dapper.FluentMap/FluentMapper.cs b/src/Dapper.FluentMap/FluentMapper.cs index d18b3c9..8a90e28 100644 --- a/src/Dapper.FluentMap/FluentMapper.cs +++ b/src/Dapper.FluentMap/FluentMapper.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Dapper.FluentMap.Configuration; using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap @@ -12,17 +14,31 @@ namespace Dapper.FluentMap /// public static class FluentMapper { + private const DynamicallyAccessedMemberTypes EntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicProperties; + private static readonly MappingRegistry _registry = new MappingRegistry(); private static readonly FluentMapConfiguration _configuration = new FluentMapConfiguration(); /// /// Gets the dictionary containing the entity mapping per entity type. /// + /// + /// This mutable dictionary is preserved for source and binary compatibility. Prefer configuring maps + /// through and use + /// for read-only inspection. + /// public static readonly ConcurrentDictionary EntityMaps = _registry.EntityMaps; /// /// Gets the dictionary containing the conventions per entity type. /// + /// + /// This mutable dictionary is preserved for source and binary compatibility. Prefer configuring conventions + /// through and use + /// for read-only inspection. + /// public static readonly ConcurrentDictionary> TypeConventions = _registry.TypeConventions; internal static MappingRegistry Registry => _registry; @@ -37,6 +53,62 @@ public static void Initialize(Action configure) configure(_configuration); } + /// + /// Validates the current Dapper.FluentMap configuration. + /// + /// + /// when one or more configuration errors are found. + /// + public static void Validate() + { + _registry.ValidateConfiguration(); + } + + /// + /// Gets a read-only snapshot of the default entity maps currently registered in Dapper.FluentMap. + /// + /// A read-only snapshot of the registered default entity maps. + public static IReadOnlyDictionary GetEntityMaps() + { + return _registry.GetEntityMapsSnapshot(); + } + + /// + /// Gets a read-only snapshot of the type conventions currently registered in Dapper.FluentMap. + /// + /// A read-only snapshot of the registered type conventions. + public static IReadOnlyDictionary> GetTypeConventions() + { + return _registry.GetTypeConventionsSnapshot(); + } + + /// + /// Explains the effective mapping configuration for the specified entity type. + /// + /// The entity type to explain. + /// A structured explanation of configured mappings, conventions and fallback mappings. + public static MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity>() + { + return _registry.Explain(typeof(TEntity)); + } + + /// + /// Explains the effective mapping configuration for the specified entity type and mapping profile. + /// + /// The entity type to explain. + /// The mapping profile marker type to explain. + /// A structured explanation of configured mappings, conventions and fallback mappings. + public static MappingExplanation Explain< + [DynamicallyAccessedMembers(EntityMemberTypes)] + TEntity, + TProfile>() + where TProfile : IMappingProfile + { + return _registry.Explain(typeof(TEntity), typeof(TProfile)); + } + /// /// Registers a Dapper type map using fluent mapping for the specified . /// diff --git a/src/Dapper.FluentMap/Mapping/EntityMap.cs b/src/Dapper.FluentMap/Mapping/EntityMap.cs index 85f5060..a66251e 100644 --- a/src/Dapper.FluentMap/Mapping/EntityMap.cs +++ b/src/Dapper.FluentMap/Mapping/EntityMap.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -23,16 +24,42 @@ public interface IEntityMap /// This serves as a marker interface for generic type inference. /// /// The type of the entity to configure the mapping for. - public interface IEntityMap : IEntityMap + public interface IEntityMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity> : IEntityMap { } + /// + /// Marker interface for a named mapping profile. + /// + public interface IMappingProfile + { + } + + /// + /// Marks an entity map as belonging to the specified mapping profile. + /// + /// The profile marker type. + public interface IProfileMap + where TProfile : IMappingProfile + { + } + + internal interface IEntityMapWithIncludedBaseTypes + { + IList IncludedBaseTypes { get; } + } + /// /// Serves as the base class for all entity mapping implementations. /// /// The type of the entity. /// The type of the property mapping. - public abstract class EntityMapBase : IEntityMap + public abstract class EntityMapBase< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity, + TPropertyMap> : IEntityMap, IEntityMapWithIncludedBaseTypes where TPropertyMap : IPropertyMap { /// @@ -41,6 +68,7 @@ public abstract class EntityMapBase : IEntityMap protected EntityMapBase() { PropertyMaps = new List(); + IncludedBaseTypes = new List(); } /// @@ -48,22 +76,56 @@ protected EntityMapBase() /// public IList PropertyMaps { get; } + IList IEntityMapWithIncludedBaseTypes.IncludedBaseTypes => IncludedBaseTypes; + + private IList IncludedBaseTypes { get; } + /// /// Returns an instance of which can perform custom mapping /// for the specified property on . /// /// Expression to the property on . /// The created instance. This enables a fluent API. - /// when a duplicate mapping is provided. + /// when a duplicate mapping is provided. protected TPropertyMap Map(Expression> expression) { - var info = (PropertyInfo)ReflectionHelper.GetMemberInfo(expression); - var propertyMap = GetPropertyMap(info); + var memberPath = ReflectionHelper.GetMemberPath(expression); + var propertyMap = GetPropertyMap(memberPath.PropertyInfo); + PropertyMapIdentity.SetMemberPath(propertyMap, memberPath); ThrowIfDuplicateMapping(propertyMap); PropertyMaps.Add(propertyMap); return propertyMap; } + /// + /// Includes the explicit mappings configured for a base entity map. + /// + /// The base entity type whose mappings should be included. + /// + /// when is not a valid base type for + /// or the same base type is included more than once. + /// + protected void IncludeBase() + where TBase : class + { + var baseType = typeof(TBase); + var entityType = typeof(TEntity); + + if (baseType == entityType || !baseType.IsClass || !baseType.IsAssignableFrom(entityType)) + { + throw new FluentMapConfigurationException( + $"Type '{baseType.FullName}' cannot be included as a base mapping for entity '{entityType.FullName}'. The included type must be a base class of the entity."); + } + + if (IncludedBaseTypes.Contains(baseType)) + { + throw new FluentMapConfigurationException( + $"Base mapping for type '{baseType.FullName}' is already included by entity '{entityType.FullName}'."); + } + + IncludedBaseTypes.Add(baseType); + } + /// /// When overridden in a derived class, gets the property mapping for the specified property. /// @@ -73,9 +135,12 @@ protected TPropertyMap Map(Expression> expression) private void ThrowIfDuplicateMapping(IPropertyMap map) { - if (PropertyMaps.Any(p => p.PropertyInfo.Name == map.PropertyInfo.Name)) + var memberPath = PropertyMapIdentity.GetMemberPath(map); + + if (PropertyMaps.Any(p => PropertyMapIdentity.GetMemberPath(p).Equals(memberPath))) { - throw new Exception($"Duplicate mapping detected. Property '{map.PropertyInfo.Name}' is already mapped to column '{map.ColumnName}'."); + var existingMap = PropertyMaps.First(p => PropertyMapIdentity.GetMemberPath(p).Equals(memberPath)); + throw new FluentMapConfigurationException($"Property path '{memberPath}' is already mapped for entity '{typeof(TEntity).FullName}'. Existing column: '{existingMap.ColumnName}'; duplicate column: '{map.ColumnName}'."); } } } @@ -84,7 +149,9 @@ private void ThrowIfDuplicateMapping(IPropertyMap map) /// Represents a typed mapping of an entity. /// /// The type of the entity to configure the mapping for. - public abstract class EntityMap : EntityMapBase + public abstract class EntityMap< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + TEntity> : EntityMapBase where TEntity : class { /// diff --git a/src/Dapper.FluentMap/Mapping/MemberPath.cs b/src/Dapper.FluentMap/Mapping/MemberPath.cs new file mode 100644 index 0000000..c8e4291 --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/MemberPath.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; + +namespace Dapper.FluentMap.Mapping +{ + internal sealed class MemberPath : IEquatable + { + private readonly PropertyInfo[] _properties; + private readonly ReadOnlyCollection _readOnlyProperties; + private readonly int _hashCode; + + private MemberPath(IEnumerable properties) + { + if (properties == null) + { + throw new ArgumentNullException(nameof(properties)); + } + + _properties = properties.ToArray(); + if (_properties.Length == 0) + { + throw new ArgumentException("A member path must contain at least one property.", nameof(properties)); + } + + if (_properties.Any(p => p == null)) + { + throw new ArgumentException("A member path cannot contain null properties.", nameof(properties)); + } + + _readOnlyProperties = new ReadOnlyCollection(_properties); + _hashCode = CalculateHashCode(_properties); + } + + internal IReadOnlyList Properties => _readOnlyProperties; + + internal PropertyInfo PropertyInfo => _properties[_properties.Length - 1]; + + internal bool IsNested => _properties.Length > 1; + + internal static MemberPath ForProperty(PropertyInfo property) + { + if (property == null) + { + throw new ArgumentNullException(nameof(property)); + } + + return new MemberPath(new[] { property }); + } + + internal static MemberPath FromProperties(IEnumerable properties) + { + return new MemberPath(properties); + } + + public bool Equals(MemberPath other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other == null || _properties.Length != other._properties.Length) + { + return false; + } + + for (var i = 0; i < _properties.Length; i++) + { + if (!MemberEquals(_properties[i], other._properties[i])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) + { + return obj is MemberPath other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + public override string ToString() + { + return string.Join(".", _properties.Select(p => p.Name)); + } + + private static bool MemberEquals(PropertyInfo left, PropertyInfo right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + if (HasSameMetadataIdentity(left, right)) + { + return true; + } + + return left.Equals(right); + } + + private static bool HasSameMetadataIdentity(PropertyInfo left, PropertyInfo right) + { + try + { + return Equals(left.Module, right.Module) && + left.MetadataToken == right.MetadataToken && + Equals(left.DeclaringType, right.DeclaringType); + } + catch (InvalidOperationException) + { + return false; + } + } + + private static int CalculateHashCode(PropertyInfo[] properties) + { + unchecked + { + var hash = 17; + + foreach (var property in properties) + { + hash = (hash * 31) + GetMemberHashCode(property); + } + + return hash; + } + } + + private static int GetMemberHashCode(PropertyInfo property) + { + try + { + unchecked + { + var hash = 17; + hash = (hash * 31) + (property.Module == null ? 0 : property.Module.GetHashCode()); + hash = (hash * 31) + property.MetadataToken; + hash = (hash * 31) + (property.DeclaringType == null ? 0 : property.DeclaringType.GetHashCode()); + return hash; + } + } + catch (InvalidOperationException) + { + return property.GetHashCode(); + } + } + } +} diff --git a/src/Dapper.FluentMap/Mapping/PropertyMap.cs b/src/Dapper.FluentMap/Mapping/PropertyMap.cs index 09bd82a..522ef1d 100644 --- a/src/Dapper.FluentMap/Mapping/PropertyMap.cs +++ b/src/Dapper.FluentMap/Mapping/PropertyMap.cs @@ -34,7 +34,7 @@ public interface IPropertyMap /// Serves as the base class for all property mapping implementations. /// /// The type of the property mapping. - public abstract class PropertyMapBase + public abstract class PropertyMapBase : IPropertyMapWithMemberPath where TPropertyMap : class, IPropertyMap { /// @@ -44,7 +44,13 @@ public abstract class PropertyMapBase /// The object representing to the property to map. protected PropertyMapBase(PropertyInfo info) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = info.Name; } @@ -57,7 +63,13 @@ protected PropertyMapBase(PropertyInfo info) /// The column name in the database to map the property to. internal PropertyMapBase(PropertyInfo info, string columnName) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; } @@ -71,7 +83,13 @@ internal PropertyMapBase(PropertyInfo info, string columnName) /// A value indicating whether the mappig should be case sensitive. internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitive) { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + PropertyInfo = info; + MemberPath = Dapper.FluentMap.Mapping.MemberPath.ForProperty(info); ColumnName = columnName; CaseSensitive = caseSensitive; } @@ -96,6 +114,15 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv /// public PropertyInfo PropertyInfo { get; } + internal MemberPath MemberPath { get; private set; } + + MemberPath IPropertyMapWithMemberPath.MemberPath => MemberPath; + + void IPropertyMapWithMemberPath.SetMemberPath(MemberPath memberPath) + { + MemberPath = memberPath; + } + /// /// Maps the current property to the specified column name. /// @@ -104,6 +131,11 @@ internal PropertyMapBase(PropertyInfo info, string columnName, bool caseSensitiv /// The current instance of . public TPropertyMap ToColumn(string columnName, bool caseSensitive = true) { + if (string.IsNullOrEmpty(columnName)) + { + throw new ArgumentException("Column name cannot be null or empty.", nameof(columnName)); + } + ColumnName = columnName; CaseSensitive = caseSensitive; return this as TPropertyMap; diff --git a/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs b/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs new file mode 100644 index 0000000..6eba4f5 --- /dev/null +++ b/src/Dapper.FluentMap/Mapping/PropertyMapIdentity.cs @@ -0,0 +1,46 @@ +using System; + +namespace Dapper.FluentMap.Mapping +{ + internal interface IPropertyMapWithMemberPath + { + MemberPath MemberPath { get; } + + void SetMemberPath(MemberPath memberPath); + } + + internal static class PropertyMapIdentity + { + internal static MemberPath GetMemberPath(IPropertyMap propertyMap) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + var mapWithPath = propertyMap as IPropertyMapWithMemberPath; + if (mapWithPath != null && mapWithPath.MemberPath != null) + { + return mapWithPath.MemberPath; + } + + return MemberPath.ForProperty(propertyMap.PropertyInfo); + } + + internal static void SetMemberPath(IPropertyMap propertyMap, MemberPath memberPath) + { + if (propertyMap == null) + { + throw new ArgumentNullException(nameof(propertyMap)); + } + + if (memberPath == null) + { + throw new ArgumentNullException(nameof(memberPath)); + } + + var mapWithPath = propertyMap as IPropertyMapWithMemberPath; + mapWithPath?.SetMemberPath(memberPath); + } + } +} diff --git a/src/Dapper.FluentMap/MappingCacheKey.cs b/src/Dapper.FluentMap/MappingCacheKey.cs index 74dad5e..4d7300a 100644 --- a/src/Dapper.FluentMap/MappingCacheKey.cs +++ b/src/Dapper.FluentMap/MappingCacheKey.cs @@ -4,32 +4,41 @@ namespace Dapper.FluentMap { internal struct MappingCacheKey : IEquatable { - private MappingCacheKey(Type type, string columnName, MappingCacheOptions options) + private MappingCacheKey(Type type, Type profileType, string columnName, MappingCacheOptions options) { Type = type; + ProfileType = profileType; ColumnName = columnName; Options = options; } internal Type Type { get; } + internal Type ProfileType { get; } + internal string ColumnName { get; } internal MappingCacheOptions Options { get; } internal static MappingCacheKey FluentMap(Type type, string columnName) { - return new MappingCacheKey(type, columnName, MappingCacheOptions.FluentMap); + return new MappingCacheKey(type, null, columnName, MappingCacheOptions.FluentMap); + } + + internal static MappingCacheKey ProfileMap(Type type, Type profileType, string columnName) + { + return new MappingCacheKey(type, profileType, columnName, MappingCacheOptions.ProfileMap); } internal static MappingCacheKey ConventionOnly(Type type, string columnName) { - return new MappingCacheKey(type, columnName, MappingCacheOptions.ConventionOnly); + return new MappingCacheKey(type, null, columnName, MappingCacheOptions.ConventionOnly); } public bool Equals(MappingCacheKey other) { return Type == other.Type && + ProfileType == other.ProfileType && string.Equals(ColumnName, other.ColumnName, StringComparison.Ordinal) && Options.Equals(other.Options); } @@ -45,6 +54,7 @@ public override int GetHashCode() { var hash = 17; hash = (hash * 31) + (Type == null ? 0 : Type.GetHashCode()); + hash = (hash * 31) + (ProfileType == null ? 0 : ProfileType.GetHashCode()); hash = (hash * 31) + (ColumnName == null ? 0 : ColumnName.GetHashCode()); hash = (hash * 31) + Options.GetHashCode(); return hash; @@ -67,6 +77,9 @@ private MappingCacheOptions(MappingCacheStrategy strategy) internal static MappingCacheOptions ConventionOnly { get; } = new MappingCacheOptions(MappingCacheStrategy.ConventionOnly); + internal static MappingCacheOptions ProfileMap { get; } = + new MappingCacheOptions(MappingCacheStrategy.ProfileMap); + public bool Equals(MappingCacheOptions other) { return _strategy == other._strategy; @@ -86,6 +99,7 @@ public override int GetHashCode() internal enum MappingCacheStrategy { FluentMap, - ConventionOnly + ConventionOnly, + ProfileMap } } diff --git a/src/Dapper.FluentMap/MappingConfigurationValidator.cs b/src/Dapper.FluentMap/MappingConfigurationValidator.cs new file mode 100644 index 0000000..49d5868 --- /dev/null +++ b/src/Dapper.FluentMap/MappingConfigurationValidator.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap +{ + internal static class MappingConfigurationValidator + { + internal static void ValidateEntityMap(Type entityType, IEntityMap entityMap) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (entityMap == null) + { + throw new ArgumentNullException(nameof(entityMap)); + } + + var maps = GetEntityMapDescriptors(entityType, entityMap).ToList(); + ValidateDuplicateMemberPaths(entityType, maps, "entity map", entityMap.GetType()); + ValidateColumnConflicts(entityType, maps, "entity map", entityMap.GetType()); + ValidateNestedMaterializationPaths(entityType, maps, "entity map", entityMap.GetType()); + } + + internal static void ValidateComposedEntityMap(Type entityType, IEntityMap entityMap, IList propertyMaps) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (entityMap == null) + { + throw new ArgumentNullException(nameof(entityMap)); + } + + if (propertyMaps == null) + { + throw new ArgumentNullException(nameof(propertyMaps)); + } + + 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()); + } + + internal static void ValidateConvention(Type entityType, Convention convention) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + + var maps = GetConventionMapDescriptors(entityType, convention).ToList(); + ValidateDuplicateMemberPaths(entityType, maps, "convention", convention.GetType()); + ValidateColumnConflicts(entityType, maps, "convention", convention.GetType()); + } + + internal static void ValidateConventionConfiguration(Type entityType, Convention convention, PropertyConventionConfiguration configuration) + { + if (configuration.PropertyConfiguration == null) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' has a matching property rule without configuration for entity '{FormatType(entityType)}'. Call Configure(...) and choose a column name, prefix or transformer."); + } + } + + internal static void ValidateConventionColumn(Type entityType, Convention convention, PropertyMap propertyMap) + { + if (string.IsNullOrEmpty(propertyMap.ColumnName)) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' produced an empty column name for property path '{PropertyMapIdentity.GetMemberPath(propertyMap)}' on entity '{FormatType(entityType)}'."); + } + } + + private static IEnumerable GetEntityMapDescriptors(Type entityType, IEntityMap entityMap) + { + if (entityMap.PropertyMaps == null) + { + throw new FluentMapConfigurationException( + $"Entity map '{FormatType(entityMap.GetType())}' for entity '{FormatType(entityType)}' returned a null property map collection."); + } + + return GetEntityMapDescriptors(entityType, entityMap.PropertyMaps, entityMap.GetType(), "entity map"); + } + + private static IEnumerable GetEntityMapDescriptors(Type entityType, IEnumerable propertyMaps, Type sourceType, string sourceKind) + { + foreach (var map in propertyMaps) + { + yield return CreateDescriptor(entityType, map, sourceType, sourceKind, requireEntityCompatibility: true); + } + } + + private static IEnumerable GetConventionMapDescriptors(Type entityType, Convention convention) + { + foreach (var map in convention.PropertyMaps) + { + if (map == null) + { + throw new FluentMapConfigurationException( + $"Convention '{FormatType(convention.GetType())}' for entity '{FormatType(entityType)}' contains a null property map."); + } + + if (!IsMapForEntity(entityType, map)) + { + continue; + } + + yield return CreateDescriptor(entityType, map, convention.GetType(), "convention", requireEntityCompatibility: false); + } + } + + private static MapDescriptor CreateDescriptor(Type entityType, IPropertyMap map, Type sourceType, string sourceKind, bool requireEntityCompatibility) + { + if (map == null) + { + throw new FluentMapConfigurationException( + $"The {sourceKind} '{FormatType(sourceType)}' for entity '{FormatType(entityType)}' contains a null property map."); + } + + if (map.PropertyInfo == null) + { + throw new FluentMapConfigurationException( + $"The {sourceKind} '{FormatType(sourceType)}' for entity '{FormatType(entityType)}' contains a property map without metadata."); + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (requireEntityCompatibility && !IsMemberPathCompatible(entityType, memberPath)) + { + throw new FluentMapConfigurationException( + $"Property path '{memberPath}' is not compatible with entity '{FormatType(entityType)}'. The first property is declared by '{FormatType(memberPath.Properties[0].DeclaringType)}'."); + } + + if (string.IsNullOrEmpty(map.ColumnName)) + { + throw new FluentMapConfigurationException( + $"Property path '{memberPath}' on entity '{FormatType(entityType)}' has an empty column name."); + } + + return new MapDescriptor(map, memberPath); + } + + private static void ValidateDuplicateMemberPaths(Type entityType, IList maps, string sourceKind, Type sourceType) + { + for (var i = 0; i < maps.Count; i++) + { + for (var j = i + 1; j < maps.Count; j++) + { + if (!maps[i].MemberPath.Equals(maps[j].MemberPath)) + { + continue; + } + + throw new FluentMapConfigurationException( + $"Property path '{maps[i].MemberPath}' is already mapped for entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. Existing column: '{maps[i].Map.ColumnName}'; duplicate column: '{maps[j].Map.ColumnName}'."); + } + } + } + + private static void ValidateColumnConflicts(Type entityType, IList maps, string sourceKind, Type sourceType) + { + for (var i = 0; i < maps.Count; i++) + { + for (var j = i + 1; j < maps.Count; j++) + { + if (!ShouldValidateColumnConflict(maps[i].Map, maps[j].Map)) + { + continue; + } + + if (!ColumnNamesOverlap(maps[i].Map, maps[j].Map)) + { + continue; + } + + var caseSensitivity = maps[i].Map.CaseSensitive == maps[j].Map.CaseSensitive + ? string.Empty + : " The mappings use different case sensitivity settings."; + + throw new FluentMapConfigurationException( + $"Column '{maps[i].Map.ColumnName}' is configured for more than one property path on entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}': '{maps[i].MemberPath}' and '{maps[j].MemberPath}'.{caseSensitivity}"); + } + } + } + + private static bool ColumnNamesOverlap(IPropertyMap left, IPropertyMap right) + { + if (string.Equals(left.ColumnName, right.ColumnName, StringComparison.Ordinal)) + { + return true; + } + + if (!left.CaseSensitive || !right.CaseSensitive) + { + return string.Equals(left.ColumnName, right.ColumnName, StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static bool ShouldValidateColumnConflict(IPropertyMap left, IPropertyMap right) + { + return left.GetType() == typeof(PropertyMap) && + right.GetType() == typeof(PropertyMap); + } + + private static void ValidateNestedMaterializationPaths(Type entityType, IList maps, string sourceKind, Type sourceType) + { + var activeMaps = maps + .Where(map => !map.Map.Ignored) + .ToList(); + + foreach (var map in activeMaps.Where(map => map.MemberPath.IsNested)) + { + ValidateNestedMaterializationPath(entityType, map.MemberPath, sourceKind, sourceType); + } + + for (var i = 0; i < activeMaps.Count; i++) + { + for (var j = i + 1; j < activeMaps.Count; j++) + { + if (!IsPathPrefix(activeMaps[i].MemberPath, activeMaps[j].MemberPath) && + !IsPathPrefix(activeMaps[j].MemberPath, activeMaps[i].MemberPath)) + { + continue; + } + + throw new FluentMapConfigurationException( + $"Property path '{activeMaps[i].MemberPath}' conflicts with property path '{activeMaps[j].MemberPath}' for entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. Nested materialization cannot map both a path and one of its descendants."); + } + } + } + + private static void ValidateNestedMaterializationPath(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType) + { + var properties = memberPath.Properties; + + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + if (property.GetIndexParameters().Length != 0) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' is an indexer."); + } + + if (IsStatic(property)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' is static."); + } + + if (!CanRead(property)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Property '{property.Name}' must have a public getter."); + } + + if (i == properties.Count - 1) + { + continue; + } + + var propertyType = property.PropertyType; + if (IsUnsupportedIntermediateType(propertyType)) + { + throw UnsupportedNestedPath(entityType, memberPath, sourceKind, sourceType, $"Intermediate property '{property.Name}' has unsupported type '{FormatType(propertyType)}'. Collections and scalar values cannot appear in the middle of a nested path."); + } + + } + } + + private static bool IsPathPrefix(MemberPath prefix, MemberPath path) + { + if (prefix.Properties.Count >= path.Properties.Count) + { + return false; + } + + for (var i = 0; i < prefix.Properties.Count; i++) + { + if (!Equals(prefix.Properties[i], path.Properties[i])) + { + return false; + } + } + + return true; + } + + private static bool CanRead(PropertyInfo property) + { + var getter = property.GetGetMethod(); + return getter != null && !getter.IsStatic; + } + + private static bool IsStatic(PropertyInfo property) + { + var getter = property.GetGetMethod(); + var setter = property.GetSetMethod(); + return (getter != null && getter.IsStatic) || (setter != null && setter.IsStatic); + } + + private static bool IsUnsupportedIntermediateType(Type type) + { + if (!type.IsClass || type == typeof(string)) + { + return true; + } + + return typeof(IEnumerable).IsAssignableFrom(type); + } + + private static FluentMapConfigurationException UnsupportedNestedPath(Type entityType, MemberPath memberPath, string sourceKind, Type sourceType, string reason) + { + return new FluentMapConfigurationException( + $"Property path '{memberPath}' is not supported for nested materialization on entity '{FormatType(entityType)}' in {sourceKind} '{FormatType(sourceType)}'. {reason}"); + } + + private static bool IsMapForEntity(Type entityType, IPropertyMap map) + { +#if NETSTANDARD1_3 + return map.PropertyInfo.DeclaringType == entityType; +#else + return map.PropertyInfo.ReflectedType == entityType; +#endif + } + + private static bool IsMemberPathCompatible(Type entityType, MemberPath memberPath) + { + var declaringType = memberPath.Properties[0].DeclaringType; + return declaringType != null && declaringType.IsAssignableFrom(entityType); + } + + private static string FormatType(Type type) + { + return type == null ? "" : type.FullName; + } + + private sealed class MapDescriptor + { + internal MapDescriptor(IPropertyMap map, MemberPath memberPath) + { + Map = map; + MemberPath = memberPath; + } + + internal IPropertyMap Map { get; } + + internal MemberPath MemberPath { get; } + } + } +} diff --git a/src/Dapper.FluentMap/MappingProfileKey.cs b/src/Dapper.FluentMap/MappingProfileKey.cs new file mode 100644 index 0000000..5f73102 --- /dev/null +++ b/src/Dapper.FluentMap/MappingProfileKey.cs @@ -0,0 +1,46 @@ +using System; + +namespace Dapper.FluentMap +{ + internal struct MappingProfileKey : IEquatable + { + internal MappingProfileKey(Type entityType, Type profileType) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (profileType == null) + { + throw new ArgumentNullException(nameof(profileType)); + } + + EntityType = entityType; + ProfileType = profileType; + } + + internal Type EntityType { get; } + + internal Type ProfileType { get; } + + public bool Equals(MappingProfileKey other) + { + return EntityType == other.EntityType && ProfileType == other.ProfileType; + } + + public override bool Equals(object obj) + { + return obj is MappingProfileKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return ((EntityType == null ? 0 : EntityType.GetHashCode()) * 397) ^ + (ProfileType == null ? 0 : ProfileType.GetHashCode()); + } + } + } +} diff --git a/src/Dapper.FluentMap/MappingRegistry.cs b/src/Dapper.FluentMap/MappingRegistry.cs index da58eb6..54e1570 100644 --- a/src/Dapper.FluentMap/MappingRegistry.cs +++ b/src/Dapper.FluentMap/MappingRegistry.cs @@ -1,10 +1,15 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Text; using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Materialization; using Dapper.FluentMap.TypeMaps; namespace Dapper.FluentMap @@ -14,29 +19,132 @@ internal sealed class MappingRegistry private readonly ConcurrentDictionary _propertyMapCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _materializationPlanCache = + new ConcurrentDictionary(); + internal ConcurrentDictionary EntityMaps { get; } = new ConcurrentDictionary(); + internal ConcurrentDictionary ProfileMaps { get; } = + new ConcurrentDictionary(); + internal ConcurrentDictionary> TypeConventions { get; } = new ConcurrentDictionary>(); internal int CacheEntryCount => _propertyMapCache.Count; + internal int MaterializationPlanCacheEntryCount => _materializationPlanCache.Count; + + internal IReadOnlyDictionary GetEntityMapsSnapshot() + { + var snapshot = EntityMaps + .OrderBy(map => map.Key.FullName, StringComparer.Ordinal) + .ToDictionary(map => map.Key, map => map.Value); + + return new ReadOnlyDictionary(snapshot); + } + + internal IReadOnlyDictionary> GetTypeConventionsSnapshot() + { + var snapshot = TypeConventions + .OrderBy(conventions => conventions.Key.FullName, StringComparer.Ordinal) + .ToDictionary( + conventions => conventions.Key, + conventions => (IReadOnlyList)new ReadOnlyCollection(conventions.Value.ToList())); + + return new ReadOnlyDictionary>(snapshot); + } + internal void AddEntityMap(IEntityMap mapper) where TEntity : class { - var type = typeof(TEntity); + AddEntityMap(typeof(TEntity), mapper); + } + + internal void AddEntityMap(Type type, IEntityMap mapper) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + if (EntityMaps.ContainsKey(type)) + { + throw new FluentMapConfigurationException($"Entity '{type}' already has a configured entity map. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); + } + + MappingConfigurationValidator.ValidateEntityMap(type, mapper); + ValidateIncludedBaseMaps(type, mapper, profileType: null); + MappingConfigurationValidator.ValidateComposedEntityMap(type, mapper, ComposeExplicitPropertyMaps(type, mapper, profileType: null)); + if (!EntityMaps.TryAdd(type, mapper)) { - throw new InvalidOperationException($"Adding entity map for type '{type}' failed. The type already exists. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); + throw new FluentMapConfigurationException($"Entity '{type}' already has a configured entity map. Current entity maps: " + string.Join(", ", EntityMaps.Select(e => e.Key.ToString()))); } InvalidateType(type); SetDapperTypeMap(type); } + internal void AddProfileMap(Type type, Type profileType, IEntityMap mapper) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (profileType == null) + { + throw new ArgumentNullException(nameof(profileType)); + } + + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + var key = new MappingProfileKey(type, profileType); + if (ProfileMaps.ContainsKey(key)) + { + throw new FluentMapConfigurationException( + $"Entity '{type}' already has a configured mapping profile '{profileType}'."); + } + + MappingConfigurationValidator.ValidateEntityMap(type, mapper); + ValidateIncludedBaseMaps(type, mapper, profileType); + MappingConfigurationValidator.ValidateComposedEntityMap( + type, + mapper, + ComposeExplicitPropertyMaps(type, mapper, profileType)); + + if (!ProfileMaps.TryAdd(key, mapper)) + { + throw new FluentMapConfigurationException( + $"Entity '{type}' already has a configured mapping profile '{profileType}'."); + } + + InvalidateType(type); + } + internal void AddConvention(Type type, Convention convention) { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (convention == null) + { + throw new ArgumentNullException(nameof(convention)); + } + + MappingConfigurationValidator.ValidateConvention(type, convention); + TypeConventions.AddOrUpdate( type, _ => new List { convention }, @@ -65,7 +173,7 @@ internal PropertyInfo GetFluentPropertyInfo(Type type, string columnName) { var cacheKey = MappingCacheKey.FluentMap(type, columnName); return _propertyMapCache - .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyInfo(type, columnName))) + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyMap(type, columnName))) .PropertyInfo; } @@ -73,15 +181,209 @@ internal PropertyInfo GetConventionPropertyInfo(Type type, string columnName) { var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); return _propertyMapCache - .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyInfo(type, columnName))) + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyMap(type, columnName))) .PropertyInfo; } + internal IPropertyMap GetFluentPropertyMap(Type type, string columnName) + { + var cacheKey = MappingCacheKey.FluentMap(type, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveFluentPropertyMap(type, columnName))) + .PropertyMap; + } + + internal IPropertyMap GetProfilePropertyMap(Type type, Type profileType, string columnName) + { + if (profileType == null) + { + return GetFluentPropertyMap(type, columnName); + } + + var cacheKey = MappingCacheKey.ProfileMap(type, profileType, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveProfilePropertyMap(type, profileType, columnName))) + .PropertyMap; + } + + internal IPropertyMap GetConventionPropertyMap(Type type, string columnName) + { + var cacheKey = MappingCacheKey.ConventionOnly(type, columnName); + return _propertyMapCache + .GetOrAdd(cacheKey, _ => new MappingCacheEntry(ResolveConventionPropertyMap(type, columnName))) + .PropertyMap; + } + + internal NestedMaterializationPlan GetMaterializationPlan(Type type, string[] columnNames) + { + return GetMaterializationPlan(type, null, columnNames); + } + + internal NestedMaterializationPlan GetMaterializationPlan(Type type, Type profileType, string[] columnNames) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + if (profileType != null && !ProfileMaps.ContainsKey(new MappingProfileKey(type, profileType))) + { + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' does not have a registered mapping profile '{profileType.FullName}'."); + } + + var cacheKey = new MaterializationPlanCacheKey(type, profileType, columnNames); + return _materializationPlanCache.GetOrAdd( + cacheKey, + key => NestedMaterializationPlan.Create(key.Type, key.ProfileType, key.ColumnNames, this)); + } + + internal void ValidateConfiguration() + { + var errors = new List(); + + foreach (var entityMap in EntityMaps.OrderBy(e => e.Key.FullName)) + { + try + { + MappingConfigurationValidator.ValidateEntityMap(entityMap.Key, entityMap.Value); + ValidateIncludedBaseMaps(entityMap.Key, entityMap.Value, profileType: null); + MappingConfigurationValidator.ValidateComposedEntityMap( + entityMap.Key, + entityMap.Value, + ComposeExplicitPropertyMaps(entityMap.Key, entityMap.Value, profileType: null)); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + + foreach (var profileMap in ProfileMaps.OrderBy(p => p.Key.EntityType.FullName).ThenBy(p => p.Key.ProfileType.FullName)) + { + try + { + MappingConfigurationValidator.ValidateEntityMap(profileMap.Key.EntityType, profileMap.Value); + ValidateIncludedBaseMaps(profileMap.Key.EntityType, profileMap.Value, profileMap.Key.ProfileType); + MappingConfigurationValidator.ValidateComposedEntityMap( + profileMap.Key.EntityType, + profileMap.Value, + ComposeExplicitPropertyMaps(profileMap.Key.EntityType, profileMap.Value, profileMap.Key.ProfileType)); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + + foreach (var typeConventions in TypeConventions.OrderBy(c => c.Key.FullName)) + { + foreach (var convention in typeConventions.Value) + { + try + { + MappingConfigurationValidator.ValidateConvention(typeConventions.Key, convention); + } + catch (Exception exception) + { + errors.Add(exception.Message); + } + } + } + + if (errors.Count == 0) + { + return; + } + + var message = new StringBuilder() + .Append("Dapper.FluentMap configuration validation found ") + .Append(errors.Count) + .Append(errors.Count == 1 ? " error:" : " errors:"); + + foreach (var error in errors) + { + message.AppendLine().Append("- ").Append(error); + } + + throw new FluentMapConfigurationException(message.ToString()); + } + + internal MappingExplanation Explain( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type) + { + return Explain(type, profileType: null); + } + + internal MappingExplanation Explain( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type, + Type profileType) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + var diagnostics = new List(); + var members = new List(); + var configuredPaths = new List(); + var entityMapType = default(Type); + + IEntityMap entityMap; + var hasEntityMap = profileType == null + ? EntityMaps.TryGetValue(type, out entityMap) + : ProfileMaps.TryGetValue(new MappingProfileKey(type, profileType), out entityMap); + + if (hasEntityMap) + { + entityMapType = entityMap.GetType(); + + foreach (var descriptor in ComposeExplicitPropertyMapDescriptors(type, entityMap, profileType)) + { + AddMemberExplanation(type, members, configuredPaths, descriptor); + } + } + + var conventionTypes = GetConventionTypes(type).ToList(); + foreach (var descriptor in GetConventionPropertyMapDescriptors(type, configuredPaths)) + { + AddMemberExplanation(type, members, configuredPaths, descriptor); + } + + AddDapperDefaultExplanations(type, members, configuredPaths); + + if (entityMapType == null && profileType != null) + { + diagnostics.Add($"No FluentMap mapping profile '{profileType.FullName}' is registered for this entity. Dapper default mapping is used."); + } + else if (entityMapType == null && conventionTypes.Count == 0) + { + diagnostics.Add("No FluentMap entity map or convention is registered for this entity. Dapper default mapping is used."); + } + + return new MappingExplanation( + type, + profileType, + entityMapType, + conventionTypes, + members.OrderBy(m => m.MemberPath, StringComparer.Ordinal).ThenBy(m => m.ColumnName, StringComparer.Ordinal), + diagnostics); + } + internal void Reset(params Type[] dapperTypes) { EntityMaps.Clear(); + ProfileMaps.Clear(); TypeConventions.Clear(); _propertyMapCache.Clear(); + _materializationPlanCache.Clear(); if (dapperTypes == null) { @@ -96,7 +398,7 @@ internal void Reset(params Type[] dapperTypes) private void SetDapperTypeMap(Type type) { - var instance = (SqlMapper.ITypeMap)Activator.CreateInstance(typeof(FluentMapTypeMap<>).MakeGenericType(type)); + var instance = new FluentMapTypeMap(type); SqlMapper.SetTypeMap(type, instance); } @@ -106,44 +408,299 @@ private void InvalidateType(Type type) { _propertyMapCache.TryRemove(key, out _); } + + foreach (var key in _materializationPlanCache.Keys.Where(k => k.Type == type)) + { + _materializationPlanCache.TryRemove(key, out _); + } } - private PropertyInfo ResolveFluentPropertyInfo(Type type, string columnName) + private IPropertyMap ResolveFluentPropertyMap(Type type, string columnName) { - var explicitPropertyMaps = GetExplicitPropertyMaps(type); + var explicitPropertyMaps = GetExplicitPropertyMaps(type, profileType: null); var explicitPropertyMap = explicitPropertyMaps.FirstOrDefault(m => MatchColumnNames(m, columnName)); if (explicitPropertyMap != null) { - if (!explicitPropertyMap.Ignored) + return explicitPropertyMap; + } + + return ResolveConventionPropertyMap(type, columnName, explicitPropertyMaps); + } + + private IPropertyMap ResolveProfilePropertyMap(Type type, Type profileType, string columnName) + { + var explicitPropertyMaps = GetExplicitPropertyMaps(type, profileType); + var explicitPropertyMap = explicitPropertyMaps.FirstOrDefault(m => MatchColumnNames(m, columnName)); + + if (explicitPropertyMap != null) + { + return explicitPropertyMap; + } + + return ResolveConventionPropertyMap(type, columnName, explicitPropertyMaps); + } + + private IList GetExplicitPropertyMaps(Type type, Type profileType) + { + if (profileType == null) + { + if (EntityMaps.TryGetValue(type, out var entityMap)) { - return explicitPropertyMap.PropertyInfo; + return ComposeExplicitPropertyMaps(type, entityMap, profileType: null); } -#if !NETSTANDARD1_3 - return new IgnoredPropertyInfo(); -#endif + return new IPropertyMap[0]; + } + + if (ProfileMaps.TryGetValue(new MappingProfileKey(type, profileType), out var profileMap)) + { + return ComposeExplicitPropertyMaps(type, profileMap, profileType); } - return ResolveConventionPropertyInfo(type, columnName, explicitPropertyMaps); + return new IPropertyMap[0]; } - private IList GetExplicitPropertyMaps(Type type) + private IEnumerable GetConventionTypes(Type type) { - if (EntityMaps.TryGetValue(type, out var entityMap)) + if (!TypeConventions.TryGetValue(type, out var conventions)) { - return entityMap.PropertyMaps; + return new Type[0]; } - return new IPropertyMap[0]; + return conventions.Select(c => c.GetType()).ToList(); + } + + private void ValidateIncludedBaseMaps(Type type, IEntityMap entityMap, Type profileType) + { + foreach (var baseType in GetIncludedBaseTypes(entityMap)) + { + if (baseType == type || !baseType.IsClass || !baseType.IsAssignableFrom(type)) + { + throw new FluentMapConfigurationException( + $"Type '{baseType.FullName}' cannot be included as a base mapping for entity '{type.FullName}'. The included type must be a base class of the entity."); + } + + var hasBaseMap = profileType == null + ? EntityMaps.ContainsKey(baseType) + : ProfileMaps.ContainsKey(new MappingProfileKey(baseType, profileType)); + + if (!hasBaseMap) + { + var profileContext = profileType == null + ? string.Empty + : $" for mapping profile '{profileType.FullName}'"; + + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}'{profileContext}, but no entity map has been registered for the base type. Register the base map before the derived map."); + } + } } - private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName) + private IList ComposeExplicitPropertyMaps(Type type, IEntityMap entityMap, Type profileType) { - return ResolveConventionPropertyInfo(type, columnName, new IPropertyMap[0]); + return ComposeExplicitPropertyMapDescriptors(type, entityMap, profileType) + .Select(d => d.Map) + .ToList(); } - private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, IList explicitPropertyMaps) + private IList ComposeExplicitPropertyMapDescriptors(Type type, IEntityMap entityMap, Type profileType) + { + var propertyMaps = new List(); + AddPropertyMapsWithOverride( + propertyMaps, + entityMap.PropertyMaps.Select(m => MappingDiagnosticDescriptor.Explicit(m))); + + foreach (var baseType in GetIncludedBaseTypes(entityMap)) + { + IEntityMap baseMap; + var hasBaseMap = profileType == null + ? EntityMaps.TryGetValue(baseType, out baseMap) + : ProfileMaps.TryGetValue(new MappingProfileKey(baseType, profileType), out baseMap); + + if (!hasBaseMap) + { + var profileContext = profileType == null + ? string.Empty + : $" for mapping profile '{profileType.FullName}'"; + + throw new FluentMapConfigurationException( + $"Entity '{type.FullName}' includes base mapping '{baseType.FullName}'{profileContext}, but no entity map has been registered for the base type. Register the base map before the derived map."); + } + + AddPropertyMapsWithOverride( + propertyMaps, + ComposeExplicitPropertyMapDescriptors(baseType, baseMap, profileType) + .Select(d => d.AsInheritedFrom(baseType))); + } + + return propertyMaps; + } + + private static void AddPropertyMapsWithOverride(IList target, IEnumerable maps) + { + foreach (var descriptor in maps) + { + var memberPath = PropertyMapIdentity.GetMemberPath(descriptor.Map); + if (target.Any(existingMap => PropertyMapIdentity.GetMemberPath(existingMap.Map).Equals(memberPath))) + { + continue; + } + + target.Add(descriptor); + } + } + + private static IList GetIncludedBaseTypes(IEntityMap entityMap) + { + var mapWithIncludedBases = entityMap as IEntityMapWithIncludedBaseTypes; + if (mapWithIncludedBases == null) + { + return new Type[0]; + } + + return mapWithIncludedBases.IncludedBaseTypes; + } + + private IEnumerable GetConventionPropertyMapDescriptors(Type type, IList configuredPaths) + { + if (!TypeConventions.TryGetValue(type, out var conventions)) + { + yield break; + } + + foreach (var convention in conventions) + { + foreach (var map in convention.PropertyMaps) + { + if (!IsMapForEntity(type, map)) + { + continue; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + if (configuredPaths.Any(path => path.Equals(memberPath))) + { + continue; + } + + yield return MappingDiagnosticDescriptor.Convention(map, convention); + } + } + } + + private void AddDapperDefaultExplanations( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] + Type type, + IList members, + IList configuredPaths) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetIndexParameters().Length == 0)) + { + var memberPath = MemberPath.ForProperty(property); + if (configuredPaths.Any(path => path.Equals(memberPath))) + { + continue; + } + + var constructorParameters = GetConstructorParameters(type, property).ToList(); + members.Add(new MemberMappingExplanation( + memberPath.ToString(), + property, + property.Name, + MappingSource.DapperDefault, + caseSensitive: false, + ignored: false, + inheritedFrom: null, + conventionType: null, + constructorParameters: constructorParameters, + materialization: MappingMaterialization.Dapper)); + configuredPaths.Add(memberPath); + } + } + + private void AddMemberExplanation( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + Type entityType, + IList members, + IList configuredPaths, + MappingDiagnosticDescriptor descriptor) + { + var memberPath = PropertyMapIdentity.GetMemberPath(descriptor.Map); + var constructorParameters = descriptor.Map.Ignored || memberPath.IsNested + ? new ConstructorParameterExplanation[0] + : GetConstructorParameters(entityType, descriptor.Map.PropertyInfo); + var materialization = GetMaterialization(memberPath); + + members.Add(new MemberMappingExplanation( + memberPath.ToString(), + descriptor.Map.PropertyInfo, + descriptor.Map.ColumnName, + descriptor.Source, + descriptor.Map.CaseSensitive, + descriptor.Map.Ignored, + descriptor.InheritedFrom, + descriptor.ConventionType, + constructorParameters, + materialization)); + configuredPaths.Add(memberPath); + } + + private static MappingMaterialization GetMaterialization(MemberPath memberPath) + { + if (!memberPath.IsNested) + { + return MappingMaterialization.Dapper; + } + + return RequiresConstructorMaterialization(memberPath) + ? MappingMaterialization.ValueObject + : MappingMaterialization.Nested; + } + + private static bool RequiresConstructorMaterialization(MemberPath memberPath) + { + var properties = memberPath.Properties; + for (var i = 0; i < properties.Count; i++) + { + if (!CanWrite(properties[i])) + { + return true; + } + + } + + return false; + } + + private static bool CanWrite(PropertyInfo property) + { + var setter = property.GetSetMethod(); + return setter != null && !setter.IsStatic; + } + + private static IEnumerable GetConstructorParameters( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + Type entityType, + PropertyInfo property) + { + foreach (var constructor in entityType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)) + { + var parameter = FluentConstructorTypeMap.MatchParameter(constructor.GetParameters(), property.Name); + if (parameter != null) + { + yield return new ConstructorParameterExplanation(constructor, parameter); + } + } + } + + private IPropertyMap ResolveConventionPropertyMap(Type type, string columnName) + { + return ResolveConventionPropertyMap(type, columnName, new IPropertyMap[0]); + } + + private IPropertyMap ResolveConventionPropertyMap(Type type, string columnName, IList explicitPropertyMaps) { if (!TypeConventions.TryGetValue(type, out var conventions)) { @@ -166,8 +723,8 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, if (maps.Count > 1) { - const string msg = "Finding mappings for column '{0}' yielded more than 1 PropertyMap. The conventions should be more specific. Type: '{1}'. Convention: '{2}'."; - throw new Exception(string.Format(msg, columnName, type, convention)); + const string msg = "Column '{0}' matched more than one convention property map for entity '{1}' in convention '{2}'. The convention should be more specific."; + throw new FluentMapConfigurationException(string.Format(msg, columnName, type, convention.GetType())); } if (maps.Count == 0) @@ -175,7 +732,7 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, continue; } - return maps[0].PropertyInfo; + return maps[0]; } return null; @@ -183,7 +740,17 @@ private PropertyInfo ResolveConventionPropertyInfo(Type type, string columnName, private static bool IsExplicitlyMapped(IPropertyMap conventionMap, IList explicitPropertyMaps) { - return explicitPropertyMaps.Any(map => map.PropertyInfo.Name == conventionMap.PropertyInfo.Name); + var conventionPath = PropertyMapIdentity.GetMemberPath(conventionMap); + return explicitPropertyMaps.Any(map => PropertyMapIdentity.GetMemberPath(map).Equals(conventionPath)); + } + + private static bool IsMapForEntity(Type type, IPropertyMap map) + { +#if NETSTANDARD1_3 + return map.PropertyInfo.DeclaringType == type; +#else + return map.PropertyInfo.ReflectedType == type; +#endif } private static bool MatchColumnNames(IPropertyMap map, string columnName) @@ -197,12 +764,68 @@ private static bool MatchColumnNames(IPropertyMap map, string columnName) private sealed class MappingCacheEntry { - internal MappingCacheEntry(PropertyInfo propertyInfo) + internal MappingCacheEntry(IPropertyMap propertyMap) { - PropertyInfo = propertyInfo; + PropertyMap = propertyMap; + + if (propertyMap == null) + { + return; + } + + if (!propertyMap.Ignored) + { + var memberPath = PropertyMapIdentity.GetMemberPath(propertyMap); + PropertyInfo = memberPath.IsNested ? null : propertyMap.PropertyInfo; + return; + } } + internal IPropertyMap PropertyMap { get; } + internal PropertyInfo PropertyInfo { get; } } + + private sealed class MappingDiagnosticDescriptor + { + private MappingDiagnosticDescriptor(IPropertyMap map, MappingSource source, Type inheritedFrom, Type conventionType) + { + Map = map; + Source = source; + InheritedFrom = inheritedFrom; + ConventionType = conventionType; + } + + internal IPropertyMap Map { get; } + + internal MappingSource Source { get; } + + internal Type InheritedFrom { get; } + + internal Type ConventionType { get; } + + internal static MappingDiagnosticDescriptor Explicit(IPropertyMap map) + { + return new MappingDiagnosticDescriptor(map, MappingSource.Explicit, null, null); + } + + internal static MappingDiagnosticDescriptor Convention(IPropertyMap map, Convention convention) + { + var source = convention is NamingPolicyConvention + ? MappingSource.NamingPolicy + : MappingSource.Convention; + + return new MappingDiagnosticDescriptor(map, source, null, convention.GetType()); + } + + internal MappingDiagnosticDescriptor AsInheritedFrom(Type baseType) + { + return new MappingDiagnosticDescriptor( + Map, + MappingSource.Inherited, + InheritedFrom ?? baseType, + ConventionType); + } + } } } diff --git a/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs new file mode 100644 index 0000000..513156a --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/MaterializationPlanCacheKey.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Dapper.FluentMap.Materialization +{ + internal sealed class MaterializationPlanCacheKey : IEquatable + { + private readonly string[] _columnNames; + private readonly int _hashCode; + + internal MaterializationPlanCacheKey(Type type, Type profileType, IEnumerable columnNames) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + Type = type; + ProfileType = profileType; + _columnNames = columnNames.ToArray(); + ColumnNames = new ReadOnlyCollection(_columnNames); + _hashCode = CalculateHashCode(type, profileType, _columnNames); + } + + internal Type Type { get; } + + internal Type ProfileType { get; } + + internal IReadOnlyList ColumnNames { get; } + + public bool Equals(MaterializationPlanCacheKey other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other == null || + Type != other.Type || + ProfileType != other.ProfileType || + _columnNames.Length != other._columnNames.Length) + { + return false; + } + + for (var i = 0; i < _columnNames.Length; i++) + { + if (!string.Equals(_columnNames[i], other._columnNames[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) + { + return obj is MaterializationPlanCacheKey other && Equals(other); + } + + public override int GetHashCode() + { + return _hashCode; + } + + private static int CalculateHashCode(Type type, Type profileType, string[] columnNames) + { + unchecked + { + var hash = type.GetHashCode(); + hash = (hash * 31) + (profileType == null ? 0 : profileType.GetHashCode()); + foreach (var columnName in columnNames) + { + hash = (hash * 31) + (columnName == null ? 0 : StringComparer.Ordinal.GetHashCode(columnName)); + } + + return hash; + } + } + } +} diff --git a/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs new file mode 100644 index 0000000..0365fcb --- /dev/null +++ b/src/Dapper.FluentMap/Materialization/NestedMaterializationPlan.cs @@ -0,0 +1,754 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.Materialization +{ + internal sealed class NestedMaterializationPlan + { + private readonly MaterializationNode _rootNode; + + private NestedMaterializationPlan(MaterializationNode rootNode) + { + _rootNode = rootNode; + } + + internal static NestedMaterializationPlan Create(Type entityType, Type profileType, IReadOnlyList columnNames, MappingRegistry registry) + { + if (entityType == null) + { + throw new ArgumentNullException(nameof(entityType)); + } + + if (columnNames == null) + { + throw new ArgumentNullException(nameof(columnNames)); + } + + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + var defaultTypeMap = new DefaultTypeMap(entityType); + var rootNode = MaterializationNode.Root(entityType); + + for (var i = 0; i < columnNames.Count; i++) + { + var columnName = columnNames[i]; + var fluentMap = registry.GetProfilePropertyMap(entityType, profileType, columnName); + if (fluentMap != null) + { + if (fluentMap.Ignored) + { + continue; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(fluentMap); + rootNode.AddPropertyPath(memberPath, i, columnName); + continue; + } + + var defaultMember = defaultTypeMap.GetMember(columnName); + if (defaultMember == null) + { + continue; + } + + if (defaultMember.Property != null) + { + rootNode.AddRootProperty(defaultMember.Property, i, columnName); + } + else if (defaultMember.Field != null) + { + rootNode.AddRootField(defaultMember.Field, i, columnName); + } + } + + rootNode.Seal(entityType); + + return new NestedMaterializationPlan(rootNode); + } + + internal object Materialize(IDataRecord record) + { + return _rootNode.MaterializeRoot(record); + } + + private static Func CreateParameterlessFactory(Type type) + { + var constructor = type.GetConstructor(Type.EmptyTypes); + if (constructor == null) + { + return null; + } + + var body = Expression.Convert(Expression.New(constructor), typeof(object)); + return Expression.Lambda>(body).Compile(); + } + + private static Func CreateConstructorFactory(ConstructorInfo constructor) + { + var args = Expression.Parameter(typeof(object[]), "args"); + var parameters = constructor.GetParameters(); + var arguments = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var item = Expression.ArrayIndex(args, Expression.Constant(i)); + arguments[i] = Expression.Convert(item, parameters[i].ParameterType); + } + + var body = Expression.Convert(Expression.New(constructor, arguments), typeof(object)); + return Expression.Lambda>(body, args).Compile(); + } + + private static Func CreateGetter(PropertyInfo property) + { + var target = Expression.Parameter(typeof(object), "target"); + var body = Expression.Convert( + Expression.Property(Expression.Convert(target, property.DeclaringType), property), + typeof(object)); + + return Expression.Lambda>(body, target).Compile(); + } + + private static Action CreatePropertySetter(PropertyInfo property) + { + if (!CanWrite(property)) + { + return null; + } + + var target = Expression.Parameter(typeof(object), "target"); + var value = Expression.Parameter(typeof(object), "value"); + var body = Expression.Assign( + Expression.Property(Expression.Convert(target, property.DeclaringType), property), + Expression.Convert(value, property.PropertyType)); + + return Expression.Lambda>(body, target, value).Compile(); + } + + private static Action CreateFieldSetter(FieldInfo field) + { + var target = Expression.Parameter(typeof(object), "target"); + var value = Expression.Parameter(typeof(object), "value"); + var body = Expression.Assign( + Expression.Field(Expression.Convert(target, field.DeclaringType), field), + Expression.Convert(value, field.FieldType)); + + return Expression.Lambda>(body, target, value).Compile(); + } + + private static Func CreateConverter(Type targetType) + { + var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (DapperTypeHandlerAdapter.HasTypeHandler(conversionType)) + { + return DapperTypeHandlerAdapter.CreateConverter(targetType); + } + + return value => ConvertValue(value, targetType); + } + + private static object ConvertValue(object value, Type targetType) + { + if (value == null || value == DBNull.Value) + { + return GetDefaultValue(targetType); + } + + var conversionType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (conversionType.IsInstanceOfType(value)) + { + return value; + } + + if (conversionType.GetTypeInfo().IsEnum) + { + return value is string text + ? Enum.Parse(conversionType, text) + : Enum.ToObject(conversionType, value); + } + + if (conversionType == typeof(Guid) && value is string guidText) + { + return new Guid(guidText); + } + + return Convert.ChangeType(value, conversionType, CultureInfo.InvariantCulture); + } + + private static object GetDefaultValue(Type type) + { + if (!type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null) + { + return null; + } + + return Activator.CreateInstance(type); + } + + private static bool CanAssignNull(Type type) + { + return !type.GetTypeInfo().IsValueType || Nullable.GetUnderlyingType(type) != null; + } + + private static bool CanRead(PropertyInfo property) + { + var getter = property.GetGetMethod(); + return getter != null && !getter.IsStatic; + } + + private static bool CanWrite(PropertyInfo property) + { + var setter = property.GetSetMethod(); + return setter != null && !setter.IsStatic; + } + + private static bool HasNonNullValue(IDataRecord record, IEnumerable columnIndexes) + { + return columnIndexes.Any(index => !record.IsDBNull(index)); + } + + private static bool IsParameterCompatible(Type parameterType, Type sourceType) + { + var parameter = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + var source = Nullable.GetUnderlyingType(sourceType) ?? sourceType; + return parameter.GetTypeInfo().IsAssignableFrom(source.GetTypeInfo()) || + source.GetTypeInfo().IsAssignableFrom(parameter.GetTypeInfo()); + } + + private static int GetCompatibilityScore(Type parameterType, Type sourceType) + { + var parameter = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + var source = Nullable.GetUnderlyingType(sourceType) ?? sourceType; + return parameter == source ? 2 : 1; + } + + private static string FormatType(Type type) + { + return type == null ? "" : type.FullName; + } + + private static string FormatConstructor(ConstructorInfo constructor) + { + var parameters = constructor.GetParameters() + .Select(parameter => FormatType(parameter.ParameterType) + " " + parameter.Name); + + return FormatType(constructor.DeclaringType) + "(" + string.Join(", ", parameters) + ")"; + } + + private sealed class MaterializationNode + { + private readonly List _leaves = new List(); + private readonly List _children = new List(); + private readonly bool _isRoot; + private int[] _subtreeColumnIndexes; + private Func _parameterlessFactory; + private ConstructorPlan _constructorPlan; + private NestedLeaf[] _postConstructorLeaves; + private MaterializationNode[] _postConstructorChildren; + + private MaterializationNode(Type type, PropertyInfo parentProperty, string memberPath, bool isRoot) + { + Type = type; + ParentProperty = parentProperty; + MemberPath = memberPath; + _isRoot = isRoot; + + if (parentProperty != null) + { + Getter = CanRead(parentProperty) ? CreateGetter(parentProperty) : null; + Setter = CreatePropertySetter(parentProperty); + } + } + + internal Type Type { get; } + + internal PropertyInfo ParentProperty { get; } + + internal string MemberPath { get; } + + internal Func Getter { get; } + + internal Action Setter { get; } + + internal bool CanAssignToParent => _isRoot || Setter != null; + + internal static MaterializationNode Root(Type type) + { + return new MaterializationNode(type, null, type.Name, isRoot: true); + } + + internal void AddPropertyPath(MemberPath memberPath, int columnIndex, string columnName) + { + var properties = memberPath.Properties; + if (!memberPath.IsNested) + { + AddRootProperty(properties[0], columnIndex, columnName); + return; + } + + var node = this; + for (var i = 0; i < properties.Count - 1; i++) + { + node = node.FindOrAddChild(properties[i]); + } + + node._leaves.Add(NestedLeaf.ForProperty(properties[properties.Count - 1], columnIndex, columnName, memberPath.ToString())); + } + + internal void AddRootProperty(PropertyInfo property, int columnIndex, string columnName) + { + _leaves.Add(NestedLeaf.ForProperty(property, columnIndex, columnName, property.Name)); + } + + internal void AddRootField(FieldInfo field, int columnIndex, string columnName) + { + _leaves.Add(NestedLeaf.ForField(field, columnIndex, columnName, field.Name)); + } + + internal void Seal(Type entityType) + { + foreach (var child in _children) + { + child.Seal(entityType); + } + + _subtreeColumnIndexes = _leaves + .Select(leaf => leaf.ColumnIndex) + .Concat(_children.SelectMany(child => child._subtreeColumnIndexes)) + .Distinct() + .ToArray(); + + SelectConstructionPlan(entityType); + } + + internal object MaterializeRoot(IDataRecord record) + { + return Materialize(record, existing: null, forceNew: true); + } + + internal object MaterializeValue(IDataRecord record) + { + if (!HasNonNullValue(record, _subtreeColumnIndexes)) + { + return null; + } + + return Materialize(record, existing: null, forceNew: true); + } + + internal void Apply(object parent, IDataRecord record) + { + if (!HasNonNullValue(record, _subtreeColumnIndexes)) + { + if (Setter != null && CanAssignNull(ParentProperty.PropertyType)) + { + Setter(parent, null); + } + + return; + } + + var existing = _constructorPlan == null && Getter != null + ? Getter(parent) + : null; + var value = Materialize(record, existing, forceNew: false); + + if (Setter != null && (!ReferenceEquals(existing, value) || _constructorPlan != null)) + { + Setter(parent, value); + } + } + + private MaterializationNode FindOrAddChild(PropertyInfo property) + { + var child = _children.FirstOrDefault(node => Equals(node.ParentProperty, property)); + if (child != null) + { + return child; + } + + var memberPath = _isRoot + ? property.Name + : MemberPath + "." + property.Name; + child = new MaterializationNode(property.PropertyType, property, memberPath, isRoot: false); + _children.Add(child); + return child; + } + + private void SelectConstructionPlan(Type entityType) + { + _parameterlessFactory = CreateParameterlessFactory(Type); + + var requiresConstructor = _parameterlessFactory == null || + _leaves.Any(leaf => !leaf.CanAssign) || + _children.Any(child => !child.CanAssignToParent); + + if (!requiresConstructor) + { + _postConstructorLeaves = _leaves.ToArray(); + _postConstructorChildren = _children.ToArray(); + return; + } + + _constructorPlan = SelectConstructor(entityType); + if (_constructorPlan == null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot be materialized. No public constructor matches the mapped properties or nested value objects. Columns: {FormatColumns()}."); + } + + _postConstructorLeaves = _leaves + .Where(leaf => !_constructorPlan.Uses(leaf)) + .ToArray(); + _postConstructorChildren = _children + .Where(child => !_constructorPlan.Uses(child)) + .ToArray(); + + var unsupportedLeaf = _postConstructorLeaves.FirstOrDefault(leaf => !leaf.CanAssign); + if (unsupportedLeaf != null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot assign mapped property '{unsupportedLeaf.MemberPath}'. It has no public setter and is not bound to constructor '{FormatConstructor(_constructorPlan.Constructor)}'. Column: '{unsupportedLeaf.ColumnName}'."); + } + + var unsupportedChild = _postConstructorChildren.FirstOrDefault(child => !child.CanAssignToParent); + if (unsupportedChild != null) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' cannot assign nested value object '{unsupportedChild.MemberPath}'. It has no public setter and is not bound to constructor '{FormatConstructor(_constructorPlan.Constructor)}'."); + } + } + + private ConstructorPlan SelectConstructor(Type entityType) + { + var candidates = Type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(constructor => TryCreateConstructorPlan(entityType, constructor)) + .Where(plan => plan != null) + .ToList(); + + if (candidates.Count == 0) + { + return null; + } + + var bestScore = candidates.Max(candidate => candidate.Score); + var best = candidates + .Where(candidate => candidate.Score == bestScore) + .ToList(); + + if (best.Count > 1) + { + throw new FluentMapConfigurationException( + $"Type '{FormatType(Type)}' at member path '{MemberPath}' on entity '{FormatType(entityType)}' has multiple public constructors that match the mapped columns: {string.Join("; ", best.Select(plan => FormatConstructor(plan.Constructor)))}."); + } + + return best[0]; + } + + private ConstructorPlan TryCreateConstructorPlan(Type entityType, ConstructorInfo constructor) + { + var bindings = new List(); + var score = 0; + + foreach (var parameter in constructor.GetParameters()) + { + var binding = TryBindParameter(parameter); + if (binding == null) + { + return null; + } + + bindings.Add(binding); + score += binding.Score; + } + + foreach (var leaf in _leaves.Where(leaf => !leaf.CanAssign)) + { + if (!bindings.Any(binding => binding.Leaf == leaf)) + { + return null; + } + } + + foreach (var child in _children.Where(child => !child.CanAssignToParent)) + { + if (!bindings.Any(binding => binding.Child == child)) + { + return null; + } + } + + return new ConstructorPlan( + entityType, + MemberPath, + constructor, + CreateConstructorFactory(constructor), + bindings, + score); + } + + private ParameterBinding TryBindParameter(ParameterInfo parameter) + { + var leafMatches = _leaves + .Where(leaf => leaf.Property != null && + string.Equals(leaf.Property.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.ParameterType, leaf.TargetType)) + .Select(leaf => ParameterBinding.ForLeaf(parameter, leaf, GetCompatibilityScore(parameter.ParameterType, leaf.TargetType))); + + var childMatches = _children + .Where(child => string.Equals(child.ParentProperty.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) && + IsParameterCompatible(parameter.ParameterType, child.ParentProperty.PropertyType)) + .Select(child => ParameterBinding.ForChild(parameter, child, GetCompatibilityScore(parameter.ParameterType, child.ParentProperty.PropertyType))); + + var matches = leafMatches + .Concat(childMatches) + .OrderByDescending(binding => binding.Score) + .ToList(); + + if (matches.Count == 0) + { + return null; + } + + var bestScore = matches[0].Score; + var best = matches.Where(match => match.Score == bestScore).ToList(); + return best.Count == 1 ? best[0] : null; + } + + private object Materialize(IDataRecord record, object existing, bool forceNew) + { + var current = _constructorPlan != null + ? _constructorPlan.Create(record) + : forceNew || existing == null + ? _parameterlessFactory() + : existing; + + foreach (var child in _postConstructorChildren) + { + child.Apply(current, record); + } + + foreach (var leaf in _postConstructorLeaves) + { + leaf.Assign(current, record); + } + + return current; + } + + private string FormatColumns() + { + return string.Join(", ", _leaves.Select(leaf => "'" + leaf.ColumnName + "'") + .Concat(_children.SelectMany(child => child.GetColumnNames().Select(column => "'" + column + "'")))); + } + + internal IEnumerable GetColumnNames() + { + return _leaves.Select(leaf => leaf.ColumnName) + .Concat(_children.SelectMany(child => child.GetColumnNames())); + } + } + + private sealed class NestedLeaf + { + private readonly Action _setter; + private readonly Func _converter; + + private NestedLeaf( + PropertyInfo property, + FieldInfo field, + int columnIndex, + string columnName, + string memberPath, + Type targetType, + Action setter) + { + Property = property; + Field = field; + ColumnIndex = columnIndex; + ColumnName = columnName; + MemberPath = memberPath; + TargetType = targetType; + _setter = setter; + _converter = CreateConverter(targetType); + } + + internal PropertyInfo Property { get; } + + internal FieldInfo Field { get; } + + internal int ColumnIndex { get; } + + internal string ColumnName { get; } + + internal string MemberPath { get; } + + internal Type TargetType { get; } + + internal bool CanAssign => _setter != null; + + internal static NestedLeaf ForProperty(PropertyInfo property, int columnIndex, string columnName, string memberPath) + { + return new NestedLeaf( + property, + null, + columnIndex, + columnName, + memberPath, + property.PropertyType, + CreatePropertySetter(property)); + } + + internal static NestedLeaf ForField(FieldInfo field, int columnIndex, string columnName, string memberPath) + { + return new NestedLeaf( + null, + field, + columnIndex, + columnName, + memberPath, + field.FieldType, + CreateFieldSetter(field)); + } + + internal object GetValue(IDataRecord record) + { + return _converter(record.GetValue(ColumnIndex)); + } + + internal void Assign(object target, IDataRecord record) + { + _setter(target, GetValue(record)); + } + } + + private sealed class ConstructorPlan + { + private readonly Type _entityType; + private readonly string _memberPath; + private readonly Func _factory; + private readonly ParameterBinding[] _bindings; + + internal ConstructorPlan( + Type entityType, + string memberPath, + ConstructorInfo constructor, + Func factory, + IEnumerable bindings, + int score) + { + _entityType = entityType; + _memberPath = memberPath; + Constructor = constructor; + _factory = factory; + _bindings = bindings.ToArray(); + Score = score; + } + + internal ConstructorInfo Constructor { get; } + + internal int Score { get; } + + 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 Create(IDataRecord record) + { + var args = new object[_bindings.Length]; + for (var i = 0; i < _bindings.Length; i++) + { + args[i] = _bindings[i].GetValue(record); + } + + try + { + return _factory(args); + } + catch (Exception exception) + { + throw new FluentMapConfigurationException( + $"Failed to materialize type '{FormatType(Constructor.DeclaringType)}' at member path '{_memberPath}' on entity '{FormatType(_entityType)}' using constructor '{FormatConstructor(Constructor)}'. Columns: {FormatColumns()}. See the inner exception for the domain failure.", + exception); + } + } + + private string FormatColumns() + { + return string.Join(", ", _bindings + .SelectMany(binding => binding.GetColumnNames()) + .Distinct() + .Select(column => "'" + column + "'")); + } + } + + private sealed class ParameterBinding + { + private ParameterBinding(ParameterInfo parameter, NestedLeaf leaf, MaterializationNode child, int score) + { + Parameter = parameter; + Leaf = leaf; + Child = child; + Score = score; + } + + internal ParameterInfo Parameter { get; } + + internal NestedLeaf Leaf { get; } + + internal MaterializationNode Child { get; } + + internal int Score { get; } + + internal static ParameterBinding ForLeaf(ParameterInfo parameter, NestedLeaf leaf, int score) + { + return new ParameterBinding(parameter, leaf, null, score); + } + + internal static ParameterBinding ForChild(ParameterInfo parameter, MaterializationNode child, int score) + { + return new ParameterBinding(parameter, null, child, score); + } + + internal object GetValue(IDataRecord record) + { + if (Leaf != null) + { + return Leaf.GetValue(record); + } + + return Child.MaterializeValue(record); + } + + internal IEnumerable GetColumnNames() + { + if (Leaf != null) + { + yield return Leaf.ColumnName; + yield break; + } + + foreach (var columnName in Child.GetColumnNames()) + { + yield return columnName; + } + } + } + } +} diff --git a/src/Dapper.FluentMap/Naming/NamingPolicy.cs b/src/Dapper.FluentMap/Naming/NamingPolicy.cs new file mode 100644 index 0000000..cb7773e --- /dev/null +++ b/src/Dapper.FluentMap/Naming/NamingPolicy.cs @@ -0,0 +1,210 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Text; + +namespace Dapper.FluentMap.Naming +{ + /// + /// Defines a reusable policy for transforming member names into database column names. + /// + public sealed class NamingPolicy + { + private readonly Func _transformer; + + private NamingPolicy(Func transformer) + { + if (transformer == null) + { + throw new ArgumentNullException(nameof(transformer)); + } + + _transformer = transformer; + } + + /// + /// Gets a policy that preserves member names unchanged. + /// + public static NamingPolicy Identity { get; } = new NamingPolicy(name => name); + + /// + /// Gets a policy that converts PascalCase or camelCase member names to snake_case column names. + /// + public static NamingPolicy SnakeCase { get; } = new NamingPolicy(ToSnakeCase); + + /// + /// Creates a policy that prepends the specified prefix to member names. + /// + /// The prefix to add to the generated column name. + /// A naming policy that adds . + public static NamingPolicy Prefix(string prefix) + { + if (prefix == null) + { + throw new ArgumentNullException(nameof(prefix)); + } + + return new NamingPolicy(name => prefix + name); + } + + /// + /// Creates a policy that appends the specified suffix to member names. + /// + /// The suffix to add to the generated column name. + /// A naming policy that adds . + public static NamingPolicy Suffix(string suffix) + { + if (suffix == null) + { + throw new ArgumentNullException(nameof(suffix)); + } + + return new NamingPolicy(name => name + suffix); + } + + /// + /// Creates a policy from a custom member-name transformer. + /// + /// A function that receives a member name and returns a column name. + /// A naming policy that uses . + public static NamingPolicy Custom(Func transformer) + { + return new NamingPolicy(transformer); + } + + /// + /// Composes the current policy with another policy. + /// + /// The next policy to apply. + /// A naming policy that applies this policy and then . + public NamingPolicy Then(NamingPolicy next) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + return new NamingPolicy(name => next.GetColumnName(GetColumnName(name))); + } + + /// + /// Composes the current policy with a custom member-name transformer. + /// + /// The next transformer to apply. + /// A naming policy that applies this policy and then . + public NamingPolicy Then(Func transformer) + { + return Then(Custom(transformer)); + } + + /// + /// Creates a policy that applies this policy and prepends the specified prefix. + /// + /// The prefix to add to the generated column name. + /// A naming policy that adds after applying this policy. + public NamingPolicy WithPrefix(string prefix) + { + return Then(Prefix(prefix)); + } + + /// + /// Creates a policy that applies this policy and appends the specified suffix. + /// + /// The suffix to add to the generated column name. + /// A naming policy that adds after applying this policy. + public NamingPolicy WithSuffix(string suffix) + { + return Then(Suffix(suffix)); + } + + /// + /// Gets the column name for the specified member name. + /// + /// The member name to transform. + /// The generated column name. + public string GetColumnName(string memberName) + { + if (memberName == null) + { + throw new ArgumentNullException(nameof(memberName)); + } + + return _transformer(memberName); + } + + private static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(name.Length + 8); + + for (var i = 0; i < name.Length; i++) + { + var current = name[i]; + if (char.IsUpper(current)) + { + if (ShouldAddUnderscore(name, i)) + { + builder.Append('_'); + } + + builder.Append(char.ToLower(current, CultureInfo.InvariantCulture)); + continue; + } + + builder.Append(current); + } + + return builder.ToString(); + } + + private static bool ShouldAddUnderscore(string name, int index) + { + if (index == 0 || name[index - 1] == '_') + { + return false; + } + + var previous = name[index - 1]; + if (char.IsLower(previous) || char.IsDigit(previous)) + { + return true; + } + + return index + 1 < name.Length && char.IsLower(name[index + 1]); + } + + #region EditorBrowsableStates + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override string ToString() + { + return base.ToString(); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object obj) + { + return base.Equals(obj); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() + { + return base.GetHashCode(); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public new Type GetType() + { + return base.GetType(); + } + #endregion + } +} diff --git a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs index 47436f6..6040a49 100644 --- a/src/Dapper.FluentMap/Properties/AssemblyInfo.cs +++ b/src/Dapper.FluentMap/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Dapper.FluentMap.Tests")] +[assembly: InternalsVisibleTo("Dapper.FluentMap.GeneratedRegistration.Tests")] diff --git a/src/Dapper.FluentMap/QueryMappedExtensions.cs b/src/Dapper.FluentMap/QueryMappedExtensions.cs new file mode 100644 index 0000000..9c51b76 --- /dev/null +++ b/src/Dapper.FluentMap/QueryMappedExtensions.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Materialization; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap +{ + /// + /// Provides opt-in query helpers for FluentMap-controlled materialization. + /// + public static class QueryMappedExtensions + { + private const DynamicallyAccessedMemberTypes MaterializedEntityMemberTypes = + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicProperties; + + private const string QueryMappedRequiresUnreferencedCodeMessage = + "QueryMapped uses runtime mapping metadata to materialize nested objects. Prefer generated materializers when publishing trimmed or Native AOT applications."; + + private const string QueryMappedRequiresDynamicCodeMessage = + "QueryMapped compiles runtime accessors for nested object materialization. Prefer generated materializers when publishing Native AOT applications."; + + /// + /// Executes a query and materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a query and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMapped( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a command and materializes rows using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + { + return ExecuteMapped(connection, command, profileType: null); + } + + /// + /// Executes a command and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static IEnumerable QueryMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + return ExecuteMapped(connection, command, typeof(TProfile)); + } + + /// + /// Executes a query and materializes exactly one row using FluentMap's opt-in nested object materializer. + /// + /// The entity type to materialize. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + { + return QueryMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); + } + + /// + /// Executes a query and materializes exactly one row using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static TEntity QueryMappedSingle< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + return QueryMapped(connection, sql, param, transaction, commandTimeout, commandType).Single(); + } + + /// + /// Executes a query asynchronously and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + if (sql == null) + { + throw new ArgumentNullException(nameof(sql)); + } + + return QueryMappedAsync( + connection, + new CommandDefinition(sql, param, transaction, commandTimeout, commandType)); + } + + /// + /// Executes a command asynchronously and materializes rows using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The command to execute. + /// The materialized rows. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static Task> QueryMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + CommandDefinition command) + where TEntity : class + where TProfile : IMappingProfile + { + return ExecuteMappedAsync(connection, command, typeof(TProfile)); + } + + /// + /// Executes a query asynchronously and materializes exactly one row using the specified FluentMap mapping profile. + /// + /// The entity type to materialize. + /// The mapping profile marker type to use. + /// The database connection. + /// The SQL query to execute. + /// Optional query parameters. + /// Optional transaction. + /// Optional command timeout. + /// Optional command type. + /// The materialized row. + [RequiresUnreferencedCode(QueryMappedRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(QueryMappedRequiresDynamicCodeMessage)] + public static async Task QueryMappedSingleAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity, + TProfile>( + this IDbConnection connection, + string sql, + object param = null, + IDbTransaction transaction = null, + int? commandTimeout = null, + CommandType? commandType = null) + where TEntity : class + where TProfile : IMappingProfile + { + var rows = await QueryMappedAsync( + connection, + sql, + param, + transaction, + commandTimeout, + commandType).ConfigureAwait(false); + + return rows.Single(); + } + + private static IEnumerable ExecuteMapped< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using (var reader = SqlMapper.ExecuteReader(connection, command)) + { + return Materialize(reader, profileType); + } + } + + private static async Task> ExecuteMappedAsync< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDbConnection connection, + CommandDefinition command, + Type profileType) + where TEntity : class + { + if (connection == null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using (var reader = await SqlMapper.ExecuteReaderAsync(connection, command).ConfigureAwait(false)) + { + return Materialize(reader, profileType); + } + } + + private static IEnumerable Materialize< + [DynamicallyAccessedMembers(MaterializedEntityMemberTypes)] + TEntity>( + IDataReader reader, + Type profileType) + where TEntity : class + { + var columnNames = GetColumnNames(reader); + var plan = FluentMapper.Registry.GetMaterializationPlan(typeof(TEntity), profileType, columnNames); + var results = new List(); + + while (reader.Read()) + { + results.Add((TEntity)plan.Materialize(reader)); + } + + return results; + } + + private static string[] GetColumnNames(IDataRecord reader) + { + var columnNames = new string[reader.FieldCount]; + for (var i = 0; i < columnNames.Length; i++) + { + columnNames[i] = reader.GetName(i); + } + + return columnNames; + } + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs b/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs new file mode 100644 index 0000000..e216430 --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/ConstructorParameterMap.cs @@ -0,0 +1,34 @@ +using System; +using System.Reflection; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class ConstructorParameterMap : SqlMapper.IMemberMap + { + internal ConstructorParameterMap(string columnName, ParameterInfo parameter) + { + if (columnName == null) + { + throw new ArgumentNullException(nameof(columnName)); + } + + if (parameter == null) + { + throw new ArgumentNullException(nameof(parameter)); + } + + ColumnName = columnName; + Parameter = parameter; + } + + public string ColumnName { get; } + + public Type MemberType => Parameter.ParameterType; + + public PropertyInfo Property => null; + + public FieldInfo Field => null; + + public ParameterInfo Parameter { get; } + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs new file mode 100644 index 0000000..1ad644b --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/FluentConstructorTypeMap.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Reflection; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class FluentConstructorTypeMap : SqlMapper.ITypeMap + { + private readonly Type _type; + private readonly Func _propertyMapResolver; + private readonly DefaultTypeMap _defaultTypeMap; + + internal FluentConstructorTypeMap(Type type, Func propertyMapResolver) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (propertyMapResolver == null) + { + throw new ArgumentNullException(nameof(propertyMapResolver)); + } + + _type = type; + _propertyMapResolver = propertyMapResolver; + _defaultTypeMap = new DefaultTypeMap(type); + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + var effectiveNames = new string[names.Length]; + var effectiveTypes = new Type[types.Length]; + var hasMappedColumn = false; + + for (var i = 0; i < names.Length; i++) + { + var map = GetSimplePropertyMap(names[i]); + + if (map != null && !map.Ignored) + { + effectiveNames[i] = map.PropertyInfo.Name; + effectiveTypes[i] = map.PropertyInfo.PropertyType; + hasMappedColumn = true; + continue; + } + + effectiveNames[i] = names[i]; + effectiveTypes[i] = types[i]; + } + + return hasMappedColumn + ? _defaultTypeMap.FindConstructor(effectiveNames, effectiveTypes) + : null; + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + var map = GetSimplePropertyMap(columnName); + if (map == null || map.Ignored) + { + return null; + } + + var parameter = MatchParameter(constructor.GetParameters(), map.PropertyInfo.Name); + return parameter == null + ? null + : new ConstructorParameterMap(columnName, parameter); + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + return null; + } + + private IPropertyMap GetSimplePropertyMap(string columnName) + { + var map = _propertyMapResolver(_type, columnName); + if (map == null) + { + return null; + } + + var memberPath = PropertyMapIdentity.GetMemberPath(map); + return memberPath.IsNested ? null : map; + } + + internal static ParameterInfo MatchParameter(ParameterInfo[] parameters, string memberName) + { + return parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(p.Name, memberName, StringComparison.OrdinalIgnoreCase)) + ?? MatchParameterWithUnderscores(parameters, memberName); + } + + private static ParameterInfo MatchParameterWithUnderscores(ParameterInfo[] parameters, string memberName) + { + if (!DefaultTypeMap.MatchNamesWithUnderscores) + { + return null; + } + + var effectiveMemberName = memberName.Replace("_", string.Empty); + return parameters.FirstOrDefault(p => string.Equals(p.Name, effectiveMemberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(p.Name, effectiveMemberName, StringComparison.OrdinalIgnoreCase)) + ?? parameters.FirstOrDefault(p => string.Equals(RemoveUnderscores(p.Name), effectiveMemberName, StringComparison.Ordinal)) + ?? parameters.FirstOrDefault(p => string.Equals(RemoveUnderscores(p.Name), effectiveMemberName, StringComparison.OrdinalIgnoreCase)); + } + + private static string RemoveUnderscores(string value) + { + return value == null ? null : value.Replace("_", string.Empty); + } + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs index 2dd7855..cc46bfe 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentConventionTypeMap.cs @@ -1,29 +1,31 @@ -using System; -using System.Reflection; +using System; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps { /// - /// Represents a Dapper type mapping strategy which first tries to map the type using a - /// - /// with the configured conventions. is used as fallback mapping strategy. + /// Represents a Dapper type mapping strategy which first tries configured conventions. + /// is used as fallback mapping strategy. /// /// The type of the entity. public class FluentConventionTypeMap : MultiTypeMap { /// /// Initializes a new instance of the class - /// which uses the and - /// as mapping strategies. + /// which uses FluentMap conventions and as mapping strategies. /// public FluentConventionTypeMap() - : base(new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), new DefaultTypeMap(typeof(TEntity))) + : base( + new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), + new DapperFluentPropertyTypeMap(typeof(TEntity), GetPropertyMap), + new DefaultTypeMap(typeof(TEntity))) { } - private static PropertyInfo GetPropertyInfo(Type type, string columnName) + private static IPropertyMap GetPropertyMap(Type type, string columnName) { - return FluentMapper.Registry.GetConventionPropertyInfo(type, columnName); + return FluentMapper.Registry.GetConventionPropertyMap(type, columnName); } } diff --git a/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs new file mode 100644 index 0000000..49d303d --- /dev/null +++ b/src/Dapper.FluentMap/TypeMaps/FluentMapTypeMap.cs @@ -0,0 +1,23 @@ +using System; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; + +namespace Dapper.FluentMap.TypeMaps +{ + internal sealed class FluentMapTypeMap : MultiTypeMap + { + internal FluentMapTypeMap(Type entityType) + : base( + new FluentConstructorTypeMap(entityType, GetPropertyMap), + new DapperFluentPropertyTypeMap(entityType, GetPropertyMap), + new DefaultTypeMap(entityType)) + { + } + + private static IPropertyMap GetPropertyMap(Type type, string columnName) + { + return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); + } + + } +} diff --git a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs index f6a37d9..2474fed 100644 --- a/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/FluentTypeMap.cs @@ -1,5 +1,6 @@ using System; -using System.Reflection; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps { @@ -16,13 +17,17 @@ public class FluentMapTypeMap : MultiTypeMap /// as mapping strategies. /// public FluentMapTypeMap() - : base(new CustomPropertyTypeMap(typeof(TEntity), GetPropertyInfo), new DefaultTypeMap(typeof(TEntity))) + : base( + new FluentConstructorTypeMap(typeof(TEntity), GetPropertyMap), + new DapperFluentPropertyTypeMap(typeof(TEntity), GetPropertyMap), + new DefaultTypeMap(typeof(TEntity))) { } - private static PropertyInfo GetPropertyInfo(Type type, string columnName) + private static IPropertyMap GetPropertyMap(Type type, string columnName) { - return FluentMapper.Registry.GetFluentPropertyInfo(type, columnName); + return FluentMapper.Registry.GetFluentPropertyMap(type, columnName); } + } } diff --git a/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs b/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs deleted file mode 100644 index b396930..0000000 --- a/src/Dapper.FluentMap/TypeMaps/IgnoredPropertyInfo.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if !NETSTANDARD1_3 -using System; -using System.Globalization; -using System.Reflection; - -namespace Dapper.FluentMap.TypeMaps -{ - internal class IgnoredPropertyInfo : PropertyInfo - { - public override Type PropertyType => throw new NotImplementedException(); - public override PropertyAttributes Attributes => throw new NotImplementedException(); - public override bool CanRead => throw new NotImplementedException(); - public override bool CanWrite => throw new NotImplementedException(); - public override string Name => throw new NotImplementedException(); - public override Type DeclaringType => throw new NotImplementedException(); - public override ParameterInfo[] GetIndexParameters() => throw new NotImplementedException(); - public override Type ReflectedType => throw new NotImplementedException(); - public override MethodInfo[] GetAccessors(bool nonPublic) => throw new NotImplementedException(); - public override object[] GetCustomAttributes(bool inherit) => throw new NotImplementedException(); - public override object[] GetCustomAttributes(Type attributeType, bool inherit) => throw new NotImplementedException(); - public override MethodInfo GetGetMethod(bool nonPublic) => throw new NotImplementedException(); - public override MethodInfo GetSetMethod(bool nonPublic) => throw new NotImplementedException(); - public override bool IsDefined(Type attributeType, bool inherit) => throw new NotImplementedException(); - public override object GetValue(object obj, BindingFlags invokeAttr, Binder binder, object[] index, CultureInfo culture) => throw new NotImplementedException(); - public override void SetValue(object obj, object value, BindingFlags invokeAttr, Binder binder, object[] index, CultureInfo culture) => throw new NotImplementedException(); - } -} -#endif \ No newline at end of file diff --git a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs index 6657840..292bfe9 100644 --- a/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs +++ b/src/Dapper.FluentMap/TypeMaps/MultiTypeMap.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Reflection; +using Dapper.FluentMap.Compatibility; using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.TypeMaps @@ -39,7 +40,12 @@ public ConstructorInfo FindConstructor(string[] names, Type[] types) } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies + // and continue to the next mapping strategy. + } + catch (NotSupportedException) + { + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -62,7 +68,12 @@ public ConstructorInfo FindExplicitConstructor() } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies + // and continue to the next mapping strategy. + } + catch (NotSupportedException) + { + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -86,7 +97,12 @@ public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies + // and continue to the next mapping strategy. + } + catch (NotSupportedException) + { + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } @@ -104,20 +120,18 @@ public SqlMapper.IMemberMap GetMember(string columnName) var result = mapper.GetMember(columnName); if (result != null) { -#if !NETSTANDARD1_3 - if (result is IgnoredPropertyInfo || result.Property is IgnoredPropertyInfo) + if (DapperIgnoredMemberMap.IsIgnored(result)) { - // The property is explicitly ignored, + // The property is explicitly ignored or FluentMap-controlled nested materialization. // return null to prevent falling back to default type map of Dapper. return null; } -#endif return result; } } catch (NotImplementedException) { - // Ignore NotImplementedException's thrown by the CustomPropertyTypeMap + // Ignore unsupported operations from mapper strategies // and continue to the next mapping strategy. } } diff --git a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs index 3009a9e..51d13be 100644 --- a/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs +++ b/src/Dapper.FluentMap/Utils/FluentMapConfigurationExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Dapper.FluentMap.Configuration; @@ -12,13 +13,17 @@ namespace Dapper.FluentMap.Utils /// public static class FluentMapConfigurationExtensions { + private const string AssemblyScanningRequiresUnreferencedCodeMessage = + "Assembly scanning discovers entity maps by reflection. Register maps explicitly with AddMap() when publishing trimmed or Native AOT applications."; + /// /// Finds all types, from provided assemblies, implementing /// and applies them to , - /// by calling and passing an instance of found type. + /// by calling AddMap and passing an instance of found type. /// /// The instance. /// The assemblies to scan for entity maps. + [RequiresUnreferencedCode(AssemblyScanningRequiresUnreferencedCodeMessage)] public static void ApplyMapsFromAssemblies(this FluentMapConfiguration configuration, params Assembly[] assemblies) { if (assemblies == null) diff --git a/src/Dapper.FluentMap/Utils/ReflectionHelper.cs b/src/Dapper.FluentMap/Utils/ReflectionHelper.cs index 8816f58..a5438fb 100644 --- a/src/Dapper.FluentMap/Utils/ReflectionHelper.cs +++ b/src/Dapper.FluentMap/Utils/ReflectionHelper.cs @@ -1,6 +1,8 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; +using Dapper.FluentMap.Mapping; namespace Dapper.FluentMap.Utils { @@ -15,40 +17,71 @@ public static class ReflectionHelper /// A lamba expression containing a MemberExpression. /// A object for the member in the specified lambda expression. public static MemberInfo GetMemberInfo(LambdaExpression lambda) + { + return GetMemberPath(lambda).PropertyInfo; + } + + internal static MemberPath GetMemberPath(LambdaExpression lambda) { if (lambda == null) { throw new ArgumentNullException(nameof(lambda)); } - Expression expr = lambda; + var properties = new Stack(); + var expr = RemoveConvert(lambda.Body); + while (true) { - switch (expr.NodeType) + if (expr == null) { - case ExpressionType.Lambda: - expr = ((LambdaExpression)expr).Body; - break; - - case ExpressionType.Convert: - expr = ((UnaryExpression)expr).Operand; - break; + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); + } + switch (expr.NodeType) + { case ExpressionType.MemberAccess: var memberExpression = (MemberExpression)expr; var member = memberExpression.Member; if (member is PropertyInfo propertyInfo) { - return propertyInfo; + if (propertyInfo.GetIndexParameters().Length > 0) + { + throw new ArgumentException($"Expression '{lambda}' refers to indexed property '{member.Name}', which is not supported.", nameof(lambda)); + } + + properties.Push(propertyInfo); + expr = RemoveConvert(memberExpression.Expression); + break; } throw new ArgumentException($"Expression '{lambda}' refers to member '{member.Name}', which is not a property.", nameof(lambda)); + case ExpressionType.Parameter: + if (properties.Count == 0) + { + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); + } + + return MemberPath.FromProperties(properties); + default: - throw new ArgumentException($"Expression '{lambda}' must resolve to a property.", nameof(lambda)); + throw new ArgumentException($"Expression '{lambda}' must resolve to a property path.", nameof(lambda)); } } } + + private static Expression RemoveConvert(Expression expression) + { + while (expression != null && + (expression.NodeType == ExpressionType.Convert || + expression.NodeType == ExpressionType.ConvertChecked)) + { + expression = ((UnaryExpression)expression).Operand; + } + + return expression; + } } } diff --git a/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj new file mode 100644 index 0000000..f2dc9d9 --- /dev/null +++ b/test/Dapper.FluentMap.Analyzers.Tests/Dapper.FluentMap.Analyzers.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs new file mode 100644 index 0000000..c2dc409 --- /dev/null +++ b/test/Dapper.FluentMap.Analyzers.Tests/FluentMapConfigurationAnalyzerTests.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap; +using Dapper.FluentMap.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace Dapper.FluentMap.Analyzers.Tests +{ + public sealed class FluentMapConfigurationAnalyzerTests + { + [Fact] + public async Task InvalidMapExpressionShouldReportDfm001() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public string Name { get; set; } + + public string GetName() => Name; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.GetName()).ToColumn(""customer_name""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidMapExpressionDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Map expression 'c.GetName()' is invalid"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.GetName()).ToColumn"); + } + + [Fact] + public async Task DuplicateMemberPathShouldReportDfm002() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + Map(c => c.Id).ToColumn(""other_id""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateMemberPathDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Property path 'Id' is mapped more than once"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.Id).ToColumn(\"other_id\")"); + } + + [Fact] + public async Task DuplicateColumnShouldReportDfm003() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""shared_column""); + Map(c => c.Name).ToColumn(""shared_column""); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateColumnDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Column 'shared_column' is mapped by more than one property path"); + AssertDiagnosticLineContains(source, diagnostic, "Map(c => c.Name).ToColumn(\"shared_column\")"); + } + + [Fact] + public async Task InvalidIncludeBaseShouldReportDfm004() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public class CustomerBase +{ + public int Id { get; set; } +} + +public sealed class Customer : CustomerBase +{ + public string Name { get; set; } +} + +public sealed class OtherCustomer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + IncludeBase(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidIncludeBaseDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Type 'OtherCustomer' cannot be included as a base mapping for entity 'Customer'"); + AssertDiagnosticLineContains(source, diagnostic, "IncludeBase()"); + } + + [Fact] + public async Task InvalidGenericMapRegistrationShouldReportDfm005() + { + var source = @" +using System.Collections.Generic; +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class NonGenericMap : IEntityMap +{ + public IList PropertyMaps { get; } = new List(); +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration.AddMap(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidGenericMapRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Entity map type 'NonGenericMap' must implement exactly one closed IEntityMap interface"); + AssertDiagnosticLineContains(source, diagnostic, "configuration.AddMap()"); + } + + [Fact] + public async Task InvalidGenericProfileRegistrationShouldReportDfm009() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration.AddProfile(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.InvalidGenericProfileRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Profile map type 'CustomerMap' must implement exactly one closed IEntityMap interface and exactly one closed IProfileMap interface"); + AssertDiagnosticLineContains(source, diagnostic, "configuration.AddProfile()"); + } + + [Fact] + public async Task DuplicateProfileRegistrationShouldReportDfm010() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstCustomerMap : EntityMap, IProfileMap +{ +} + +public sealed class SecondCustomerMap : EntityMap, IProfileMap +{ +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration + .AddProfile() + .AddProfile(); + } +}"; + + var diagnostic = await GetSingleDiagnosticAsync(source, FluentMapConfigurationAnalyzer.DuplicateProfileRegistrationDiagnosticId); + + AssertDiagnostic(diagnostic, DiagnosticSeverity.Error, "Entity 'Customer' registers mapping profile 'LegacyProfile' more than once"); + AssertDiagnosticLineContains(source, diagnostic, ".AddProfile()"); + } + + [Fact] + public async Task ValidMappingConfigurationShouldNotReportDiagnostics() + { + var source = @" +using Dapper.FluentMap.Configuration; +using Dapper.FluentMap.Mapping; + +public sealed record ConstructorCustomer(int Id, string Name); + +public class CustomerBase +{ + public int Id { get; set; } +} + +public sealed class Customer : CustomerBase +{ + public string Name { get; set; } + + public Rank Rank { get; set; } + + public Seniority Seniority { get; set; } +} + +public sealed class Rank +{ + public int Level { get; set; } +} + +public sealed class Seniority +{ + public int Level { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + IncludeBase(); + Map(c => c.Name).ToColumn(""customer_name""); + Map(c => c.Rank.Level).ToColumn(""rank_level""); + Map(c => c.Seniority.Level).ToColumn(""seniority_level""); + } +} + +public sealed class CustomerBaseMap : EntityMap +{ + public CustomerBaseMap() + { + Map(c => c.Id).ToColumn(""customer_id""); + } +} + +public sealed class ConstructorCustomerMap : EntityMap +{ + public ConstructorCustomerMap() + { + Map(c => c.Id).ToColumn(""constructor_customer_id""); + } +} + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(c => c.Id).ToColumn(""legacy_customer_id""); + } +} + +public sealed class Startup +{ + public void Configure(FluentMapConfiguration configuration) + { + configuration + .AddMap() + .AddMap() + .AddMap() + .AddProfile(); + } +}"; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } + + [Fact] + public async Task CaseSensitiveColumnNamesWithDifferentCasingShouldNotReportDfm003() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(c => c.Id).ToColumn(""Customer""); + Map(c => c.Name).ToColumn(""customer""); + } +}"; + + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == FluentMapConfigurationAnalyzer.DuplicateColumnDiagnosticId); + } + + private static async Task GetSingleDiagnosticAsync(string source, string diagnosticId) + { + var diagnostics = await GetAnalyzerDiagnosticsAsync(source); + return Assert.Single(diagnostics, diagnostic => diagnostic.Id == diagnosticId); + } + + private static async Task> GetAnalyzerDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), + path: "Test0.cs"); + + var references = GetMetadataReferences(); + var compilation = CSharpCompilation.Create( + "AnalyzerTest", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var compilerErrors = compilation + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + + Assert.Empty(compilerErrors); + + var analyzer = new FluentMapConfigurationAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + + return diagnostics + .Where(diagnostic => diagnostic.Id.StartsWith("DFM", StringComparison.Ordinal)) + .OrderBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location.SourceSpan.Start) + .ToList(); + } + + private static IReadOnlyList GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + + var explicitAssemblies = new[] + { + typeof(FluentMapper).Assembly.Location, + typeof(Dapper.SqlMapper).Assembly.Location + } + .Select(path => MetadataReference.CreateFromFile(path)); + + return trustedPlatformAssemblies + .Concat(explicitAssemblies) + .GroupBy(reference => reference.Display, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private static void AssertDiagnostic(Diagnostic diagnostic, DiagnosticSeverity severity, string messageFragment) + { + Assert.Equal(severity, diagnostic.Severity); + Assert.Contains(messageFragment, diagnostic.GetMessage(), StringComparison.Ordinal); + } + + private static void AssertDiagnosticLineContains(string source, Diagnostic diagnostic, string expectedLineFragment) + { + var line = diagnostic.Location.GetLineSpan().StartLinePosition.Line; + var sourceLine = source.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[line]; + + Assert.Contains(expectedLineFragment, sourceLine, StringComparison.Ordinal); + } + } +} diff --git a/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj new file mode 100644 index 0000000..d870c52 --- /dev/null +++ b/test/Dapper.FluentMap.AotSmoke/Dapper.FluentMap.AotSmoke.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + false + enable + enable + + + + + + ..\..\src\Dapper.FluentMap\bin\$(Configuration)\netstandard2.0\Dapper.FluentMap.dll + + + + + + + diff --git a/test/Dapper.FluentMap.AotSmoke/Program.cs b/test/Dapper.FluentMap.AotSmoke/Program.cs new file mode 100644 index 0000000..8c0c364 --- /dev/null +++ b/test/Dapper.FluentMap.AotSmoke/Program.cs @@ -0,0 +1,195 @@ +using System; +using System.Linq; +using Dapper; +using Dapper.FluentMap; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; + +#if AOT_SMOKE_GENERATED +const string scenario = "generated"; +FluentMapper.Initialize(configuration => +{ + configuration.AddGeneratedMappings(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); +}); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); +AssertConstructorMapping(); +AssertExplain(); +AssertValueObjectExplain(); +AssertProfileExplain(); +#elif AOT_SMOKE_SCANNING +const string scenario = "scanning"; +FluentMapper.Initialize(configuration => configuration.AddMapsFromAssemblyContaining()); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +#else +const string scenario = "explicit"; +FluentMapper.Initialize(configuration => +{ + configuration.AddMap(); + configuration.AddMap(); + configuration.AddMap(); + configuration.AddProfile(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); +}); + +AssertMappedMember("customer_id", nameof(Customer.Id)); +AssertMappedMember("created_at", nameof(NamingCustomer.CreatedAt)); +AssertConstructorMapping(); +AssertExplain(); +AssertValueObjectExplain(); +AssertProfileExplain(); +#endif + +Console.WriteLine(scenario + ":ok"); + +static void AssertMappedMember(string columnName, string propertyName) +{ + var member = SqlMapper.GetTypeMap(typeof(TEntity)).GetMember(columnName); + if (member?.Property?.Name != propertyName) + { + throw new InvalidOperationException( + $"Column '{columnName}' was not mapped to property '{propertyName}'."); + } +} + +#if !AOT_SMOKE_SCANNING +static void AssertConstructorMapping() +{ + var typeMap = SqlMapper.GetTypeMap(typeof(ImmutableCustomer)); + var constructor = typeMap.FindConstructor( + new[] { "customer_id", "name" }, + new[] { typeof(int), typeof(string) }); + + if (constructor == null) + { + throw new InvalidOperationException("Constructor mapping was not resolved."); + } + + var member = typeMap.GetConstructorParameter(constructor, "customer_id"); + if (member?.Parameter?.Name != "id") + { + throw new InvalidOperationException("Constructor parameter mapping was not resolved."); + } +} + +static void AssertExplain() +{ + var explanation = FluentMapper.Explain(); + if (!explanation.Members.Any(member => + member.MemberPath == nameof(Customer.Id) && + member.ColumnName == "customer_id")) + { + throw new InvalidOperationException("Explain did not include the explicit mapping."); + } +} + +static void AssertValueObjectExplain() +{ + var explanation = FluentMapper.Explain(); + if (!explanation.Members.Any(member => + member.MemberPath == "Cpf.Number" && + member.ColumnName == "cpf" && + member.Materialization == MappingMaterialization.ValueObject)) + { + throw new InvalidOperationException("Explain did not include the value object mapping."); + } +} + +static void AssertProfileExplain() +{ + var explanation = FluentMapper.Explain(); + if (explanation.ProfileType != typeof(LegacyProfile) || + !explanation.Members.Any(member => + member.MemberPath == nameof(Customer.Id) && + member.ColumnName == "legacy_id")) + { + throw new InvalidOperationException("Explain did not include the profile mapping."); + } +} +#endif + +public sealed class Customer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } +} + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + } +} + +public sealed class NamingCustomer +{ + public DateTime CreatedAt { get; set; } +} + +public sealed class ImmutableCustomer +{ + public ImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } +} + +public sealed class ImmutableCustomerMap : EntityMap +{ + public ImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("name"); + } +} + +public sealed class ValueObjectCustomer +{ + public ValueObjectCustomer(Cpf cpf) + { + Cpf = cpf; + } + + public Cpf Cpf { get; } +} + +public sealed class Cpf +{ + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } +} + +public sealed class ValueObjectCustomerMap : EntityMap +{ + public ValueObjectCustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } +} diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj new file mode 100644 index 0000000..f7b3468 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/Dapper.FluentMap.GeneratedRegistration.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs new file mode 100644 index 0000000..4aaacb1 --- /dev/null +++ b/test/Dapper.FluentMap.GeneratedRegistration.Tests/GeneratedRegistrationIntegrationTests.cs @@ -0,0 +1,180 @@ +using System; +using Dapper; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.GeneratedRegistration.Tests +{ + public sealed class GeneratedRegistrationIntegrationTests + { + [Fact] + [Trait("Category", "Integration")] + public void GeneratedRegistrationShouldWorkWithDapperAndExistingMappingFeatures() + { + ResetMapper(); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddGeneratedMappings(); + configuration.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 7 AS customer_id, 'Ada' AS Name;"); + var internalCustomer = connection.QuerySingle( + "SELECT 8 AS internal_id;"); + var derived = connection.QuerySingle( + "SELECT 9 AS base_id, 'Lovelace' AS derived_name;"); + var immutable = connection.QuerySingle( + "SELECT 10 AS immutable_id, 'Grace' AS name;"); + var named = connection.QuerySingle( + "SELECT '2026-07-26T10:30:00' AS created_at;"); + var profiled = connection.QueryMappedSingle( + "SELECT 11 AS legacy_id, 'Profiled' AS legacy_name;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("Ada", customer.Name); + Assert.Equal(8, internalCustomer.Id); + Assert.Equal(9, derived.Id); + Assert.Equal("Lovelace", derived.Name); + Assert.Equal(10, immutable.Id); + Assert.Equal("Grace", immutable.Name); + Assert.Equal(new DateTime(2026, 7, 26, 10, 30, 0), named.CreatedAt); + Assert.Equal(11, profiled.Id); + Assert.Equal("Profiled", profiled.Name); + } + } + finally + { + ResetMapper(); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper() + { + FluentMapper.Reset( + typeof(GeneratedCustomer), + typeof(GeneratedInternalCustomer), + typeof(GeneratedBaseCustomer), + typeof(GeneratedDerivedCustomer), + typeof(GeneratedImmutableCustomer), + typeof(GeneratedNamingCustomer), + typeof(GeneratedProfileCustomer)); + } + } + + public sealed class GeneratedCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public sealed class GeneratedCustomerMap : EntityMap + { + public GeneratedCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } + } + + internal sealed class GeneratedInternalCustomer + { + public int Id { get; set; } + } + + internal sealed class GeneratedInternalCustomerMap : EntityMap + { + public GeneratedInternalCustomerMap() + { + Map(customer => customer.Id).ToColumn("internal_id"); + } + } + + public class GeneratedBaseCustomer + { + public int Id { get; set; } + } + + public sealed class GeneratedDerivedCustomer : GeneratedBaseCustomer + { + public string Name { get; set; } + } + + public sealed class GeneratedBaseCustomerMap : EntityMap + { + public GeneratedBaseCustomerMap() + { + Map(customer => customer.Id).ToColumn("base_id"); + } + } + + public sealed class GeneratedDerivedCustomerMap : EntityMap + { + public GeneratedDerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Name).ToColumn("derived_name"); + } + } + + public sealed class GeneratedImmutableCustomer + { + public GeneratedImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + public sealed class GeneratedImmutableCustomerMap : EntityMap + { + public GeneratedImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("immutable_id"); + Map(customer => customer.Name).ToColumn("name"); + } + } + + public sealed class GeneratedNamingCustomer + { + public DateTime CreatedAt { get; set; } + } + + public sealed class GeneratedLegacyProfile : IMappingProfile + { + } + + public sealed class GeneratedProfileCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public sealed class GeneratedLegacyProfileCustomerMap : EntityMap, IProfileMap + { + public GeneratedLegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } +} diff --git a/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj new file mode 100644 index 0000000..e1e7a3b --- /dev/null +++ b/test/Dapper.FluentMap.Generators.Tests/Dapper.FluentMap.Generators.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs new file mode 100644 index 0000000..71ab96f --- /dev/null +++ b/test/Dapper.FluentMap.Generators.Tests/MappingRegistrationGeneratorTests.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Dapper.FluentMap; +using Dapper.FluentMap.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Dapper.FluentMap.Generators.Tests +{ + public sealed class MappingRegistrationGeneratorTests + { + [Fact] + public void ZeroMappingsShouldGenerateNoOpRegistration() + { + var source = @" +using Dapper.FluentMap; + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains("return configuration;", result.GeneratedSource, StringComparison.Ordinal); + Assert.DoesNotContain(".AddMap<", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void OneMappingShouldGenerateExplicitAddMapCall() + { + var source = @" +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void MultipleMappingsShouldBeGeneratedInDeterministicOrder() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Order +{ + public int Id { get; set; } +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class ZOrderMap : EntityMap +{ + public ZOrderMap() + { + Map(order => order.Id).ToColumn(""order_id""); + } +} + +public sealed class ACustomerMap : EntityMap +{ + public ACustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.True( + result.GeneratedSource.IndexOf("ACustomerMap", StringComparison.Ordinal) < + result.GeneratedSource.IndexOf("ZOrderMap", StringComparison.Ordinal)); + } + + [Fact] + public void InternalMappingShouldBeSupported() + { + var source = @" +using Dapper.FluentMap.Mapping; + +internal sealed class InternalCustomer +{ + public int Id { get; set; } +} + +internal sealed class InternalCustomerMap : EntityMap +{ + public InternalCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void AbstractMappingShouldReportSkippedDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public abstract class CustomerMapBase : EntityMap +{ +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Info, diagnostic.Severity); + Assert.Contains("abstract", diagnostic.GetMessage(), StringComparison.Ordinal); + Assert.DoesNotContain(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void OpenGenericMappingShouldReportSkippedDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class GenericCustomerMap : EntityMap +{ + public GenericCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var result = RunGenerator(source); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.SkippedGeneratedMapDiagnosticId, diagnostic.Id); + Assert.Contains("open generic", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void DuplicateEntityMappingsShouldReportDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstCustomerMap : EntityMap +{ + public FirstCustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class SecondCustomerMap : EntityMap +{ + public SecondCustomerMap() + { + Map(customer => customer.Id).ToColumn(""other_id""); + } +}"; + + var result = RunGenerator(source, assertCompiles: false); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.DuplicateGeneratedEntityMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("multiple generated entity maps", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void ProfileMappingShouldGenerateAddProfileCall() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class LegacyCustomerMap : EntityMap, IProfileMap +{ + public LegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""legacy_id""); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddProfile()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void DuplicateProfileMappingsShouldReportDiagnostic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class LegacyProfile : IMappingProfile +{ +} + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class FirstLegacyCustomerMap : EntityMap, IProfileMap +{ + public FirstLegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""legacy_id""); + } +} + +public sealed class SecondLegacyCustomerMap : EntityMap, IProfileMap +{ + public SecondLegacyCustomerMap() + { + Map(customer => customer.Id).ToColumn(""other_legacy_id""); + } +}"; + + var result = RunGenerator(source, assertCompiles: false); + var diagnostic = Assert.Single(result.DfmDiagnostics); + + Assert.Equal(MappingRegistrationGenerator.DuplicateGeneratedProfileMapDiagnosticId, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("multiple generated maps for profile", diagnostic.GetMessage(), StringComparison.Ordinal); + } + + [Fact] + public void DistinctNamespacesShouldGenerateFullyQualifiedNames() + { + var source = @" +using Dapper.FluentMap.Mapping; + +namespace Sales +{ + public sealed class Customer + { + public int Id { get; set; } + } + + public sealed class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""sales_customer_id""); + } + } +} + +namespace Support +{ + public sealed class Ticket + { + public int Id { get; set; } + } + + public sealed class TicketMap : EntityMap + { + public TicketMap() + { + Map(ticket => ticket.Id).ToColumn(""ticket_id""); + } + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void SameMapClassNameInDifferentNamespacesShouldGenerateBothMappings() + { + var source = @" +using Dapper.FluentMap.Mapping; + +namespace Sales +{ + public sealed class Customer + { + public int Id { get; set; } + } + + public sealed class EntityMap : Dapper.FluentMap.Mapping.EntityMap + { + public EntityMap() + { + Map(customer => customer.Id).ToColumn(""sales_customer_id""); + } + } +} + +namespace Support +{ + public sealed class Ticket + { + public int Id { get; set; } + } + + public sealed class EntityMap : Dapper.FluentMap.Mapping.EntityMap + { + public EntityMap() + { + Map(ticket => ticket.Id).ToColumn(""ticket_id""); + } + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.DfmDiagnostics); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + Assert.Contains(".AddMap()", result.GeneratedSource, StringComparison.Ordinal); + } + + [Fact] + public void GeneratedOutputShouldBeDeterministic() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + + var first = RunGenerator(source); + var second = RunGenerator(source); + + Assert.Equal(first.GeneratedSource, second.GeneratedSource); + } + + [Fact] + public void IncrementalGeneratorShouldProduceStableOutputAcrossRepeatedRuns() + { + var source = @" +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +}"; + var compilation = CreateCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MappingRegistrationGenerator()); + + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + var first = GetGeneratedSource(driver); + + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + var second = GetGeneratedSource(driver); + + Assert.Equal(first, second); + } + + [Fact] + public void GeneratedRegistrationSourceShouldCompileWithConsumerCode() + { + var source = @" +using Dapper.FluentMap; +using Dapper.FluentMap.Mapping; + +public sealed class Customer +{ + public int Id { get; set; } +} + +public sealed class CustomerMap : EntityMap +{ + public CustomerMap() + { + Map(customer => customer.Id).ToColumn(""customer_id""); + } +} + +public sealed class Startup +{ + public void Configure() + { + FluentMapper.Initialize(configuration => configuration.AddGeneratedMappings()); + } +}"; + + var result = RunGenerator(source); + + Assert.Empty(result.CompilerErrors); + Assert.Contains("AddGeneratedMappings", result.GeneratedSource, StringComparison.Ordinal); + } + + private static GeneratorTestResult RunGenerator(string source, bool assertCompiles = true) + { + var compilation = CreateCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MappingRegistrationGenerator()); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics, + TestContext.Current.CancellationToken); + + var compilerErrors = outputCompilation + .GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()) + .ToList(); + + if (assertCompiles) + { + Assert.Empty(compilerErrors); + } + + return new GeneratorTestResult( + GetGeneratedSource(driver), + generatorDiagnostics + .Where(diagnostic => diagnostic.Id.StartsWith("DFM", StringComparison.Ordinal)) + .OrderBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location.SourceSpan.Start) + .ToList(), + compilerErrors); + } + + private static CSharpCompilation CreateCompilation(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + path: "Test0.cs"); + + return CSharpCompilation.Create( + "GeneratorTest", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static string GetGeneratedSource(GeneratorDriver driver) + { + var runResult = driver.GetRunResult(); + var generatorResult = Assert.Single(runResult.Results); + var generatedSource = Assert.Single( + generatorResult.GeneratedSources, + source => source.HintName == "DapperFluentMapGeneratedRegistration.g.cs"); + + return generatedSource.SourceText.ToString(); + } + + private static IReadOnlyList GetMetadataReferences() + { + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + + var explicitAssemblies = new[] + { + typeof(FluentMapper).Assembly.Location, + typeof(Dapper.SqlMapper).Assembly.Location + } + .Select(path => MetadataReference.CreateFromFile(path)); + + return trustedPlatformAssemblies + .Concat(explicitAssemblies) + .GroupBy(reference => reference.Display, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private sealed class GeneratorTestResult + { + internal GeneratorTestResult( + string generatedSource, + IReadOnlyList dfmDiagnostics, + IReadOnlyList compilerErrors) + { + GeneratedSource = generatedSource; + DfmDiagnostics = dfmDiagnostics; + CompilerErrors = compilerErrors; + } + + internal string GeneratedSource { get; } + + internal IReadOnlyList DfmDiagnostics { get; } + + internal IReadOnlyList CompilerErrors { get; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs new file mode 100644 index 0000000..d9e206a --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConfigurationLifecycleTests.cs @@ -0,0 +1,149 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConfigurationLifecycleTests + { + [Fact] + public void InitializeShouldAllowAdditiveConfigurationAcrossRepeatedCalls() + { + ResetMapper(typeof(FirstLifecycleEntity), typeof(SecondLifecycleEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new FirstLifecycleMap())); + FluentMapper.Initialize(c => c.AddMap(new SecondLifecycleMap())); + + Assert.IsType(FluentMapper.EntityMaps[typeof(FirstLifecycleEntity)]); + Assert.IsType(FluentMapper.EntityMaps[typeof(SecondLifecycleEntity)]); + Assert.Equal(2, FluentMapper.EntityMaps.Count); + } + finally + { + ResetMapper(typeof(FirstLifecycleEntity), typeof(SecondLifecycleEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void RuntimeRegistrationShouldRemainCompatibleWhenAccessIsSerialized() + { + ResetMapper(typeof(RuntimeConventionEntity)); + + try + { + using (var connection = OpenConnection()) + { + var beforeConfiguration = connection.QuerySingle( + "SELECT 1 AS Id, 'before' AS Name;"); + + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + var afterConfiguration = connection.QuerySingle( + "SELECT 2 AS cfgId, 'after' AS cfgName;"); + + Assert.Equal(1, beforeConfiguration.Id); + Assert.Equal("before", beforeConfiguration.Name); + Assert.Equal(2, afterConfiguration.Id); + Assert.Equal("after", afterConfiguration.Name); + } + } + finally + { + ResetMapper(typeof(RuntimeConventionEntity)); + } + } + + [Fact] + public void DirectEntityMapsMutationShouldRemainLegacySurfaceAndBypassDapperTypeMapInstallation() + { + ResetMapper(typeof(DirectMutationEntity)); + + try + { + var added = FluentMapper.EntityMaps.TryAdd(typeof(DirectMutationEntity), new DirectMutationMap()); + var member = SqlMapper.GetTypeMap(typeof(DirectMutationEntity)).GetMember("legacy_id"); + + Assert.True(added); + Assert.Null(member); + Assert.IsType(FluentMapper.EntityMaps[typeof(DirectMutationEntity)]); + } + finally + { + ResetMapper(typeof(DirectMutationEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class FirstLifecycleEntity + { + public int Id { get; set; } + } + + private sealed class FirstLifecycleMap : EntityMap + { + public FirstLifecycleMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class SecondLifecycleEntity + { + public string Name { get; set; } + } + + private sealed class SecondLifecycleMap : EntityMap + { + public SecondLifecycleMap() + { + Map(entity => entity.Name).ToColumn("second_name"); + } + } + + private sealed class RuntimeConventionEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class RuntimePrefixConvention : Convention + { + public RuntimePrefixConvention() + { + Properties() + .Configure(configuration => configuration.HasPrefix("cfg")); + } + } + + private sealed class DirectMutationEntity + { + public int Id { get; set; } + } + + private sealed class DirectMutationMap : EntityMap + { + public DirectMutationMap() + { + Map(entity => entity.Id).ToColumn("legacy_id"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs new file mode 100644 index 0000000..0c90802 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConfigurationValidationTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConfigurationValidationTests + { + [Fact] + public void ValidConfigurationShouldRegisterEntityMap() + { + PreTest(typeof(ValidEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ValidMap())); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ValidEntity))); + } + + [Fact] + public void DuplicateMemberPathShouldThrowConfigurationException() + { + PreTest(typeof(NestedLevelEntity)); + + var exception = Assert.Throws(() => new DuplicateNestedLevelMap()); + + Assert.Contains("Rank.Level", exception.Message); + Assert.Contains(typeof(NestedLevelEntity).FullName, exception.Message); + } + + [Fact] + public void DistinctPathsWithSameTerminalNameShouldRemainValid() + { + PreTest(typeof(NestedLevelEntity)); + + var map = new DistinctNestedLevelMap(); + + Assert.Equal(2, map.PropertyMaps.Count); + } + + [Fact] + public void DuplicateEntityMapRegistrationShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ValidMap())); + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new AlternateValidMap()))); + + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains("already has a configured entity map", exception.Message); + } + + [Fact] + public void ExplicitColumnConflictShouldThrowConfigurationException() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new ColumnConflictMap()))); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Id), exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Name), exception.Message); + Assert.Contains(typeof(ColumnConflictEntity).FullName, exception.Message); + } + + [Fact] + public void CaseSensitivityColumnConflictShouldThrowConfigurationException() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new CaseSensitivityConflictMap()))); + + Assert.Contains("case sensitivity", exception.Message); + Assert.Contains("shared_column", exception.Message); + Assert.Contains(typeof(ColumnConflictEntity).FullName, exception.Message); + } + + [Fact] + public void AmbiguousConventionShouldThrowConfigurationExceptionDuringConfiguration() + { + PreTest(typeof(ColumnConflictEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddConvention().ForEntity())); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Id), exception.Message); + Assert.Contains(nameof(ColumnConflictEntity.Name), exception.Message); + Assert.Contains(typeof(AmbiguousConvention).FullName, exception.Message); + } + + [Fact] + public void InvalidExpressionShouldThrowArgumentExceptionWithUsefulMessage() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => new InvalidExpressionMap()); + + Assert.Contains("property path", exception.Message); + Assert.Contains("ToString", exception.Message); + } + + [Fact] + public void IncompatiblePropertyMetadataShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new IncompatibleMetadataMap()))); + + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains(typeof(ForeignMetadataEntity).FullName, exception.Message); + Assert.Contains("not compatible", exception.Message); + } + + [Fact] + public void ExternalPropertyMapsWithSameColumnShouldRemainValid() + { + PreTest(typeof(ColumnConflictEntity)); + + FluentMapper.Initialize(c => c.AddMap(new ExternalColumnReuseMap())); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ColumnConflictEntity))); + } + + [Fact] + public void ConventionWithoutConfigureShouldThrowConfigurationException() + { + PreTest(typeof(ValidEntity)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddConvention().ForEntity())); + + Assert.Contains(typeof(MissingConfigureConvention).FullName, exception.Message); + Assert.Contains(typeof(ValidEntity).FullName, exception.Message); + Assert.Contains("without configuration", exception.Message); + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class ValidEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ValidMap : EntityMap + { + public ValidMap() + { + Map(e => e.Id).ToColumn("valid_id"); + } + } + + private class AlternateValidMap : EntityMap + { + public AlternateValidMap() + { + Map(e => e.Name).ToColumn("valid_name"); + } + } + + private class InvalidExpressionMap : EntityMap + { + public InvalidExpressionMap() + { + Map(e => e.Id.ToString()).ToColumn("id_text"); + } + } + + private class ColumnConflictEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ColumnConflictMap : EntityMap + { + public ColumnConflictMap() + { + Map(e => e.Id).ToColumn("shared_column"); + Map(e => e.Name).ToColumn("shared_column"); + } + } + + private class CaseSensitivityConflictMap : EntityMap + { + public CaseSensitivityConflictMap() + { + Map(e => e.Id).ToColumn("shared_column", caseSensitive: false); + Map(e => e.Name).ToColumn("SHARED_COLUMN"); + } + } + + private class ExternalColumnReuseMap : IEntityMap + { + public ExternalColumnReuseMap() + { + var idMap = new ExternalPropertyMap(typeof(ColumnConflictEntity).GetProperty(nameof(ColumnConflictEntity.Id))) + .ToColumn("shared_column"); + var nameMap = new ExternalPropertyMap(typeof(ColumnConflictEntity).GetProperty(nameof(ColumnConflictEntity.Name))) + .ToColumn("shared_column"); + + PropertyMaps = new List { idMap, nameMap }; + } + + public IList PropertyMaps { get; } + } + + private class ExternalPropertyMap : PropertyMapBase, IPropertyMap + { + public ExternalPropertyMap(PropertyInfo info) + : base(info) + { + } + } + + private class AmbiguousConvention : Convention + { + public AmbiguousConvention() + { + Properties().Configure(c => c.HasColumnName("shared_column")); + } + } + + private class MissingConfigureConvention : Convention + { + public MissingConfigureConvention() + { + Properties(); + } + } + + private class NestedLevelEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + + private class DistinctNestedLevelMap : EntityMap + { + public DistinctNestedLevelMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class DuplicateNestedLevelMap : EntityMap + { + public DuplicateNestedLevelMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Rank.Level).ToColumn("rank_level_again"); + } + } + + private class ForeignMetadataEntity + { + public int Id { get; set; } + } + + private class IncompatibleMetadataMap : IEntityMap + { + public IncompatibleMetadataMap() + { + var foreignProperty = typeof(ForeignMetadataEntity).GetProperty(nameof(ForeignMetadataEntity.Id)); + PropertyMaps = new List { new PropertyMap(foreignProperty, "foreign_id") }; + } + + public IList PropertyMaps { get; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs b/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs new file mode 100644 index 0000000..0556b4c --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ConstructorMappingTests.cs @@ -0,0 +1,550 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ConstructorMappingTests + { + [Fact] + [Trait("Category", "Integration")] + public void TraditionalPocoShouldContinueMaterializingConfiguredColumn() + { + PreTest(typeof(TraditionalPoco)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new TraditionalPocoMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 1 AS person_id, 'Ada' AS Name;"); + + Assert.Equal(1, entity.Id); + Assert.Equal("Ada", entity.Name); + } + } + finally + { + PreTest(typeof(TraditionalPoco)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void PositionalRecordShouldMaterializeExplicitColumns() + { + PreTest(typeof(ExplicitRecord)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitRecordMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 2 AS person_id, 'Grace Hopper' AS full_name;"); + + Assert.Equal(2, entity.Id); + Assert.Equal("Grace Hopper", entity.FullName); + } + } + finally + { + PreTest(typeof(ExplicitRecord)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ImmutableClassShouldMaterializeExplicitColumns() + { + PreTest(typeof(ExplicitImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitImmutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 3 AS person_id, 'Katherine Johnson' AS full_name;"); + + Assert.Equal(3, entity.Id); + Assert.Equal("Katherine Johnson", entity.FullName); + } + } + finally + { + PreTest(typeof(ExplicitImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NamingPolicyShouldMaterializeConstructorParameters() + { + PreTest(typeof(PolicyImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 4 AS customer_id, 'Barbara Liskov' AS full_name;"); + + Assert.Equal(4, entity.CustomerId); + Assert.Equal("Barbara Liskov", entity.FullName); + } + } + finally + { + PreTest(typeof(PolicyImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ConventionShouldMaterializeConstructorParameters() + { + PreTest(typeof(ConventionImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 5 AS colId, 'Margaret Hamilton' AS colName;"); + + Assert.Equal(5, entity.Id); + Assert.Equal("Margaret Hamilton", entity.Name); + } + } + finally + { + PreTest(typeof(ConventionImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void MultipleConstructorsShouldUseMappedNamesForDapperSelection() + { + PreTest(typeof(MultipleConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new MultipleConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 6 AS person_id, 'Anita Borg' AS full_name;"); + + Assert.Equal(6, entity.Id); + Assert.Equal("Anita Borg", entity.FullName); + Assert.Equal("id-name", entity.ConstructorUsed); + } + } + finally + { + PreTest(typeof(MultipleConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ParameterlessConstructorShouldContinueUsingSettableProperties() + { + PreTest(typeof(ParameterlessAndSettableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ParameterlessAndSettableCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 7 AS person_id, 'Joan Clarke' AS full_name;"); + + Assert.Equal(7, entity.Id); + Assert.Equal("Joan Clarke", entity.FullName); + Assert.Equal("parameterless", entity.ConstructorUsed); + } + } + finally + { + PreTest(typeof(ParameterlessAndSettableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void CaseInsensitiveExplicitMappingShouldMaterializeConstructorParameter() + { + PreTest(typeof(CaseInsensitiveConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CaseInsensitiveConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 8 AS PERSON_ID;"); + + Assert.Equal(8, entity.Id); + } + } + finally + { + PreTest(typeof(CaseInsensitiveConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void ConstructorParameterMappingShouldFallbackToDapperDefault() + { + PreTest(typeof(PartialExplicitConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new PartialExplicitConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 9 AS Id, 'Radia Perlman' AS full_name;"); + + Assert.Equal(9L, entity.Id); + Assert.Equal("Radia Perlman", entity.FullName); + } + } + finally + { + PreTest(typeof(PartialExplicitConstructorCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void IncludedBaseMappingShouldMaterializeConstructorParameter() + { + PreTest(typeof(ImmutableBaseCustomer), typeof(ImmutableDerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ImmutableBaseCustomerMap()); + c.AddMap(new ImmutableDerivedCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 10 AS person_id, 'Evelyn Boyd Granville' AS Name;"); + + Assert.Equal(10, entity.Id); + Assert.Equal("Evelyn Boyd Granville", entity.Name); + } + } + finally + { + PreTest(typeof(ImmutableBaseCustomer), typeof(ImmutableDerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedMemberPathMappingShouldNotActAsConstructorParameterMapping() + { + PreTest(typeof(NestedPathConstructorCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedPathConstructorCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + connection.QuerySingle( + "SELECT 11 AS rank_level;")); + + Assert.Contains("constructor", exception.Message); + } + } + finally + { + PreTest(typeof(NestedPathConstructorCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params System.Type[] types) + { + FluentMapper.Reset(types); + } + + private class TraditionalPoco + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class TraditionalPocoMap : EntityMap + { + public TraditionalPocoMap() + { + Map(e => e.Id).ToColumn("person_id"); + } + } + + private sealed record ExplicitRecord(int Id, string FullName); + + private class ExplicitRecordMap : EntityMap + { + public ExplicitRecordMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class ExplicitImmutableCustomer + { + public ExplicitImmutableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private class ExplicitImmutableCustomerMap : EntityMap + { + public ExplicitImmutableCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class PolicyImmutableCustomer + { + public PolicyImmutableCustomer(int customerId, string fullName) + { + CustomerId = customerId; + FullName = fullName; + } + + public int CustomerId { get; } + + public string FullName { get; } + } + + private sealed class ConventionImmutableCustomer + { + public ConventionImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + private sealed class MultipleConstructorCustomer + { + public MultipleConstructorCustomer(int id) + { + Id = id; + ConstructorUsed = "id"; + } + + public MultipleConstructorCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + ConstructorUsed = "id-name"; + } + + public int Id { get; } + + public string FullName { get; } + + public string ConstructorUsed { get; } + } + + private class MultipleConstructorCustomerMap : EntityMap + { + public MultipleConstructorCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class ParameterlessAndSettableCustomer + { + public ParameterlessAndSettableCustomer() + { + ConstructorUsed = "parameterless"; + } + + public ParameterlessAndSettableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + ConstructorUsed = "id-name"; + } + + public int Id { get; set; } + + public string FullName { get; set; } + + public string ConstructorUsed { get; } + } + + private class ParameterlessAndSettableCustomerMap : EntityMap + { + public ParameterlessAndSettableCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private sealed class CaseInsensitiveConstructorCustomer + { + public CaseInsensitiveConstructorCustomer(int id) + { + Id = id; + } + + public int Id { get; } + } + + private class CaseInsensitiveConstructorCustomerMap : EntityMap + { + public CaseInsensitiveConstructorCustomerMap() + { + Map(e => e.Id).ToColumn("person_id", caseSensitive: false); + } + } + + private sealed class PartialExplicitConstructorCustomer + { + public PartialExplicitConstructorCustomer(long id, string fullName) + { + Id = id; + FullName = fullName; + } + + public long Id { get; } + + public string FullName { get; } + } + + private class PartialExplicitConstructorCustomerMap : EntityMap + { + public PartialExplicitConstructorCustomerMap() + { + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private class ImmutableBaseCustomer + { + public ImmutableBaseCustomer(int id) + { + Id = id; + } + + public int Id { get; } + } + + private sealed class ImmutableDerivedCustomer : ImmutableBaseCustomer + { + public ImmutableDerivedCustomer(int id, string name) + : base(id) + { + Name = name; + } + + public string Name { get; } + } + + private class ImmutableBaseCustomerMap : EntityMap + { + public ImmutableBaseCustomerMap() + { + Map(e => e.Id).ToColumn("person_id"); + } + } + + private class ImmutableDerivedCustomerMap : EntityMap + { + public ImmutableDerivedCustomerMap() + { + IncludeBase(); + } + } + + private sealed class NestedPathConstructorCustomer + { + public NestedPathConstructorCustomer(int level) + { + Level = level; + } + + public int Level { get; } + + public RankInfo Rank { get; set; } + } + + private sealed class RankInfo + { + public int Level { get; set; } + } + + private class NestedPathConstructorCustomerMap : EntityMap + { + public NestedPathConstructorCustomerMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + + private class PrefixConvention : Convention + { + public PrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs b/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs new file mode 100644 index 0000000..8978a2b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/DapperCompatibilityAdapterTests.cs @@ -0,0 +1,329 @@ +using System; +using Dapper; +using Dapper.FluentMap.Compatibility; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class DapperCompatibilityAdapterTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseRegisteredDapperTypeHandler() + { + PreTest(typeof(TypeHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new TypeHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '12345678909' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(TypeHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseRegisteredDapperTypeHandlerForNullableValue() + { + PreTest(typeof(NullableHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new SmallCodeTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NullableHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS code;"); + + Assert.True(customer.Code.HasValue); + Assert.Equal(7, customer.Code.Value.Value); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NullableHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldKeepNullableTypeHandlerValueNullWhenColumnIsDbNull() + { + PreTest(typeof(NullableHandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new SmallCodeTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NullableHandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS code;"); + + Assert.False(customer.Code.HasValue); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NullableHandlerCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDefaultConversionWhenNoTypeHandlerIsRegistered() + { + PreTest(typeof(DefaultConversionCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new DefaultConversionCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '42' AS customer_id;"); + + Assert.Equal(42, customer.Id); + } + } + finally + { + PreTest(typeof(DefaultConversionCustomer)); + } + } + + [Fact] + public void TypeHandlerBoundaryShouldFailWithDiagnosticWhenDapperCacheShapeIsMissing() + { + var exception = Assert.Throws( + () => DapperTypeHandlerAdapter.CreateConverter(typeof(Cpf), () => null)); + + Assert.Contains("Dapper TypeHandler compatibility failed", exception.Message); + Assert.Contains("TypeHandlerCache", exception.Message); + Assert.Contains("upgrading Dapper", exception.Message); + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotMapIgnoredRootPropertyOrFallbackToDefault() + { + PreTest(typeof(IgnoredRootCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredRootCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 99 AS Id, 'Ada' AS Name;"); + + Assert.Equal(0, customer.Id); + Assert.Equal("Ada", customer.Name); + } + } + finally + { + PreTest(typeof(IgnoredRootCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotMapIgnoredNestedPathOrFallbackToRootProperty() + { + PreTest(typeof(IgnoredNestedCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredNestedCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 'leaked' AS City;"); + + Assert.Null(customer.City); + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(IgnoredNestedCustomer)); + } + } + + [Fact] + public void TypeMapShouldReturnNullForIgnoredMemberWithoutThrowing() + { + PreTest(typeof(IgnoredRootCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new IgnoredRootCustomerMap())); + + var typeMap = SqlMapper.GetTypeMap(typeof(IgnoredRootCustomer)); + var member = typeMap.GetMember("Id"); + + Assert.Null(member); + } + finally + { + PreTest(typeof(IgnoredRootCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class TypeHandlerCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class TypeHandlerCustomerMap : EntityMap + { + public TypeHandlerCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed class NullableHandlerCustomer + { + public SmallCode? Code { get; set; } + } + + private sealed class NullableHandlerCustomerMap : EntityMap + { + public NullableHandlerCustomerMap() + { + Map(customer => customer.Code).ToColumn("code"); + } + } + + private readonly struct SmallCode + { + public SmallCode(int value) + { + Value = value; + } + + public int Value { get; } + } + + private sealed class SmallCodeTypeHandler : SqlMapper.TypeHandler + { + public override SmallCode Parse(object value) + { + return new SmallCode(Convert.ToInt32(value)); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, SmallCode value) + { + parameter.Value = value.Value; + } + } + + private sealed class DefaultConversionCustomer + { + public int Id { get; set; } + } + + private sealed class DefaultConversionCustomerMap : EntityMap + { + public DefaultConversionCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + } + } + + private sealed class IgnoredRootCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class IgnoredRootCustomerMap : EntityMap + { + public IgnoredRootCustomerMap() + { + Map(customer => customer.Id).Ignore(); + } + } + + private sealed class IgnoredNestedCustomer + { + public string City { get; set; } + + public IgnoredAddress Address { get; set; } + } + + private sealed class IgnoredAddress + { + public string City { get; set; } + } + + private sealed class IgnoredNestedCustomerMap : EntityMap + { + public IgnoredNestedCustomerMap() + { + Map(customer => customer.Address.City).Ignore(); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs new file mode 100644 index 0000000..05c31f2 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/DiagnosticsApiTests.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class DiagnosticsApiTests + { + [Fact] + public void ValidateShouldSucceedForValidConfigurationAndBeRepeatable() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + FluentMapper.Validate(); + FluentMapper.Validate(); + + Assert.True(FluentMapper.EntityMaps.ContainsKey(typeof(ExplicitDiagnosticEntity))); + Assert.Equal(0, FluentMapper.Registry.CacheEntryCount); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ValidateShouldAggregateErrorsFromCurrentConfiguration() + { + PreTest(typeof(InvalidEmptyColumnEntity), typeof(InvalidForeignMetadataEntity)); + + try + { + FluentMapper.EntityMaps.TryAdd(typeof(InvalidEmptyColumnEntity), new EmptyColumnMap()); + FluentMapper.EntityMaps.TryAdd(typeof(InvalidForeignMetadataEntity), new ForeignMetadataMap()); + + var exception = Assert.Throws(() => FluentMapper.Validate()); + + Assert.Contains("2 errors", exception.Message); + Assert.Contains(typeof(InvalidEmptyColumnEntity).FullName, exception.Message); + Assert.Contains(typeof(InvalidForeignMetadataEntity).FullName, exception.Message); + Assert.Contains("empty column name", exception.Message); + Assert.Contains("not compatible", exception.Message); + } + finally + { + PreTest(typeof(InvalidEmptyColumnEntity), typeof(InvalidForeignMetadataEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeExplicitMappingAndDapperFallback() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + + var id = SingleMember(explanation, nameof(ExplicitDiagnosticEntity.Id)); + var name = SingleMember(explanation, nameof(ExplicitDiagnosticEntity.Name)); + + Assert.Equal(typeof(ExplicitDiagnosticEntity), explanation.EntityType); + Assert.Equal(typeof(ExplicitDiagnosticMap), explanation.EntityMapType); + Assert.Equal("explicit_id", id.ColumnName); + Assert.Equal(MappingSource.Explicit, id.Source); + Assert.Equal("Name", name.ColumnName); + Assert.Equal(MappingSource.DapperDefault, name.Source); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeInheritedMappings() + { + PreTest(typeof(DiagnosticBaseEntity), typeof(DiagnosticDerivedEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DiagnosticBaseMap()); + c.AddMap(new DiagnosticDerivedMap()); + }); + + var explanation = FluentMapper.Explain(); + var id = SingleMember(explanation, nameof(DiagnosticBaseEntity.Id)); + + Assert.Equal("base_id", id.ColumnName); + Assert.Equal(MappingSource.Inherited, id.Source); + Assert.Equal(typeof(DiagnosticBaseEntity), id.InheritedFrom); + } + finally + { + PreTest(typeof(DiagnosticBaseEntity), typeof(DiagnosticDerivedEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeConventionMappings() + { + PreTest(typeof(ConventionDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddConvention().ForEntity()); + + var explanation = FluentMapper.Explain(); + var name = SingleMember(explanation, nameof(ConventionDiagnosticEntity.Name)); + + Assert.Equal("colName", name.ColumnName); + Assert.Equal(MappingSource.Convention, name.Source); + Assert.Equal(typeof(DiagnosticPrefixConvention), name.ConventionType); + Assert.Contains(typeof(DiagnosticPrefixConvention), explanation.ConventionTypes); + } + finally + { + PreTest(typeof(ConventionDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeNamingPolicyMappings() + { + PreTest(typeof(PolicyDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity()); + + var explanation = FluentMapper.Explain(); + var customerId = SingleMember(explanation, nameof(PolicyDiagnosticEntity.CustomerId)); + + Assert.Equal("customer_id", customerId.ColumnName); + Assert.Equal(MappingSource.NamingPolicy, customerId.Source); + Assert.False(customerId.CaseSensitive); + Assert.NotNull(customerId.ConventionType); + } + finally + { + PreTest(typeof(PolicyDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeConstructorParameterDestinations() + { + PreTest(typeof(ImmutableDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ImmutableDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + var fullName = SingleMember(explanation, nameof(ImmutableDiagnosticEntity.FullName)); + + Assert.Equal("full_name", fullName.ColumnName); + Assert.Equal(MappingSource.Explicit, fullName.Source); + Assert.Contains(fullName.ConstructorParameters, p => p.Name == "fullName" && p.ParameterType == typeof(string)); + } + finally + { + PreTest(typeof(ImmutableDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDescribeUnconfiguredEntityWithDapperDefaultFallback() + { + PreTest(typeof(UnconfiguredDiagnosticEntity)); + + try + { + var explanation = FluentMapper.Explain(); + var createdAt = SingleMember(explanation, nameof(UnconfiguredDiagnosticEntity.CreatedAt)); + + Assert.Null(explanation.EntityMapType); + Assert.Empty(explanation.ConventionTypes); + Assert.Contains("Dapper default mapping", explanation.Diagnostics.Single()); + Assert.Equal("CreatedAt", createdAt.ColumnName); + Assert.Equal(MappingSource.DapperDefault, createdAt.Source); + } + finally + { + PreTest(typeof(UnconfiguredDiagnosticEntity)); + } + } + + [Fact] + public void ExplainShouldDistinguishSameTerminalMemberPath() + { + PreTest(typeof(NestedDiagnosticsEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedDiagnosticsMap())); + + var explanation = FluentMapper.Explain(); + + Assert.Contains(explanation.Members, m => m.MemberPath == "Rank.Level" && m.ColumnName == "rank_level"); + Assert.Contains(explanation.Members, m => m.MemberPath == "Seniority.Level" && m.ColumnName == "seniority_level"); + } + finally + { + PreTest(typeof(NestedDiagnosticsEntity)); + } + } + + [Fact] + public void ExplainMetadataShouldBeReadOnlySnapshots() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var explanation = FluentMapper.Explain(); + var members = Assert.IsAssignableFrom>(explanation.Members); + var conventionTypes = Assert.IsAssignableFrom>(explanation.ConventionTypes); + + Assert.True(members.IsReadOnly); + Assert.True(conventionTypes.IsReadOnly); + Assert.Throws(() => members.Add(explanation.Members[0])); + Assert.Throws(() => conventionTypes.Add(typeof(DiagnosticPrefixConvention))); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + [Fact] + public void ExplainRepeatedCallsShouldBeConsistentAndAvoidCacheSideEffects() + { + PreTest(typeof(ExplicitDiagnosticEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ExplicitDiagnosticMap())); + + var first = FluentMapper.Explain(); + var second = FluentMapper.Explain(); + + Assert.Equal( + first.Members.Select(m => m.MemberPath + ":" + m.ColumnName + ":" + m.Source), + second.Members.Select(m => m.MemberPath + ":" + m.ColumnName + ":" + m.Source)); + Assert.Equal(0, FluentMapper.Registry.CacheEntryCount); + } + finally + { + PreTest(typeof(ExplicitDiagnosticEntity)); + } + } + + private static MemberMappingExplanation SingleMember(MappingExplanation explanation, string memberPath) + { + return explanation.Members.Single(m => m.MemberPath == memberPath); + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class ExplicitDiagnosticEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class ExplicitDiagnosticMap : EntityMap + { + public ExplicitDiagnosticMap() + { + Map(e => e.Id).ToColumn("explicit_id"); + } + } + + private class DiagnosticBaseEntity + { + public int Id { get; set; } + } + + private class DiagnosticDerivedEntity : DiagnosticBaseEntity + { + public string Name { get; set; } + } + + private class DiagnosticBaseMap : EntityMap + { + public DiagnosticBaseMap() + { + Map(e => e.Id).ToColumn("base_id"); + } + } + + private class DiagnosticDerivedMap : EntityMap + { + public DiagnosticDerivedMap() + { + IncludeBase(); + } + } + + private class ConventionDiagnosticEntity + { + public string Name { get; set; } + } + + private class DiagnosticPrefixConvention : Convention + { + public DiagnosticPrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + + private class PolicyDiagnosticEntity + { + public int CustomerId { get; set; } + } + + private class ImmutableDiagnosticEntity + { + public ImmutableDiagnosticEntity(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private class ImmutableDiagnosticMap : EntityMap + { + public ImmutableDiagnosticMap() + { + Map(e => e.Id).ToColumn("person_id"); + Map(e => e.FullName).ToColumn("full_name"); + } + } + + private class UnconfiguredDiagnosticEntity + { + public DateTime CreatedAt { get; set; } + } + + private class NestedDiagnosticsEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + + private class NestedDiagnosticsMap : EntityMap + { + public NestedDiagnosticsMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + Map(e => e.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class InvalidEmptyColumnEntity + { + public int Id { get; set; } + } + + private class InvalidForeignMetadataEntity + { + public int Id { get; set; } + } + + private class ForeignEntity + { + public int Id { get; set; } + } + + private class EmptyColumnMap : IEntityMap + { + public EmptyColumnMap() + { + PropertyMaps = new List + { + new PropertyMap(typeof(InvalidEmptyColumnEntity).GetProperty(nameof(InvalidEmptyColumnEntity.Id)), string.Empty) + }; + } + + public IList PropertyMaps { get; } + } + + private class ForeignMetadataMap : IEntityMap + { + public ForeignMetadataMap() + { + PropertyMaps = new List + { + new PropertyMap(typeof(ForeignEntity).GetProperty(nameof(ForeignEntity.Id)), "foreign_id") + }; + } + + public IList PropertyMaps { get; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs b/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs new file mode 100644 index 0000000..4b8ddfc --- /dev/null +++ b/test/Dapper.FluentMap.Tests/GeneratedMaterializerSpikeTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Data; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class GeneratedMaterializerSpikeTests + { + [Fact] + public void GeneratedLikeMaterializerShouldMaterializeSimpleEntityWithNestedMutableObjectAndDbNull() + { + using (var reader = CreateReader( + new[] { "customer_id", "full_name", "city", "note" }, + new object[] { 1, "Ada Lovelace", "London", DBNull.Value }, + new object[] { 2, "Grace Hopper", DBNull.Value, "compiler" })) + { + Assert.True(reader.Read()); + var first = GeneratedCustomerMaterializer.ReadDefault(reader); + + Assert.Equal(1, first.Id); + Assert.Equal("Ada Lovelace", first.Name); + Assert.NotNull(first.Address); + Assert.Equal("London", first.Address.City); + Assert.Null(first.Note); + + Assert.True(reader.Read()); + var second = GeneratedCustomerMaterializer.ReadDefault(reader); + + Assert.Equal(2, second.Id); + Assert.Equal("Grace Hopper", second.Name); + Assert.Null(second.Address); + Assert.Equal("compiler", second.Note); + } + } + + [Fact] + public void GeneratedLikeMaterializerShouldSupportImmutableValueObjectConstructorAndProfiles() + { + using (var reader = CreateReader( + new[] { "legacy_id", "legacy_cpf", "legal_name" }, + new object[] { 7, "12345678909", "Legacy Ada" }, + new object[] { 8, DBNull.Value, "Legacy Grace" })) + { + Assert.True(reader.Read()); + var first = GeneratedCustomerMaterializer.ReadLegacyProfile(reader); + + Assert.Equal(7, first.Id); + Assert.Equal("Legacy Ada", first.Name); + Assert.NotNull(first.Cpf); + Assert.Equal("12345678909", first.Cpf.Number); + + Assert.True(reader.Read()); + var second = GeneratedCustomerMaterializer.ReadLegacyProfile(reader); + + Assert.Equal(8, second.Id); + Assert.Equal("Legacy Grace", second.Name); + Assert.Null(second.Cpf); + } + } + + private static IDataReader CreateReader(string[] columns, params object[][] rows) + { + var table = new DataTable(); + foreach (var column in columns) + { + table.Columns.Add(column, typeof(object)); + } + + foreach (var row in rows) + { + table.Rows.Add(row); + } + + return table.CreateDataReader(); + } + + private static class GeneratedCustomerMaterializer + { + internal static GeneratedCustomer ReadDefault(IDataRecord record) + { + var customer = new GeneratedCustomer + { + Id = ReadInt32(record, 0), + Name = ReadString(record, 1), + Note = ReadString(record, 3) + }; + + if (!record.IsDBNull(2)) + { + customer.Address = new GeneratedAddress + { + City = ReadString(record, 2) + }; + } + + return customer; + } + + internal static GeneratedCustomer ReadLegacyProfile(IDataRecord record) + { + return new GeneratedCustomer( + ReadInt32(record, 0), + record.IsDBNull(1) ? null : new GeneratedCpf(ReadString(record, 1)), + ReadString(record, 2)); + } + + private static int ReadInt32(IDataRecord record, int ordinal) + { + return record.IsDBNull(ordinal) ? default : Convert.ToInt32(record.GetValue(ordinal)); + } + + private static string ReadString(IDataRecord record, int ordinal) + { + return record.IsDBNull(ordinal) ? null : Convert.ToString(record.GetValue(ordinal)); + } + } + + private sealed class GeneratedCustomer + { + public GeneratedCustomer() + { + } + + public GeneratedCustomer(int id, GeneratedCpf cpf, string name) + { + Id = id; + Cpf = cpf; + Name = name; + } + + public int Id { get; set; } + + public string Name { get; set; } + + public string Note { get; set; } + + public GeneratedAddress Address { get; set; } + + public GeneratedCpf Cpf { get; } + } + + private sealed class GeneratedAddress + { + public string City { get; set; } + } + + private sealed class GeneratedCpf + { + public GeneratedCpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs new file mode 100644 index 0000000..811c5b8 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/InheritedMappingTests.cs @@ -0,0 +1,535 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class InheritedMappingTests + { + [Fact] + public void IncludedBaseMappingShouldResolveInheritedProperty() + { + PreTest(typeof(SimpleBaseUser), typeof(SimpleAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new SimpleBaseUserMap()); + c.AddMap(new SimpleAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(SimpleAdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(SimpleBaseUser).GetProperty(nameof(SimpleBaseUser.Id)), member.Property); + } + + [Fact] + public void DerivedMappingShouldResolveOwnPropertyWithIncludedBase() + { + PreTest(typeof(DerivedPropertyBaseUser), typeof(DerivedPropertyAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new DerivedPropertyBaseUserMap()); + c.AddMap(new DerivedPropertyAdminUserMap()); + }); + + var baseMember = SqlMapper.GetTypeMap(typeof(DerivedPropertyAdminUser)).GetMember("user_id"); + var derivedMember = SqlMapper.GetTypeMap(typeof(DerivedPropertyAdminUser)).GetMember("admin_permission"); + + Assert.NotNull(baseMember); + Assert.NotNull(derivedMember); + Assert.Equal(typeof(DerivedPropertyBaseUser).GetProperty(nameof(DerivedPropertyBaseUser.Id)), baseMember.Property); + Assert.Equal(typeof(DerivedPropertyAdminUser).GetProperty(nameof(DerivedPropertyAdminUser.Permission)), derivedMember.Property); + } + + [Fact] + public void DerivedMappingShouldOverrideIncludedBaseForSameMemberPath() + { + PreTest(typeof(OverrideBaseUser), typeof(OverrideAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new OverrideBaseUserMap()); + c.AddMap(new OverrideAdminUserMap()); + }); + + var derivedMember = SqlMapper.GetTypeMap(typeof(OverrideAdminUser)).GetMember("admin_id"); + var baseMember = SqlMapper.GetTypeMap(typeof(OverrideAdminUser)).GetMember("user_id"); + + Assert.NotNull(derivedMember); + Assert.Null(baseMember); + Assert.Equal(typeof(OverrideBaseUser).GetProperty(nameof(OverrideBaseUser.Id)), derivedMember.Property); + } + + [Fact] + public void IncludedBaseMappingShouldTakePrecedenceOverConventionForSameMemberPath() + { + PreTest(typeof(ConventionBaseUser), typeof(ConventionAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new ConventionBaseUserMap()); + c.AddMap(new ConventionAdminUserMap()); + c.AddConvention().ForEntity(); + }); + + var inheritedExplicitMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("user_id"); + var conventionForInheritedMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("colId"); + var conventionForDerivedMember = SqlMapper.GetTypeMap(typeof(ConventionAdminUser)).GetMember("colPermission"); + + Assert.NotNull(inheritedExplicitMember); + Assert.Null(conventionForInheritedMember); + Assert.NotNull(conventionForDerivedMember); + Assert.Equal(typeof(ConventionBaseUser).GetProperty(nameof(ConventionBaseUser.Id)), inheritedExplicitMember.Property); + Assert.Equal(typeof(ConventionAdminUser).GetProperty(nameof(ConventionAdminUser.Permission)), conventionForDerivedMember.Property); + } + + [Fact] + public void IncludedBaseMappingShouldPreserveInheritedMemberPath() + { + PreTest(typeof(MemberPathBaseUser), typeof(MemberPathAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new MemberPathBaseUserMap()); + c.AddMap(new MemberPathAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(MemberPathAdminUser)).GetMember("rank_level"); + var explanation = FluentMapper.Explain(); + + Assert.Null(member); + Assert.Contains( + explanation.Members, + m => m.MemberPath == "Rank.Level" && + m.ColumnName == "rank_level" && + m.PropertyInfo == typeof(InheritedRankInfo).GetProperty(nameof(InheritedRankInfo.Level))); + } + + [Fact] + public void MultipleInheritanceLevelsShouldComposeNearestMappingsBeforeBaseMappings() + { + PreTest(typeof(MultiLevelBaseUser), typeof(MultiLevelStaffUser), typeof(MultiLevelAdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new MultiLevelBaseUserMap()); + c.AddMap(new MultiLevelStaffUserMap()); + c.AddMap(new MultiLevelAdminUserMap()); + }); + + var baseMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("user_id"); + var intermediateMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("staff_code"); + var derivedMember = SqlMapper.GetTypeMap(typeof(MultiLevelAdminUser)).GetMember("admin_permission"); + + Assert.NotNull(baseMember); + Assert.NotNull(intermediateMember); + Assert.NotNull(derivedMember); + Assert.Equal(typeof(MultiLevelBaseUser).GetProperty(nameof(MultiLevelBaseUser.Id)), baseMember.Property); + Assert.Equal(typeof(MultiLevelStaffUser).GetProperty(nameof(MultiLevelStaffUser.StaffCode)), intermediateMember.Property); + Assert.Equal(typeof(MultiLevelAdminUser).GetProperty(nameof(MultiLevelAdminUser.Permission)), derivedMember.Property); + } + + [Fact] + public void MissingBaseMapShouldThrowConfigurationException() + { + PreTest(typeof(MissingBaseUser), typeof(MissingBaseAdminUser)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new MissingBaseAdminUserMap()))); + + Assert.Contains(typeof(MissingBaseAdminUser).FullName, exception.Message); + Assert.Contains(typeof(MissingBaseUser).FullName, exception.Message); + Assert.Contains("Register the base map before the derived map", exception.Message); + } + + [Fact] + public void InvalidBaseTypeShouldThrowConfigurationException() + { + var exception = Assert.Throws(() => new InvalidBaseAdminUserMap()); + + Assert.Contains(typeof(UnrelatedUser).FullName, exception.Message); + Assert.Contains(typeof(InvalidBaseAdminUser).FullName, exception.Message); + Assert.Contains("base class", exception.Message); + } + + [Fact] + public void ColumnConflictBetweenDerivedAndIncludedBaseShouldThrowConfigurationException() + { + PreTest(typeof(ConflictBaseUser), typeof(ConflictAdminUser)); + + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => + { + c.AddMap(new ConflictBaseUserMap()); + c.AddMap(new ConflictAdminUserMap()); + })); + + Assert.Contains("shared_column", exception.Message); + Assert.Contains(nameof(ConflictBaseUser.Id), exception.Message); + Assert.Contains(nameof(ConflictAdminUser.Permission), exception.Message); + Assert.Contains(typeof(ConflictAdminUser).FullName, exception.Message); + } + + [Fact] + public void DerivedMapMustBeRegisteredAfterIncludedBaseMap() + { + PreTest(typeof(RegistrationBaseUser), typeof(RegistrationAdminUser)); + + Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMap(new RegistrationAdminUserMap()))); + + FluentMapper.Initialize(c => + { + c.AddMap(new RegistrationBaseUserMap()); + c.AddMap(new RegistrationAdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(RegistrationAdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(RegistrationBaseUser).GetProperty(nameof(RegistrationBaseUser.Id)), member.Property); + } + + [Fact] + [Trait("Category", "Integration")] + public void IncludedBaseMappingShouldMaterializeWithDapper() + { + PreTest(typeof(IntegrationBaseUser), typeof(IntegrationAdminUser)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new IntegrationBaseUserMap()); + c.AddMap(new IntegrationAdminUserMap()); + }); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 42 AS user_id, 'deploy' AS admin_permission;"); + + Assert.Equal(42, entity.Id); + Assert.Equal("deploy", entity.Permission); + } + } + finally + { + PreTest(typeof(IntegrationBaseUser), typeof(IntegrationAdminUser)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class SimpleBaseUser + { + public int Id { get; set; } + } + + private class SimpleAdminUser : SimpleBaseUser + { + } + + private class SimpleBaseUserMap : EntityMap + { + public SimpleBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class SimpleAdminUserMap : EntityMap + { + public SimpleAdminUserMap() + { + IncludeBase(); + } + } + + private class DerivedPropertyBaseUser + { + public int Id { get; set; } + } + + private class DerivedPropertyAdminUser : DerivedPropertyBaseUser + { + public string Permission { get; set; } + } + + private class DerivedPropertyBaseUserMap : EntityMap + { + public DerivedPropertyBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class DerivedPropertyAdminUserMap : EntityMap + { + public DerivedPropertyAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class OverrideBaseUser + { + public int Id { get; set; } + } + + private class OverrideAdminUser : OverrideBaseUser + { + } + + private class OverrideBaseUserMap : EntityMap + { + public OverrideBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class OverrideAdminUserMap : EntityMap + { + public OverrideAdminUserMap() + { + IncludeBase(); + Map(e => e.Id).ToColumn("admin_id"); + } + } + + private class ConventionBaseUser + { + public int Id { get; set; } + } + + private class ConventionAdminUser : ConventionBaseUser + { + public string Permission { get; set; } + } + + private class ConventionBaseUserMap : EntityMap + { + public ConventionBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class ConventionAdminUserMap : EntityMap + { + public ConventionAdminUserMap() + { + IncludeBase(); + } + } + + private class MemberPathBaseUser + { + public InheritedRankInfo Rank { get; set; } + } + + private class MemberPathAdminUser : MemberPathBaseUser + { + } + + private class InheritedRankInfo + { + public int Level { get; set; } + } + + private class MemberPathBaseUserMap : EntityMap + { + public MemberPathBaseUserMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + + private class MemberPathAdminUserMap : EntityMap + { + public MemberPathAdminUserMap() + { + IncludeBase(); + } + } + + private class MultiLevelBaseUser + { + public int Id { get; set; } + } + + private class MultiLevelStaffUser : MultiLevelBaseUser + { + public string StaffCode { get; set; } + } + + private class MultiLevelAdminUser : MultiLevelStaffUser + { + public string Permission { get; set; } + } + + private class MultiLevelBaseUserMap : EntityMap + { + public MultiLevelBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class MultiLevelStaffUserMap : EntityMap + { + public MultiLevelStaffUserMap() + { + IncludeBase(); + Map(e => e.StaffCode).ToColumn("staff_code"); + } + } + + private class MultiLevelAdminUserMap : EntityMap + { + public MultiLevelAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class MissingBaseUser + { + public int Id { get; set; } + } + + private class MissingBaseAdminUser : MissingBaseUser + { + } + + private class MissingBaseAdminUserMap : EntityMap + { + public MissingBaseAdminUserMap() + { + IncludeBase(); + } + } + + private class InvalidBaseAdminUser + { + } + + private class UnrelatedUser + { + } + + private class InvalidBaseAdminUserMap : EntityMap + { + public InvalidBaseAdminUserMap() + { + IncludeBase(); + } + } + + private class ConflictBaseUser + { + public int Id { get; set; } + } + + private class ConflictAdminUser : ConflictBaseUser + { + public string Permission { get; set; } + } + + private class ConflictBaseUserMap : EntityMap + { + public ConflictBaseUserMap() + { + Map(e => e.Id).ToColumn("shared_column"); + } + } + + private class ConflictAdminUserMap : EntityMap + { + public ConflictAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("shared_column"); + } + } + + private class RegistrationBaseUser + { + public int Id { get; set; } + } + + private class RegistrationAdminUser : RegistrationBaseUser + { + } + + private class RegistrationBaseUserMap : EntityMap + { + public RegistrationBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class RegistrationAdminUserMap : EntityMap + { + public RegistrationAdminUserMap() + { + IncludeBase(); + } + } + + private class IntegrationBaseUser + { + public int Id { get; set; } + } + + private class IntegrationAdminUser : IntegrationBaseUser + { + public string Permission { get; set; } + } + + private class IntegrationBaseUserMap : EntityMap + { + public IntegrationBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class IntegrationAdminUserMap : EntityMap + { + public IntegrationAdminUserMap() + { + IncludeBase(); + Map(e => e.Permission).ToColumn("admin_permission"); + } + } + + private class PrefixConvention : Convention + { + public PrefixConvention() + { + Properties() + .Configure(c => c.HasPrefix("col")); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs index 7071fd0..5bedc22 100644 --- a/test/Dapper.FluentMap.Tests/ManualMappingTests.cs +++ b/test/Dapper.FluentMap.Tests/ManualMappingTests.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using Dapper.FluentMap.Mapping; -using Dapper.FluentMap.TypeMaps; using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] @@ -16,7 +15,8 @@ public void DuplicateMappingShouldThrow_Exception() PreTest(); // Act & Assert - Assert.Throws(() => new MapWithDuplicateMapping()); + var exception = Assert.Throws(() => new MapWithDuplicateMapping()); + Assert.Contains(nameof(TestEntity.Id), exception.Message); } [Fact] @@ -117,7 +117,8 @@ public void FluentMapperInitializeShouldAddDapperTypeMap() // Assert Assert.NotNull(typeMap); - Assert.IsType>(typeMap); + var member = typeMap.GetMember("test"); + Assert.Equal(typeof(TestEntity).GetProperty(nameof(TestEntity.Id)), member.Property); } [Fact] @@ -147,6 +148,25 @@ public void PropertyMapShouldMapValueObjectProperties() Assert.Equal(typeof(EmailTestValueObject), email.PropertyInfo.DeclaringType); } + [Fact] + public void PropertyMapShouldDistinguishNestedPropertiesWithSameTerminalName() + { + PreTest(); + + var map = new NestedLevelMap(); + + Assert.Equal(2, map.PropertyMaps.Count); + } + + [Fact] + public void DuplicateNestedPropertyPathShouldThrow_Exception() + { + PreTest(); + + var exception = Assert.Throws(() => new DuplicateNestedLevelMap()); + Assert.Contains("Rank.Level", exception.Message); + } + private static void PreTest() { FluentMapper.Reset(typeof(TestEntity), typeof(DerivedTestEntity), typeof(ValueObjectTestEntity)); @@ -205,5 +225,40 @@ public ValueObjectMap() Map(x => x.Email.Address).ToColumn("email"); } } + + private class NestedLevelMap : EntityMap + { + public NestedLevelMap() + { + Map(x => x.Rank.Level).ToColumn("rank_level"); + Map(x => x.Seniority.Level).ToColumn("seniority_level"); + } + } + + private class DuplicateNestedLevelMap : EntityMap + { + public DuplicateNestedLevelMap() + { + Map(x => x.Rank.Level).ToColumn("rank_level"); + Map(x => x.Rank.Level).ToColumn("rank_level_again"); + } + } + + private class NestedLevelEntity + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } } } diff --git a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs index 89ba6fe..b31b61a 100644 --- a/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs +++ b/test/Dapper.FluentMap.Tests/MappingCompositionTests.cs @@ -20,6 +20,23 @@ public void ExplicitMappingShouldResolveColumn() Assert.Equal(typeof(ExplicitOnlyEntity).GetProperty(nameof(ExplicitOnlyEntity.Id)), member.Property); } + [Fact] + public void IncludedBaseMappingShouldResolveColumnForDerivedEntity() + { + PreTest(typeof(BaseUser), typeof(AdminUser)); + + FluentMapper.Initialize(c => + { + c.AddMap(new BaseUserMap()); + c.AddMap(new AdminUserMap()); + }); + + var member = SqlMapper.GetTypeMap(typeof(AdminUser)).GetMember("user_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(BaseUser).GetProperty(nameof(BaseUser.Id)), member.Property); + } + [Fact] public void ConventionShouldResolveColumn() { @@ -85,6 +102,23 @@ public void ExplicitMappingShouldOverrideConventionForSameProperty() Assert.Equal(typeof(ExplicitOverrideEntity).GetProperty(nameof(ExplicitOverrideEntity.Id)), explicitMember.Property); } + [Fact] + public void ExplicitNestedMappingShouldNotOverrideConventionForDistinctPropertyWithSameTerminalName() + { + PreTest(typeof(NestedExplicitWithConventionEntity)); + + FluentMapper.Initialize(c => + { + c.AddMap(new NestedExplicitWithConventionMap()); + c.AddConvention().ForEntity(); + }); + + var conventionMember = SqlMapper.GetTypeMap(typeof(NestedExplicitWithConventionEntity)).GetMember("colLevel"); + + Assert.NotNull(conventionMember); + Assert.Equal(typeof(NestedExplicitWithConventionEntity).GetProperty(nameof(NestedExplicitWithConventionEntity.Level)), conventionMember.Property); + } + [Fact] public void RegistrationOrderShouldNotMatterWhenExplicitMappingIsAddedFirst() { @@ -164,6 +198,34 @@ public ExplicitOnlyMap() } } + private class BaseUser + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class AdminUser : BaseUser + { + public string Permission { get; set; } + } + + private class BaseUserMap : EntityMap + { + public BaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class AdminUserMap : EntityMap + { + public AdminUserMap() + { + IncludeBase(); + } + } + private class ConventionOnlyEntity { public string Name { get; set; } @@ -214,6 +276,26 @@ public ExplicitOverrideMap() } } + private class NestedExplicitWithConventionEntity + { + public int Level { get; set; } + + public NestedRankInfo Rank { get; set; } + } + + private class NestedRankInfo + { + public int Level { get; set; } + } + + private class NestedExplicitWithConventionMap : EntityMap + { + public NestedExplicitWithConventionMap() + { + Map(e => e.Rank.Level).ToColumn("rank_level"); + } + } + private class MapFirstEntity { public int Id { get; set; } diff --git a/test/Dapper.FluentMap.Tests/MappingProfileTests.cs b/test/Dapper.FluentMap.Tests/MappingProfileTests.cs new file mode 100644 index 0000000..2bb304f --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingProfileTests.cs @@ -0,0 +1,622 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingProfileTests + { + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldContinueUsingDefaultMapping() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 1 AS customer_id, 'Default' AS customer_name;"); + + Assert.Equal(1, customer.Id); + Assert.Equal("Default", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseAlternativeProfile() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 2 AS id, 'Legacy' AS legal_name;"); + + Assert.Equal(2, customer.Id); + Assert.Equal("Legacy", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDifferentProfilesWithoutLeaking() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var legacy = connection.QueryMappedSingle( + "SELECT 3 AS id, 'Legacy' AS legal_name;"); + var reporting = connection.QueryMappedSingle( + "SELECT 4 AS report_customer_id, 'Reporting' AS report_customer_name;"); + + Assert.Equal(3, legacy.Id); + Assert.Equal("Legacy", legacy.Name); + Assert.Equal(4, reporting.Id); + Assert.Equal("Reporting", reporting.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedDefaultShouldStillUseDefaultAfterProfileQuery() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new DefaultProfileCustomerMap()); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var profile = connection.QueryMappedSingle( + "SELECT 5 AS id, 'Legacy' AS legal_name;"); + var defaultCustomer = connection.QueryMappedSingle( + "SELECT 6 AS customer_id, 'Default' AS customer_name;"); + + Assert.Equal("Legacy", profile.Name); + Assert.Equal(6, defaultCustomer.Id); + Assert.Equal("Default", defaultCustomer.Name); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldRunParallelProfileQueriesWithoutLeakingMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + var results = Enumerable.Range(0, 100) + .AsParallel() + .Select(index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var customer = connection.QueryMappedSingle( + $"SELECT {index} AS id, 'legacy-{index}' AS legal_name;"); + return customer.Id == index && customer.Name == $"legacy-{index}"; + } + + var reporting = connection.QueryMappedSingle( + $"SELECT {index} AS report_customer_id, 'report-{index}' AS report_customer_name;"); + return reporting.Id == index && reporting.Name == $"report-{index}"; + } + }) + .ToList(); + + Assert.All(results, Assert.True); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task QueryMappedAsyncShouldRunConcurrentProfileQueriesWithoutLeakingMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + var tasks = Enumerable.Range(0, 40) + .Select(async index => + { + using (var connection = OpenConnection()) + { + if (index % 2 == 0) + { + var customer = await connection.QueryMappedSingleAsync( + $"SELECT {index} AS id, 'legacy-async-{index}' AS legal_name;"); + return customer.Id == index && customer.Name == $"legacy-async-{index}"; + } + + var reporting = await connection.QueryMappedSingleAsync( + $"SELECT {index} AS report_customer_id, 'report-async-{index}' AS report_customer_name;"); + return reporting.Id == index && reporting.Name == $"report-async-{index}"; + } + }) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, Assert.True); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportNestedMappings() + { + PreTest(typeof(ProfileCustomerWithAddress)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS legacy_city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(ProfileCustomerWithAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportValueObjects() + { + PreTest(typeof(ProfileCustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '12345678909' AS legacy_cpf;"); + + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + PreTest(typeof(ProfileCustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldUseProfileBaseMappingForInheritance() + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS legacy_id, 'gold' AS legacy_tier;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("gold", customer.Tier); + } + } + finally + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldApplyEntityNamingPolicy() + { + PreTest(typeof(ProfilePolicyCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddProfile(); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 8 AS CUSTOMER_ID, 'policy@example.com' AS legacy_email;"); + + Assert.Equal(8, customer.CustomerId); + Assert.Equal("policy@example.com", customer.Email.Value); + } + } + finally + { + PreTest(typeof(ProfilePolicyCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedProfileShouldSupportConstructorMapping() + { + PreTest(typeof(ProfileImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 9 AS legacy_id, 'Immutable Legacy' AS legacy_name;"); + + Assert.Equal(9, customer.Id); + Assert.Equal("Immutable Legacy", customer.Name); + } + } + finally + { + PreTest(typeof(ProfileImmutableCustomer)); + } + } + + [Fact] + public void QueryMappedProfileShouldRejectMissingProfile() + { + PreTest(typeof(ProfileCustomer)); + + try + { + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 1 AS id, 'Legacy' AS legal_name;")); + + Assert.Contains("does not have a registered mapping profile", exception.Message); + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + } + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + public void AddProfileShouldRejectDuplicateProfileForEntity() + { + PreTest(typeof(ProfileCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => + { + c.AddProfile(); + c.AddProfile(); + })); + + Assert.Contains("already has a configured mapping profile", exception.Message); + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + [Fact] + public void AddProfileShouldRejectProfileBaseMappingWhenSameProfileBaseIsMissing() + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddProfile())); + + Assert.Contains(typeof(LegacyProfile).FullName, exception.Message); + Assert.Contains(typeof(ProfileBaseCustomer).FullName, exception.Message); + } + finally + { + PreTest(typeof(ProfileBaseCustomer), typeof(ProfileDerivedCustomer)); + } + } + + [Fact] + public void ExplainShouldDescribeProfileMappings() + { + PreTest(typeof(ProfileCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddProfile()); + + var explanation = FluentMapper.Explain(); + var name = explanation.Members.Single(m => m.MemberPath == nameof(ProfileCustomer.Name)); + + Assert.Equal(typeof(LegacyProfile), explanation.ProfileType); + Assert.Equal(typeof(LegacyProfileCustomerMap), explanation.EntityMapType); + Assert.Equal("legal_name", name.ColumnName); + Assert.Equal(MappingSource.Explicit, name.Source); + } + finally + { + PreTest(typeof(ProfileCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class LegacyProfile : IMappingProfile + { + } + + private sealed class ReportingProfile : IMappingProfile + { + } + + private sealed class ProfileCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class DefaultProfileCustomerMap : EntityMap + { + public DefaultProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Name).ToColumn("customer_name"); + } + } + + private sealed class LegacyProfileCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("id"); + Map(customer => customer.Name).ToColumn("legal_name"); + } + } + + private sealed class SecondLegacyProfileCustomerMap : EntityMap, IProfileMap + { + public SecondLegacyProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("other_id"); + } + } + + private sealed class ReportingProfileCustomerMap : EntityMap, IProfileMap + { + public ReportingProfileCustomerMap() + { + Map(customer => customer.Id).ToColumn("report_customer_id"); + Map(customer => customer.Name).ToColumn("report_customer_name"); + } + } + + private sealed class ProfileCustomerWithAddress + { + public ProfileAddress Address { get; set; } + } + + private sealed class ProfileAddress + { + public string City { get; set; } + } + + private sealed class LegacyProfileCustomerWithAddressMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerWithAddressMap() + { + Map(customer => customer.Address.City).ToColumn("legacy_city"); + } + } + + private sealed class ProfileCustomerWithCpf + { + public ProfileCustomerWithCpf(ProfileCpf cpf) + { + Cpf = cpf; + } + + public ProfileCpf Cpf { get; } + } + + private sealed class ProfileCpf + { + public ProfileCpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class LegacyProfileCustomerWithCpfMap : EntityMap, IProfileMap + { + public LegacyProfileCustomerWithCpfMap() + { + Map(customer => customer.Cpf.Number).ToColumn("legacy_cpf"); + } + } + + private class ProfileBaseCustomer + { + public int Id { get; set; } + } + + private sealed class ProfileDerivedCustomer : ProfileBaseCustomer + { + public string Tier { get; set; } + } + + private sealed class LegacyProfileBaseCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileBaseCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + } + } + + private sealed class LegacyProfileDerivedCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileDerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("legacy_tier"); + } + } + + private sealed class ProfilePolicyCustomer + { + public ProfilePolicyCustomer(int customerId, ProfileEmail email) + { + CustomerId = customerId; + Email = email; + } + + public int CustomerId { get; } + + public ProfileEmail Email { get; } + } + + private sealed record ProfileEmail(string Value); + + private sealed class LegacyProfilePolicyCustomerMap : EntityMap, IProfileMap + { + public LegacyProfilePolicyCustomerMap() + { + Map(customer => customer.Email.Value).ToColumn("legacy_email"); + } + } + + private sealed class ProfileImmutableCustomer + { + public ProfileImmutableCustomer(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; } + } + + private sealed class LegacyProfileImmutableCustomerMap : EntityMap, IProfileMap + { + public LegacyProfileImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("legacy_id"); + Map(customer => customer.Name).ToColumn("legacy_name"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs new file mode 100644 index 0000000..1c9be9b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingRegistrationTests.cs @@ -0,0 +1,639 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Dapper; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingRegistrationTests + { + [Fact] + public void InstanceRegistrationShouldContinueToAddEntityMap() + { + ResetMapper(typeof(InstanceRegistrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new InstanceRegistrationMap())); + + var entityMap = FluentMapper.EntityMaps.Single(); + Assert.Equal(typeof(InstanceRegistrationEntity), entityMap.Key); + Assert.IsType(entityMap.Value); + } + finally + { + ResetMapper(typeof(InstanceRegistrationEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldAddEntityMapAndDapperTypeMap() + { + ResetMapper(typeof(GenericRegistrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap()); + + Assert.IsType(FluentMapper.EntityMaps[typeof(GenericRegistrationEntity)]); + var typeMap = SqlMapper.GetTypeMap(typeof(GenericRegistrationEntity)); + var member = typeMap.GetMember("generic_id"); + Assert.Equal(typeof(GenericRegistrationEntity).GetProperty(nameof(GenericRegistrationEntity.Id)), member.Property); + + var property = FluentMapper.Registry.GetFluentPropertyInfo(typeof(GenericRegistrationEntity), "generic_id"); + Assert.Equal(typeof(GenericRegistrationEntity).GetProperty(nameof(GenericRegistrationEntity.Id)), property); + } + finally + { + ResetMapper(typeof(GenericRegistrationEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void GenericRegistrationShouldMaterializeConfiguredColumnWithDapper() + { + ResetMapper(typeof(GenericIntegrationEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMap()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 31 AS integration_id, 'modern' AS Name;"); + + Assert.Equal(31, entity.Id); + Assert.Equal("modern", entity.Name); + } + } + finally + { + ResetMapper(typeof(GenericIntegrationEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldChainMultipleExplicitMappings() + { + ResetMapper(typeof(FirstExplicitEntity), typeof(SecondExplicitEntity)); + + try + { + FluentMapper.Initialize(c => c + .AddMap() + .AddMap()); + + Assert.Equal(2, FluentMapper.EntityMaps.Count); + Assert.IsType(FluentMapper.EntityMaps[typeof(FirstExplicitEntity)]); + Assert.IsType(FluentMapper.EntityMaps[typeof(SecondExplicitEntity)]); + } + finally + { + ResetMapper(typeof(FirstExplicitEntity), typeof(SecondExplicitEntity)); + } + } + + [Fact] + public void AddMapsFromAssemblyShouldRegisterDiscoveredMaps() + { + ResetMapper( + typeof(MappingRegistrationScan.Basic.Customer), + typeof(MappingRegistrationScan.Basic.Order)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssembly( + typeof(MappingRegistrationScan.Basic.Marker).GetTypeInfo().Assembly, + typeof(MappingRegistrationScan.Basic.Marker).Namespace)); + + Assert.Equal(2, FluentMapper.EntityMaps.Count); + Assert.IsType( + FluentMapper.EntityMaps[typeof(MappingRegistrationScan.Basic.Customer)]); + Assert.IsType( + FluentMapper.EntityMaps[typeof(MappingRegistrationScan.Basic.Order)]); + } + finally + { + ResetMapper( + typeof(MappingRegistrationScan.Basic.Customer), + typeof(MappingRegistrationScan.Basic.Order)); + } + } + + [Fact] + public void AddMapsFromAssemblyContainingShouldUseMarkerAssembly() + { + ResetMapper(typeof(MappingRegistrationScan.MarkerType.MarkerEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.MarkerType.Marker).Namespace)); + + var property = FluentMapper.Registry.GetFluentPropertyInfo( + typeof(MappingRegistrationScan.MarkerType.MarkerEntity), + "marker_id"); + + Assert.Equal(typeof(MappingRegistrationScan.MarkerType.MarkerEntity).GetProperty(nameof(MappingRegistrationScan.MarkerType.MarkerEntity.Id)), property); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.MarkerType.MarkerEntity)); + } + } + + [Fact] + public void AddMapsFromAssemblyShouldIgnoreAbstractMaps() + { + ResetMapper(typeof(MappingRegistrationScan.AbstractOnly.AbstractEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.AbstractOnly.Marker).Namespace)); + + Assert.Empty(FluentMapper.EntityMaps); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.AbstractOnly.AbstractEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldRejectInvalidMapType() + { + ResetMapper(); + + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("exactly one closed IEntityMap", exception.Message); + } + + [Fact] + public void RegisteringSameMapTwiceShouldThrow() + { + ResetMapper(typeof(DuplicateRegistrationEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c + .AddMap() + .AddMap())); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(DuplicateRegistrationEntity)); + } + } + + [Fact] + public void RegisteringDifferentMapsForSameEntityShouldThrow() + { + ResetMapper(typeof(DuplicateEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c + .AddMap() + .AddMap())); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(DuplicateEntity)); + } + } + + [Fact] + public void ScanningDuplicateEntityMapsShouldThrowBeforeRegistration() + { + ResetMapper(typeof(MappingRegistrationScan.DuplicateScan.DuplicateScanEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.DuplicateScan.Marker).Namespace))); + + Assert.Contains("Multiple entity maps were discovered", exception.Message); + Assert.Empty(FluentMapper.EntityMaps); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.DuplicateScan.DuplicateScanEntity)); + } + } + + [Fact] + public void ScanningAfterExplicitRegistrationShouldThrowDuplicate() + { + ResetMapper(typeof(MappingRegistrationScan.ExplicitThenScan.ExplicitScanEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => + { + c.AddMap(); + c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.ExplicitThenScan.Marker).Namespace); + })); + + Assert.Contains("already has a configured entity map", exception.Message); + } + finally + { + ResetMapper(typeof(MappingRegistrationScan.ExplicitThenScan.ExplicitScanEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldWrapConstructorErrors() + { + ResetMapper(typeof(ThrowingConstructorEntity)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("could not be created", exception.Message); + Assert.NotNull(exception.InnerException); + } + finally + { + ResetMapper(typeof(ThrowingConstructorEntity)); + } + } + + [Fact] + public void GenericRegistrationShouldUseExistingValidation() + { + ResetMapper(typeof(ValidationRegistrationEntity)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap())); + + Assert.Contains("configured for more than one property path", exception.Message); + } + finally + { + ResetMapper(typeof(ValidationRegistrationEntity)); + } + } + + [Fact] + public void AssemblyScanningShouldRegisterIncludedBaseMapsBeforeDerivedMaps() + { + ResetMapper( + typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity), + typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + + try + { + FluentMapper.Initialize(c => c.AddMapsFromAssemblyContaining( + typeof(MappingRegistrationScan.InheritedScan.Marker).Namespace)); + + var typeMap = SqlMapper.GetTypeMap(typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + var inheritedMember = typeMap.GetMember("base_id"); + var derivedMember = typeMap.GetMember("derived_name"); + + Assert.Equal(typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity).GetProperty(nameof(MappingRegistrationScan.InheritedScan.BaseScanEntity.Id)), inheritedMember.Property); + Assert.Equal(typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity).GetProperty(nameof(MappingRegistrationScan.InheritedScan.DerivedScanEntity.Name)), derivedMember.Property); + } + finally + { + ResetMapper( + typeof(MappingRegistrationScan.InheritedScan.BaseScanEntity), + typeof(MappingRegistrationScan.InheritedScan.DerivedScanEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class InstanceRegistrationEntity + { + public int Id { get; set; } + } + + private class InstanceRegistrationMap : EntityMap + { + public InstanceRegistrationMap() + { + Map(e => e.Id).ToColumn("instance_id"); + } + } + + private class GenericRegistrationEntity + { + public int Id { get; set; } + } + + private class GenericRegistrationMap : EntityMap + { + public GenericRegistrationMap() + { + Map(e => e.Id).ToColumn("generic_id"); + } + } + + private class GenericIntegrationEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class GenericIntegrationMap : EntityMap + { + public GenericIntegrationMap() + { + Map(e => e.Id).ToColumn("integration_id"); + } + } + + private class FirstExplicitEntity + { + public int Id { get; set; } + } + + private class FirstExplicitMap : EntityMap + { + public FirstExplicitMap() + { + Map(e => e.Id).ToColumn("first_id"); + } + } + + private class SecondExplicitEntity + { + public string Name { get; set; } + } + + private class SecondExplicitMap : EntityMap + { + public SecondExplicitMap() + { + Map(e => e.Name).ToColumn("second_name"); + } + } + + private class NonGenericEntityMap : IEntityMap + { + public IList PropertyMaps { get; } = new List(); + } + + private class DuplicateRegistrationEntity + { + public int Id { get; set; } + } + + private class DuplicateRegistrationMap : EntityMap + { + public DuplicateRegistrationMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + private class DuplicateEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class FirstDuplicateEntityMap : EntityMap + { + public FirstDuplicateEntityMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + private class SecondDuplicateEntityMap : EntityMap + { + public SecondDuplicateEntityMap() + { + Map(e => e.Name).ToColumn("duplicate_name"); + } + } + + private class ExplicitScanMap : EntityMap + { + public ExplicitScanMap() + { + Map(e => e.Id).ToColumn("explicit_id"); + } + } + + private class ThrowingConstructorEntity + { + public int Id { get; set; } + } + + private class ThrowingConstructorMap : EntityMap + { + public ThrowingConstructorMap() + { + throw new InvalidOperationException("Constructor failed."); + } + } + + private class ValidationRegistrationEntity + { + public int Id { get; set; } + + public int OtherId { get; set; } + } + + private class ValidationRegistrationMap : EntityMap + { + public ValidationRegistrationMap() + { + Map(e => e.Id).ToColumn("same_column"); + Map(e => e.OtherId).ToColumn("same_column"); + } + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.Basic +{ + public class Marker + { + } + + public class Customer + { + public int Id { get; set; } + } + + public class Order + { + public string Number { get; set; } + } + + public class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(e => e.Id).ToColumn("customer_id"); + } + } + + public class OrderMap : EntityMap + { + public OrderMap() + { + Map(e => e.Number).ToColumn("order_number"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.MarkerType +{ + public class Marker + { + } + + public class MarkerEntity + { + public int Id { get; set; } + } + + public class MarkerEntityMap : EntityMap + { + public MarkerEntityMap() + { + Map(e => e.Id).ToColumn("marker_id"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.AbstractOnly +{ + public class Marker + { + } + + public class AbstractEntity + { + public int Id { get; set; } + } + + public abstract class AbstractEntityMap : EntityMap + { + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.DuplicateScan +{ + public class Marker + { + } + + public class DuplicateScanEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public class FirstDuplicateScanMap : EntityMap + { + public FirstDuplicateScanMap() + { + Map(e => e.Id).ToColumn("duplicate_id"); + } + } + + public class SecondDuplicateScanMap : EntityMap + { + public SecondDuplicateScanMap() + { + Map(e => e.Name).ToColumn("duplicate_name"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.ExplicitThenScan +{ + public class Marker + { + } + + public class ExplicitScanEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + public class ScannedExplicitEntityMap : EntityMap + { + public ScannedExplicitEntityMap() + { + Map(e => e.Name).ToColumn("scanned_name"); + } + } +} + +namespace Dapper.FluentMap.Tests.MappingRegistrationScan.InheritedScan +{ + public class Marker + { + } + + public class BaseScanEntity + { + public int Id { get; set; } + } + + public class DerivedScanEntity : BaseScanEntity + { + public string Name { get; set; } + } + + public class ADerivedScanMap : EntityMap + { + public ADerivedScanMap() + { + IncludeBase(); + Map(e => e.Name).ToColumn("derived_name"); + } + } + + public class ZBaseScanMap : EntityMap + { + public ZBaseScanMap() + { + Map(e => e.Id).ToColumn("base_id"); + } + } +} diff --git a/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs b/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs new file mode 100644 index 0000000..4fa6e14 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MappingStateEncapsulationTests.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MappingStateEncapsulationTests + { + [Fact] + public void OfficialMapRegistrationShouldInvalidateCachedMissAndInstallDapperTypeMap() + { + ResetMapper(typeof(OfficialMapEntity)); + + try + { + var miss = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialMapEntity), "official_id"); + + FluentMapper.Initialize(configuration => configuration.AddMap(new OfficialMap())); + + var hit = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialMapEntity), "official_id"); + var dapperMember = SqlMapper.GetTypeMap(typeof(OfficialMapEntity)).GetMember("official_id"); + + Assert.Null(miss); + Assert.Equal(typeof(OfficialMapEntity).GetProperty(nameof(OfficialMapEntity.Id)), hit); + Assert.Equal(nameof(OfficialMapEntity.Id), dapperMember.Property.Name); + Assert.Equal(1, FluentMapper.Registry.CacheEntryCount); + } + finally + { + ResetMapper(typeof(OfficialMapEntity)); + } + } + + [Fact] + public void OfficialConventionRegistrationShouldInvalidateCachedMissAndInstallDapperTypeMap() + { + ResetMapper(typeof(OfficialConventionEntity)); + + try + { + var miss = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialConventionEntity), "cfgId"); + + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + var hit = FluentMapper.Registry.GetFluentPropertyInfo(typeof(OfficialConventionEntity), "cfgId"); + var dapperMember = SqlMapper.GetTypeMap(typeof(OfficialConventionEntity)).GetMember("cfgId"); + + Assert.Null(miss); + Assert.Equal(typeof(OfficialConventionEntity).GetProperty(nameof(OfficialConventionEntity.Id)), hit); + Assert.Equal(nameof(OfficialConventionEntity.Id), dapperMember.Property.Name); + Assert.Equal(1, FluentMapper.Registry.CacheEntryCount); + } + finally + { + ResetMapper(typeof(OfficialConventionEntity)); + } + } + + [Fact] + public void EntityMapsSnapshotShouldBeReadOnlyAndNotTrackLaterRegistrations() + { + ResetMapper(typeof(ReadOnlyFirstEntity), typeof(ReadOnlySecondEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new ReadOnlyFirstMap())); + + var snapshot = FluentMapper.GetEntityMaps(); + + FluentMapper.Initialize(configuration => configuration.AddMap(new ReadOnlySecondMap())); + + Assert.Single(snapshot); + Assert.True(snapshot.ContainsKey(typeof(ReadOnlyFirstEntity))); + Assert.False(snapshot.ContainsKey(typeof(ReadOnlySecondEntity))); + + var mutableSnapshot = Assert.IsAssignableFrom>(snapshot); + Assert.Throws(() => + mutableSnapshot.Add(typeof(ReadOnlySecondEntity), new ReadOnlySecondMap())); + } + finally + { + ResetMapper(typeof(ReadOnlyFirstEntity), typeof(ReadOnlySecondEntity)); + } + } + + [Fact] + public void TypeConventionsSnapshotShouldBeReadOnlyAndNotExposeMutableConventionLists() + { + ResetMapper(typeof(ReadOnlyConventionEntity), typeof(ReadOnlySecondConventionEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + var snapshot = FluentMapper.GetTypeConventions(); + + FluentMapper.Initialize(configuration => configuration + .AddConvention() + .ForEntity()); + + Assert.Single(snapshot); + Assert.True(snapshot.ContainsKey(typeof(ReadOnlyConventionEntity))); + Assert.False(snapshot.ContainsKey(typeof(ReadOnlySecondConventionEntity))); + + var mutableSnapshot = Assert.IsAssignableFrom>>(snapshot); + Assert.Throws(() => + mutableSnapshot.Add(typeof(ReadOnlySecondConventionEntity), new List())); + + var mutableConventions = Assert.IsAssignableFrom>(snapshot[typeof(ReadOnlyConventionEntity)]); + Assert.Throws(() => + mutableConventions.Add(new SnapshotPrefixConvention())); + } + finally + { + ResetMapper(typeof(ReadOnlyConventionEntity), typeof(ReadOnlySecondConventionEntity)); + } + } + + [Fact] + public void LegacyEntityMapReplacementCanBypassCacheInvalidation() + { + ResetMapper(typeof(LegacyReplacementEntity)); + + try + { + FluentMapper.Initialize(configuration => configuration.AddMap(new LegacyIdMap())); + var beforeReplacement = FluentMapper.Registry.GetFluentPropertyInfo(typeof(LegacyReplacementEntity), "shared_column"); + + FluentMapper.EntityMaps[typeof(LegacyReplacementEntity)] = new LegacyNameMap(); + var afterReplacement = FluentMapper.Registry.GetFluentPropertyInfo(typeof(LegacyReplacementEntity), "shared_column"); + + Assert.Equal(typeof(LegacyReplacementEntity).GetProperty(nameof(LegacyReplacementEntity.Id)), beforeReplacement); + Assert.Equal(typeof(LegacyReplacementEntity).GetProperty(nameof(LegacyReplacementEntity.Id)), afterReplacement); + Assert.IsType(FluentMapper.EntityMaps[typeof(LegacyReplacementEntity)]); + } + finally + { + ResetMapper(typeof(LegacyReplacementEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void EntityMapSnapshotShouldPreserveDefaultMapWhileProfilesRemainQueryScoped() + { + ResetMapper(typeof(ProfileSnapshotEntity)); + + try + { + FluentMapper.Initialize(configuration => + { + configuration.AddMap(new ProfileSnapshotDefaultMap()); + configuration.AddProfile(); + }); + + var snapshot = FluentMapper.GetEntityMaps(); + + using (var connection = OpenConnection()) + { + var defaultEntity = connection.QuerySingle( + "SELECT 1 AS default_id;"); + var profileEntity = connection.QueryMappedSingle( + "SELECT 2 AS profile_id;"); + + Assert.IsType(snapshot[typeof(ProfileSnapshotEntity)]); + Assert.Equal(1, defaultEntity.Id); + Assert.Equal(2, profileEntity.Id); + } + } + finally + { + ResetMapper(typeof(ProfileSnapshotEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void ResetMapper(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class SnapshotPrefixConvention : Convention + { + public SnapshotPrefixConvention() + { + Properties() + .Configure(configuration => configuration.HasPrefix("cfg")); + } + } + + private sealed class OfficialMapEntity + { + public int Id { get; set; } + } + + private sealed class OfficialMap : EntityMap + { + public OfficialMap() + { + Map(entity => entity.Id).ToColumn("official_id"); + } + } + + private sealed class OfficialConventionEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlyFirstEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlyFirstMap : EntityMap + { + public ReadOnlyFirstMap() + { + Map(entity => entity.Id).ToColumn("first_id"); + } + } + + private sealed class ReadOnlySecondEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlySecondMap : EntityMap + { + public ReadOnlySecondMap() + { + Map(entity => entity.Id).ToColumn("second_id"); + } + } + + private sealed class ReadOnlyConventionEntity + { + public int Id { get; set; } + } + + private sealed class ReadOnlySecondConventionEntity + { + public int Id { get; set; } + } + + private sealed class LegacyReplacementEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class LegacyIdMap : EntityMap + { + public LegacyIdMap() + { + Map(entity => entity.Id).ToColumn("shared_column"); + } + } + + private sealed class LegacyNameMap : EntityMap + { + public LegacyNameMap() + { + Map(entity => entity.Name).ToColumn("shared_column"); + } + } + + private sealed class ProfileSnapshot + : IMappingProfile + { + } + + private sealed class ProfileSnapshotEntity + { + public int Id { get; set; } + } + + private sealed class ProfileSnapshotDefaultMap : EntityMap + { + public ProfileSnapshotDefaultMap() + { + Map(entity => entity.Id).ToColumn("default_id"); + } + } + + private sealed class ProfileSnapshotAlternateMap : + EntityMap, + IProfileMap + { + public ProfileSnapshotAlternateMap() + { + Map(entity => entity.Id).ToColumn("profile_id"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/MemberPathTests.cs b/test/Dapper.FluentMap.Tests/MemberPathTests.cs new file mode 100644 index 0000000..9a6560f --- /dev/null +++ b/test/Dapper.FluentMap.Tests/MemberPathTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Dapper.FluentMap.Utils; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class MemberPathTests + { + [Fact] + public void GetMemberPathShouldReturnSimplePath() + { + Expression> expression = e => e.Name; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.False(memberPath.IsNested); + Assert.Equal("Name", memberPath.ToString()); + Assert.Equal(typeof(MemberPathEntity).GetProperty(nameof(MemberPathEntity.Name)), memberPath.PropertyInfo); + Assert.Equal(new[] { "Name" }, memberPath.Properties.Select(p => p.Name)); + } + + [Fact] + public void GetMemberPathShouldReturnNestedPathInOrder() + { + Expression> expression = e => e.Address.City; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.True(memberPath.IsNested); + Assert.Equal("Address.City", memberPath.ToString()); + Assert.Equal(typeof(MemberPathEntity).GetProperty(nameof(MemberPathEntity.Address)), memberPath.Properties[0]); + Assert.Equal(typeof(AddressInfo).GetProperty(nameof(AddressInfo.City)), memberPath.Properties[1]); + Assert.Equal(typeof(AddressInfo).GetProperty(nameof(AddressInfo.City)), memberPath.PropertyInfo); + } + + [Fact] + public void MemberPathShouldDistinguishPathsWithSameTerminalPropertyName() + { + Expression> rankExpression = e => e.Rank.Level; + Expression> seniorityExpression = e => e.Seniority.Level; + + var rankPath = ReflectionHelper.GetMemberPath(rankExpression); + var seniorityPath = ReflectionHelper.GetMemberPath(seniorityExpression); + + Assert.NotEqual(rankPath, seniorityPath); + Assert.Equal("Rank.Level", rankPath.ToString()); + Assert.Equal("Seniority.Level", seniorityPath.ToString()); + Assert.Equal(rankPath.PropertyInfo.Name, seniorityPath.PropertyInfo.Name); + } + + [Fact] + public void MemberPathShouldTreatSamePathAsEqual() + { + Expression> firstExpression = e => e.Rank.Level; + Expression> secondExpression = e => e.Rank.Level; + + var firstPath = ReflectionHelper.GetMemberPath(firstExpression); + var secondPath = ReflectionHelper.GetMemberPath(secondExpression); + + Assert.Equal(firstPath, secondPath); + Assert.Equal(firstPath.GetHashCode(), secondPath.GetHashCode()); + } + + [Fact] + public void GetMemberPathShouldHandleConvertForValueTypes() + { + Expression> expression = e => e.Rank.Level; + + var memberPath = ReflectionHelper.GetMemberPath(expression); + + Assert.Equal("Rank.Level", memberPath.ToString()); + Assert.Equal(typeof(int), memberPath.PropertyInfo.PropertyType); + } + + [Fact] + public void GetMemberPathShouldThrowArgumentExceptionForInvalidExpression() + { + Expression> expression = e => e.Name.ToString(); + + var exception = Assert.Throws(() => ReflectionHelper.GetMemberPath(expression)); + + Assert.Contains("property path", exception.Message); + } + + private class MemberPathEntity + { + public string Name { get; set; } + + public AddressInfo Address { get; set; } + + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private class AddressInfo + { + public string City { get; set; } + } + + private class RankInfo + { + public int Level { get; set; } + } + + private class SeniorityInfo + { + public int Level { get; set; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs b/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs new file mode 100644 index 0000000..4c2950b --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NamingPolicyTests.cs @@ -0,0 +1,453 @@ +using System; +using Dapper; +using Dapper.FluentMap.Conventions; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NamingPolicyTests + { + [Fact] + public void WithoutNamingPolicyShouldUseDapperDefaultFallback() + { + PreTest(typeof(DefaultPolicyEntity)); + + try + { + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 3 AS Id, 'Ada' AS Name;"); + + Assert.Equal(3, entity.Id); + Assert.Equal("Ada", entity.Name); + } + } + finally + { + PreTest(typeof(DefaultPolicyEntity)); + } + } + + [Fact] + public void SnakeCaseNamingPolicyShouldResolveColumn() + { + PreTest(typeof(SnakeCaseEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(SnakeCaseEntity)).GetMember("customer_id"); + + Assert.NotNull(member); + Assert.Equal(typeof(SnakeCaseEntity).GetProperty(nameof(SnakeCaseEntity.CustomerId)), member.Property); + } + finally + { + PreTest(typeof(SnakeCaseEntity)); + } + } + + [Fact] + public void PrefixNamingPolicyShouldResolveColumn() + { + PreTest(typeof(PrefixPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(PrefixPolicyEntity)).GetMember("usr_name"); + + Assert.NotNull(member); + Assert.Equal(typeof(PrefixPolicyEntity).GetProperty(nameof(PrefixPolicyEntity.Name)), member.Property); + } + finally + { + PreTest(typeof(PrefixPolicyEntity)); + } + } + + [Fact] + public void SuffixNamingPolicyShouldResolveColumn() + { + PreTest(typeof(SuffixPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase.WithSuffix("_txt")).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(SuffixPolicyEntity)).GetMember("first_name_txt"); + + Assert.NotNull(member); + Assert.Equal(typeof(SuffixPolicyEntity).GetProperty(nameof(SuffixPolicyEntity.FirstName)), member.Property); + } + finally + { + PreTest(typeof(SuffixPolicyEntity)); + } + } + + [Fact] + public void CustomNamingPolicyShouldResolveColumn() + { + PreTest(typeof(CustomPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(name => "x_" + name.ToLowerInvariant()).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(CustomPolicyEntity)).GetMember("x_code"); + + Assert.NotNull(member); + Assert.Equal(typeof(CustomPolicyEntity).GetProperty(nameof(CustomPolicyEntity.Code)), member.Property); + } + finally + { + PreTest(typeof(CustomPolicyEntity)); + } + } + + [Fact] + public void ExplicitMappingShouldTakePrecedenceOverNamingPolicy() + { + PreTest(typeof(ExplicitPolicyEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new ExplicitPolicyMap()); + c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity(); + }); + + var explicitMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(ExplicitPolicyEntity), "person_name"); + var policyMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(ExplicitPolicyEntity), "first_name"); + + Assert.Equal(typeof(ExplicitPolicyEntity).GetProperty(nameof(ExplicitPolicyEntity.FirstName)), explicitMember); + Assert.Null(policyMember); + } + finally + { + PreTest(typeof(ExplicitPolicyEntity)); + } + } + + [Fact] + public void InheritedMappingShouldTakePrecedenceOverNamingPolicy() + { + PreTest(typeof(PolicyBaseUser), typeof(PolicyAdminUser)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new PolicyBaseUserMap()); + c.AddMap(new PolicyAdminUserMap()); + c.UseNamingPolicy(NamingPolicy.Prefix("col")).ForEntity(); + }); + + var inheritedMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(PolicyAdminUser), "user_id"); + var policyMember = FluentMapper.Registry.GetFluentPropertyInfo(typeof(PolicyAdminUser), "colId"); + + Assert.Equal(typeof(PolicyBaseUser).GetProperty(nameof(PolicyBaseUser.Id)), inheritedMember); + Assert.Null(policyMember); + } + finally + { + PreTest(typeof(PolicyBaseUser), typeof(PolicyAdminUser)); + } + } + + [Fact] + public void NamingPolicyAndConventionShouldResolveTogether() + { + PreTest(typeof(PolicyWithConventionEntity)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase.WithPrefix("usr_")).ForEntity(); + c.AddConvention().ForEntity(); + }); + + var policyMember = SqlMapper.GetTypeMap(typeof(PolicyWithConventionEntity)).GetMember("usr_name"); + var conventionMember = SqlMapper.GetTypeMap(typeof(PolicyWithConventionEntity)).GetMember("key_id"); + + Assert.NotNull(policyMember); + Assert.NotNull(conventionMember); + Assert.Equal(typeof(PolicyWithConventionEntity).GetProperty(nameof(PolicyWithConventionEntity.Name)), policyMember.Property); + Assert.Equal(typeof(PolicyWithConventionEntity).GetProperty(nameof(PolicyWithConventionEntity.Id)), conventionMember.Property); + } + finally + { + PreTest(typeof(PolicyWithConventionEntity)); + } + } + + [Fact] + public void CaseInsensitiveNamingPolicyShouldMatchDifferentCase() + { + PreTest(typeof(CaseInsensitivePolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.Prefix("col"), caseSensitive: false).ForEntity()); + + var member = SqlMapper.GetTypeMap(typeof(CaseInsensitivePolicyEntity)).GetMember("COLName"); + + Assert.NotNull(member); + Assert.Equal(typeof(CaseInsensitivePolicyEntity).GetProperty(nameof(CaseInsensitivePolicyEntity.Name)), member.Property); + } + finally + { + PreTest(typeof(CaseInsensitivePolicyEntity)); + } + } + + [Fact] + public void NamingPolicyShouldApplySameConfigurationToDifferentTypes() + { + PreTest(typeof(FirstSharedPolicyEntity), typeof(SecondSharedPolicyEntity)); + + try + { + FluentMapper.Initialize(c => + c.UseNamingPolicy(NamingPolicy.SnakeCase) + .ForEntity() + .ForEntity()); + + var firstMember = SqlMapper.GetTypeMap(typeof(FirstSharedPolicyEntity)).GetMember("customer_id"); + var secondMember = SqlMapper.GetTypeMap(typeof(SecondSharedPolicyEntity)).GetMember("customer_id"); + + Assert.NotNull(firstMember); + Assert.NotNull(secondMember); + Assert.Equal(typeof(FirstSharedPolicyEntity).GetProperty(nameof(FirstSharedPolicyEntity.CustomerId)), firstMember.Property); + Assert.Equal(typeof(SecondSharedPolicyEntity).GetProperty(nameof(SecondSharedPolicyEntity.CustomerId)), secondMember.Property); + } + finally + { + PreTest(typeof(FirstSharedPolicyEntity), typeof(SecondSharedPolicyEntity)); + } + } + + [Fact] + public void InvalidNamingPolicyShouldThrowConfigurationException() + { + PreTest(typeof(InvalidPolicyEntity)); + + try + { + var exception = Assert.Throws(() => + FluentMapper.Initialize(c => c.UseNamingPolicy(_ => null).ForEntity())); + + Assert.Contains("empty column name", exception.Message); + Assert.Contains(nameof(InvalidPolicyEntity.Name), exception.Message); + } + finally + { + PreTest(typeof(InvalidPolicyEntity)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NamingPolicyShouldMaterializeWithDapper() + { + PreTest(typeof(IntegrationPolicyEntity)); + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + using (var connection = OpenConnection()) + { + var entity = connection.QuerySingle( + "SELECT 42 AS customer_id, 'Grace' AS first_name;"); + + Assert.Equal(42, entity.CustomerId); + Assert.Equal("Grace", entity.FirstName); + } + } + finally + { + PreTest(typeof(IntegrationPolicyEntity)); + } + } + + [Fact] + public void NamingPolicyShouldNotChangeDapperMatchNamesWithUnderscores() + { + PreTest(typeof(SnakeCaseEntity)); + var original = DefaultTypeMap.MatchNamesWithUnderscores; + + try + { + FluentMapper.Initialize(c => c.UseNamingPolicy(NamingPolicy.SnakeCase).ForEntity()); + + Assert.Equal(original, DefaultTypeMap.MatchNamesWithUnderscores); + } + finally + { + DefaultTypeMap.MatchNamesWithUnderscores = original; + PreTest(typeof(SnakeCaseEntity)); + } + } + + [Fact] + public void DapperUnderscoreMatchingShouldMapSnakeCaseOnlyWhenGlobalFlagIsEnabled() + { + PreTest(typeof(NativeUnderscoreEntity)); + var original = DefaultTypeMap.MatchNamesWithUnderscores; + + try + { + DefaultTypeMap.MatchNamesWithUnderscores = false; + var defaultMember = new DefaultTypeMap(typeof(NativeUnderscoreEntity)).GetMember("customer_id"); + + DefaultTypeMap.MatchNamesWithUnderscores = true; + var underscoreMember = new DefaultTypeMap(typeof(NativeUnderscoreEntity)).GetMember("customer_id"); + + Assert.Null(defaultMember); + Assert.NotNull(underscoreMember); + Assert.Equal(typeof(NativeUnderscoreEntity).GetProperty(nameof(NativeUnderscoreEntity.CustomerId)), underscoreMember.Property); + } + finally + { + DefaultTypeMap.MatchNamesWithUnderscores = original; + PreTest(typeof(NativeUnderscoreEntity)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private class DefaultPolicyEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class SnakeCaseEntity + { + public int CustomerId { get; set; } + } + + private class PrefixPolicyEntity + { + public string Name { get; set; } + } + + private class SuffixPolicyEntity + { + public string FirstName { get; set; } + } + + private class CustomPolicyEntity + { + public string Code { get; set; } + } + + private class ExplicitPolicyEntity + { + public string FirstName { get; set; } + } + + private class ExplicitPolicyMap : EntityMap + { + public ExplicitPolicyMap() + { + Map(e => e.FirstName).ToColumn("person_name"); + } + } + + private class PolicyBaseUser + { + public int Id { get; set; } + } + + private class PolicyAdminUser : PolicyBaseUser + { + } + + private class PolicyBaseUserMap : EntityMap + { + public PolicyBaseUserMap() + { + Map(e => e.Id).ToColumn("user_id"); + } + } + + private class PolicyAdminUserMap : EntityMap + { + public PolicyAdminUserMap() + { + IncludeBase(); + } + } + + private class PolicyWithConventionEntity + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private class KeyConvention : Convention + { + public KeyConvention() + { + Properties() + .Where(p => p.Name == "Id") + .Configure(c => c.HasColumnName("key_id")); + } + } + + private class CaseInsensitivePolicyEntity + { + public string Name { get; set; } + } + + private class FirstSharedPolicyEntity + { + public int CustomerId { get; set; } + } + + private class SecondSharedPolicyEntity + { + public int CustomerId { get; set; } + } + + private class InvalidPolicyEntity + { + public string Name { get; set; } + } + + private class IntegrationPolicyEntity + { + public int CustomerId { get; set; } + + public string FirstName { get; set; } + } + + private class NativeUnderscoreEntity + { + public int CustomerId { get; set; } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs new file mode 100644 index 0000000..e3553b2 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NestedMaterializationSpikeTests.cs @@ -0,0 +1,347 @@ +using System; +using System.Reflection; +using Dapper; +using Dapper.FluentMap.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NestedMaterializationSpikeTests + { + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotTreatNestedMutablePathAsRootProperty() + { + PreTest(typeof(NestedMutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NestedMutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle("SELECT 'Recife' AS city;"); + + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(NestedMutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedPathsWithSameTerminalShouldBeConfiguredButDapperQueryShouldNotMaterializeThem() + { + PreTest(typeof(SameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SameTerminalCustomerMap())); + + var explanation = FluentMapper.Explain(); + Assert.Contains(explanation.Members, m => m.MemberPath == "Rank.Level" && m.ColumnName == "rank_level"); + Assert.Contains(explanation.Members, m => m.MemberPath == "Seniority.Level" && m.ColumnName == "seniority_level"); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT 'gold' AS rank_level, 'staff' AS seniority_level;"); + + Assert.Null(customer.Rank); + Assert.Null(customer.Seniority); + } + } + finally + { + PreTest(typeof(SameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void TypeHandlerShouldMaterializeScalarValueObjectProperty() + { + PreTest(typeof(ScalarValueObjectCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new ScalarValueObjectCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT '12345678909' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(ScalarValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void DapperQueryShouldNotUseTypeHandlerForNestedValueObjectPath() + { + PreTest(typeof(NestedValueObjectCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new NestedValueObjectCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle( + "SELECT '12345678909' AS cpf;"); + + Assert.Null(customer.Cpf); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(NestedValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void NestedRecordShouldNotMaterializeThroughConstructorMapping() + { + PreTest(typeof(RecordCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new RecordCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws(() => + connection.QuerySingle( + "SELECT 42 AS customer_id, 'Olinda' AS city;")); + + Assert.Contains("constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + } + } + finally + { + PreTest(typeof(RecordCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void PureITypeMapReturningNestedLeafPropertyShouldWriteLeafValueIntoRootSlot() + { + PreTest(typeof(PureTypeMapCustomer)); + + try + { + SqlMapper.SetTypeMap( + typeof(PureTypeMapCustomer), + new LeafPropertyTypeMap(typeof(PureTypeMapAddress).GetProperty(nameof(PureTypeMapAddress.City)))); + + using (var connection = OpenConnection()) + { + var customer = connection.QuerySingle("SELECT 'Natal' AS city;"); + + var assignedValue = (object)customer.Address; + + Assert.IsType(assignedValue); + Assert.Equal("Natal", assignedValue); + } + } + finally + { + PreTest(typeof(PureTypeMapCustomer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class NestedMutableCustomer + { + public NestedMutableAddress Address { get; set; } + } + + private sealed class NestedMutableAddress + { + public string City { get; set; } + } + + private sealed class NestedMutableCustomerMap : EntityMap + { + public NestedMutableCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class SameTerminalCustomer + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private sealed class RankInfo + { + public string Level { get; set; } + } + + private sealed class SeniorityInfo + { + public string Level { get; set; } + } + + private sealed class SameTerminalCustomerMap : EntityMap + { + public SameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class ScalarValueObjectCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class ScalarValueObjectCustomerMap : EntityMap + { + public ScalarValueObjectCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class NestedValueObjectCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class NestedValueObjectCustomerMap : EntityMap + { + public NestedValueObjectCustomerMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + Number = number; + } + + public string Number { get; } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed record RecordAddress(string City); + + private sealed record RecordCustomer(int Id, RecordAddress Address); + + private sealed class RecordCustomerMap : EntityMap + { + public RecordCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class PureTypeMapCustomer + { + public PureTypeMapAddress Address { get; set; } + } + + private sealed class PureTypeMapAddress + { + public string City { get; set; } + } + + private sealed class LeafPropertyTypeMap : SqlMapper.ITypeMap + { + private readonly PropertyInfo _property; + + public LeafPropertyTypeMap(PropertyInfo property) + { + _property = property; + } + + public ConstructorInfo FindConstructor(string[] names, Type[] types) + { + return typeof(PureTypeMapCustomer).GetConstructor(Type.EmptyTypes); + } + + public ConstructorInfo FindExplicitConstructor() + { + return null; + } + + public SqlMapper.IMemberMap GetConstructorParameter(ConstructorInfo constructor, string columnName) + { + return null; + } + + public SqlMapper.IMemberMap GetMember(string columnName) + { + return new LeafMemberMap(columnName, _property); + } + } + + private sealed class LeafMemberMap : SqlMapper.IMemberMap + { + public LeafMemberMap(string columnName, PropertyInfo property) + { + ColumnName = columnName; + Property = property; + } + + public string ColumnName { get; } + + public Type MemberType => Property.PropertyType; + + public PropertyInfo Property { get; } + + public FieldInfo Field => null; + + public ParameterInfo Parameter => null; + } + } +} diff --git a/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs new file mode 100644 index 0000000..cfe5e89 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/NestedObjectMaterializationTests.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class NestedObjectMaterializationTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleNestedObject() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Sao Paulo' AS city;"); + + Assert.Equal(7, customer.Id); + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeThreeLevelNestedObject() + { + PreTest(typeof(CustomerWithCountry)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCountryMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Brazil' AS country_name;"); + + Assert.NotNull(customer.Address); + Assert.NotNull(customer.Address.Country); + Assert.Equal("Brazil", customer.Address.Country.Name); + } + } + finally + { + PreTest(typeof(CustomerWithCountry)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPreserveSameTerminalMemberPaths() + { + PreTest(typeof(SameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SameTerminalCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 10 AS rank_level, 20 AS seniority_level;"); + + Assert.NotNull(customer.Rank); + Assert.NotNull(customer.Seniority); + Assert.Equal(10, customer.Rank.Level); + Assert.Equal(20, customer.Seniority.Level); + } + } + finally + { + PreTest(typeof(SameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyNamingPolicyToRootPropertiesAndExplicitNestedMappings() + { + PreTest(typeof(PolicyCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddMap(new PolicyCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 42 AS CUSTOMER_ID, 'Campinas' AS city;"); + + Assert.Equal(42, customer.CustomerId); + Assert.NotNull(customer.Address); + Assert.Equal("Campinas", customer.Address.City); + } + } + finally + { + PreTest(typeof(PolicyCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyInheritedNestedMapping() + { + PreTest(typeof(BaseCustomer), typeof(DerivedCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new BaseCustomerMap()); + c.AddMap(new DerivedCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Recife' AS city, 'vip' AS tier;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Recife", customer.Address.City); + Assert.Equal("vip", customer.Tier); + } + } + finally + { + PreTest(typeof(BaseCustomer), typeof(DerivedCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldKeepNestedObjectNullWhenAllNestedColumnsAreNull() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, NULL AS city;"); + + Assert.Equal(7, customer.Id); + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldCreateNestedObjectWhenSomeNestedColumnsAreNotNull() + { + PreTest(typeof(CustomerWithPostalCode)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithPostalCodeMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS city, '01000' AS postal_code;"); + + Assert.NotNull(customer.Address); + Assert.Null(customer.Address.City); + Assert.Equal("01000", customer.Address.PostalCode); + } + } + finally + { + PreTest(typeof(CustomerWithPostalCode)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseExistingIntermediateObjectWhenAvailable() + { + PreTest(typeof(CustomerWithExistingAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithExistingAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Niteroi' AS city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("created by constructor", customer.Address.CreatedBy); + Assert.Equal("Niteroi", customer.Address.City); + } + } + finally + { + PreTest(typeof(CustomerWithExistingAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldClearExistingIntermediateObjectWhenAllNestedColumnsAreNull() + { + PreTest(typeof(CustomerWithExistingAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithExistingAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT NULL AS city;"); + + Assert.Null(customer.Address); + } + } + finally + { + PreTest(typeof(CustomerWithExistingAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeMultipleRows() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + using (var connection = OpenConnection()) + { + var customers = connection.QueryMapped( + "SELECT 1 AS customer_id, 'Santos' AS city UNION ALL SELECT 2, 'Osasco';") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(1, first.Id); + Assert.Equal("Santos", first.Address.City); + }, + second => + { + Assert.Equal(2, second.Id); + Assert.Equal("Osasco", second.Address.City); + }); + } + } + finally + { + PreTest(typeof(Customer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldContinueMaterializingTraditionalPocoFallback() + { + PreTest(typeof(TraditionalCustomer)); + + try + { + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 9 AS Id, 'Curitiba' AS Name;"); + + Assert.Equal(9, customer.Id); + Assert.Equal("Curitiba", customer.Name); + } + } + finally + { + PreTest(typeof(TraditionalCustomer)); + } + } + + [Fact] + public void InitializeShouldRejectUnsupportedCollectionInNestedPath() + { + PreTest(typeof(CollectionPathCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new CollectionPathCustomerMap()))); + + Assert.Contains("collection", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Items.Value", exception.Message, StringComparison.Ordinal); + } + finally + { + PreTest(typeof(CollectionPathCustomer)); + } + } + + [Fact] + public void QueryMappedShouldRejectNestedTypeWithoutPublicParameterlessConstructor() + { + PreTest(typeof(NonConstructibleCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new NonConstructibleCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle("SELECT 'Sao Paulo' AS city;")); + + Assert.Contains("No public constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + } + } + finally + { + PreTest(typeof(NonConstructibleCustomer)); + } + } + + [Fact] + public void QueryMappedShouldRejectReadonlyNestedPathWithoutMatchingConstructor() + { + PreTest(typeof(ReadOnlyPathCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ReadOnlyPathCustomerMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle("SELECT 'Sao Paulo' AS city;")); + + Assert.Contains("No public constructor", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + Assert.Contains("city", exception.Message, StringComparison.Ordinal); + } + } + finally + { + PreTest(typeof(ReadOnlyPathCustomer)); + } + } + + [Fact] + public void InitializeShouldRejectConflictingNestedPathPrefix() + { + PreTest(typeof(ConflictingPathCustomer)); + + try + { + var exception = Assert.Throws( + () => FluentMapper.Initialize(c => c.AddMap(new ConflictingPathCustomerMap()))); + + Assert.Contains("conflicts", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Address", exception.Message, StringComparison.Ordinal); + Assert.Contains("Address.City", exception.Message, StringComparison.Ordinal); + } + finally + { + PreTest(typeof(ConflictingPathCustomer)); + } + } + + [Fact] + public void ExplainShouldDescribeNestedMaterialization() + { + PreTest(typeof(Customer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerMap())); + + var explanation = FluentMapper.Explain(); + var city = explanation.Members.Single(m => m.MemberPath == "Address.City"); + + Assert.Equal("city", city.ColumnName); + Assert.Equal(MappingSource.Explicit, city.Source); + Assert.Equal(MappingMaterialization.Nested, city.Materialization); + } + finally + { + PreTest(typeof(Customer)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class Customer + { + public int Id { get; set; } + + public Address Address { get; set; } + } + + private sealed class CustomerMap : EntityMap + { + public CustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class Address + { + public string City { get; set; } + } + + private sealed class CustomerWithCountry + { + public AddressWithCountry Address { get; set; } + } + + private sealed class CustomerWithCountryMap : EntityMap + { + public CustomerWithCountryMap() + { + Map(customer => customer.Address.Country.Name).ToColumn("country_name"); + } + } + + private sealed class AddressWithCountry + { + public Country Country { get; set; } + } + + private sealed class Country + { + public string Name { get; set; } + } + + private sealed class SameTerminalCustomer + { + public RankInfo Rank { get; set; } + + public SeniorityInfo Seniority { get; set; } + } + + private sealed class RankInfo + { + public int Level { get; set; } + } + + private sealed class SeniorityInfo + { + public int Level { get; set; } + } + + private sealed class SameTerminalCustomerMap : EntityMap + { + public SameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class PolicyCustomer + { + public int CustomerId { get; set; } + + public PolicyAddress Address { get; set; } + } + + private sealed class PolicyAddress + { + public string City { get; set; } + } + + private sealed class PolicyCustomerMap : EntityMap + { + public PolicyCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private class BaseCustomer + { + public Address Address { get; set; } + } + + private sealed class DerivedCustomer : BaseCustomer + { + public string Tier { get; set; } + } + + private sealed class BaseCustomerMap : EntityMap + { + public BaseCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class DerivedCustomerMap : EntityMap + { + public DerivedCustomerMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } + } + + private sealed class CustomerWithPostalCode + { + public PostalAddress Address { get; set; } + } + + private sealed class PostalAddress + { + public string City { get; set; } + + public string PostalCode { get; set; } + } + + private sealed class CustomerWithPostalCodeMap : EntityMap + { + public CustomerWithPostalCodeMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + Map(customer => customer.Address.PostalCode).ToColumn("postal_code"); + } + } + + private sealed class CustomerWithExistingAddress + { + public CustomerWithExistingAddress() + { + Address = new ExistingAddress { CreatedBy = "created by constructor" }; + } + + public ExistingAddress Address { get; set; } + } + + private sealed class ExistingAddress + { + public string CreatedBy { get; set; } + + public string City { get; set; } + } + + private sealed class CustomerWithExistingAddressMap : EntityMap + { + public CustomerWithExistingAddressMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class TraditionalCustomer + { + public int Id { get; set; } + + public string Name { get; set; } + } + + private sealed class CollectionPathCustomer + { + public CollectionItem Items { get; set; } + } + + private sealed class CollectionItem : List + { + public string Value { get; set; } + } + + private sealed class CollectionLeaf + { + public string Value { get; set; } + } + + private sealed class CollectionPathCustomerMap : EntityMap + { + public CollectionPathCustomerMap() + { + Map(customer => customer.Items.Value).ToColumn("value"); + } + } + + private sealed class NonConstructibleCustomer + { + public NonConstructibleAddress Address { get; set; } + } + + private sealed class NonConstructibleAddress + { + public NonConstructibleAddress(string seed) + { + City = seed; + } + + public string City { get; set; } + } + + private sealed class NonConstructibleCustomerMap : EntityMap + { + public NonConstructibleCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class ReadOnlyPathCustomer + { + public ReadOnlyAddress Address { get; set; } + } + + private sealed class ReadOnlyAddress + { + public string City { get; } + } + + private sealed class ReadOnlyPathCustomerMap : EntityMap + { + public ReadOnlyPathCustomerMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class ConflictingPathCustomer + { + public Address Address { get; set; } + } + + private sealed class ConflictingPathCustomerMap : EntityMap + { + public ConflictingPathCustomerMap() + { + Map(customer => customer.Address).ToColumn("address"); + Map(customer => customer.Address.City).ToColumn("city"); + } + } + } +} diff --git a/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs b/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs new file mode 100644 index 0000000..6a77e57 --- /dev/null +++ b/test/Dapper.FluentMap.Tests/ValueObjectMaterializationTests.cs @@ -0,0 +1,808 @@ +using System; +using System.Linq; +using Dapper; +using Dapper.FluentMap.Diagnostics; +using Dapper.FluentMap.Mapping; +using Dapper.FluentMap.Naming; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.FluentMap.Tests +{ + public class ValueObjectMaterializationTests + { + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleValueObjectThroughConstructor() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 1 AS customer_id, '12345678909' AS cpf;"); + + Assert.Equal(1, customer.Id); + Assert.NotNull(customer.Cpf); + Assert.Equal("12345678909", customer.Cpf.Number); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSingleValueRecordThroughConstructor() + { + PreTest(typeof(CustomerWithEmail)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithEmailMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 2 AS customer_id, 'ada@example.com' AS email;"); + + Assert.Equal(2, customer.Id); + Assert.Equal(new Email("ada@example.com"), customer.Email); + } + } + finally + { + PreTest(typeof(CustomerWithEmail)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeMultiComponentValueObjectThroughConstructor() + { + PreTest(typeof(CustomerWithMoney)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithMoneyMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 12.50 AS amount, 'BRL' AS currency;"); + + Assert.NotNull(customer.Balance); + Assert.Equal(12.50m, customer.Balance.Amount); + Assert.Equal("BRL", customer.Balance.Currency); + } + } + finally + { + PreTest(typeof(CustomerWithMoney)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPassNullForNullableValueObjectWhenSqlValueIsNull() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 3 AS customer_id, NULL AS cpf;"); + + Assert.Equal(3, customer.Id); + Assert.Null(customer.Cpf); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldWrapDomainExceptionWithMappingContext() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 4 AS customer_id, '' AS cpf;")); + + Assert.IsType(exception.InnerException); + Assert.Contains(typeof(CustomerWithCpf).FullName, exception.Message); + Assert.Contains(typeof(Cpf).FullName, exception.Message); + Assert.Contains("Cpf", exception.Message); + Assert.Contains("cpf", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeNestedImmutableObject() + { + PreTest(typeof(CustomerWithImmutableAddress)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithImmutableAddressMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 'Sao Paulo' AS city;"); + + Assert.NotNull(customer.Address); + Assert.Equal("Sao Paulo", customer.Address.City); + } + } + finally + { + PreTest(typeof(CustomerWithImmutableAddress)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeTwoValueObjectsInSameEntity() + { + PreTest(typeof(CustomerWithTwoCpfs)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithTwoCpfsMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '11111111111' AS cpf, '22222222222' AS backup_cpf;"); + + Assert.Equal("11111111111", customer.Cpf.Number); + Assert.Equal("22222222222", customer.BackupCpf.Number); + } + } + finally + { + PreTest(typeof(CustomerWithTwoCpfs)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldPreserveSameTerminalInImmutablePaths() + { + PreTest(typeof(ImmutableSameTerminalCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new ImmutableSameTerminalCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 5 AS rank_level, 9 AS seniority_level;"); + + Assert.Equal(5, customer.Rank.Level); + Assert.Equal(9, customer.Seniority.Level); + } + } + finally + { + PreTest(typeof(ImmutableSameTerminalCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyNamingPolicyToImmutableRootConstructor() + { + PreTest(typeof(PolicyValueObjectCustomer)); + + try + { + FluentMapper.Initialize(c => + { + c.UseNamingPolicy(NamingPolicy.SnakeCase, caseSensitive: false).ForEntity(); + c.AddMap(new PolicyValueObjectCustomerMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 6 AS CUSTOMER_ID, 'grace@example.com' AS email;"); + + Assert.Equal(6, customer.CustomerId); + Assert.Equal("grace@example.com", customer.Email.Value); + } + } + finally + { + PreTest(typeof(PolicyValueObjectCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldApplyInheritedValueObjectMapping() + { + PreTest(typeof(BaseCustomerWithCpf), typeof(DerivedCustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => + { + c.AddMap(new BaseCustomerWithCpfMap()); + c.AddMap(new DerivedCustomerWithCpfMap()); + }); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '33333333333' AS cpf, 'vip' AS tier;"); + + Assert.Equal("33333333333", customer.Cpf.Number); + Assert.Equal("vip", customer.Tier); + } + } + finally + { + PreTest(typeof(BaseCustomerWithCpf), typeof(DerivedCustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeSimpleImmutableConstructorMapping() + { + PreTest(typeof(SimpleImmutableCustomer)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new SimpleImmutableCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT 7 AS customer_id, 'Katherine Johnson' AS full_name;"); + + Assert.Equal(7, customer.Id); + Assert.Equal("Katherine Johnson", customer.FullName); + } + } + finally + { + PreTest(typeof(SimpleImmutableCustomer)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldMaterializeValueObjectsAcrossMultipleRows() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + using (var connection = OpenConnection()) + { + var customers = connection.QueryMapped( + "SELECT 8 AS customer_id, '44444444444' AS cpf UNION ALL SELECT 9, '55555555555';") + .ToList(); + + Assert.Collection( + customers, + first => + { + Assert.Equal(8, first.Id); + Assert.Equal("44444444444", first.Cpf.Number); + }, + second => + { + Assert.Equal(9, second.Id); + Assert.Equal("55555555555", second.Cpf.Number); + }); + } + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + [Fact] + [Trait("Category", "Integration")] + public void QueryMappedShouldUseDapperTypeHandlerForScalarValueObjectProperty() + { + PreTest(typeof(HandlerCustomer)); + + try + { + SqlMapper.AddTypeHandler(new CpfTypeHandler()); + FluentMapper.Initialize(c => c.AddMap(new HandlerCustomerMap())); + + using (var connection = OpenConnection()) + { + var customer = connection.QueryMappedSingle( + "SELECT '66666666666' AS cpf;"); + + Assert.NotNull(customer.Cpf); + Assert.Equal("66666666666", customer.Cpf.Number); + } + } + finally + { + SqlMapper.ResetTypeHandlers(); + PreTest(typeof(HandlerCustomer)); + } + } + + [Fact] + public void QueryMappedShouldRejectMissingConstructorParameterColumn() + { + PreTest(typeof(CustomerWithIncompleteValueObject)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithIncompleteValueObjectMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT '77777777777' AS cpf;")); + + Assert.Contains("No public constructor", exception.Message); + Assert.Contains("IncompleteCpf", exception.Message); + Assert.Contains("cpf", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithIncompleteValueObject)); + } + } + + [Fact] + public void QueryMappedShouldRejectAmbiguousValueObjectConstructors() + { + PreTest(typeof(CustomerWithAmbiguousValueObject)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithAmbiguousValueObjectMap())); + + using (var connection = OpenConnection()) + { + var exception = Assert.Throws( + () => connection.QueryMappedSingle( + "SELECT 'abc' AS code;")); + + Assert.Contains("multiple public constructors", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AmbiguousCode", exception.Message); + Assert.Contains("Code", exception.Message); + } + } + finally + { + PreTest(typeof(CustomerWithAmbiguousValueObject)); + } + } + + [Fact] + public void ExplainShouldDescribeValueObjectMaterialization() + { + PreTest(typeof(CustomerWithCpf)); + + try + { + FluentMapper.Initialize(c => c.AddMap(new CustomerWithCpfMap())); + + var explanation = FluentMapper.Explain(); + var cpf = explanation.Members.Single(m => m.MemberPath == "Cpf.Number"); + + Assert.Equal("cpf", cpf.ColumnName); + Assert.Equal(MappingSource.Explicit, cpf.Source); + Assert.Equal(MappingMaterialization.ValueObject, cpf.Materialization); + } + finally + { + PreTest(typeof(CustomerWithCpf)); + } + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static void PreTest(params Type[] types) + { + FluentMapper.Reset(types); + } + + private sealed class CustomerWithCpf + { + public CustomerWithCpf(int id, Cpf cpf) + { + Id = id; + Cpf = cpf; + } + + public int Id { get; } + + public Cpf Cpf { get; } + } + + private sealed class CustomerWithCpfMap : EntityMap + { + public CustomerWithCpfMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class Cpf + { + public Cpf(string number) + { + if (string.IsNullOrWhiteSpace(number)) + { + throw new ArgumentException("CPF cannot be empty.", nameof(number)); + } + + Number = number; + } + + public string Number { get; } + } + + private sealed record Email(string Value); + + private sealed class CustomerWithEmail + { + public CustomerWithEmail(int id, Email email) + { + Id = id; + Email = email; + } + + public int Id { get; } + + public Email Email { get; } + } + + private sealed class CustomerWithEmailMap : EntityMap + { + public CustomerWithEmailMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private sealed class Money + { + public Money(decimal amount, string currency) + { + Amount = amount; + Currency = currency; + } + + public decimal Amount { get; } + + public string Currency { get; } + } + + private sealed class CustomerWithMoney + { + public CustomerWithMoney(Money balance) + { + Balance = balance; + } + + public Money Balance { get; } + } + + private sealed class CustomerWithMoneyMap : EntityMap + { + public CustomerWithMoneyMap() + { + Map(customer => customer.Balance.Amount).ToColumn("amount"); + Map(customer => customer.Balance.Currency).ToColumn("currency"); + } + } + + private sealed class CustomerWithImmutableAddress + { + public CustomerWithImmutableAddress(ImmutableAddress address) + { + Address = address; + } + + public ImmutableAddress Address { get; } + } + + private sealed class ImmutableAddress + { + public ImmutableAddress(string city) + { + City = city; + } + + public string City { get; } + } + + private sealed class CustomerWithImmutableAddressMap : EntityMap + { + public CustomerWithImmutableAddressMap() + { + Map(customer => customer.Address.City).ToColumn("city"); + } + } + + private sealed class CustomerWithTwoCpfs + { + public CustomerWithTwoCpfs(Cpf cpf, Cpf backupCpf) + { + Cpf = cpf; + BackupCpf = backupCpf; + } + + public Cpf Cpf { get; } + + public Cpf BackupCpf { get; } + } + + private sealed class CustomerWithTwoCpfsMap : EntityMap + { + public CustomerWithTwoCpfsMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + Map(customer => customer.BackupCpf.Number).ToColumn("backup_cpf"); + } + } + + private sealed class ImmutableSameTerminalCustomer + { + public ImmutableSameTerminalCustomer(ImmutableRank rank, ImmutableSeniority seniority) + { + Rank = rank; + Seniority = seniority; + } + + public ImmutableRank Rank { get; } + + public ImmutableSeniority Seniority { get; } + } + + private sealed class ImmutableRank + { + public ImmutableRank(int level) + { + Level = level; + } + + public int Level { get; } + } + + private sealed class ImmutableSeniority + { + public ImmutableSeniority(int level) + { + Level = level; + } + + public int Level { get; } + } + + private sealed class ImmutableSameTerminalCustomerMap : EntityMap + { + public ImmutableSameTerminalCustomerMap() + { + Map(customer => customer.Rank.Level).ToColumn("rank_level"); + Map(customer => customer.Seniority.Level).ToColumn("seniority_level"); + } + } + + private sealed class PolicyValueObjectCustomer + { + public PolicyValueObjectCustomer(int customerId, Email email) + { + CustomerId = customerId; + Email = email; + } + + public int CustomerId { get; } + + public Email Email { get; } + } + + private sealed class PolicyValueObjectCustomerMap : EntityMap + { + public PolicyValueObjectCustomerMap() + { + Map(customer => customer.Email.Value).ToColumn("email"); + } + } + + private class BaseCustomerWithCpf + { + public BaseCustomerWithCpf(Cpf cpf) + { + Cpf = cpf; + } + + public Cpf Cpf { get; } + } + + private sealed class DerivedCustomerWithCpf : BaseCustomerWithCpf + { + public DerivedCustomerWithCpf(Cpf cpf, string tier) + : base(cpf) + { + Tier = tier; + } + + public string Tier { get; } + } + + private sealed class BaseCustomerWithCpfMap : EntityMap + { + public BaseCustomerWithCpfMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class DerivedCustomerWithCpfMap : EntityMap + { + public DerivedCustomerWithCpfMap() + { + IncludeBase(); + Map(customer => customer.Tier).ToColumn("tier"); + } + } + + private sealed class SimpleImmutableCustomer + { + public SimpleImmutableCustomer(int id, string fullName) + { + Id = id; + FullName = fullName; + } + + public int Id { get; } + + public string FullName { get; } + } + + private sealed class SimpleImmutableCustomerMap : EntityMap + { + public SimpleImmutableCustomerMap() + { + Map(customer => customer.Id).ToColumn("customer_id"); + Map(customer => customer.FullName).ToColumn("full_name"); + } + } + + private sealed class HandlerCustomer + { + public Cpf Cpf { get; set; } + } + + private sealed class HandlerCustomerMap : EntityMap + { + public HandlerCustomerMap() + { + Map(customer => customer.Cpf).ToColumn("cpf"); + } + } + + private sealed class CpfTypeHandler : SqlMapper.TypeHandler + { + public override Cpf Parse(object value) + { + return new Cpf((string)value); + } + + public override void SetValue(System.Data.IDbDataParameter parameter, Cpf value) + { + parameter.Value = value == null ? DBNull.Value : value.Number; + } + } + + private sealed class CustomerWithIncompleteValueObject + { + public CustomerWithIncompleteValueObject(IncompleteCpf cpf) + { + Cpf = cpf; + } + + public IncompleteCpf Cpf { get; } + } + + private sealed class IncompleteCpf + { + public IncompleteCpf(string number, string kind) + { + Number = number; + Kind = kind; + } + + public string Number { get; } + + public string Kind { get; } + } + + private sealed class CustomerWithIncompleteValueObjectMap : EntityMap + { + public CustomerWithIncompleteValueObjectMap() + { + Map(customer => customer.Cpf.Number).ToColumn("cpf"); + } + } + + private sealed class CustomerWithAmbiguousValueObject + { + public CustomerWithAmbiguousValueObject(AmbiguousCode code) + { + Code = code; + } + + public AmbiguousCode Code { get; } + } + + private sealed class AmbiguousCode + { + public AmbiguousCode(object value) + { + Value = (string)value; + } + + public AmbiguousCode(IComparable value) + { + Value = value.ToString(); + } + + public string Value { get; } + } + + private sealed class CustomerWithAmbiguousValueObjectMap : EntityMap + { + public CustomerWithAmbiguousValueObjectMap() + { + Map(customer => customer.Code.Value).ToColumn("code"); + } + } + } +}