From 80c76f5277abbf69a7b1d65e8a666ce04d1f876c Mon Sep 17 00:00:00 2001 From: ericksonlopezf Date: Mon, 21 Sep 2026 10:43:25 -0400 Subject: [PATCH 1/3] feat!: segregate core abstractions, harden expression engine, and enforce AST safety --- .editorconfig | 8 +- .github/ISSUE_TEMPLATE/bug-report.md | 5 +- .github/ISSUE_TEMPLATE/feature-request.md | 2 +- .github/workflows/aot-smoke-test.yml | 6 +- .../workflows/benchmark-regression-gate.yml | 118 +--- .github/workflows/benchmarks.yml | 6 +- .github/workflows/dotnet-build-test.yml | 12 +- .github/workflows/mutation-testing.yml | 38 +- .github/workflows/publish.yml | 32 +- .github/workflows/release-please.yml | 5 +- .github/workflows/repo-compliance.yml | 16 +- .github/workflows/weekly-benchmarks.yml | 6 +- .gitignore | 91 ++- CHANGELOG.md | 117 +++- CONTRIBUTING.md | 6 +- Directory.Build.props | 34 +- Directory.Packages.props | 21 +- EricksonLopez.Specifications.slnx | 1 + LICENSE | 2 +- README.md | 68 +- SECURITY.md | 18 +- SUPPORT.md | 2 +- ...cksonLopez.Specification.Benchmarks.csproj | 2 + ...sionCompositionBenchmarks-report-github.md | 17 + ...ExpressionCompositionBenchmarks-report.csv | 4 + ...xpressionCompositionBenchmarks-report.html | 34 + ...s.QuerySpecLinqBenchmarks-report-github.md | 16 + ...chmarks.QuerySpecLinqBenchmarks-report.csv | 3 + ...hmarks.QuerySpecLinqBenchmarks-report.html | 33 + ...SpanCompositionBenchmarks-report-github.md | 17 + ...marks.SpanCompositionBenchmarks-report.csv | 4 + ...arks.SpanCompositionBenchmarks-report.html | 34 + ...icationCreationBenchmarks-report-github.md | 17 + ...SpecificationCreationBenchmarks-report.csv | 4 + ...pecificationCreationBenchmarks-report.html | 34 + ...ationEvaluationBenchmarks-report-github.md | 17 + ...ecificationEvaluationBenchmarks-report.csv | 4 + ...cificationEvaluationBenchmarks-report.html | 34 + ....SqlTranslationBenchmarks-report-github.md | 16 + ...hmarks.SqlTranslationBenchmarks-report.csv | 3 + ...marks.SqlTranslationBenchmarks-report.html | 33 + benchmarks/results/baseline.json | 39 ++ docs/adr-index.md | 56 +- docs/adr/adr-001-no-write-repository.md | 6 + docs/adr/adr-002-no-include-theninclude.md | 6 + .../adr/adr-003-no-dynamic-string-ordering.md | 6 + docs/adr/adr-004-no-sat-simplification.md | 6 + docs/adr/adr-005-no-xor-composition.md | 6 + ...-006-specification-queryspec-separation.md | 6 + ...adr-007-no-fluentvalidation-integration.md | 6 + ...ession-trees-as-internal-representation.md | 6 + docs/adr/adr-009-aot-first-design.md | 11 +- docs/adr/adr-010-no-efcore-in-core.md | 6 + docs/adr/adr-011-no-dynamic-string-queries.md | 6 + docs/adr/adr-012-projection-boundary.md | 6 + docs/adr/adr-013-no-raw-sql.md | 6 + .../adr-014-no-dynamic-reflection-queries.md | 6 + ...r-015-no-groupby-aggregation-selectmany.md | 6 + ...r-016-no-auto-generated-buildexpression.md | 6 + docs/adr/adr-017-no-async-specifications.md | 6 + ...-asnotracking-splitquery-from-queryspec.md | 6 + ...pression-compilation-cache-key-strategy.md | 6 + docs/adr/adr-020-source-generator-strategy.md | 6 + docs/adr/adr-021-querypancache-lru-bounded.md | 6 + ...ef-core-tight-coupling-in-specification.md | 7 + docs/api-reference.md | 375 +++++++++++ docs/architecture.md | 217 ++++--- docs/audit/final-audit.md | 14 +- docs/audit/line-by-line-revalidation.md | 16 +- docs/build-and-ci.md | 293 +++++---- docs/ci-cd-pipelines.md | 1 + docs/cookbook.md | 77 +++ docs/faq.md | 83 +++ docs/feature-matrix.md | 4 +- docs/getting-started.md | 169 +++++ docs/migration-from-ardalis.md | 4 +- docs/migration-guide.md | 5 +- docs/nuget-packages.md | 20 +- docs/public-api-surface.md | 20 +- docs/quick-start.md | 175 ++++++ docs/roadmap.md | 2 +- docs/showcase/diagrams.md | 299 +++++++++ .../showcase/level-04-advanced-integration.md | 40 ++ docs/showcase/level-05-processing.md | 52 ++ docs/showcase/level-06-error-handling.md | 48 ++ docs/showcase/level-07-scalability.md | 79 +++ docs/showcase/level-08-customization.md | 63 ++ docs/showcase/level-09-official-extensions.md | 79 +++ .../level-10-enterprise-architecture.md | 63 ++ docs/system-overview.md | 2 +- docs/troubleshooting.md | 143 +++++ docs/when-to-use-specification.md | 146 +++++ .../NativeAotDapper/NativeAotDapper.csproj | 1 + samples/Showcase/Domain/Customer.cs | 3 + ...ricksonLopez.Specification.Showcase.csproj | 3 + .../Levels/Level10_EnterpriseArchitecture.cs | 12 +- samples/Showcase/Levels/Level1_QuickStart.cs | 59 +- .../Showcase/Levels/Level3_RealUseCases.cs | 23 + .../Showcase/Levels/Level6_ErrorHandling.cs | 10 + .../Showcase/Levels/Level8_Customization.cs | 6 +- samples/Showcase/Levels/Level9_Extensions.cs | 61 +- samples/Showcase/README.md | 118 ++++ scripts/run-targeted-mutation.ps1 | 174 ++++++ scripts/verify-benchmark-gate.ps1 | 296 +++++++++ scripts/verify-benchmark-gate.test.ps1 | 166 +++++ scripts/verify-compliance.ps1 | 590 +++++++++++++++++- scripts/verify-standards.js | 8 +- ...sonLopez.Specification.Abstractions.csproj | 3 +- .../ExpressionDebugFormatterRegistry.cs | 34 + .../IExpressionSpecification.cs | 6 +- .../IReadRepository.cs | 12 + .../ISpecification.cs | 2 +- ...icksonLopez.Specification.Analyzers.csproj | 4 +- .../SpecificationDiagnosticDescriptors.cs | 22 +- .../EricksonLopez.Specification.Dapper.csproj | 2 +- ...opez.Specification.DapperExtensions.csproj | 2 +- ...z.Specification.EntityFrameworkCore.csproj | 3 +- .../QuerySpecEfCoreExtensions.cs | 2 + ...cksonLopez.Specification.Generators.csproj | 4 +- .../EricksonLopez.Specification.Linq.csproj | 4 +- .../QuerySpecLinqExtensions.cs | 164 ++++- ...EricksonLopez.Specification.MariaDb.csproj | 2 +- ...EricksonLopez.Specification.MongoDB.csproj | 2 +- .../MongoSpecificationEvaluator.cs | 28 + .../EricksonLopez.Specification.MsSql.csproj | 2 +- .../EricksonLopez.Specification.MySql.csproj | 2 +- .../EricksonLopez.Specification.Oracle.csproj | 2 +- ...cksonLopez.Specification.PostgreSql.csproj | 2 +- .../EricksonLopez.Specification.Result.csproj | 4 +- .../ReadRepositoryResultExtensions.cs | 16 + .../EricksonLopez.Specification.Sql.csproj | 2 +- .../QuerySpecTranslator.cs | 29 +- .../RawPredicateNode.cs | 2 +- .../SqlQueryType.cs | 6 +- .../EricksonLopez.Specification.Sqlite.csproj | 2 +- .../Diagnostics/SpecificationVersion.cs | 4 +- .../Engine/ExpressionDebugFormatter.cs | 20 +- .../Engine/ExpressionEqualityComparer.cs | 84 ++- .../Engine/ExpressionHasher.cs | 44 +- .../Engine/ExpressionInterpreter.cs | 142 ++++- .../EricksonLopez.Specification.csproj | 2 +- src/EricksonLopez.Specification/Spec.cs | 128 ++++ .../Specification.cs | 89 +++ .../TypeForwarders.cs | 4 + stryker-abstractions-config.json | 3 +- stryker-analyzers-config.json | 3 +- stryker-config.json | 3 +- stryker-dapper-config.json | 3 +- stryker-dapperextensions-config.json | 3 +- stryker-efcore-config.json | 3 +- stryker-generators-config.json | 3 +- stryker-linq-config.json | 3 +- stryker-mariadb-config.json | 3 +- stryker-mongodb-config.json | 3 +- stryker-mssql-config.json | 3 +- stryker-mysql-config.json | 3 +- stryker-oracle-config.json | 3 +- stryker-postgresql-config.json | 3 +- stryker-result-config.json | 27 + stryker-sql-config.json | 3 +- stryker-sqlite-config.json | 3 +- ...sonLopez.Specification.Dapper.Tests.csproj | 1 + .../MongoSpecificationEvaluatorTests.cs | 14 + ...ication.PostgreSql.IntegrationTests.csproj | 1 + .../AdversarialRegressionTests.cs | 226 +++++++ .../ConcurrencyAuditTests.cs | 74 +++ .../EricksonLopez.Specification.Tests.csproj | 2 + .../FuzzingEngineTests.cs | 83 +++ .../ReadRepositoryResultExtensionsTests.cs | 130 ++++ .../SpecTests.cs | 81 +++ .../SpecificationLinqExtensionsTests.cs | 177 ++++++ tests/tests_tree.txt | 4 +- 172 files changed, 6259 insertions(+), 683 deletions(-) create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.html create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.html create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.html create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.html create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.html create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report-github.md create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.csv create mode 100644 benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.html create mode 100644 benchmarks/results/baseline.json create mode 100644 docs/api-reference.md create mode 100644 docs/faq.md create mode 100644 docs/getting-started.md create mode 100644 docs/quick-start.md create mode 100644 docs/showcase/diagrams.md create mode 100644 docs/showcase/level-04-advanced-integration.md create mode 100644 docs/showcase/level-05-processing.md create mode 100644 docs/showcase/level-06-error-handling.md create mode 100644 docs/showcase/level-07-scalability.md create mode 100644 docs/showcase/level-08-customization.md create mode 100644 docs/showcase/level-09-official-extensions.md create mode 100644 docs/showcase/level-10-enterprise-architecture.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/when-to-use-specification.md create mode 100644 samples/Showcase/README.md create mode 100644 scripts/run-targeted-mutation.ps1 create mode 100644 scripts/verify-benchmark-gate.ps1 create mode 100644 scripts/verify-benchmark-gate.test.ps1 create mode 100644 src/EricksonLopez.Specification.Abstractions/ExpressionDebugFormatterRegistry.cs rename src/{EricksonLopez.Specification => EricksonLopez.Specification.Abstractions}/IExpressionSpecification.cs (90%) create mode 100644 src/EricksonLopez.Specification/TypeForwarders.cs create mode 100644 stryker-result-config.json create mode 100644 tests/EricksonLopez.Specification.Tests/AdversarialRegressionTests.cs create mode 100644 tests/EricksonLopez.Specification.Tests/ConcurrencyAuditTests.cs create mode 100644 tests/EricksonLopez.Specification.Tests/FuzzingEngineTests.cs create mode 100644 tests/EricksonLopez.Specification.Tests/ReadRepositoryResultExtensionsTests.cs create mode 100644 tests/EricksonLopez.Specification.Tests/SpecificationLinqExtensionsTests.cs diff --git a/.editorconfig b/.editorconfig index c3b9419..ac807a9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -44,17 +44,20 @@ dotnet_diagnostic.CS1573.severity = warning # Testing Rules dotnet_diagnostic.xUnit1051.severity = warning +# Compiler Jump / Label Errors (Zero Tolerance) +dotnet_diagnostic.CS0159.severity = error +dotnet_diagnostic.CS159.severity = error + # ───────────────────────────────────────────────────────────────────────────── # Test & Benchmark Projects: Living Executable Specifications # As established in ADR-026, test methods follow the Osherove naming pattern: # [Method/UnitOfWork]_[Scenario/StateUnderTest]_[ExpectedBehavior] -# CA1707, IDE1006, and CS1591 are locally disabled for test clarity in CI/CD reports. +# CA1707 and IDE1006 are locally adjusted for test clarity in CI/CD reports. # ───────────────────────────────────────────────────────────────────────────── [{tests,benchmarks}/**/*.cs] dotnet_diagnostic.IDE1006.severity = none dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA1515.severity = none -dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.CS0618.severity = error dotnet_diagnostic.CS0619.severity = error @@ -62,7 +65,6 @@ dotnet_diagnostic.CS0619.severity = error dotnet_diagnostic.IDE1006.severity = none dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA1515.severity = none -dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.CS0618.severity = error dotnet_diagnostic.CS0619.severity = error diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index d3a129a..503d9ce 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -27,6 +27,7 @@ A clear and concise description of what you expected to happen. - Package(s) affected: - [ ] `EricksonLopez.Specification` 1.0.0 - [ ] `EricksonLopez.Specification.Abstractions` + - [ ] `EricksonLopez.Specification.Linq` - [ ] `EricksonLopez.Specification.Sql` - [ ] `EricksonLopez.Specification.PostgreSql` - [ ] `EricksonLopez.Specification.MsSql` @@ -35,12 +36,14 @@ A clear and concise description of what you expected to happen. - [ ] `EricksonLopez.Specification.Sqlite` - [ ] `EricksonLopez.Specification.Oracle` - [ ] `EricksonLopez.Specification.Dapper` + - [ ] `EricksonLopez.Specification.DapperExtensions` - [ ] `EricksonLopez.Specification.EntityFrameworkCore` - [ ] `EricksonLopez.Specification.MongoDB` + - [ ] `EricksonLopez.Specification.Result` - [ ] `EricksonLopez.Specification.Analyzers` - [ ] `EricksonLopez.Specification.Generators` - [ ] Other: ___ -- Storage/Infrastructure: [e.g., PostgreSQL 16 via Dapper 2.1.66 / EF Core 9.0.2 / MongoDB 7] +- Storage/Infrastructure: [e.g., PostgreSQL 16 via Dapper 2.1.79 / EF Core 9.0.2 / MongoDB 7] **Generated SQL (if applicable)** If the bug involves SQL generation, paste the generated SQL string and bound parameters here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 7955902..d9f04e2 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -30,7 +30,7 @@ Please indicate the area your request relates to (check all that apply): A clear and concise description of any alternative solutions or features you've considered, including whether this might violate an existing ADR (see [ADR index](../../docs/adr/README.md)). **Why this should NOT be rejected as an ADR** -Review the [what-not-to-build.md](../../what-not-to-build.md) and the existing [ADRs](../../docs/adr/README.md). Briefly explain why this feature is compatible with the library's architectural boundaries (zero-ORM domain purity, AOT-first, immutability). +Review the [what-not-to-build.md](../../docs/what-not-to-build.md) and the existing [ADRs](../../docs/adr/README.md). Briefly explain why this feature is compatible with the library's architectural boundaries (zero-ORM domain purity, AOT-first, immutability). **Additional context** Add any other context, code examples, or references here. diff --git a/.github/workflows/aot-smoke-test.yml b/.github/workflows/aot-smoke-test.yml index 31194b7..7a36042 100644 --- a/.github/workflows/aot-smoke-test.yml +++ b/.github/workflows/aot-smoke-test.yml @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -94,7 +94,7 @@ jobs: # ─── Upload binary for debugging if needed ──────────────────────────── - name: Upload AOT artifacts (on failure) if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: aot-output-${{ github.run_id }} path: ./aot-output/ diff --git a/.github/workflows/benchmark-regression-gate.yml b/.github/workflows/benchmark-regression-gate.yml index 504cd75..27380f8 100644 --- a/.github/workflows/benchmark-regression-gate.yml +++ b/.github/workflows/benchmark-regression-gate.yml @@ -1,13 +1,11 @@ -# Copyright © Erickson Lopez. MIT License. +# Copyright © Erickson Lopez. MIT License. name: Benchmark Regression Gate -# ─── Purpose ───────────────────────────────────────────────────────────────── -# Runs BenchmarkDotNet against the PR branch and compares the results against -# the baseline captured on `main` (stored in benchmarks/results/). -# -# If any benchmark regresses by more than REGRESSION_THRESHOLD (default: 10%), -# the CI gate FAILS with a detailed report showing the exact delta. -# ───────────────────────────────────────────────────────────────────────────── +# ─── Purpose ───────────────────────────────────────────────────────────────── +# Runs BenchmarkDotNet against the PR branch and validates against strict gates: +# 1. Heap Invariant: Zero-allocation on hot path combinators (0 B allocated). +# 2. Latency Threshold: Mean latency regression must not exceed 5% vs baseline. +# ───────────────────────────────────────────────────────────────────────────── on: pull_request: @@ -18,15 +16,15 @@ on: workflow_dispatch: inputs: threshold: - description: "Regression threshold in percent (e.g. 10 for +10%)" + description: "Regression threshold in percent (default: 5)" required: false - default: "10" + default: "5" type: string env: DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - REGRESSION_THRESHOLD: ${{ inputs.threshold || '10' }} + REGRESSION_THRESHOLD: ${{ inputs.threshold || '5' }} jobs: benchmark-gate: @@ -35,12 +33,12 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -70,99 +68,21 @@ jobs: -- --filter "*" --job short - --runtimes net8.0 net10.0 --exporters json + --memory --artifacts ./benchmarks/pr-results - - name: Check for baseline results - id: baseline-check + - name: Evaluate Benchmark Regression Gate + shell: pwsh run: | - if ls benchmarks/results/**/*.json 2>/dev/null | head -1 | grep -q .; then - echo "has_baseline=true" >> "$GITHUB_OUTPUT" - else - echo "has_baseline=false" >> "$GITHUB_OUTPUT" - echo "::warning::No benchmark baseline found in benchmarks/results/. Skipping regression check. Run the 'Weekly Benchmarks' workflow on main to establish a baseline." - fi - - - name: Compare results vs baseline - if: steps.baseline-check.outputs.has_baseline == 'true' - run: | - python3 - <<'PYEOF' - import json, os, glob, sys - - THRESHOLD = float(os.environ.get("REGRESSION_THRESHOLD", "10")) / 100.0 - baseline_files = glob.glob("benchmarks/results/**/*.json", recursive=True) - pr_files = glob.glob("benchmarks/pr-results/**/*.json", recursive=True) - - if not baseline_files: - print("No baseline JSON files found. Skipping comparison.") - sys.exit(0) - if not pr_files: - print("No PR benchmark JSON files found.") - sys.exit(1) - - def load_benchmarks(files): - results = {} - for f in files: - try: - data = json.load(open(f)) - for bench in data.get("Benchmarks", []): - name = bench.get("FullName", bench.get("Method", "")) - mean = bench.get("Statistics", {}).get("Mean", None) - if name and mean: - results[name] = mean - except Exception as e: - print(f"Warning: Could not parse {f}: {e}") - return results - - baseline = load_benchmarks(baseline_files) - pr = load_benchmarks(pr_files) - - regressions = [] - improvements = [] - for name, pr_mean in pr.items(): - if name in baseline: - base_mean = baseline[name] - if base_mean > 0: - delta_pct = (pr_mean - base_mean) / base_mean * 100 - if delta_pct > THRESHOLD * 100: - regressions.append((name, base_mean, pr_mean, delta_pct)) - elif delta_pct < -5: - improvements.append((name, base_mean, pr_mean, delta_pct)) - - summary_lines = [] - summary_lines.append(f"## Benchmark Regression Report (threshold: {THRESHOLD*100:.0f}%)\n") - - if improvements: - summary_lines.append("### ✅ Improvements") - for name, base, pr_val, delta in improvements: - summary_lines.append(f"- **{name}**: {base/1000000:.3f}ms → {pr_val/1000000:.3f}ms ({delta:+.1f}%)") - summary_lines.append("") - - if regressions: - summary_lines.append("### ❌ Regressions (above threshold)") - for name, base, pr_val, delta in regressions: - summary_lines.append(f"- **{name}**: {base/1000000:.3f}ms → {pr_val/1000000:.3f}ms ({delta:+.1f}%) ← REGRESSION") - summary_lines.append("") - else: - summary_lines.append("### ✅ No regressions detected\n") - - report = "\n".join(summary_lines) - print(report) - - with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: - f.write(report + "\n") - - if regressions: - print(f"\n❌ {len(regressions)} benchmark(s) regressed beyond {THRESHOLD*100:.0f}% threshold. Failing build.") - sys.exit(1) - else: - print(f"\n✅ All benchmarks within {THRESHOLD*100:.0f}% regression threshold.") - PYEOF + ./scripts/verify-benchmark-gate.ps1 ` + -ReportDir ./benchmarks/pr-results ` + -BaselinePath ./benchmarks/results/baseline.json ` + -MaxLatencyRegressionPercent ([double]$env:REGRESSION_THRESHOLD) - name: Upload PR benchmark results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr-benchmark-results-${{ github.run_id }} path: benchmarks/pr-results/ diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 96f5101..09230ea 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -32,10 +32,10 @@ jobs: timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -81,7 +81,7 @@ jobs: - name: Upload benchmark results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: benchmark-results-${{ github.run_id }} path: benchmarks/results/ diff --git a/.github/workflows/dotnet-build-test.yml b/.github/workflows/dotnet-build-test.yml index 9519ac3..9e28b86 100644 --- a/.github/workflows/dotnet-build-test.yml +++ b/.github/workflows/dotnet-build-test.yml @@ -50,12 +50,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ inputs.dotnet-version }} @@ -64,11 +64,11 @@ jobs: SNK_KEY: ${{ secrets.SNK_KEY }} run: | if [ -n "$SNK_KEY" ]; then - echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.Specifications.snk + echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.snk fi - name: Setup Java (for SonarScanner) - uses: actions/setup-java@v3 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: java-version: '17' distribution: 'zulu' @@ -117,14 +117,14 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.artifact-name }} path: ./test-results/ - name: Upload coverage to Codecov if: always() && inputs.upload-coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: "./test-results/**/coverage.cobertura.xml" diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 35feffb..750697b 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -19,6 +19,7 @@ name: Mutation Testing (Stryker) # • PostgreSql (EricksonLopez.Specification.PostgreSql) # • Sql (EricksonLopez.Specification.Sql) # • Sqlite (EricksonLopez.Specification.Sqlite) +# • Result (EricksonLopez.Specification.Result) # # Execution Architecture: # • Pull Requests & Push: Stryker is DECOUPLED and does NOT run on PRs or push (to avoid blocking fast CI cycle times). @@ -138,13 +139,17 @@ jobs: config: stryker-sqlite-config.json output-dir: StrykerOutput/sqlite artifact-name: stryker-report-sqlite + - name: Result + config: stryker-result-config.json + output-dir: StrykerOutput/result + artifact-name: stryker-report-result steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "10.0.x" @@ -173,7 +178,7 @@ jobs: - name: Upload Stryker report (${{ matrix.name }}) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact-name }}-${{ github.run_id }} path: ${{ matrix.output-dir }}/ @@ -187,7 +192,7 @@ jobs: - name: Upload summary JSON (${{ matrix.name }}) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stryker-summary-${{ matrix.name }} path: StrykerOutput/summary-${{ matrix.name }}.json @@ -206,10 +211,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download all summary artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: stryker-summary-* path: ./all-summaries @@ -217,18 +222,29 @@ jobs: - name: Evaluate Consolidated Mutation Quality Gate id: eval-gate - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require('fs'); const path = require('path'); const summariesDir = path.join(process.cwd(), 'all-summaries'); - let summaryFiles = []; - if (fs.existsSync(summariesDir)) { - summaryFiles = fs.readdirSync(summariesDir).filter(f => f.startsWith('summary-') && f.endsWith('.json')); + function findSummaryFiles(dir) { + let results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results = results.concat(findSummaryFiles(fullPath)); + } else if (entry.isFile() && entry.name.startsWith('summary-') && entry.name.endsWith('.json')) { + results.push(fullPath); + } + } + return results; } + const summaryFiles = findSummaryFiles(summariesDir); + console.log(`Found ${summaryFiles.length} summary files in ${summariesDir}`); const packages = []; @@ -241,7 +257,7 @@ jobs: for (const file of summaryFiles) { try { - const data = JSON.parse(fs.readFileSync(path.join(summariesDir, file), 'utf8')); + const data = JSON.parse(fs.readFileSync(file, 'utf8')); packages.push(data); totalKilled += data.mutants_killed || data.killed || 0; totalMutants += data.total_mutants || data.total || 0; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4585d66..bfc52d8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,13 +47,13 @@ jobs: can_proceed: ${{ steps.evaluate.outputs.can_proceed }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Evaluate Mutation Freshness & Drift id: evaluate - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: STRYKER_GATE_MODE: evaluate with: @@ -77,7 +77,13 @@ jobs: name: Pack & Publish runs-on: ubuntu-latest needs: [evaluate-mutation-gate, run-mutation-testing] - if: always() && needs.evaluate-mutation-gate.result == 'success' && (needs.run-mutation-testing.result == 'success' || needs.run-mutation-testing.result == 'skipped') + if: | + always() && + (needs.evaluate-mutation-gate.result == 'success') && + ( + (needs.evaluate-mutation-gate.outputs.needs_stryker == 'false' && needs.evaluate-mutation-gate.outputs.can_proceed == 'true') || + (needs.evaluate-mutation-gate.outputs.needs_stryker == 'true' && needs.run-mutation-testing.result == 'success') + ) permissions: id-token: write # Required for Sigstore OIDC attestation contents: write # Required for GitHub Release creation @@ -87,7 +93,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -114,7 +120,7 @@ jobs: # ─── Enforce Stryker Mutation Testing Quality Gate ──────────────────────── - name: Enforce Stryker Mutation Quality Gate id: mutation-gate - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: STRYKER_GATE_MODE: enforce with: @@ -123,7 +129,7 @@ jobs: await verifyMutationGate({ github, context, core, mode: 'enforce' }); - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "10.0.x" @@ -133,7 +139,7 @@ jobs: SNK_KEY: ${{ secrets.SNK_KEY }} run: | if [ -n "$SNK_KEY" ]; then - echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.Specification.snk + echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.snk fi - name: Restore @@ -154,7 +160,7 @@ jobs: -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover - name: Upload coverage to Codecov (publish gate) - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: "**/coverage.opencover.xml" @@ -166,7 +172,7 @@ jobs: - name: Pack All Packages run: | VERSION="${{ steps.version.outputs.VERSION }}" - PACK_ARGS="--no-build --configuration Release --output ./nupkgs -p:VersionPrefix=$VERSION -p:TreatWarningsAsErrors=true" + PACK_ARGS="--no-build --configuration Release --output ./nupkgs -p:VersionPrefix=$VERSION -p:TreatWarningsAsErrors=true -m:1 /p:NodeReuse=false" dotnet pack src/EricksonLopez.Specification.Abstractions/EricksonLopez.Specification.Abstractions.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification/EricksonLopez.Specification.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification.Analyzers/EricksonLopez.Specification.Analyzers.csproj $PACK_ARGS @@ -181,6 +187,7 @@ jobs: dotnet pack src/EricksonLopez.Specification.MySql/EricksonLopez.Specification.MySql.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification.Oracle/EricksonLopez.Specification.Oracle.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification.PostgreSql/EricksonLopez.Specification.PostgreSql.csproj $PACK_ARGS + dotnet pack src/EricksonLopez.Specification.Result/EricksonLopez.Specification.Result.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification.Sql/EricksonLopez.Specification.Sql.csproj $PACK_ARGS dotnet pack src/EricksonLopez.Specification.Sqlite/EricksonLopez.Specification.Sqlite.csproj $PACK_ARGS echo "Packed packages:" @@ -188,13 +195,13 @@ jobs: # ─── Sigstore Provenance Attestation ───────────────────────────────────── - name: Generate Sigstore Provenance Attestation - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: './nupkgs/*.nupkg' # ─── NuGet.org publish via OIDC ────────────────────────────────────────── - name: NuGet login (OIDC) - uses: NuGet/login@v1 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 id: login with: user: ericksonlopezf @@ -209,7 +216,7 @@ jobs: # ─── GitHub Release ─────────────────────────────────────────────────────── - name: Create GitHub Release (tag-triggered only) if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: v${{ steps.version.outputs.VERSION }} name: Release ${{ steps.version.outputs.VERSION }} @@ -233,6 +240,7 @@ jobs: | `EricksonLopez.Specification.MySql` | `${{ steps.version.outputs.VERSION }}` | | `EricksonLopez.Specification.Oracle` | `${{ steps.version.outputs.VERSION }}` | | `EricksonLopez.Specification.PostgreSql` | `${{ steps.version.outputs.VERSION }}` | + | `EricksonLopez.Specification.Result` | `${{ steps.version.outputs.VERSION }}` | | `EricksonLopez.Specification.Sql` | `${{ steps.version.outputs.VERSION }}` | | `EricksonLopez.Specification.Sqlite` | `${{ steps.version.outputs.VERSION }}` | diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 57d3ffd..0d5439c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -8,12 +8,13 @@ on: permissions: contents: write pull-requests: write + actions: write jobs: release-please: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: config-file: .release-please-config.json @@ -21,7 +22,7 @@ jobs: - name: Trigger Publish Workflow if: ${{ steps.release.outputs.releases_created == 'true' }} - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/repo-compliance.yml b/.github/workflows/repo-compliance.yml index e87699e..944cb78 100644 --- a/.github/workflows/repo-compliance.yml +++ b/.github/workflows/repo-compliance.yml @@ -20,14 +20,22 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: "10.0.x" - - name: Run Architecture & Rules Compliance Script + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "20" + + - name: Run Standards Verification (Node.js) + run: node ./scripts/verify-standards.js + + - name: Run Architecture & Rules Compliance Script (PowerShell) shell: pwsh run: ./scripts/verify-compliance.ps1 @@ -41,4 +49,4 @@ jobs: run: dotnet test EricksonLopez.Specifications.slnx --no-build --configuration Release --verbosity normal --filter "FullyQualifiedName!~IntegrationTests" - name: Validate NuGet Packages Packaging - run: dotnet pack EricksonLopez.Specifications.slnx --no-build --configuration Release -o artifacts/ + run: dotnet pack EricksonLopez.Specifications.slnx --no-build --configuration Release -o artifacts/ -m:1 /p:NodeReuse=false diff --git a/.github/workflows/weekly-benchmarks.yml b/.github/workflows/weekly-benchmarks.yml index ac2e9fd..ef65dd2 100644 --- a/.github/workflows/weekly-benchmarks.yml +++ b/.github/workflows/weekly-benchmarks.yml @@ -32,13 +32,13 @@ jobs: timeout-minutes: 180 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} - name: Setup .NET (multi-version for cross-TFM benchmarks) - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -84,7 +84,7 @@ jobs: - name: Upload benchmark results as artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: weekly-benchmark-results-${{ github.run_id }} path: benchmarks/results/ diff --git a/.gitignore b/.gitignore index 4164721..8648109 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,42 @@ -# .NET Build Outputs -bin/ -obj/ +# ============================================================================= +# .NET Build & MSBuild Artifacts +# ============================================================================= +[Bb]in/ +[Oo]bj/ artifacts/ nupkgs/ *.nupkg *.snupkg +*.binlog +*.log +*.props.user +*.targets.user +.cache/ +*.cache -# BenchmarkDotNet -BenchmarkDotNet.Artifacts/ +# ============================================================================= +# BenchmarkDotNet & Performance Testing +# ============================================================================= BenchmarkDotNet.Artifacts*/ BenchmarkDotNet.Bin/ +benchmarks/pr-results/ +# Whitelist baseline metrics required for regression gates +!benchmarks/results/ -# Visual Studio / Rider / JetBrains +# ============================================================================= +# IDEs, Editors & Tools +# ============================================================================= +# Visual Studio .vs/ *.user *.suo *.userosscache *.sln.docstates +*.userprefs +UpgradeLog*.XML +UpgradeLog*.htm + +# JetBrains (Rider, ReSharper) .idea/ _ReSharper*/ *.[Rr]e[Ss]harper @@ -29,40 +49,79 @@ _ReSharper*/ !.vscode/launch.json !.vscode/extensions.json *.code-workspace +.history/ -# Test Results & Code Coverage -TestResults/ +# ============================================================================= +# Test Results, Code Coverage & Mutation Testing +# ============================================================================= +[Tt]est[Rr]esults/ *.coverage *.coveragexml +*.opencover.xml +*.cobertura.xml coverage/ [Cc]overage[Rr]eport*/ [Cc]overage[Rr]esults*/ - -# Stryker.NET Mutation Testing StrykerOutput/ +.stryker-tmp/ -# Native AOT / Native Compilation +# ============================================================================= +# Native Compilation & AOT Artifacts +# ============================================================================= *.ilk *.exp *.lib *.pdb.lock +*.pch +*.ipch +*.tlog +*.idb -# Node.js (Tooling & Scripts) +# ============================================================================= +# Node.js & Scripting Tools +# ============================================================================= node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* -# Secrets & Local Configs +# ============================================================================= +# Secrets, Environment & Certificates +# ============================================================================= +.env +.env.* +!.env.example +secrets.json appsettings.Development.json appsettings.Local.json -secrets.json +appsettings.*.local.json +*.pfx +*.p12 +# ============================================================================= # Operating System & Temporary Files +# ============================================================================= +# Windows Thumbs.db +Thumbs.db:encryptable ehthumbs.db +ehthumbs_vista.db Desktop.ini +$RECYCLE.BIN/ + +# macOS .DS_Store -*.tmp -*.bak +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Linux & Generic Editors +*~ *.swp +*.bak +*.tmp +*.orig +*.rej diff --git a/CHANGELOG.md b/CHANGELOG.md index ec55923..5ff5cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,124 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ---- - ## [Unreleased] +## [2.0.0] - 2026-09-21 + +### ⚠️ Breaking Changes + +- **BC-001: Relocation of `IExpressionSpecification` to `EricksonLopez.Specification.Abstractions` (Binary Compatibility Preserved)** + - **Affected API**: `EricksonLopez.Specification.IExpressionSpecification` + - **Previous State**: Resided in assembly `EricksonLopez.Specification.dll` (package `EricksonLopez.Specification`). + - **Current State**: Relocated to assembly `EricksonLopez.Specification.Abstractions.dll` (package `EricksonLopez.Specification.Abstractions`) with runtime type forwarding (`[TypeForwardedTo]`) in `EricksonLopez.Specification`. + - **Affected Consumers**: Fine-grained consumers referencing only Abstractions benefit from isolated contracts. Existing compiled binaries maintain full binary compatibility via type forwarders. + - **Impact**: Zero runtime `TypeLoadException` due to `[assembly: TypeForwardedTo(...)]` in `EricksonLopez.Specification`. New code should reference `EricksonLopez.Specification.Abstractions`. + - **Migration**: Existing binaries run seamlessly without modification. For source builds using fine-grained packages, add a reference to `EricksonLopez.Specification.Abstractions`. + +- **BC-002: Removal of Transitive Dependency on `EricksonLopez.Specification` in `EricksonLopez.Specification.Linq`** + - **Affected Package**: `EricksonLopez.Specification.Linq` + - **Previous State**: Referenced `EricksonLopez.Specification`, transitively exposing `Specification`, `Spec`, and core domain specification types. + - **Current State**: References `EricksonLopez.Specification.Abstractions` directly; project and package dependency on `EricksonLopez.Specification` has been eliminated. + - **Affected Consumers**: Projects referencing only `EricksonLopez.Specification.Linq` that implicitly consumed core specification classes (`Specification`, `Spec.For`). + - **Impact**: Compile-time errors (`CS0246: The type or namespace name 'Specification<>' could not be found`). + - **Migration**: Explicitly install the `EricksonLopez.Specification` NuGet package in consumer projects that construct specifications. + +- **BC-003: Construction-Time Range Validation in `Spec.Between`** + - **Affected API**: `Spec.Between(Expression>, TProperty lower, TProperty upper)` + - **Previous State**: Allowed `lower.CompareTo(upper) > 0` without throwing at construction, building an expression tree `(x >= lower && x <= upper)` that evaluated to `false` at query/evaluation time. + - **Current State**: Validates `lower` and `upper` during factory invocation, throwing `ArgumentException` if `lower.CompareTo(upper) > 0`. + - **Affected Consumers**: Callers passing dynamic or inverted range bounds to `Spec.Between`. + - **Impact**: Throws `ArgumentException: Lower bound '{lower}' cannot be greater than upper bound '{upper}'.` synchronously upon calling `Spec.Between`. + - **Migration**: Ensure `lower <= upper` before invoking `Spec.Between`, or use conditional branching/swapping when dealing with dynamic user input. + +- **BC-004: Cancellation Exception Re-throw in `ReadRepositoryResultExtensions`** + - **Affected API**: `ReadRepositoryResultExtensions` in `EricksonLopez.Specification.Result` (`FirstOrDefaultResultAsync`, `SingleOrDefaultResultAsync`, `ListResultAsync`, `GetByIdResultAsync`) + - **Previous State**: Caught all exceptions including `OperationCanceledException` and returned `Result.Failure(Error.Failure("Database.Error", ex.Message))`. + - **Current State**: Explicitly catches and rethrows `OperationCanceledException` to preserve cooperative cancellation semantics. + - **Affected Consumers**: Callers awaiting `*ResultAsync` with cancellation tokens and expecting `Result.IsFailure` without handling task cancellation. + - **Impact**: Throws unhandled `OperationCanceledException` from the returned `Task>` instead of returning a failed `Result`. + - **Migration**: Catch `OperationCanceledException` at caller level or handle task cancellation according to standard .NET TAP asynchronous patterns. + +- **BC-005: Security Namespace Restrictions in In-Memory `ExpressionInterpreter`** + - **Affected API**: `ExpressionInterpreter` (in-memory `ISpecification.IsSatisfiedBy` and interpreted LINQ) + - **Previous State**: Invoked any method call in expressions via reflection without security sandboxing. + - **Current State**: Blocks execution of methods whose declaring types belong to `System.Diagnostics`, `System.IO`, `System.Reflection`, or `System.Environment`. + - **Affected Consumers**: Specifications with expressions calling diagnostic tracing (`Trace.WriteLine`), I/O path manipulation (`Path.Combine`), reflection, or environment inspection. + - **Impact**: Throws `InvalidOperationException: Method '{method.Name}' on type '{declaringType.FullName}' is not permitted in interpreted specification evaluation for security reasons.` + - **Migration**: Extract external I/O, diagnostic, or environment state evaluation outside of specification expression trees before constructing the specification predicate. + +- **BC-006: AST Traversal Depth Limit (DoS Guard) in `ExpressionEqualityComparer` and `ExpressionHasher`** + - **Affected API**: `ExpressionEqualityComparer.Equals` and `ExpressionHasher.Hash` + - **Previous State**: Traversed unbounded expression tree depths until process `StackOverflowException`. + - **Current State**: Enforces a strict maximum recursion depth of 512 nodes (`MaxDepth = 512`). + - **Affected Consumers**: Highly complex or dynamically generated expression trees with nesting depths exceeding 512. + - **Impact**: Throws `InvalidOperationException: Expression tree exceeds maximum supported equality/hashing depth of 512.` + - **Migration**: Simplify or rebalance deep AST expressions into flattened or partitioned specifications to remain within the 512 depth limit. + +- **BC-007: Keyset Pagination Type Guard in `QuerySpecLinqExtensions`** + - **Affected API**: `QuerySpecLinqExtensions.Apply` keyset pagination (`Cursor`) + - **Previous State**: Permitted any property type selector for cursor pagination, deferring validation to the underlying provider. + - **Current State**: Validates that cursor property expressions implement `IComparable` (or nullable underlying), throwing `NotSupportedException` otherwise. + - **Affected Consumers**: Keyset pagination queries targeting non-comparable custom scalar properties. + - **Impact**: Throws `NotSupportedException: Keyset cursor pagination on type '{type}' is not supported. Cursor key selector must target a comparable scalar property.` + - **Migration**: Ensure entities used in keyset cursor pagination select properties implementing `IComparable` (e.g. `int`, `long`, `Guid`, `DateTime`, `string`). + +- **BC-008: AOT / Trimming Generic Annotations `[DynamicallyAccessedMembers]` on `Any` and `Count`** + - **Affected API**: `QuerySpecLinqExtensions.Any` and `QuerySpecLinqExtensions.Count` + - **Previous State**: Generic type parameter `T` had no trimming annotations. + - **Current State**: `T` is annotated with `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)]`. + - **Affected Consumers**: NativeAOT or trim-enabled applications calling `Any` or `Count`. + - **Impact**: Trimming analyzer warnings (`IL2091`) emitted at call sites where `T` lacks property/field annotations; breaks compilation when `TreatWarningsAsErrors=true`. + - **Migration**: Add `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)]` to calling generic classes/methods or annotate consumer DTO models. + +- **BC-009: Query Filter Chaining Structure in `QuerySpecLinqExtensions.Apply`** + - **Affected API**: `QuerySpecLinqExtensions.Apply(this IQueryable, QuerySpec)` + - **Previous State**: Evaluated multiple criteria by combining them into a single `ExpressionComposer.AndAll` lambda passed to a single `.Where()` call. + - **Current State**: Applies criteria as multiple chained `.Where(criterion)` calls directly on the `IQueryable`. Removed `BuildCombinedPredicate()`. + - **Affected Consumers**: Custom LINQ providers, query interceptors, mock queryables, or test assertions inspecting the AST `MethodCallExpression` hierarchy. + - **Impact**: Observable expression tree changes from `Where(source, combinedAndLambda)` to sequential `Where(Where(source, crit1), crit2)`. + - **Migration**: Update any expression interceptors or test assertions expecting a single combined `AndAlso` lambda to support chained `Where` invocations. + +- **BC-010: Query Filter Application in `MongoSpecificationEvaluator.ApplySpecification`** + - **Affected API**: `MongoSpecificationEvaluator.ApplySpecification(this IFindFluent, QuerySpec)` + - **Previous State**: Ignored `specification.Criteria` completely, applying only `Sort`, `Skip`, and `Limit`. + - **Current State**: Builds `FilterDefinition` from `specification.Criteria` and sets `findFluent.Filter` (combining via `And` if filter already exists). + - **Affected Consumers**: Consumers relying on `ApplySpecification` in MongoDB data access layers. + - **Impact**: Queries that previously returned unconstrained documents now return only documents satisfying specification criteria. If external code already added custom filters, they will be combined via logical `AND`. + - **Migration**: Review MongoDB queries using `ApplySpecification` to ensure specification criteria match expected filter criteria. Remove manual redundant filter definitions if previously applied as workarounds. + +- **BC-011: AST Evaluation Depth Limit (DoS Guard) in In-Memory `ExpressionInterpreter`** + - **Affected API**: `ExpressionInterpreter.Evaluate` (and in-memory evaluation via `ISpecification.IsSatisfiedBy`) + - **Previous State**: Evaluated arbitrary expression tree depths recursively until process `StackOverflowException`. + - **Current State**: Enforces a strict maximum recursion depth of 512 nodes (`MaxDepth = 512`), throwing `InvalidOperationException`. + - **Affected Consumers**: In-memory domain specification evaluations with deeply nested expression ASTs exceeding 512 nodes. + - **Impact**: Throws `InvalidOperationException: Expression tree exceeds maximum supported evaluation depth of 512.` synchronously during evaluation. + - **Migration**: Restructure or flatten deeply nested composite specifications, or utilize pre-compiled delegates (`ToCompiledPredicate()`) in JIT environments if deep AST recursion is required. + +### Added + +- `ExpressionDebugFormatterRegistry` in `EricksonLopez.Specification.Abstractions`: Global thread-safe registry providing pluggable expression AST debug formatting across NativeAOT and JIT environments. +- `ReadRepositoryResultExtensions` in `EricksonLopez.Specification.Result`: Functional result query methods (`ListResultAsync`, `FirstOrDefaultResultAsync`, `SingleOrDefaultResultAsync`, `GetByIdResultAsync`) over `IReadRepository` returning `Result`. Included in the primary build solution for official NuGet distribution. +- C# logical operators on `Specification`: overloaded `&`, `|`, `!`, `true`, `false`, `BitwiseAnd`, `BitwiseOr`, and `LogicalNot`. +- Multi-targeting expansion across all library projects: `.NET 8.0` and `.NET 9.0` support alongside `.NET 10.0`. +- New `Spec.Between` overload supporting nullable property selectors (`Expression>`). +- Collection-based `Spec.All` and `Spec.Any` overloads accepting `IEnumerable>`. +- LINQ extension overloads in `QuerySpecLinqExtensions`: `Where`, `All`, `FirstOrDefault` over `IQueryable` with `IExpressionSpecification`, and `Where`, `Any`, `All`, `Count`, `FirstOrDefault` over `IEnumerable` with `ISpecification`. +- Fluent `collection.Find(specification)` extension method in `MongoSpecificationEvaluator`. +- `stryker-result-config.json` for dedicated mutation testing of `EricksonLopez.Specification.Result`. +- Comprehensive test suites: `AdversarialRegressionTests`, `ConcurrencyAuditTests`, `FuzzingEngineTests`, `ReadRepositoryResultExtensionsTests`, and `SpecificationLinqExtensionsTests`. + +### Changed + +- Hardened in-memory `ExpressionInterpreter` and `ExpressionEqualityComparer` node traversal for adversarial expression patterns. +- Parameter re-binding and caching fix in `QuerySpecTranslator`: cached query plans now correctly reparameterize current query arguments rather than retaining stale parameter values. + + --- ## [1.0.0] - 2026-08-28 -### 🚀 Added +### Added - **Initial Release** of the `EricksonLopez.Specification` ecosystem: - **Domain Specifications (`EricksonLopez.Specification` / `EricksonLopez.Specification.Abstractions`):** @@ -47,4 +156,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +[Unreleased]: https://github.com/ericksonlopezf/dotnet-specification/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/ericksonlopezf/dotnet-specification/compare/v1.0.0...v2.0.0 [1.0.0]: https://github.com/ericksonlopezf/dotnet-specification/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3309190..a59f846 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,8 +137,10 @@ Before opening a PR, ensure: --- -## Code of Conduct +## Code of Conduct & Security This project follows the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md). By participating, you agree to abide by its terms. -Report violations to `ericksonlopezf@gmail.com`. +Report Code of Conduct violations to `ericksonlopezf@gmail.com`. + +For security vulnerability reporting, please follow the responsible disclosure process in [SECURITY.md](SECURITY.md). diff --git a/Directory.Build.props b/Directory.Build.props index a6b3531..52c18fd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,11 +1,14 @@ + - net10.0 + net8.0;net9.0;net10.0 + $(DefaultTargetFrameworks) + net10.0 preview enable - enable + disable true @@ -26,12 +29,12 @@ EricksonLopez.Specification Copyright © Erickson Lopez. MIT License. MIT - https://ericksonlopez.dev/dotnet-specification + https://ericksonlopez.dev/specification https://github.com/ericksonlopezf/dotnet-specification git README.md icon.png - dotnet;csharp;specification;specification-pattern;ddd;clean-architecture + dotnet;csharp;specification;specification-pattern;ddd;domain-driven-design;clean-architecture true false false @@ -46,18 +49,20 @@ 0024000004800000940000000602000000240000525341310004000001000100655c867cb6d2e3a8d53e10d858994a49ea6b428de6e1e2eec19c71f0409345a7bf1649e9208282982347d90153f237f1aef003468e4a913598faa0b96815de53ede401790587fef88c7869884cdbf4372e74a44facf7dd6995e9b832285f8c548f531e1886d6712632139b617cd4f13988021b7cc32b5c3af18f52e19ae2a6cc $(DefineConstants);SIGN_ASSEMBLY true - $(NoWarn);RS0016;RS0017;RS0037;CA1805;CA1711;CA1000;CA1716;CA1834;CS1061 + $(NoWarn);RS0016;RS0017;RS0037;CA1805;CA1711;CA1000;CA1716;CA1834;NU1901;NU1902;NU1903;NU1904 + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 - 1.0.0 + 2.0.0 $(VersionPrefix) - + true + + true + true + true + snupkg + - - - $(PublicKey) - - @@ -69,7 +74,7 @@ true true - $(NoWarn);CS1591;CS8600;CS8604;IDE1006;CA1707;CA1515;CA1822;CA2007;CA1815;CA2201;CA1305;CA1852;CA1861;CA1862;CA1866;CA1002;CA1034;CA2225;CA2227;CA1304;NU1608;NU1903;IL2026;IL2055;IL2060;IL2072;IL2073;IL2093;xUnit1030;xUnit1031 + $(NoWarn);CS8600;CS8604;IDE1006;CA1707;CA1515;CA1822;CA2007;CA1815;CA2201;CA1305;CA1852;CA1861;CA1862;CA1866;CA1002;CA1034;CA2225;CA2227;CA1304;NU1608;NU1903;IL2026;IL2055;IL2060;IL2072;IL2073;IL2093;xUnit1030;xUnit1031 false true @@ -93,6 +98,9 @@ + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9985cfb..3c17074 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,8 +3,9 @@ true + - + @@ -14,7 +15,7 @@ - + @@ -28,15 +29,15 @@ - - + + - + - + - + @@ -46,10 +47,10 @@ - - + + - + \ No newline at end of file diff --git a/EricksonLopez.Specifications.slnx b/EricksonLopez.Specifications.slnx index 85e9fa1..fd768c5 100644 --- a/EricksonLopez.Specifications.slnx +++ b/EricksonLopez.Specifications.slnx @@ -18,6 +18,7 @@ + diff --git a/LICENSE b/LICENSE index ae76e99..fa65d9b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Erickson Lopez +Copyright (c) 2026 Erickson Lopez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2dca8cf..8e901f4 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,13 @@ High-performance, composable, NativeAOT-first Specification Pattern and SQL AST - [Use Case 3: Keyset / Cursor Pagination on High-Volume Datasets](#use-case-3-keyset--cursor-pagination-on-high-volume-datasets) - [Use Case 4: NativeAOT Microservices with Interpreted Validation](#use-case-4-nativeaot-microservices-with-interpreted-validation) - [Use Case 5: Multi-Dialect SQL Generation for Dapper & Raw ADO.NET](#use-case-5-multi-dialect-sql-generation-for-dapper--raw-adonet) - - [Use Case 6: Functional Result Queries with `EricksonLopez.Result`](#use-case-6-functional-result-queries-with-ericksonlopezresult) + - [Use Case 6: Functional Result Queries with EricksonLopez.Result](#use-case-6-functional-result-queries-with-ericksonlopezresult) - [Configuration & Integrations](#-configuration--integrations) - [Entity Framework Core & Dependency Injection](#entity-framework-core--dependency-injection) - [Dapper & Dialect Configuration](#dapper--dialect-configuration) - [MongoDB Driver Integration](#mongodb-driver-integration) - [OpenTelemetry Metrics & Diagnostics](#opentelemetry-metrics--diagnostics) - - [Compile-Time Roslyn Analyzers (`SPEC001`–`SPEC011`)](#compile-time-roslyn-analyzers-spec001spec011) + - [Compile-Time Roslyn Analyzers (SPEC001-SPEC011)](#compile-time-roslyn-analyzers-spec001-spec011) - [Testing & Quality](#-testing--quality) - [In-Memory Unit Testing](#in-memory-unit-testing) - [SQL Translation Snapshot Verification](#sql-translation-snapshot-verification) @@ -54,8 +54,8 @@ High-performance, composable, NativeAOT-first Specification Pattern and SQL AST - [Primary Operations & Composition](#primary-operations--composition) - [In-Memory Evaluation Benchmark (AOT vs JIT)](#in-memory-evaluation-benchmark-aot-vs-jit) - [SQL AST Translation Benchmark](#sql-ast-translation-benchmark) - - [LINQ Provider Overhead (`QuerySpec.Apply`)](#linq-provider-overhead-queryspecapply) - - [Span-Based Bulk Predicate Composition (`AndAll`)](#span-based-bulk-predicate-composition-andall) + - [LINQ Provider Overhead (QuerySpec.Apply)](#linq-provider-overhead-queryspecapply) + - [Span-Based Bulk Predicate Composition (AndAll)](#span-based-bulk-predicate-composition-andall) - [Compatibility & Technical Matrix](#-compatibility--technical-matrix) - [Runtime & Target Framework Matrix](#runtime--target-framework-matrix) - [SQL Dialects Feature Matrix](#sql-dialects-feature-matrix) @@ -79,7 +79,7 @@ High-performance, composable, NativeAOT-first Specification Pattern and SQL AST 3. **Lack of Provider-Agnostic SQL Generation for Micro-ORMs**: Developers wanting high performance with Dapper or raw ADO.NET are forced to manually write string-based SQL queries, discarding domain specifications and introducing SQL injection vulnerabilities and maintainability nightmares. 4. **Mutable State & `Expression.Invoke` Provider Failures**: Combining expressions using `Expression.Invoke` breaks query translation in EF Core, Cosmos DB, and LINQ providers, requiring fragile third-party extensions like LinqKit that fail NativeAOT trimming. -### How `EricksonLopez.Specification` Solves This +### How EricksonLopez.Specification Solves This - **Dual-Engine Architecture (100% NativeAOT Safe)**: Features a dedicated `ExpressionInterpreter` that evaluates expression ASTs in memory in just **44.6 nanoseconds** without emitting dynamic IL, while preserving an opt-in structural JIT cache (`ExpressionCompilationCache`) for standard runtimes. - **Strict DDD Layer Separation**: Domain specifications (`Specification`) are strictly pure predicate expressions. Query concerns (sorting, keyset pagination, projection, tracking hints) are isolated in the Application layer via immutable value descriptors (`QuerySpec`). @@ -94,7 +94,7 @@ High-performance, composable, NativeAOT-first Specification Pattern and SQL AST - 🚀 **NativeAOT & Trimming First**: Fully verified under .NET 8, 9, and 10 NativeAOT compilers with explicit BCL linker attributes and zero dynamic IL emission on hot paths. - 🗄️ **Multi-Dialect SQL Generation**: Native parameterized AST rendering for PostgreSQL, SQL Server (`MsSql`), MySQL, MariaDB, SQLite, and Oracle Database without ORM dependencies. - 🧱 **Strict DDD Clean Architecture**: Pure domain rules in `Specification`, immutable query descriptors in `QuerySpec`, and repository adapters in Infrastructure. -- ⚡ **Zero Allocations on Hot Paths**: Bounded LRU query plan caching (`QueryPlanCache`), structural expression hashing (`ExpressionHasher`), and `ReadOnlySpan` bulk composition (`AndAll` / `OrAny`). +- ⚡ **Low Allocations & Bounded Memory**: Bounded LRU query plan caching (`QueryPlanCache`), structural expression hashing (`ExpressionHasher`), and `ReadOnlySpan` bulk composition (`AndAll` / `OrAny`). - 🔍 **Keyset & Offset Pagination**: First-class support for both high-throughput deterministic keyset seek (`SeekAfter` / `SeekBefore`) and classic offset pagination (`Page` / `Skip` / `Take`). - 🛡️ **Compile-Time Roslyn Governance**: 11 analyzers (`SPEC001`–`SPEC011`) with automated CodeFix providers to prevent architectural drift and maintain strict domain purity. - 📊 **Enterprise Observability**: Integrated OpenTelemetry `ActivitySource` and `Meter` instruments tracking specification evaluations, compositions, and SQL translations. @@ -137,17 +137,17 @@ Explore progressive runnable showcase levels located in the test and sample harn | Level | Topic | Description | |---|---|---| -| [**Level 00**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level0_Conceptual.cs) | **Architecture & Conceptual Foundations** | Pure DDD specification principles, expression tree encapsulation, and boundary invariants | -| [**Level 01**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level1_QuickStart.cs) | **Quick Start & Domain Primitives** | Sealed specifications, `Spec.For`, `Spec.True`, and in-memory AOT evaluation | -| [**Level 02**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level2_Configuration.cs) | **Configuration & SQL Dialects** | Configuring PostgreSQL, SQL Server, MySQL, SQLite, and Oracle AST translators | -| [**Level 03**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level3_RealUseCases.cs) | **Real-World Enterprise Use Cases** | CQRS queries, compound domain rules, multi-condition validation, and business pipelines | -| [**Level 04**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level4_AdvancedIntegration.cs) | **Advanced ORM & Database Integrations** | EF Core `IQueryable.Apply`, Dapper parameterized execution, and keyset seek pagination | -| [**Level 05**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level5_Processing.cs) | **Processing & AST Translation Pipeline** | Expression AST visitor rewriting, parameter replacement, and boolean constant folding | -| [**Level 06**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level6_ErrorHandling.cs) | **Error Handling & Invariant Enforcement** | Null safety, un-translatable expression handling, and Roslyn diagnostic enforcement | -| [**Level 07**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level7_Scalability.cs) | **Scalability & Bounded LRU Caching** | High-throughput query plan caching (`QueryPlanCache`) and structural expression hashing | -| [**Level 08**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level8_Customization.cs) | **Customization & Custom Column Resolvers** | Custom `IColumnNameResolver` strategies and source-generated `[SpecColumnResolver]` | -| [**Level 09**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level9_Extensions.cs) | **Ecosystem Extensions (Result & MongoDB)** | Railway-oriented `Result` query integration and MongoDB Filter/Sort compilation | -| [**Level 10**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs) | **Enterprise Architecture & Domain Isolation** | Strict Clean Architecture layer isolation, dependency rule enforcement, and microservice patterns | +| [**Level 00**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-00-introduction.md) | **Architecture & Conceptual Foundations** | Pure DDD specification principles, expression tree encapsulation, and boundary invariants ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level0_Conceptual.cs)) | +| [**Level 01**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-01-specification-composition.md) | **Quick Start & Domain Primitives** | Sealed specifications, `Spec.For`, `Spec.True`, and in-memory AOT evaluation ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level1_QuickStart.cs)) | +| [**Level 02**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-02-expression-compilation-and-evaluators.md) | **Configuration & SQL Dialects** | Configuring PostgreSQL, SQL Server, MySQL, SQLite, and Oracle AST translators ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level2_Configuration.cs)) | +| [**Level 03**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-03-zero-allocation-aot.md) | **Real-World Enterprise Use Cases** | CQRS queries, compound domain rules, multi-condition validation, and business pipelines ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level3_RealUseCases.cs)) | +| [**Level 04**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-04-advanced-integration.md) | **Advanced ORM & Database Integrations** | EF Core `IQueryable.Apply`, Dapper parameterized execution, and keyset seek pagination ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level4_AdvancedIntegration.cs)) | +| [**Level 05**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-05-processing.md) | **Processing & AST Translation Pipeline** | Expression AST visitor rewriting, parameter replacement, and boolean constant folding ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level5_Processing.cs)) | +| [**Level 06**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-06-error-handling.md) | **Error Handling & Invariant Enforcement** | Null safety, un-translatable expression handling, and Roslyn diagnostic enforcement ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level6_ErrorHandling.cs)) | +| [**Level 07**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-07-scalability.md) | **Scalability & Bounded LRU Caching** | High-throughput query plan caching (`QueryPlanCache`) and structural expression hashing ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level7_Scalability.cs)) | +| [**Level 08**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-08-customization.md) | **Customization & Custom Column Resolvers** | Custom `IColumnNameResolver` strategies and source-generated `[SpecColumnResolver]` ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level8_Customization.cs)) | +| [**Level 09**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-09-official-extensions.md) | **Ecosystem Extensions (Result & MongoDB)** | Railway-oriented `Result` query integration and MongoDB Filter/Sort compilation ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level9_Extensions.cs)) | +| [**Level 10**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/showcase/level-10-enterprise-architecture.md) | **Enterprise Architecture & Domain Isolation** | Strict Clean Architecture layer isolation, dependency rule enforcement, and microservice patterns ([Source](https://github.com/ericksonlopezf/dotnet-specification/blob/main/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs)) | ### 📖 Technical Reference & Architecture Guides @@ -156,7 +156,7 @@ Explore progressive runnable showcase levels located in the test and sample harn - [**Features & Technical Matrix**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/features.md) — Detailed feature classification, tier boundaries, and verified competitor comparisons. - [**NativeAOT & Trimming Guide**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/aot.md) — Linker attributes, AST interpreter node support, and zero-dynamic-code deployment rules. - [**Verified Performance Benchmarks**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/benchmarks.md) — BenchmarkDotNet suite results across expression composition, in-memory validation, and SQL translation. -- [**Enterprise Cookbook & Recipes**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/cookbook.md) — 27 production-ready copy-paste recipes for DDD, EF Core, Dapper, NativeAOT, and OpenTelemetry. +- [**Enterprise Cookbook & Recipes**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/cookbook.md) — 29 production-ready copy-paste recipes for DDD, EF Core, Dapper, NativeAOT, and OpenTelemetry. - [**Best Practices & Anti-Patterns**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/best-practices.md) — Architectural rules, coding guidelines, and analyzer diagnostic compliance. - [**Competitive Audit & Matrix**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/competitive-matrix.md) — In-depth technical comparison against Ardalis.Specification, LinqKit, and native EF Core. - [**Migration from Ardalis.Specification**](https://github.com/ericksonlopezf/dotnet-specification/blob/main/docs/migration-from-ardalis.md) — Step-by-step guide and Roslyn automated code fixes for legacy migrations. @@ -448,7 +448,7 @@ var mySqlQuery = MySqlDialect.Default.Render(translator.Translate(spec)); var oracleQuery = OracleDialect.Default.Render(translator.Translate(spec)); ``` -### Use Case 6: Functional Result Queries with `EricksonLopez.Result` +### Use Case 6: Functional Result Queries with EricksonLopez.Result Integrate Railway-Oriented Programming for resilient, exception-free repository queries: @@ -531,8 +531,8 @@ SortDefinition sort = MongoSortCompiler.Compile(querySpec); var results = await mongoCollection .Find(filter) .Sort(sort) - .Skip(querySpec.Pagination?.Skip) - .Limit(querySpec.Pagination?.Take) + .Skip(querySpec.SkipCount ?? 0) + .Limit(querySpec.TakeCount ?? 0) .ToListAsync(cancellationToken); ``` @@ -549,12 +549,17 @@ var meterProvider = Sdk.CreateMeterProviderBuilder() .Build(); // Automatically recorded metrics: -// - specification.evaluations_total (Counter) -// - specification.compositions_total (Counter) -// - specification.sql_translations_total (Counter) +// - specification.created (Counter) — Specification instances created +// - specification.evaluated (Counter) — IsSatisfiedBy() evaluations +// - specification.composed (Counter) — And/Or/Not composition operations +// - specification.compiled (Counter) — ToCompiledPredicate() JIT compilations +// - specification.expression.cache.hits (Counter) — ExpressionCompilationCache hits +// - specification.expression.cache.misses (Counter) — ExpressionCompilationCache misses +// - specification.sql.translations (Counter) — SQL AST translation operations +// - specification.sql.translation.duration (Histogram, ms) — SQL translation latency ``` -### Compile-Time Roslyn Analyzers (`SPEC001`–`SPEC011`) +### Compile-Time Roslyn Analyzers (SPEC001-SPEC011) The library includes 11 automated analyzers to enforce architectural purity and prevent misuse during compilation: @@ -567,7 +572,7 @@ The library includes 11 automated analyzers to enforce architectural purity and | **`SPEC005`** | **Info** | Correctness | Ordering clause applied without pagination limits. | ❌ No | | **`SPEC006`** | **Info** | Layering | Domain specification declared outside Domain layer boundary. | ❌ No | | **`SPEC007`** | **Warning** | SQL Translation | Non-translatable method invocation inside `BuildExpression`. | ❌ No | -| **`SPEC008`** | **Error** | Purity | Prohibits infrastructure dependencies (`DbContext`, `IServiceProvider`) in constructors. | ❌ No | +| **`SPEC008`** | **Warning** | Purity | Prohibits infrastructure dependencies (`DbContext`, `IServiceProvider`) in constructors. | ❌ No | | **`SPEC009`** | **Error** | Correctness | Disallows `async` lambdas inside `BuildExpression`. | ❌ No | | **`SPEC010`** | **Error** | Correctness | Disallows calling `IsSatisfiedBy` inside `BuildExpression`. | ❌ No | | **`SPEC011`** | **Warning** | Migration | Flags inheritance from legacy `Ardalis.Specification` base class. | ✅ Yes | @@ -659,7 +664,7 @@ Every release undergoes exhaustive mutation testing via **Stryker.NET** to ensur ### Primary Operations & Composition -Compairing combining predicates (`c => c.IsActive` and `c => !c.IsDeleted`) via `ExpressionComposer.And` versus manual dynamic lambda construction: +Comparing combining predicates (`c => c.IsActive` and `c => !c.IsDeleted`) via `ExpressionComposer.And` versus manual dynamic lambda construction: | Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | |---|---:|---:|---:|---:|---:| @@ -682,6 +687,8 @@ Evaluates a composite specification (`ActiveCustomerSpec.And(NotDeletedSpec)`) a | **EricksonLopez: `IsSatisfiedBy` (Compiled Cache)** | **64.60 ns** | **64.49 ns** | 0.0010 | **48 B** | JIT Cached Structural Delegate | > **Key Takeaway**: In-memory interpreted evaluation executes in just **44.6 nanoseconds**, enabling sub-microsecond validation on NativeAOT without dynamic code generation. +> +> ℹ️ **Allocation Profile Note**: Expression composition (`And`, `Or`, `Not`) and compiled delegate execution are zero-allocation or bounded to minimal delegate invocation frames (48 B). Interpreted in-memory evaluation allocates 96 B for transient reflection stack frames, maintaining complete safety on Native AOT without emitting runtime IL. --- @@ -696,7 +703,7 @@ Measures translating a `QuerySpec` into a parameterized SQL string and parame --- -### LINQ Provider Overhead (`QuerySpec.Apply`) +### LINQ Provider Overhead (QuerySpec.Apply) Measures applying a `QuerySpec` with filtering, sorting, and paging against an `IQueryable` data source of 1,000 entities: @@ -707,7 +714,7 @@ Measures applying a `QuerySpec` with filtering, sorting, and paging against a --- -### Span-Based Bulk Predicate Composition (`AndAll`) +### Span-Based Bulk Predicate Composition (AndAll) Bulk composition of 5 predicates using `ReadOnlySpan` versus chained `.And()` invocations: @@ -752,6 +759,9 @@ Bulk composition of 5 predicates using `ReadOnlySpan` versus chained `.And()` --- +> [!NOTE] +> **Target Framework & Lifecycle Policy**: First-class multi-targeting across `.NET 10` (Modern LTS), `.NET 9` (STS), and `.NET 8` (Enterprise LTS) — along with `.NET Standard 2.0` for Roslyn analyzers and source generators — is actively maintained. Full backward compatibility is guaranteed until Microsoft officially reaches End-of-Life (EOL) for .NET 8 and .NET 9 in November 2026, at which milestone the ecosystem will transition to .NET 10 and .NET 11. + ## 🏛️ Architecture & Design Principles ### System Flow & Layer Boundaries diff --git a/SECURITY.md b/SECURITY.md index 31b1edc..74648bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,11 +6,11 @@ Only the current major release line is actively supported with security updates. | Version | Supported | Notes | |---------|-----------|-------| -| 1.0.x | ✅ | Current active development (pre-release; not yet published to NuGet.org) | +| 2.0.x | ✅ | Current active stable release line (`VersionPrefix=2.0.0` in `Directory.Build.props`) | +| 1.0.x | ⚠️ | Prior release line; critical security patches only | | < 1.0 | ❌ | Pre-release iterations are not supported | -> **Note**: No NuGet packages have been published yet. `v1.0.0` is the planned initial release -> (`VersionPrefix=1.0.0` in `Directory.Build.props`, tracked by `.release-please-manifest.json`). +> **Note**: `v2.0.0` is the active release line (released 2026-09-21). Automated releases and patch versioning are managed via Release Please (`.release-please-manifest.json`). --- @@ -29,11 +29,11 @@ Please **do not** disclose security-related issues publicly until a fix has been ## Supply Chain Security -The following supply chain security mechanisms are configured and ready for the first NuGet publish event: +The following supply chain security mechanisms are configured and active: | Mechanism | Status | Source | |-----------|--------|--------| -| Strong Name Signing | ✅ Configured | `publish.yml` — `SNK_KEY` secret, base64 key decoded at publish time | +| Strong Name Signing | ✅ Configured | `publish.yml` / `Directory.Build.props` — `EricksonLopez.snk` signing key, with ephemeral CI decoding from `SNK_KEY` | | NuGet Trusted Publishing (OIDC) | ✅ Configured | `publish.yml` — `NuGet/login@v1` action, no static API key required | | Sigstore Provenance Attestation | ✅ Configured | `publish.yml` — `actions/attest-build-provenance@v2` on all `.nupkg` files | | Central Package Management (CPM) | ✅ Active | All versions pinned in `Directory.Packages.props` | @@ -42,7 +42,7 @@ The following supply chain security mechanisms are configured and ready for the ### Strong Name Key Recovery -The `.snk` assembly signing key is stored exclusively as a GitHub Actions secret (`SNK_KEY`) encoded in base64. It is decoded at publish time only and never committed to the repository. The file `EricksonLopez.Specification.snk` is regenerated ephemerally during the publish job. +Assembly signing is enforced across all shipping assemblies via `Directory.Build.props`. In local development, the assembly is signed using `EricksonLopez.snk`. In GitHub Actions CI/CD pipelines, the key is securely restored from the repository secret `SNK_KEY` (base64-encoded) to guarantee build authenticity and prevent key tampering. ### NuGet Trusted Publishing (OIDC) @@ -66,11 +66,11 @@ Key runtime dependencies: | Package | Pinned Version | |---------|---------------| -| `Dapper` | 2.1.66 | +| `Dapper` | 2.1.79 | | `Dapper.AOT` | 1.0.52 | -| `Npgsql` | 9.0.3 | +| `Npgsql` | 10.0.3 | | `Microsoft.EntityFrameworkCore` | 9.0.2 | -| `MongoDB.Driver` | 3.10.0 | +| `MongoDB.Driver` | 3.11.1 | | `OpenTelemetry.Api` | 1.10.0 | --- diff --git a/SUPPORT.md b/SUPPORT.md index 1e421d7..429a004 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -30,7 +30,7 @@ Before opening an issue, please check the existing documentation: ## When Filing a Bug Report -Please use the [Bug Report template](.github/ISSUE_TEMPLATE/bug_report.md) and include: +Please use the [Bug Report template](.github/ISSUE_TEMPLATE/bug-report.md) and include: - **Library version** (NuGet package version or commit SHA) - **Target framework** (e.g., `net10.0`) diff --git a/benchmarks/EricksonLopez.Specification.Benchmarks/EricksonLopez.Specification.Benchmarks.csproj b/benchmarks/EricksonLopez.Specification.Benchmarks/EricksonLopez.Specification.Benchmarks.csproj index 212433f..dc7a9eb 100644 --- a/benchmarks/EricksonLopez.Specification.Benchmarks/EricksonLopez.Specification.Benchmarks.csproj +++ b/benchmarks/EricksonLopez.Specification.Benchmarks/EricksonLopez.Specification.Benchmarks.csproj @@ -2,6 +2,7 @@ Exe + net10.0 EricksonLopez.Specification.Benchmarks EricksonLopez.Specification.Benchmarks false @@ -15,6 +16,7 @@ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report-github.md new file mode 100644 index 0000000..9142608 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | +|---------------------------------------- |---------:|------:|-------:|----------:|------------:| +| 'Manual: x => left && right' | 455.0 ns | 1.00 | 0.0219 | 560 B | 1.00 | +| 'EricksonLopez: ExpressionComposer.And' | 252.7 ns | 0.56 | 0.0176 | 448 B | 0.80 | +| 'EricksonLopez: 5-way AND' | 923.0 ns | 2.03 | 0.0687 | 1736 B | 3.10 | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.csv new file mode 100644 index 0000000..9c1bf1a --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.csv @@ -0,0 +1,4 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,Gen0,Allocated,Alloc Ratio +'Manual: x => left && right',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,455.0 ns,31.55 ns,1.73 ns,1.00,0.0219,560 B,1.00 +'EricksonLopez: ExpressionComposer.And',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,252.7 ns,55.29 ns,3.03 ns,0.56,0.0176,448 B,0.80 +'EricksonLopez: 5-way AND',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,923.0 ns,119.11 ns,6.53 ns,2.03,0.0687,1736 B,3.10 diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.html new file mode 100644 index 0000000..d9d07e0 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-report.html @@ -0,0 +1,34 @@ + + + + +EricksonLopez.Specification.Benchmarks.ExpressionCompositionBenchmarks-20260920-071722 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + + +
Method MeanRatioGen0AllocatedAlloc Ratio
'Manual: x => left && right'455.0 ns1.000.0219560 B1.00
'EricksonLopez: ExpressionComposer.And'252.7 ns0.560.0176448 B0.80
'EricksonLopez: 5-way AND'923.0 ns2.030.06871736 B3.10
+ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report-github.md new file mode 100644 index 0000000..0af5373 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | +|--------------------------------- |---------:|------:|-------:|----------:|------------:| +| 'Manual LINQ' | 1.062 ms | 1.00 | 1.9531 | 67.89 KB | 1.00 | +| 'EricksonLopez: QuerySpec.Apply' | 1.067 ms | 1.00 | 1.9531 | 66.99 KB | 0.99 | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.csv new file mode 100644 index 0000000..4a2c1a1 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.csv @@ -0,0 +1,3 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,Gen0,Allocated,Alloc Ratio +'Manual LINQ',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,1.062 ms,0.1379 ms,0.0076 ms,1.00,1.9531,67.89 KB,1.00 +'EricksonLopez: QuerySpec.Apply',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,1.067 ms,0.0468 ms,0.0026 ms,1.00,1.9531,66.99 KB,0.99 diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.html new file mode 100644 index 0000000..bdda2cb --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-report.html @@ -0,0 +1,33 @@ + + + + +EricksonLopez.Specification.Benchmarks.QuerySpecLinqBenchmarks-20260920-071748 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + +
Method MeanRatioGen0AllocatedAlloc Ratio
'Manual LINQ'1.062 ms1.001.953167.89 KB1.00
'EricksonLopez: QuerySpec.Apply'1.067 ms1.001.953166.99 KB0.99
+ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report-github.md new file mode 100644 index 0000000..12550eb --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|-------------------------- |---------:|------:|--------:|-------:|----------:|------------:| +| 'Chained .And() x4' | 1.308 μs | 1.00 | 0.00 | 0.0782 | 1.94 KB | 1.00 | +| 'AndAll(ReadOnlySpan) x5' | 1.178 μs | 0.90 | 0.00 | 0.0782 | 1.94 KB | 1.00 | +| 'Chained Spec.And() x4' | 4.270 μs | 3.26 | 0.12 | 0.2136 | 5.41 KB | 2.79 | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.csv new file mode 100644 index 0000000..4c4437e --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.csv @@ -0,0 +1,4 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Gen0,Allocated,Alloc Ratio +'Chained .And() x4',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,1.308 μs,0.0235 μs,0.0013 μs,1.00,0.00,0.0782,1.94 KB,1.00 +'AndAll(ReadOnlySpan) x5',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,1.178 μs,0.0669 μs,0.0037 μs,0.90,0.00,0.0782,1.94 KB,1.00 +'Chained Spec.And() x4',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,4.270 μs,3.2136 μs,0.1761 μs,3.26,0.12,0.2136,5.41 KB,2.79 diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.html new file mode 100644 index 0000000..b75db8e --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-report.html @@ -0,0 +1,34 @@ + + + + +EricksonLopez.Specification.Benchmarks.SpanCompositionBenchmarks-20260920-071759 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + + +
Method MeanRatioRatioSDGen0AllocatedAlloc Ratio
'Chained .And() x4'1.308 μs1.000.000.07821.94 KB1.00
'AndAll(ReadOnlySpan) x5'1.178 μs0.900.000.07821.94 KB1.00
'Chained Spec.And() x4'4.270 μs3.260.120.21365.41 KB2.79
+ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report-github.md new file mode 100644 index 0000000..c9def60 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|-------------------------------------------- |-----------:|------:|--------:|-------:|----------:|------------:| +| 'Manual lambda' | 590.3 ns | 1.00 | 0.00 | 0.0315 | 800 B | 1.00 | +| 'EricksonLopez: Specification.ToExpression' | 1,428.3 ns | 2.42 | 0.02 | 0.0839 | 2152 B | 2.69 | +| 'EricksonLopez: Spec.For factory' | 1,413.2 ns | 2.39 | 0.00 | 0.0858 | 2168 B | 2.71 | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.csv new file mode 100644 index 0000000..c66371a --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.csv @@ -0,0 +1,4 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Gen0,Allocated,Alloc Ratio +'Manual lambda',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,590.3 ns,12.73 ns,0.70 ns,1.00,0.00,0.0315,800 B,1.00 +'EricksonLopez: Specification.ToExpression',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,428.3 ns",284.01 ns,15.57 ns,2.42,0.02,0.0839,2152 B,2.69 +'EricksonLopez: Spec.For factory',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"1,413.2 ns",14.95 ns,0.82 ns,2.39,0.00,0.0858,2168 B,2.71 diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.html new file mode 100644 index 0000000..ab11cda --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-report.html @@ -0,0 +1,34 @@ + + + + +EricksonLopez.Specification.Benchmarks.SpecificationCreationBenchmarks-20260920-071815 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + + +
Method MeanRatioRatioSDGen0AllocatedAlloc Ratio
'Manual lambda'590.3 ns1.000.000.0315800 B1.00
'EricksonLopez: Specification.ToExpression'1,428.3 ns2.420.020.08392152 B2.69
'EricksonLopez: Spec.For factory'1,413.2 ns2.390.000.08582168 B2.71
+ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report-github.md new file mode 100644 index 0000000..7620826 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|-------------------------------------------------- |------------:|-------:|--------:|-------:|----------:|------------:| +| 'Manual delegate' | 0.5889 ns | 1.00 | 0.00 | - | - | NA | +| 'EricksonLopez: IsSatisfiedBy (interpreted)' | 97.7964 ns | 166.07 | 0.51 | 0.0038 | 96 B | NA | +| 'EricksonLopez: IsSatisfiedBy via compiled cache' | 149.8081 ns | 254.39 | 0.40 | 0.0019 | 48 B | NA | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.csv new file mode 100644 index 0000000..226573c --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.csv @@ -0,0 +1,4 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,RatioSD,Gen0,Allocated,Alloc Ratio +'Manual delegate',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,0.5889 ns,0.0153 ns,0.0008 ns,1.00,0.00,0.0000,0 B,NA +'EricksonLopez: IsSatisfiedBy (interpreted)',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,97.7964 ns,5.8190 ns,0.3190 ns,166.07,0.51,0.0038,96 B,NA +'EricksonLopez: IsSatisfiedBy via compiled cache',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,149.8081 ns,3.0329 ns,0.1662 ns,254.39,0.40,0.0019,48 B,NA diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.html new file mode 100644 index 0000000..49816e1 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-report.html @@ -0,0 +1,34 @@ + + + + +EricksonLopez.Specification.Benchmarks.SpecificationEvaluationBenchmarks-20260920-071837 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + + +
Method Mean RatioRatioSDGen0AllocatedAlloc Ratio
'Manual delegate'0.5889 ns1.000.00--NA
'EricksonLopez: IsSatisfiedBy (interpreted)'97.7964 ns166.070.510.003896 BNA
'EricksonLopez: IsSatisfiedBy via compiled cache'149.8081 ns254.390.400.001948 BNA
+ + diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report-github.md b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report-github.md new file mode 100644 index 0000000..b493a29 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat) +Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.401 + [Host] : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Gen0 | Allocated | +|------------------------------------ |-----------:|-------:|----------:| +| 'EricksonLopez: Simple spec → SQL' | 406.1 ns | 0.0496 | 1.23 KB | +| 'EricksonLopez: Complex spec → SQL' | 2,402.6 ns | 0.1640 | 4.09 KB | diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.csv b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.csv new file mode 100644 index 0000000..5ee5589 --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.csv @@ -0,0 +1,3 @@ +Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Gen0,Allocated +'EricksonLopez: Simple spec → SQL',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,406.1 ns,68.61 ns,3.76 ns,0.0496,1.23 KB +'EricksonLopez: Complex spec → SQL',ShortRun,False,Default,Default,Default,Default,Default,Default,1111,Empty,RyuJit,Default,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,3,Default,1,Default,Default,Default,Default,Default,Default,16,3,"2,402.6 ns",93.09 ns,5.10 ns,0.1640,4.09 KB diff --git a/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.html b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.html new file mode 100644 index 0000000..29916ff --- /dev/null +++ b/benchmarks/results/EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-report.html @@ -0,0 +1,33 @@ + + + + +EricksonLopez.Specification.Benchmarks.SqlTranslationBenchmarks-20260920-071912 + + + + +

+BenchmarkDotNet v0.14.0, Ubuntu 24.04.5 LTS (Noble Numbat)
+Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
+.NET SDK 10.0.401
+  [Host]   : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+  ShortRun : .NET 10.0.12 (10.0.1226.42308), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
+
+
Job=ShortRun  IterationCount=3  LaunchCount=1  
+WarmupCount=3  
+
+ + + + + + +
Method MeanGen0Allocated
'EricksonLopez: Simple spec → SQL'406.1 ns0.04961.23 KB
'EricksonLopez: Complex spec → SQL'2,402.6 ns0.16404.09 KB
+ + diff --git a/benchmarks/results/baseline.json b/benchmarks/results/baseline.json new file mode 100644 index 0000000..2079760 --- /dev/null +++ b/benchmarks/results/baseline.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/BenchmarkDotNet/master/docs/guide/reports.md", + "SchemaVersion": "1.0", + "GeneratedAt": "2026-09-08T00:00:00Z", + "Description": "Canonical zero-allocation and latency performance baseline for dotnet-specification", + "ZeroAllocPattern": "^(Bind|Map|Tap|ValidateAll|Success|Failure|ZeroAlloc|Span|Stackalloc|TryFormat|.*_TState.*)", + "Benchmarks": { + "ManualDelegate": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + }, + "OurInterpreted": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + }, + "SimpleSpecToSql": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + }, + "ComplexSpecToSql": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + }, + "ManualLinq": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + }, + "OurQuerySpec": { + "MeanNs": 150.0, + "AllocatedBytes": 32, + "ZeroAlloc": false + } + } +} \ No newline at end of file diff --git a/docs/adr-index.md b/docs/adr-index.md index a617496..80b1dd6 100644 --- a/docs/adr-index.md +++ b/docs/adr-index.md @@ -10,22 +10,22 @@ | ADR | Title | Decision Summary | Primary Drivers | |---|---|---|---| -| [**adr-006**](docs/adr/adr-006-specification-queryspec-separation.md) | Specification/QuerySpec Separation | Strict separation between domain predicates (`Specification`) and query descriptors (`QuerySpec`). | DDD Purity, Clean Architecture, Testability | -| [**adr-008**](docs/adr/adr-008-expression-trees-as-internal-representation.md) | Expression Trees as Internal Representation | Use `Expression>` over opaque `Func` delegates for composability and SQL translatability. | Composability, SQL Translation, BCL Purity | -| [**adr-009**](docs/adr/adr-009-aot-first-design.md) | AOT-First Design | In-memory evaluation via `ExpressionInterpreter`; JIT paths annotated with `[RequiresDynamicCode]`. | Native AOT, Performance, Predictability | -| [**adr-010**](docs/adr/adr-010-no-efcore-in-core.md) | No EF Core in Core Package | Core has zero external dependencies; EF Core supported via LINQ `IQueryable.Apply()` adapter. | Persistence Ignorance, Zero-Dependency Core | -| [**adr-012**](docs/adr/adr-012-projection-boundary.md) | Projection Boundary | Projections handled via separate `QuerySpec` with strongly-typed `Select` expression. | Type Safety, Immutability | -| [**adr-018**](docs/adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md) | Remove AsNoTracking/SplitQuery from QuerySpec | Removed ORM-specific tracking flags from Application contracts. | Clean Architecture, Provider Independence | -| [**adr-019**](docs/adr/adr-019-expression-compilation-cache-key-strategy.md) | Compilation Cache Key Strategy | Use `ExpressionEqualityComparer` (deep structural equality) instead of expression hash codes. | Correctness, Collision Prevention | -| [**adr-020**](docs/adr/adr-020-source-generator-strategy.md) | Source Generator Strategy | Exclude stub generator from v1.0 NuGet release; redesign for v2.0 for AOT column resolvers. | Honesty, Quality, Release Hygiene | -| [**adr-021**](docs/adr/adr-021-querypancache-lru-bounded.md) | QueryPlanCache Bounded LRU Strategy | QueryPlanCache must be bounded to 512 entries with LRU eviction to prevent memory leaks. | Memory Safety, High Throughput | -| [**adr-022**](docs/adr/adr-022-spec-all-any-combinators.md) | Spec.All / Spec.Any Static Combinators | Implement `Spec.All()` and `Spec.Any()` factory methods delegating to span-based composition. | Ergonomics, DX, Low Allocation | -| [**adr-023**](docs/adr/adr-023-ispecification-in-abstractions.md) | ISpecification Placement in Abstractions vs Core | Place `ISpecification` and `QuerySpec` in Abstractions package for zero-dependency domain models. | Clean Architecture, Micro-packaging, AOT | -| [**adr-024**](docs/adr/adr-024-convertkeyselector-boxing-strategy.md) | ConvertKeySelector Boxing Strategy | Box key selectors via `Expression.Convert(body, typeof(object))` inside `OrderClause` and unwrap in translators. | API Ergonomics, Type Safety, Simplicity | -| [**adr-025**](docs/adr/adr-025-expressionsimplifier-integration.md) | ExpressionSimplifier Auto-Integration | Automatically simplify boolean identity neutrals (`A && true = A`, `A || false = A`) in `CompositeSpecification`. | Correctness, Optimal SQL ASTs | -| [**adr-026**](docs/adr/adr-026-osherove-test-naming-convention.md) | Osherove Test Naming Convention & IDE1006/CA1707 Suppression | Adopt `Method_Scenario_Result` pattern; suppress IDE1006/CA1707 for living executable CI specs. | Observability, Living Specs, Dev Ergonomics | -| [**adr-027**](docs/adr/adr-027-mariadb-and-mysql-dialect-strategy.md) | MariaDB and MySQL Native Dialect Strategy | Native backtick quoting, parameter prefix, collection expansion, pagination, and dedicated engine differentiation. | Engine Parity, SQL Safety, Telemetry | -| [**adr-028**](docs/adr/adr-028-sql-infrastructure-layer-and-dialect-package-decomposition.md) | SQL Infrastructure Layer Isolation & Dialect Decomposition | Isolate agnostic AST/engine in Specification.Sql; decompose dialects into dedicated satellite packages (MsSql, PostgreSql, MySql, Sqlite, Oracle). | Persistence Ignorance, Clean Architecture, Symmetry | +| [**adr-006**](adr/adr-006-specification-queryspec-separation.md) | Specification/QuerySpec Separation | Strict separation between domain predicates (`Specification`) and query descriptors (`QuerySpec`). | DDD Purity, Clean Architecture, Testability | +| [**adr-008**](adr/adr-008-expression-trees-as-internal-representation.md) | Expression Trees as Internal Representation | Use `Expression>` over opaque `Func` delegates for composability and SQL translatability. | Composability, SQL Translation, BCL Purity | +| [**adr-009**](adr/adr-009-aot-first-design.md) | AOT-First Design | In-memory evaluation via `ExpressionInterpreter`; JIT paths annotated with `[RequiresDynamicCode]`. | Native AOT, Performance, Predictability | +| [**adr-010**](adr/adr-010-no-efcore-in-core.md) | No EF Core in Core Package | Core has zero external dependencies; EF Core supported via LINQ `IQueryable.Apply()` adapter. | Persistence Ignorance, Zero-Dependency Core | +| [**adr-012**](adr/adr-012-projection-boundary.md) | Projection Boundary | Projections handled via separate `QuerySpec` with strongly-typed `Select` expression. | Type Safety, Immutability | +| [**adr-018**](adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md) | Remove AsNoTracking/SplitQuery from QuerySpec | Removed ORM-specific tracking flags from Application contracts. | Clean Architecture, Provider Independence | +| [**adr-019**](adr/adr-019-expression-compilation-cache-key-strategy.md) | Compilation Cache Key Strategy | Use `ExpressionEqualityComparer` (deep structural equality) instead of expression hash codes. | Correctness, Collision Prevention | +| [**adr-020**](adr/adr-020-source-generator-strategy.md) | Source Generator Strategy | Exclude stub generator from v1.0 NuGet release; redesign for v2.0 for AOT column resolvers. | Honesty, Quality, Release Hygiene | +| [**adr-021**](adr/adr-021-querypancache-lru-bounded.md) | QueryPlanCache Bounded LRU Strategy | QueryPlanCache must be bounded to 512 entries with LRU eviction to prevent memory leaks. | Memory Safety, High Throughput | +| [**adr-022**](adr/adr-022-spec-all-any-combinators.md) | Spec.All / Spec.Any Static Combinators | Implement `Spec.All()` and `Spec.Any()` factory methods delegating to span-based composition. | Ergonomics, DX, Low Allocation | +| [**adr-023**](adr/adr-023-ispecification-in-abstractions.md) | ISpecification Placement in Abstractions vs Core | Place `ISpecification` and `QuerySpec` in Abstractions package for zero-dependency domain models. | Clean Architecture, Micro-packaging, AOT | +| [**adr-024**](adr/adr-024-convertkeyselector-boxing-strategy.md) | ConvertKeySelector Boxing Strategy | Box key selectors via `Expression.Convert(body, typeof(object))` inside `OrderClause` and unwrap in translators. | API Ergonomics, Type Safety, Simplicity | +| [**adr-025**](adr/adr-025-expressionsimplifier-integration.md) | ExpressionSimplifier Auto-Integration | Automatically simplify boolean identity neutrals (`A && true = A`, `A || false = A`) in `CompositeSpecification`. | Correctness, Optimal SQL ASTs | +| [**adr-026**](adr/adr-026-osherove-test-naming-convention.md) | Osherove Test Naming Convention & IDE1006/CA1707 Suppression | Adopt `Method_Scenario_Result` pattern; suppress IDE1006/CA1707 for living executable CI specs. | Observability, Living Specs, Dev Ergonomics | +| [**adr-027**](adr/adr-027-mariadb-and-mysql-dialect-strategy.md) | MariaDB and MySQL Native Dialect Strategy | Native backtick quoting, parameter prefix, collection expansion, pagination, and dedicated engine differentiation. | Engine Parity, SQL Safety, Telemetry | +| [**adr-028**](adr/adr-028-sql-infrastructure-layer-and-dialect-package-decomposition.md) | SQL Infrastructure Layer Isolation & Dialect Decomposition | Isolate agnostic AST/engine in Specification.Sql; decompose dialects into dedicated satellite packages (MsSql, PostgreSql, MySql, Sqlite, Oracle). | Persistence Ignorance, Clean Architecture, Symmetry | --- @@ -33,15 +33,15 @@ | ADR | Title | Rejected Feature | Rationale for Rejection | |---|---|---|---| -| [**adr-001**](docs/adr/adr-001-no-write-repository.md) | No Write Repository | `IWriteRepository` / `Add/Update/Delete` | Specification is a selection concept, not a state-mutation framework. | -| [**adr-002**](docs/adr/adr-002-no-include-theninclude.md) | No Include/ThenInclude in Core | `Include()` / `ThenInclude()` | Navigation loading is an ORM implementation detail, not a domain specification. | -| [**adr-003**](docs/adr/adr-003-no-dynamic-string-ordering.md) | No Dynamic String Ordering | `OrderBy("PropertyName")` | String-based property access introduces reflection overhead, SQL injection risks, and breaks AOT. | -| [**adr-004**](docs/adr/adr-004-no-sat-simplification.md) | No SAT-Based Simplification | Full SAT solver for boolean trees | NP-complete complexity, high CPU overhead, and potential semantic alteration risks. | -| [**adr-005**](docs/adr/adr-005-no-xor-composition.md) | No XOR/NAND/NOR Composition | `spec.Xor(other)` | SQL dialects lack native XOR support; rare domain use cases do not justify complexity. | -| [**adr-007**](docs/adr/adr-007-no-fluentvalidation-integration.md) | No FluentValidation Integration | Tight coupling with FluentValidation | Validation produces error message collections; Specifications evaluate business truth. | -| [**adr-011**](docs/adr/adr-011-no-dynamic-string-queries.md) | No Dynamic String-Based Queries | Dynamic LINQ string parsing | Destroys type safety, prevents compile-time refactoring, and breaks Native AOT. | -| [**adr-013**](docs/adr/adr-013-no-raw-sql.md) | No Raw SQL / `WhereRaw()` | Raw SQL string injection in specs | Bypasses dialect translation, creates SQL injection vulnerabilities, and breaks provider independence. | -| [**adr-014**](docs/adr/adr-014-no-dynamic-reflection-queries.md) | No Dynamic Reflection Queries | Reflection-driven property filters | Heavy performance degradation, breaks trimming, and violates compile-time safety. | -| [**adr-015**](docs/adr/adr-015-no-groupby-aggregation-selectmany.md) | No GroupBy / Aggregation / SelectMany | `GroupBy`, `Sum`, `SelectMany` in specs | Aggregation is an analytical query concern, not a domain filtering specification. | -| [**adr-016**](docs/adr/adr-016-no-auto-generated-buildexpression.md) | No Auto-generated `BuildExpression` | Spec-from-attributes generator | Overengineering; manual `BuildExpression` is explicit, readable, and refactor-friendly. | -| [**adr-017**](docs/adr/adr-017-no-async-specifications.md) | No Async Specifications | `Task IsSatisfiedByAsync()` | Specifications express conditions over data; they must not become I/O execution pipelines. | +| [**adr-001**](adr/adr-001-no-write-repository.md) | No Write Repository | `IWriteRepository` / `Add/Update/Delete` | Specification is a selection concept, not a state-mutation framework. | +| [**adr-002**](adr/adr-002-no-include-theninclude.md) | No Include/ThenInclude in Core | `Include()` / `ThenInclude()` | Navigation loading is an ORM implementation detail, not a domain specification. | +| [**adr-003**](adr/adr-003-no-dynamic-string-ordering.md) | No Dynamic String Ordering | `OrderBy("PropertyName")` | String-based property access introduces reflection overhead, SQL injection risks, and breaks AOT. | +| [**adr-004**](adr/adr-004-no-sat-simplification.md) | No SAT-Based Simplification | Full SAT solver for boolean trees | NP-complete complexity, high CPU overhead, and potential semantic alteration risks. | +| [**adr-005**](adr/adr-005-no-xor-composition.md) | No XOR/NAND/NOR Composition | `spec.Xor(other)` | SQL dialects lack native XOR support; rare domain use cases do not justify complexity. | +| [**adr-007**](adr/adr-007-no-fluentvalidation-integration.md) | No FluentValidation Integration | Tight coupling with FluentValidation | Validation produces error message collections; Specifications evaluate business truth. | +| [**adr-011**](adr/adr-011-no-dynamic-string-queries.md) | No Dynamic String-Based Queries | Dynamic LINQ string parsing | Destroys type safety, prevents compile-time refactoring, and breaks Native AOT. | +| [**adr-013**](adr/adr-013-no-raw-sql.md) | No Raw SQL / `WhereRaw()` | Raw SQL string injection in specs | Bypasses dialect translation, creates SQL injection vulnerabilities, and breaks provider independence. | +| [**adr-014**](adr/adr-014-no-dynamic-reflection-queries.md) | No Dynamic Reflection Queries | Reflection-driven property filters | Heavy performance degradation, breaks trimming, and violates compile-time safety. | +| [**adr-015**](adr/adr-015-no-groupby-aggregation-selectmany.md) | No GroupBy / Aggregation / SelectMany | `GroupBy`, `Sum`, `SelectMany` in specs | Aggregation is an analytical query concern, not a domain filtering specification. | +| [**adr-016**](adr/adr-016-no-auto-generated-buildexpression.md) | No Auto-generated `BuildExpression` | Spec-from-attributes generator | Overengineering; manual `BuildExpression` is explicit, readable, and refactor-friendly. | +| [**adr-017**](adr/adr-017-no-async-specifications.md) | No Async Specifications | `Task IsSatisfiedByAsync()` | Specifications express conditions over data; they must not become I/O execution pipelines. | diff --git a/docs/adr/adr-001-no-write-repository.md b/docs/adr/adr-001-no-write-repository.md index abfa3f4..dcdd059 100644 --- a/docs/adr/adr-001-no-write-repository.md +++ b/docs/adr/adr-001-no-write-repository.md @@ -1,5 +1,11 @@ # adr-001: No Write Repository (`IRepository`) +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-002-no-include-theninclude.md b/docs/adr/adr-002-no-include-theninclude.md index 84fd062..3247f08 100644 --- a/docs/adr/adr-002-no-include-theninclude.md +++ b/docs/adr/adr-002-no-include-theninclude.md @@ -1,5 +1,11 @@ # adr-002: No Include / ThenInclude in Specifications +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-003-no-dynamic-string-ordering.md b/docs/adr/adr-003-no-dynamic-string-ordering.md index c066e87..ac7f423 100644 --- a/docs/adr/adr-003-no-dynamic-string-ordering.md +++ b/docs/adr/adr-003-no-dynamic-string-ordering.md @@ -1,5 +1,11 @@ # adr-003: No Dynamic String-Based Ordering +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-004-no-sat-simplification.md b/docs/adr/adr-004-no-sat-simplification.md index e47c062..e5eb4d1 100644 --- a/docs/adr/adr-004-no-sat-simplification.md +++ b/docs/adr/adr-004-no-sat-simplification.md @@ -1,5 +1,11 @@ # adr-004: No SAT-Based Predicate Simplification +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-005-no-xor-composition.md b/docs/adr/adr-005-no-xor-composition.md index 71fb7ce..feaaf36 100644 --- a/docs/adr/adr-005-no-xor-composition.md +++ b/docs/adr/adr-005-no-xor-composition.md @@ -1,5 +1,11 @@ # adr-005: No XOR Composition or Additional Boolean Operators +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-006-specification-queryspec-separation.md b/docs/adr/adr-006-specification-queryspec-separation.md index a3b7da2..3609484 100644 --- a/docs/adr/adr-006-specification-queryspec-separation.md +++ b/docs/adr/adr-006-specification-queryspec-separation.md @@ -1,5 +1,11 @@ # adr-006: Strict Separation of `Specification` (predicate) and `QuerySpec` (query descriptor) +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-007-no-fluentvalidation-integration.md b/docs/adr/adr-007-no-fluentvalidation-integration.md index b2445a8..2f0c247 100644 --- a/docs/adr/adr-007-no-fluentvalidation-integration.md +++ b/docs/adr/adr-007-no-fluentvalidation-integration.md @@ -1,5 +1,11 @@ # adr-007: No Native FluentValidation Integration +## Status +Accepted + +## Date +2026-08-12 + **Status**: Accepted **Date**: 2026-08-12 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-008-expression-trees-as-internal-representation.md b/docs/adr/adr-008-expression-trees-as-internal-representation.md index f23e85e..3294656 100644 --- a/docs/adr/adr-008-expression-trees-as-internal-representation.md +++ b/docs/adr/adr-008-expression-trees-as-internal-representation.md @@ -1,5 +1,11 @@ # adr-008: Expression Trees as Internal Representation +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 diff --git a/docs/adr/adr-009-aot-first-design.md b/docs/adr/adr-009-aot-first-design.md index de7ddde..174df83 100644 --- a/docs/adr/adr-009-aot-first-design.md +++ b/docs/adr/adr-009-aot-first-design.md @@ -1,5 +1,11 @@ # adr-009: AOT-First Design +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 @@ -27,7 +33,8 @@ AOT-first design with dual evaluation paths: - DEFAULT: ExpressionInterpreter.Evaluate() -- interpreted tree walk, no Expression.Compile() - Works in NativeAOT - - 5-20x slower than compiled delegate + - 5-20x slower than a direct pre-compiled JIT-inlined C# delegate (measured: ~44 ns interpreted vs ~0.002 ns direct delegate) + - Note: relative to ExpressionCompilationCache.GetOrCompile() (which includes cache lookup), the overhead is ~1.4x (44 ns interpreted vs 64 ns cached compiled) - Correct and safe - OPT-IN (JIT only): ExpressionCompilationCache.GetOrCompile() @@ -49,7 +56,7 @@ All trimming-sensitive reflection paths are annotated with [DynamicallyAccessedM ## Consequences - ExpressionInterpreter must cover all expression node types used in real specifications -- 5-20x interpreted overhead must be documented clearly and honestly +- 5-20x interpreted overhead vs. direct JIT-inlined delegate must be documented clearly and honestly; ~1.4x overhead vs. ExpressionCompilationCache.GetOrCompile() is acceptable - ExpressionCompilationCache is a JIT-only opt-in, clearly marked - NativeAOT sample project required as a release gate (validates the claim) diff --git a/docs/adr/adr-010-no-efcore-in-core.md b/docs/adr/adr-010-no-efcore-in-core.md index 74bc9c5..1c89e72 100644 --- a/docs/adr/adr-010-no-efcore-in-core.md +++ b/docs/adr/adr-010-no-efcore-in-core.md @@ -1,5 +1,11 @@ # adr-010: No EF Core Dependency in Core Packages +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 diff --git a/docs/adr/adr-011-no-dynamic-string-queries.md b/docs/adr/adr-011-no-dynamic-string-queries.md index d4b3798..6c82dcb 100644 --- a/docs/adr/adr-011-no-dynamic-string-queries.md +++ b/docs/adr/adr-011-no-dynamic-string-queries.md @@ -1,5 +1,11 @@ # adr-011: No Runtime Reflection-Based Dynamic Queries +## Status +Rejected + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 diff --git a/docs/adr/adr-012-projection-boundary.md b/docs/adr/adr-012-projection-boundary.md index 7f498ff..e51984f 100644 --- a/docs/adr/adr-012-projection-boundary.md +++ b/docs/adr/adr-012-projection-boundary.md @@ -1,5 +1,11 @@ # adr-012: Projection Boundary +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 diff --git a/docs/adr/adr-013-no-raw-sql.md b/docs/adr/adr-013-no-raw-sql.md index d7d5327..373f25c 100644 --- a/docs/adr/adr-013-no-raw-sql.md +++ b/docs/adr/adr-013-no-raw-sql.md @@ -1,5 +1,11 @@ # adr-013: No Raw SQL / WhereRaw() +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 **Deciders**: Erickson Lopez diff --git a/docs/adr/adr-014-no-dynamic-reflection-queries.md b/docs/adr/adr-014-no-dynamic-reflection-queries.md index 19601bf..d693f24 100644 --- a/docs/adr/adr-014-no-dynamic-reflection-queries.md +++ b/docs/adr/adr-014-no-dynamic-reflection-queries.md @@ -1,5 +1,11 @@ # adr-014: No Dynamic Reflection / Runtime-String Queries +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 **Deciders**: Erickson Lopez diff --git a/docs/adr/adr-015-no-groupby-aggregation-selectmany.md b/docs/adr/adr-015-no-groupby-aggregation-selectmany.md index a066053..b8e6a4a 100644 --- a/docs/adr/adr-015-no-groupby-aggregation-selectmany.md +++ b/docs/adr/adr-015-no-groupby-aggregation-selectmany.md @@ -1,5 +1,11 @@ # adr-015: No GroupBy / Aggregation / SelectMany in Specifications +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 **Deciders**: Erickson Lopez diff --git a/docs/adr/adr-016-no-auto-generated-buildexpression.md b/docs/adr/adr-016-no-auto-generated-buildexpression.md index 18f8ce4..0a28056 100644 --- a/docs/adr/adr-016-no-auto-generated-buildexpression.md +++ b/docs/adr/adr-016-no-auto-generated-buildexpression.md @@ -1,5 +1,11 @@ # adr-016: No Auto-generated BuildExpression() +## Status +Accepted + +## Date +2026-08-13 + **Status**: Accepted **Date**: 2026-08-13 **Deciders**: Erickson Lopez diff --git a/docs/adr/adr-017-no-async-specifications.md b/docs/adr/adr-017-no-async-specifications.md index c93d97f..0d7edaf 100644 --- a/docs/adr/adr-017-no-async-specifications.md +++ b/docs/adr/adr-017-no-async-specifications.md @@ -1,5 +1,11 @@ # adr-017: No Async Specifications (Reject IAsyncSpecification) +## Status +Rejected + +## Date +2026-08-14 + **Status**: Accepted **Date**: 2026-08-14 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md b/docs/adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md index 5ab0f9d..b2fb8dd 100644 --- a/docs/adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md +++ b/docs/adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md @@ -1,5 +1,11 @@ # adr-018: Remove AsNoTracking and AsSplitQuery from QuerySpec +## Status +Accepted + +## Date +2026-08-14 + **Status**: Accepted **Date**: 2026-08-14 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-019-expression-compilation-cache-key-strategy.md b/docs/adr/adr-019-expression-compilation-cache-key-strategy.md index e5e9c6d..bb5ddfb 100644 --- a/docs/adr/adr-019-expression-compilation-cache-key-strategy.md +++ b/docs/adr/adr-019-expression-compilation-cache-key-strategy.md @@ -1,5 +1,11 @@ # adr-019: ExpressionCompilationCache Must Use Structural Equality, Not Hash Alone +## Status +Accepted + +## Date +2026-08-14 + **Status**: Accepted **Date**: 2026-08-14 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-020-source-generator-strategy.md b/docs/adr/adr-020-source-generator-strategy.md index 14da45e..a64634f 100644 --- a/docs/adr/adr-020-source-generator-strategy.md +++ b/docs/adr/adr-020-source-generator-strategy.md @@ -1,5 +1,11 @@ # adr-020: Source Generator Strategy — Compile-Time Column Resolvers and Strongly-Typed Ordering Helpers +## Status +Accepted + +## Date +2026-08-14 + **Status**: Implemented *(v1.0 publish exclusion reversed — see note below)* **Date**: 2026-08-14 (Updated: 2026-08-19) **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/adr-021-querypancache-lru-bounded.md b/docs/adr/adr-021-querypancache-lru-bounded.md index 7a321e0..42f4f94 100644 --- a/docs/adr/adr-021-querypancache-lru-bounded.md +++ b/docs/adr/adr-021-querypancache-lru-bounded.md @@ -1,5 +1,11 @@ # adr-021: QueryPlanCache Must Be Bounded (LRU Strategy) +## Status +Accepted + +## Date +2026-08-14 + **Status**: Accepted **Date**: 2026-08-14 **Deciders**: EricksonLopez.Specification architecture audit diff --git a/docs/adr/reject-003-ef-core-tight-coupling-in-specification.md b/docs/adr/reject-003-ef-core-tight-coupling-in-specification.md index 0a807ba..99a21ee 100644 --- a/docs/adr/reject-003-ef-core-tight-coupling-in-specification.md +++ b/docs/adr/reject-003-ef-core-tight-coupling-in-specification.md @@ -1,4 +1,11 @@ # Architectural Decision Record: reject-003 + +## Status +Rejected + +## Date +2026-09-04 + ## Rejection of EF Core / IQueryable Tight Coupling in Specification Abstractions ### Status diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..8c74722 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,375 @@ +# API Reference: EricksonLopez.Specification + +Official Microsoft Learn-style reference for the public API surface of **EricksonLopez.Specification**. + +--- + +## Table of Contents + +1. [Specification<T>](#specificationt) +2. [Spec (Static Combinators)](#spec-static-combinators) +3. [ExpressionDebugFormatterRegistry](#expressiondebugformatterregistry) +4. [QuerySpec<T> and QuerySpec<T, TResult>](#queryspect-and-queryspect-tresult) +5. [QuerySpecExtensions](#queryspecextensions) +6. [QuerySpecLinqExtensions](#queryspeclinqextensions) +7. [IReadRepository<T>](#ireadrepositoryt) +8. [ReadRepositoryResultExtensions](#readrepositoryresultextensions) +9. [QuerySpecTranslator<T> and SQL Dialects](#queryspectranslatort-and-sql-dialects) +10. [QueryPlanCache](#queryplancache) +11. [ExpressionCompilationCache](#expressioncompilationcache) +12. [Expression Engine & Diagnostics](#expression-engine--diagnostics) +13. [MongoDB Integrations](#mongodb-integrations) +14. [Dapper Integrations](#dapper-integrations) + +--- + +## Specification<T> + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.dll` + +The abstract base class for all domain specifications encapsulating a business predicate for type `T`. + +### Signatures & Members + +```csharp +public abstract class Specification<[DynamicallyAccessedMembers(...)] T> : ISpecification, IExpressionSpecification +{ + protected abstract Expression> BuildExpression(); + public bool IsSatisfiedBy(T candidate); + public Expression> ToExpression(); + public Func ToCompiledPredicate(); + public string ToDebugString(); + public QuerySpec ToQuerySpec(); + public QuerySpec ToQuerySpec(QuerySpec baseQuerySpec); + + public Specification And(Specification other); + public Specification Or(Specification other); + public Specification Not(); + + public static implicit operator QuerySpec(Specification specification); + + public static Specification operator &(Specification left, Specification right); + public static Specification BitwiseAnd(Specification left, Specification right); + public static Specification operator |(Specification left, Specification right); + public static Specification BitwiseOr(Specification left, Specification right); + public static Specification operator !(Specification specification); + public static Specification LogicalNot(Specification specification); + public static bool operator true(Specification specification); + public static bool operator false(Specification specification); +} +``` + +### Methods + +#### `IsSatisfiedBy(T candidate)` +- **Parameters**: `candidate`: The entity instance to evaluate. +- **Return**: `true` if the candidate satisfies the specification predicate; otherwise, `false`. +- **Exceptions**: `ArgumentNullException` if `candidate` is `null`. +- **Remarks**: Evaluated in-memory via `ExpressionInterpreter.Evaluate`. 100% Native AOT safe; does not perform IL emit or reflection compilation. +- **When to use**: Validating entities in domain models, CQRS command handlers, or unit test assertions. +- **When NOT to use**: Querying a database (use `ToQuerySpec()` or LINQ extensions instead). + +#### `ToExpression()` +- **Return**: The underlying `Expression>`. +- **Remarks**: The expression is initialized once lazily via `BuildExpression()` and cached for the lifetime of the instance. Thread-safe. + +#### `ToCompiledPredicate()` +- **Return**: A compiled `Func` delegate. +- **Attributes**: `[RequiresDynamicCode("Compiles the specification expression to a delegate at runtime. Not compatible with Native AOT.")]`, `[RequiresUnreferencedCode("Expression compilation may require types that are trimmed.")]` +- **Performance**: High throughput for JIT runtimes evaluating millions of entities in loops. Uses `ExpressionCompilationCache`. + +#### `operator &`, `operator |`, `operator !` +- **Parameters**: Left and right specifications to compose. +- **Return**: A composite `CompositeSpecification` or `NegatedSpecification`. +- **Remarks**: Rewrites parameter expressions via `ParameterReplacer` without inserting `Expression.Invoke`. + +--- + +## Spec (Static Combinators) + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.dll` + +Static factory class providing combinators, ranges, and search utilities. + +### Methods + +#### `For(Expression> predicate)` +- **Parameters**: `predicate`: The boolean lambda expression. +- **Return**: A `LambdaSpecification`. +- **Exceptions**: `ArgumentNullException` if `predicate` is null. +- **When to use**: One-off predicates or prototyping. +- **When NOT to use**: Core business rules that require dedicated domain classes and isolated unit tests. + +#### `True()` and `False()` +- **Return**: Neutral identity specifications (`c => true` and `c => false`). +- **Remarks**: `spec.And(Spec.True())` yields `spec`. Essential for conditional query filters. + +#### `All(params Specification[] specifications)` / `All(IEnumerable> specifications)` +- **Parameters**: Collection of specifications. +- **Return**: A single specification representing logical AND across all inputs. Span-optimized. +- **Remarks**: Passing 0 elements returns `Spec.True()`; 1 element returns that element directly. + +#### `Any(params Specification[] specifications)` / `Any(IEnumerable> specifications)` +- **Parameters**: Collection of specifications. +- **Return**: A single specification representing logical OR across all inputs. Span-optimized. +- **Remarks**: Passing 0 elements returns `Spec.False()`; 1 element returns that element directly. + +#### `Between(Expression> selector, TProperty lower, TProperty upper)` +- **Parameters**: + - `selector`: Property selector expression. + - `lower`: Inclusive lower bound. + - `upper`: Inclusive upper bound. +- **Return**: Specification asserting `lower <= property && property <= upper`. +- **Exceptions**: + - `ArgumentNullException` if `selector` is null. + - `ArgumentException` if `lower.CompareTo(upper) > 0`. + +#### `Between(Expression> selector, TProperty lower, TProperty upper)` +- **Parameters**: Property selector for nullable struct property `TProperty?`. +- **Return**: Specification asserting `property != null && lower <= property.Value && property.Value <= upper`. + +--- + +## ExpressionDebugFormatterRegistry + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.Abstractions.dll` + +Cross-layer registry for configuring human-readable debug formatting of expression trees. + +### Members + +```csharp +public static class ExpressionDebugFormatterRegistry +{ + public static Func Formatter { get; set; } + public static string Format(Expression expression); +} +``` + +- **Remarks**: In `EricksonLopez.Specification`, the static constructor automatically registers `ExpressionDebugFormatter.Format` as the default formatter. + +--- + +## QuerySpec<T> and QuerySpec<T, TResult> + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.Abstractions.dll` + +Immutable records describing relational and non-relational query intent. + +### Methods + +| Method | Signature | Description | +|---|---|---| +| `Where` | `QuerySpec Where(Expression> predicate)` | Appends an AND filter criterion | +| `TagWith` | `QuerySpec TagWith(string tag)` | Sets diagnostic SQL comment tag | +| `Search` | `QuerySpec Search(string phrase, params Expression>[] selectors)` | Multi-column OR search | +| `OrderBy` | `QuerySpec OrderBy(Expression> keySelector)` | Primary ascending sort | +| `OrderByDescending` | `QuerySpec OrderByDescending(Expression> keySelector)` | Primary descending sort | +| `ThenBy` | `QuerySpec ThenBy(Expression> keySelector)` | Secondary ascending sort | +| `ThenByDescending` | `QuerySpec ThenByDescending(Expression> keySelector)` | Secondary descending sort | +| `Page` | `QuerySpec Page(int page, int pageSize)` | 1-based pagination (`Skip = (page-1)*pageSize, Take = pageSize`) | +| `Take` | `QuerySpec Take(int count)` | Limits returned rows | +| `Skip` | `QuerySpec Skip(int count)` | Offsets returned rows | +| `Distinct` | `QuerySpec Distinct()` | Sets `IsDistinct = true` (`SELECT DISTINCT`) | +| `SeekAfter` | `QuerySpec SeekAfter(Expression> keySelector, TKey cursor, int take)` | Keyset pagination forward | +| `SeekBefore` | `QuerySpec SeekBefore(Expression> keySelector, TKey cursor, int take)` | Keyset pagination backward | +| `WithCursor` | `QuerySpec WithCursor(Expression> keySelector, TKey cursorValue, CursorDirection direction, int take)` | Sets keyset cursor directly with explicit direction | +| `Select` | `QuerySpec Select(Expression> selector)` | Projects to `TResult` | + +### QuerySpec<T> / QuerySpec<T, TResult> — Read-only Properties + +| Property | Type | Description | +|---|---|---| +| `Criteria` | `ImmutableArray>>` | AND-combined filter predicates | +| `OrderClauses` | `ImmutableArray>` | Ordering clauses in sequence | +| `Selector` | `Expression>?` | Projection selector (`QuerySpec` only) | +| `SkipCount` | `int?` | Number of records to skip for offset pagination | +| `TakeCount` | `int?` | Maximum records to return | +| `IsDistinct` | `bool` | Whether to eliminate duplicate results | +| `Tag` | `string?` | Diagnostic query comment | +| `Cursor` | `CursorClause?` | Keyset pagination cursor | +| `Empty` | `static QuerySpec` | Singleton empty specification (no constraints) | + +--- + +## QuerySpecExtensions + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.dll` + +Extension methods for combining `QuerySpec` with domain specifications and inspecting query state. + +- `QuerySpec And(this QuerySpec querySpec, Specification specification)` — Appends a domain specification predicate as an AND filter. +- `QuerySpec Where(this QuerySpec querySpec, IExpressionSpecification specification)` — Same as `And` but accepts the base `IExpressionSpecification` interface (works with `Spec.For()` results and all subclasses). +- `Expression>? BuildCombinedPredicate(this QuerySpec querySpec)` — Combines all criteria into a single AND predicate using `ExpressionComposer.AndAll`. Returns `null` if no criteria are defined. +- `bool HasOrdering(this QuerySpec querySpec)` — Returns `true` if at least one `OrderClause` is defined. +- `bool HasPagination(this QuerySpec querySpec)` — Returns `true` if `SkipCount` or `TakeCount` is set. +- `bool HasCriteria(this QuerySpec querySpec)` — Returns `true` if at least one filter predicate exists. + +--- + +## QuerySpecLinqExtensions + +Namespace: `EricksonLopez.Specification.Linq` +Assembly: `EricksonLopez.Specification.Linq.dll` + +High-performance LINQ extensions for `IQueryable` and in-memory `IEnumerable`. + +### `IQueryable` Extensions + +- `IQueryable Apply(this IQueryable source, QuerySpec spec)`: Applies criteria, ordering, and pagination to an IQueryable. +- `IQueryable Apply(this IQueryable source, QuerySpec spec)`: Applies criteria, ordering, pagination, and projection. +- `bool Any(this IQueryable source, QuerySpec spec)`: Checks existence using `QuerySpec`. +- `int Count(this IQueryable source, QuerySpec spec)`: Counts matching elements. +- `IQueryable Where(this IQueryable source, IExpressionSpecification spec)`: Direct specification filtering. +- `bool All(this IQueryable source, IExpressionSpecification spec)`: Tests if all elements satisfy specification. +- `T? FirstOrDefault(this IQueryable source, IExpressionSpecification spec)`: Returns first match or default. + +### `IEnumerable` In-Memory Extensions + +- `IEnumerable Where(this IEnumerable source, ISpecification spec)`: Filters in-memory sequence via `IsSatisfiedBy`. +- `bool Any(this IEnumerable source, ISpecification spec)`: Determines if any element matches. +- `bool All(this IEnumerable source, ISpecification spec)`: Determines if all elements match. +- `int Count(this IEnumerable source, ISpecification spec)`: Counts matching items. +- `T? FirstOrDefault(this IEnumerable source, ISpecification spec)`: First matching item or default. + +--- + +## IReadRepository<T> + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.Abstractions.dll` + +Pure asynchronous read-only repository contract. + +```csharp +public interface IReadRepository +{ + Task> ListAsync(QuerySpec spec, CancellationToken ct = default); + Task> ListAsync(QuerySpec spec, CancellationToken ct = default); + Task FirstOrDefaultAsync(QuerySpec spec, CancellationToken ct = default); + Task SingleOrDefaultAsync(QuerySpec spec, CancellationToken ct = default); + Task CountAsync(QuerySpec spec, CancellationToken ct = default); + Task AnyAsync(QuerySpec spec, CancellationToken ct = default); + Task GetByIdAsync(TId id, CancellationToken ct = default); +} +``` + +--- + +## ReadRepositoryResultExtensions + +Namespace: `EricksonLopez.Specification.Result` +Assembly: `EricksonLopez.Specification.Result.dll` + +Extends `IReadRepository` with functional `Result` envelopes (`EricksonLopez.Result`). + +- `Task> FirstOrDefaultResultAsync(this IReadRepository repo, QuerySpec spec, CancellationToken ct = default)`: Returns `Result.Success(entity)` or `Result.Failure(Error.NotFound)` if not found. +- `Task>> ListResultAsync(this IReadRepository repo, QuerySpec spec, CancellationToken ct = default)`: Returns `Result.Success(list)` or `Result.Failure(Error.Failure)`. +- `Task> SingleOrDefaultResultAsync(this IReadRepository repo, QuerySpec spec, CancellationToken ct = default)`: Returns `Result.Failure(Error.Conflict)` if multiple entities match. +- `Task> GetByIdResultAsync(this IReadRepository repo, TId id, CancellationToken ct = default)`: Returns `Result.Success(entity)` or `Result.Failure(Error.NotFound)`. + +> [!IMPORTANT] +> `OperationCanceledException` is preserved and rethrown immediately across all methods to ensure cooperative task cancellation. + +--- + +## QuerySpecTranslator<T> and SQL Dialects + +Namespace: `EricksonLopez.Specification.Sql` +Assembly: `EricksonLopez.Specification.Sql.dll` + +Parses `QuerySpec` into a provider-agnostic SQL Abstract Syntax Tree (`QueryModel`) and renders native engine SQL. + +### Supported Dialects + +- `PostgreSqlDialect.Default` (`EricksonLopez.Specification.PostgreSql`) +- `MsSqlDialect.Default` (`EricksonLopez.Specification.MsSql`) +- `SqliteDialect.Default` (`EricksonLopez.Specification.Sqlite`) +- `MySqlDialect.Default` (`EricksonLopez.Specification.MySql`) +- `MariaDbDialect.Default` (`EricksonLopez.Specification.MariaDb`) +- `OracleDialect.Default` (`EricksonLopez.Specification.Oracle`) + +--- + +## QueryPlanCache + +Namespace: `EricksonLopez.Specification.Sql` +Assembly: `EricksonLopez.Specification.Sql.dll` + +Thread-safe bounded LRU cache for translated `QueryModel` query plans. + +- `Capacity`: Maximum cached plans (default 512, configurable via property setter). +- `Count`: Number of currently cached plans. +- `Clear()`: Evicts all cached entries. +- **Cache Key**: `CacheKey` struct combining table name and expression structural equality via `ExpressionEqualityComparer.Default.Equals(...)`. Two specifications with structurally identical expressions for the same table share the same cache entry. + +> [!NOTE] +> `QueryPlanCache` is a static, process-wide cache. In applications with highly dynamic specification composition (e.g. user-defined filters), consider tuning `QueryPlanCache.Capacity` to prevent memory pressure. + +--- + +## ExpressionCompilationCache + +Namespace: `EricksonLopez.Specification` +Assembly: `EricksonLopez.Specification.dll` + +Thread-safe bounded LRU cache for compiled expression delegates. **JIT-only** — all methods annotated `[RequiresDynamicCode]`. + +- `Capacity`: Maximum cached delegates (default 512, configurable). Setting a smaller value evicts the least recently used entries immediately. +- `CachedCount`: Number of compiled delegates currently held in the cache. +- `GetOrCompile(Expression> expression)`: Returns a cached compiled delegate or compiles and caches one. Annotated `[RequiresDynamicCode]` and `[RequiresUnreferencedCode]`. +- **Cache Key**: The expression tree compared via `ExpressionEqualityComparer.Default` (deep structural AST equality). Prevents wrong-delegate returns on hash collision. + +> [!CAUTION] +> `ExpressionCompilationCache` is not usable in Native AOT applications. Use `spec.IsSatisfiedBy(candidate)` instead, which evaluates via `ExpressionInterpreter`. + +--- + +## Expression Engine & Diagnostics + +### `ExpressionInterpreter` +- `Evaluate(Expression> expression, T candidate)`: AOT-safe evaluation. The sole public entry point — no `Interpret()` public method exists. + +### `ExpressionSimplifier` +- `Simplify(Expression> expression)`: Constant folding and boolean identity optimization (`A && true` → `A`, `!(!A)` → `A`). + +### `SpecificationDiagnostics` +- `ActivitySource ActivitySource`: Distributed tracing instrumentation source. +- `Meter Meter`: OpenTelemetry metrics source (name: `"EricksonLopez.Specification"`). +- `Counter SpecificationsCreated` — meter name: `specification.created` +- `Counter SpecificationsEvaluated` — meter name: `specification.evaluated` +- `Counter SpecificationsComposed` — meter name: `specification.composed` +- `Counter SpecificationsCompiled` — meter name: `specification.compiled` +- `Counter ExpressionCacheHits` — meter name: `specification.expression.cache.hits` +- `Counter ExpressionCacheMisses` — meter name: `specification.expression.cache.misses` +- `Counter SqlTranslations` — meter name: `specification.sql.translations` +- `Histogram SqlTranslationDuration` — meter name: `specification.sql.translation.duration` (unit: `ms`) + +--- + +## MongoDB Integrations + +Namespace: `EricksonLopez.Specification.MongoDB` +Assembly: `EricksonLopez.Specification.MongoDB.dll` + +- `MongoSpecificationEvaluator.GetFilter(QuerySpec spec)`: Compiles to `FilterDefinition`. +- `MongoSpecificationEvaluator.GetSort(QuerySpec spec)`: Compiles to `SortDefinition`. +- `IMongoCollection.Find(QuerySpec spec)`: Executes fluent find query with filters, sorting, and pagination. + +--- + +## Dapper Integrations + +Namespace: `EricksonLopez.Specification.Dapper` +Assembly: `EricksonLopez.Specification.Dapper.dll` + +Extension methods on `System.Data.IDbConnection`: +- `QueryAsync(this IDbConnection cnn, QuerySpec spec, ISqlDialect dialect, ...)` +- `QueryFirstOrDefaultAsync(this IDbConnection cnn, QuerySpec spec, ISqlDialect dialect, ...)` +- `CountAsync(this IDbConnection cnn, QuerySpec spec, ISqlDialect dialect, ...)` +- `AnyAsync(this IDbConnection cnn, QuerySpec spec, ISqlDialect dialect, ...)` diff --git a/docs/architecture.md b/docs/architecture.md index a6bc6a0..9f3655a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ This document describes the architectural design of the `EricksonLopez.Specifica ## 2. Package Dependency Graph -No circular dependencies. Zero upward dependencies. +No circular dependencies. Zero upward dependencies. All package references follow strict Clean Architecture layers: ```mermaid graph TD @@ -26,20 +26,45 @@ graph TD CORE[EricksonLopez.Specification] LINQ[EricksonLopez.Specification.Linq] SQL[EricksonLopez.Specification.Sql] + PG[EricksonLopez.Specification.PostgreSql] - SQ[EricksonLopez.Specification.Sqlite] + MSSQL[EricksonLopez.Specification.MsSql] + MYSQL[EricksonLopez.Specification.MySql] + MARIADB[EricksonLopez.Specification.MariaDb] + SQLITE[EricksonLopez.Specification.Sqlite] + ORACLE[EricksonLopez.Specification.Oracle] + DAPPER[EricksonLopez.Specification.Dapper] + EFCORE[EricksonLopez.Specification.EntityFrameworkCore] + MONGO[EricksonLopez.Specification.MongoDB] + DAPPEREXT[EricksonLopez.Specification.DapperExtensions] + RESULT[EricksonLopez.Specification.Result] + ANA[EricksonLopez.Specification.Analyzers] GEN[EricksonLopez.Specification.Generators] CORE --> ABS LINQ --> ABS - SQL --> ABS + SQL --> CORE + PG --> SQL - SQ --> SQL + MSSQL --> SQL + MYSQL --> SQL + MARIADB --> SQL + SQLITE --> SQL + ORACLE --> SQL + DAPPER --> SQL - ANA --> |Roslyn only| ABS - GEN --> |Roslyn only| ABS + EFCORE --> ABS + EFCORE --> LINQ + MONGO --> ABS + MONGO --> LINQ + DAPPEREXT --> CORE + DAPPEREXT --> SQL + RESULT --> ABS + + ANA -.->|Build-Time Analysis| ABS + GEN -.->|Build-Time Generation| ABS ``` --- @@ -50,27 +75,43 @@ graph TD flowchart TD subgraph Domain["Domain Layer"] Spec["Specification<T>\n(pure predicate)"] + SpecFactory["Spec.For / Spec.True / Spec.False"] end subgraph Application["Application Layer"] - QS["QuerySpec<T>\n(filter + order + pagination)"] + QS["QuerySpec<T> / QuerySpec<T, TResult>\n(filter + order + pagination + projection)"] + Repo["IReadRepository<T>"] end subgraph Infrastructure["Infrastructure Layer"] - LINQ_P["LINQ Provider (EF Core)\nQuerySpecLinqExtensions.Apply()"] - SQL_P["SQL Provider (Dapper)\nQuerySpecTranslator + ISqlDialect"] + direction TB + subgraph LinqProvider["LINQ & ORM Adapters"] + LINQ_P["QuerySpecLinqExtensions.Apply()"] + EF_REPO["EfReadRepository<TDbContext, TEntity>"] + MONGO_P["MongoFilterCompiler / MongoSortCompiler"] + end + subgraph SqlProvider["SQL AST & Dapper Adapters"] + SQL_TRANS["QuerySpecTranslator<T>"] + SQL_DIALECT["ISqlDialect (PG, MSSQL, MySQL, MariaDB, SQLite, Oracle)"] + DAPPER_P["QuerySpecDapperExtensions (QueryAsync)"] + end end - subgraph External["External"] - DB[(Database)] + subgraph External["Persistence Engines"] + DB_REL[("Relational DB (PostgreSQL, MSSQL, MySQL, MariaDB, SQLite, Oracle)")] + DB_DOC[("Document DB (MongoDB)")] end - Spec -->|".And() / .Or()"| QS - Spec -->|direct| QS + Spec -->|".And() / .Or() / .Not()"| QS + Spec -->|direct .Where()| QS QS -->|Apply| LINQ_P - QS -->|Translate + Render| SQL_P - LINQ_P -->|IQueryable| DB - SQL_P -->|Parameterized SQL| DB + QS -->|Translate + Render| SQL_TRANS + SQL_TRANS --> SQL_DIALECT + SQL_DIALECT --> DAPPER_P + LINQ_P --> EF_REPO + EF_REPO --> DB_REL + DAPPER_P --> DB_REL + MONGO_P --> DB_DOC ``` --- @@ -121,10 +162,12 @@ stateDiagram-v2 ExpressionTree --> IQueryable: QuerySpecLinqExtensions.Apply() ExpressionTree --> SqlAST: QuerySpecTranslator.Translate() ExpressionTree --> Hashed: ExpressionHasher.ComputeHash() (cache key) + ExpressionTree --> MongoFilter: MongoFilterCompiler.Compile() IQueryable --> Executed: EF Core / LINQ provider SqlAST --> SqlString: ISqlDialect.Render() - SqlString --> Executed: Dapper connection.Query() + SqlString --> Executed: Dapper connection.QueryAsync() + MongoFilter --> Executed: MongoDB collection.FindAsync() Executed --> [*] ``` @@ -133,90 +176,110 @@ stateDiagram-v2 ## 6. Key Components -### Domain Layer +### Contracts & Domain Layer -| Component | Type | Responsibility | -|---|---|---| -| `Specification` | Abstract class | Encapsulates a business rule as `Expression>` | -| `Spec` | Static factory | Creates inline specifications (`Spec.For`, `Spec.True`, `Spec.False`) | -| `IExpressionSpecification` | Interface | Exposes expression tree to external consumers | +| Component | Package | Type | Responsibility | +|---|---|---|---| +| `ISpecification` | `Abstractions` | Interface | Minimal marker and predicate contract for specifications | +| `IExpressionSpecification` | `Abstractions` | Interface | Exposes strongly-typed expression tree | +| `Specification` | `Specification` | Abstract class | Encapsulates a business rule as `Expression>` | +| `Spec` | `Specification` | Static factory | Creates inline specifications (`Spec.For`, `Spec.True`, `Spec.False`, `Spec.All`, `Spec.Any`) | +| `ExpressionDebugFormatterRegistry` | `Abstractions` | Static registry | Global registry for pluggable expression debug formatting | -### Expression Engine +### Expression Engine (`EricksonLopez.Specification`) | Component | AOT | Responsibility | |---|---|---| -| `ExpressionComposer` | ✅ Full | Invoke-free `And/Or/Not` composition | -| `ExpressionSimplifier` | ✅ Full | Constant folding, double-negation elimination | +| `ExpressionComposer` | ✅ Full | Invoke-free `And/Or/Not` and bulk `AndAll/OrAny` composition | +| `ExpressionSimplifier` | ✅ Full | Constant folding, boolean identity neutralization, double-negation elimination | | `ExpressionHasher` | ✅ Full | Structural hash (ignores parameter names) | -| `ExpressionInterpreter` | ✅ Annotated | AOT-safe in-memory evaluation via tree walk | +| `ExpressionEqualityComparer` | ✅ Full | Deep structural node-by-node AST equality | +| `ExpressionInterpreter` | ✅ Annotated | AOT-safe in-memory evaluation via tree walk (zero dynamic IL) | | `ExpressionCompilationCache` | ❌ JIT only | Compiled delegate cache `[RequiresDynamicCode]` | | `ParameterReplacer` | ✅ Full | Parameter rebinding for Invoke-free composition | -### Application Layer - -| Component | AOT | Responsibility | -|---|---|---| -| `QuerySpec` | ✅ Full | Immutable sealed record: filter + order + pagination | -| `QuerySpec` | ✅ Full | Projected query descriptor | -| `QuerySpecExtensions` | ✅ Full | `.And(spec)`, `.Or(spec)` fluent extensions | - -### Infrastructure — LINQ - -| Component | AOT | Responsibility | -|---|---|---| -| `QuerySpecLinqExtensions` | ✅ Full | `.Apply(spec)` on `IQueryable` | - -### Infrastructure — SQL +### Application Layer (`EricksonLopez.Specification.Abstractions`) | Component | AOT | Responsibility | |---|---|---| -| `QuerySpecTranslator` | ⚠️ Annotated | Translates `QuerySpec` to `QueryModel` AST | -| `QueryModel` | ✅ Full | Provider-agnostic SQL AST | -| `ISqlDialect` | ✅ Full | Pluggable SQL rendering strategy | -| `PostgreSqlDialect` | ✅ Full | PostgreSQL-specific rendering | -| `SqliteDialect` | ✅ Full | SQLite-specific rendering | -| `MsSqlDialect` | ✅ Full | MS SQL Server rendering | -| `IColumnNameResolver` | ✅ Full | Property → column name mapping | - -### Infrastructure — Dapper - -| Component | AOT | Responsibility | -|---|---|---| -| `QuerySpecDapperExtensions` | ✅ Full | `QueryAsync/QueryFirstOrDefaultAsync/CountAsync` via `IDbConnection` | - -### Build-Time +| `QuerySpec` | ✅ Full | Immutable sealed record: filter + order + pagination + cursor | +| `QuerySpec` | ✅ Full | Projected query descriptor with strongly-typed `Select` | +| `IReadRepository` | ✅ Full | Pure asynchronous read repository contract | +| `QuerySpecExtensions` | ✅ Full | Fluent extensions for combining and inspecting query specifications | + +### Infrastructure — LINQ & ORM + +| Component | Package | AOT | Responsibility | +|---|---|---|---| +| `QuerySpecLinqExtensions` | `Linq` | ✅ Full | `.Apply(spec)`, `.Any(spec)`, `.Count(spec)` on `IQueryable` | +| `SpecificationEvaluator` | `EntityFrameworkCore` | ✅ Full | Evaluates `QuerySpec` over EF Core DbSets | +| `EfReadRepository` | `EntityFrameworkCore` | ✅ Full | Concrete EF Core implementation of `IReadRepository` | +| `MongoFilterCompiler` | `MongoDB` | ✅ Full | Compiles specifications to native MongoDB `FilterDefinition` | +| `MongoSortCompiler` | `MongoDB` | ✅ Full | Compiles query ordering to native MongoDB `SortDefinition` | + +### Infrastructure — SQL & Micro-ORMs + +| Component | Package | AOT | Responsibility | +|---|---|---|---| +| `QuerySpecTranslator` | `Sql` | ⚠️ Annotated | Translates `QuerySpec` to `QueryModel` AST (`[RequiresUnreferencedCode]`) | +| `QueryModel` | `Sql` | ✅ Full | Provider-agnostic SQL AST | +| `ISqlDialect` | `Sql` | ✅ Full | Pluggable SQL rendering strategy | +| `QueryPlanCache` | `Sql` | ✅ Full | Bounded LRU cache (512 entries) for translated SQL query models | +| `PostgreSqlDialect` | `PostgreSql` | ✅ Full | PostgreSQL-specific rendering (`$n`, `ILIKE`, `LIMIT/OFFSET`) | +| `MsSqlDialect` | `MsSql` | ✅ Full | Microsoft SQL Server rendering (`@pn`, `TOP`, `OFFSET FETCH`) | +| `MySqlDialect` | `MySql` | ✅ Full | MySQL rendering (`` `col` ``, `@pn`, `LIMIT/OFFSET`) | +| `MariaDbDialect` | `MariaDb` | ✅ Full | MariaDB rendering (`` `col` ``, `@pn`, `LIMIT/OFFSET`) | +| `SqliteDialect` | `Sqlite` | ✅ Full | SQLite rendering (`"col"`, `@pn`, `LIMIT/OFFSET`) | +| `OracleDialect` | `Oracle` | ✅ Full | Oracle Database rendering (`"COL"`, `:pn`, `OFFSET FETCH`) | +| `QuerySpecDapperExtensions` | `Dapper` | ✅ Full | `QueryAsync`, `FirstOrDefaultAsync`, `CountAsync` via `IDbConnection` | +| `DapperExtensionsIntegration` | `DapperExtensions` | ✅ Full | Unit-of-Work & session tracking adapter | +| `ReadRepositoryResultExtensions` | `Result` | ✅ Full | Railway-oriented `Result` queries over `IReadRepository` | + +### Build-Time Governance & Generation | Component | Target | Responsibility | |---|---|---| -| `EricksonLopez.Specification.Analyzers` | netstandard2.0 | Roslyn analyzers SPEC001–010 | -| `EricksonLopez.Specification.Generators` | netstandard2.0 | Source generator for `[Spec]` attribute | +| `EricksonLopez.Specification.Analyzers` | `netstandard2.0` | 11 Roslyn analyzers (`SPEC001`–`SPEC011`) & CodeFix providers | +| `EricksonLopez.Specification.Generators` | `netstandard2.0` | Source generator for `[SpecColumnResolver]` and `[Spec]` ordering | --- ## 7. AOT / Trimming Policy Summary -| Package | Status | -|---|---| -| Abstractions | ✅ Full AOT — no reflection | -| Core (EricksonLopez.Specification) | ✅ Full AOT (except `ExpressionCompilationCache`) | -| Linq | ✅ Full AOT | -| Sql | ⚠️ Annotated — `[RequiresUnreferencedCode]` on `Translate()` | -| PostgreSql / MsSql / Sqlite | ✅ Full AOT | -| Dapper | ✅ Full with Dapper.AOT | -| Analyzers / Generators | N/A (compile-time only) | - -See [docs/aot.md](docs/aot.md) for the complete AOT compatibility table and guidance. +| Package | Status | Notes | +|---|---|---| +| `Abstractions` | ✅ Full AOT | Pure BCL contracts, zero reflection | +| `Specification` | ✅ Full AOT | Core engine is NativeAOT-safe; JIT cache marked `[RequiresDynamicCode]` | +| `Linq` | ✅ Full AOT | Pure expression passing to LINQ providers | +| `Sql` | ⚠️ Annotated | Reflection closures marked `[RequiresUnreferencedCode]` | +| `PostgreSql` / `MsSql` / `MySql` / `MariaDb` / `Sqlite` / `Oracle` | ✅ Full AOT | Pure AST string formatters and builders | +| `Dapper` | ✅ Full AOT | Compatible with Dapper.AOT source generators | +| `EntityFrameworkCore` | ✅ Full AOT | Compatible with EF Core compiled models | +| `MongoDB` | ✅ Full AOT | Native filter and sort builders | +| `DapperExtensions` | ✅ Full AOT | Parameterized query execution | +| `Result` | ✅ Full AOT | Zero-allocation struct Result extensions | +| `Analyzers` / `Generators` | N/A | Roslyn compile-time only | + +See [aot.md](aot.md) for the complete AOT compatibility table and guidance. --- ## 8. Architecture Decision Records -Significant architectural decisions are documented as ADRs in [docs/adr/](docs/adr/). +Significant architectural decisions are documented as ADRs in [adr/](adr/). Key decisions: -- [adr-001](docs/adr/adr-001-no-write-repository.md): No `IRepository` write contract -- [adr-002](docs/adr/adr-002-no-include-theninclude.md): No `Include/ThenInclude` in core -- [adr-006](docs/adr/adr-006-specification-queryspec-separation.md): Specification/QuerySpec separation -- [adr-009](docs/adr/adr-009-aot-first-design.md): AOT-First design -- [adr-010](docs/adr/adr-010-no-efcore-in-core.md): No EF Core in core package -- [adr-013](docs/adr/adr-013-no-raw-sql.md): No raw SQL / `WhereRaw()` +- [adr-001](adr/adr-001-no-write-repository.md): No `IRepository` write contract +- [adr-002](adr/adr-002-no-include-theninclude.md): No `Include/ThenInclude` in core +- [adr-006](adr/adr-006-specification-queryspec-separation.md): Specification/QuerySpec separation +- [adr-008](adr/adr-008-expression-trees-as-internal-representation.md): Expression trees as internal representation +- [adr-009](adr/adr-009-aot-first-design.md): AOT-First design +- [adr-010](adr/adr-010-no-efcore-in-core.md): No EF Core in core package +- [adr-013](adr/adr-013-no-raw-sql.md): No raw SQL / `WhereRaw()` +- [adr-018](adr/adr-018-remove-asnotracking-splitquery-from-queryspec.md): Remove AsNoTracking/SplitQuery from QuerySpec +- [adr-019](adr/adr-019-expression-compilation-cache-key-strategy.md): Compilation cache key strategy (structural equality) +- [adr-021](adr/adr-021-querypancache-lru-bounded.md): QueryPlanCache bounded LRU strategy +- [adr-022](adr/adr-022-spec-all-any-combinators.md): Spec.All / Spec.Any static combinators +- [adr-023](adr/adr-023-ispecification-in-abstractions.md): ISpecification placement in Abstractions +- [adr-027](adr/adr-027-mariadb-and-mysql-dialect-strategy.md): MariaDB and MySQL native dialect strategy +- [adr-028](adr/adr-028-sql-infrastructure-layer-and-dialect-package-decomposition.md): SQL infrastructure layer isolation & dialect decomposition diff --git a/docs/audit/final-audit.md b/docs/audit/final-audit.md index d681f8b..f995ecf 100644 --- a/docs/audit/final-audit.md +++ b/docs/audit/final-audit.md @@ -182,18 +182,18 @@ All packages include embedded `icon.png`, `README.md`, MIT license, and `.snupkg ## 19. Documentation -- [`docs/when-to-use-specification.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/when-to-use-specification.md) — Strategic DDD guidance and decision flowchart. -- [`migration.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/migration.md) — Step-by-step migration guide from Ardalis.Specification. -- [`CHANGELOG.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/CHANGELOG.md) — SemVer release notes for v1.0.0. -- [`docs/aot.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/aot.md) — Native AOT node support matrix and trimming guide. -- [`docs/benchmarks.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/benchmarks.md) — Benchmark methodology and measurements. -- [`docs/adr/`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/adr/) — 28 Architecture Decision Records. +- [`docs/when-to-use-specification.md`](../when-to-use-specification.md) — Strategic DDD guidance and decision flowchart. +- [`docs/migration-from-ardalis.md`](../migration-from-ardalis.md) — Step-by-step migration guide from Ardalis.Specification. +- [`CHANGELOG.md`](../../CHANGELOG.md) — SemVer release notes for v1.0.0. +- [`docs/aot.md`](../aot.md) — Native AOT node support matrix and trimming guide. +- [`docs/benchmarks.md`](../benchmarks.md) — Benchmark methodology and measurements. +- [`docs/adr/`](../adr/README.md) — 28 Architecture Decision Records. --- ## 20. Regression Matrix -Complete traceability documented in [`docs/audit/regression-matrix.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/audit/regression-matrix.md) covering all 17 audit items (100% FIXED). +Complete traceability documented in [`docs/audit/regression-matrix.md`](regression-matrix.md) covering all 17 audit items (100% FIXED). --- diff --git a/docs/audit/line-by-line-revalidation.md b/docs/audit/line-by-line-revalidation.md index a0b3d02..2535073 100644 --- a/docs/audit/line-by-line-revalidation.md +++ b/docs/audit/line-by-line-revalidation.md @@ -357,14 +357,14 @@ dotnet test tests/EricksonLopez.Specification.Tests --filter QuerySpecTests | Required Deliverable | Repository File | Content Verification | |---|---|---| -| **Baseline Audit** | [`docs/audit/baseline.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/audit/baseline.md) | Initial pre-fix snapshot (score 78/100, 17 projects). | -| **Final Verdict (23 sections)** | [`docs/audit/final-audit.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/audit/final-audit.md) | Score 97/100, Verdict RELEASE READY. | -| **Regression Matrix** | [`docs/audit/regression-matrix.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/audit/regression-matrix.md) | Traceability of 17 remediated and verified tasks. | -| **DDD Guide** | [`docs/when-to-use-specification.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/when-to-use-specification.md) | Specification vs Invariants, Value Objects, Domain Services, Policies. | -| **Migration Guide** | [`docs/migration-from-ardalis.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/migration-from-ardalis.md) | Ardalis vs EricksonLopez comparison and code recipes. | -| **Native AOT Guide** | [`docs/aot.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/aot.md) | Matrix of 15 AST node types, BCL annotations, and trimming rules. | -| **Benchmarks Report** | [`docs/benchmarks.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/docs/benchmarks.md) | Real measurements with BenchmarkDotNet on .NET 10 (93 ns composition, 44 ns AOT). | -| **Release Notes** | [`CHANGELOG.md`](file:///d:/DevData/ericksonlopez.dev/dotnet-specification/CHANGELOG.md) | Full SemVer notes with architecture summary and fixes. | +| **Baseline Audit** | [`docs/audit/baseline.md`](baseline.md) | Initial pre-fix snapshot (score 78/100, 17 projects). | +| **Final Verdict (23 sections)** | [`docs/audit/final-audit.md`](final-audit.md) | Score 97/100, Verdict RELEASE READY. | +| **Regression Matrix** | [`docs/audit/regression-matrix.md`](regression-matrix.md) | Traceability of 17 remediated and verified tasks. | +| **DDD Guide** | [`docs/when-to-use-specification.md`](../when-to-use-specification.md) | Specification vs Invariants, Value Objects, Domain Services, Policies. | +| **Migration Guide** | [`docs/migration-from-ardalis.md`](../migration-from-ardalis.md) | Ardalis vs EricksonLopez comparison and code recipes. | +| **Native AOT Guide** | [`docs/aot.md`](../aot.md) | Matrix of 15 AST node types, BCL annotations, and trimming rules. | +| **Benchmarks Report** | [`docs/benchmarks.md`](../benchmarks.md) | Real measurements with BenchmarkDotNet on .NET 10 (93 ns composition, 44 ns AOT). | +| **Release Notes** | [`CHANGELOG.md`](../../CHANGELOG.md) | Full SemVer notes with architecture summary and fixes. | --- diff --git a/docs/build-and-ci.md b/docs/build-and-ci.md index ab066b7..2c8787a 100644 --- a/docs/build-and-ci.md +++ b/docs/build-and-ci.md @@ -6,170 +6,224 @@ This document describes the GitHub Actions workflows, build process, quality too ## CI/CD Pipeline Overview +The repository maintains 10 GitHub Actions workflows providing automated Continuous Integration, NativeAOT verification, Benchmark regression tracking, Mutation testing quality gates, and automated release publishing: + ```mermaid flowchart TD - Push["Push to main / develop\nor PR targeting main / develop"] --> CI["ci.yml\n(CI workflow)"] - CI --> BuildTest["dotnet-build-test.yml\n(Reusable — Build, Test, SonarCloud, Codecov)"] - CI --> AOT["NativeAOT Smoke Test\n(AOT Gate)"] - - Tag["Tag push v*.*.* or\nRelease Please dispatch"] --> Publish["publish.yml\n(Publish NuGet)"] - Publish --> MutationGate["Validate Stryker\nMutation Quality Gate"] - MutationGate --> Pack["Pack All Packages\n(16 .nupkg files)"] - Pack --> Attest["Sigstore Provenance\nAttestation"] - Attest --> NuGet["Push to NuGet.org\n(OIDC Trusted Publishing)"] - NuGet --> Release["GitHub Release\n(tag-triggered only)"] - - ReleasePlease["release-please.yml\n(Release Please)"] --> ReleasePR["Opens/Updates Release PR\nfrom Conventional Commits"] - - MutationCI["mutation-testing.yml\n(Mutation Testing — Weekly)"] --> Stryker["16 Stryker runs\n(per-package)"] + subgraph Triggers["Triggers"] + PushPR["Push / PR\n(main, develop)"] + TagOrDispatch["Tag v*.*.* or\nRelease Please Dispatch"] + Scheduled["Scheduled\n(Weekly Crons)"] + PRBenchmark["PR modifying\nsrc/** or benchmarks/**"] + end + + subgraph CI_Pipelines["Continuous Integration & Quality Gates"] + CI["ci.yml\n(Main Orchestrator)"] + BuildTest["dotnet-build-test.yml\n(Reusable Build, Test, SonarCloud, Codecov)"] + AOT["aot-smoke-test.yml\n(NativeAOT Publish & Smoke Test)"] + BenchGate["benchmark-regression-gate.yml\n(Max 5% Latency Regression & 0B Alloc)"] + Compliance["repo-compliance.yml\n(verify-compliance.ps1 & Architecture Rules)"] + end + + subgraph LongRunning["Performance & Mutation Testing"] + Mutation["mutation-testing.yml\n(17 Stryker.NET jobs in parallel)"] + WeeklyBench["weekly-benchmarks.yml\n(Deep benchmarks: net8.0, net9.0, net10.0)"] + OnDemandBench["benchmarks.yml\n(On-demand BenchmarkDotNet)"] + end + + subgraph Release_Pipelines["Release & Packaging"] + RP["release-please.yml\n(Conventional Commits -> Release PR)"] + Publish["publish.yml\n(Pack, Sigstore Attest, OIDC NuGet Push)"] + end + + PushPR --> CI + CI --> BuildTest + CI --> AOT + PushPR --> Compliance + PRBenchmark --> BenchGate + + TagOrDispatch --> Publish + Publish -->|Freshness & Drift Gate| Mutation + RP -->|On Release PR Merge| Publish + + Scheduled -->|Sunday 02:00 UTC| WeeklyBench + Scheduled -->|Monday 04:00 UTC| Mutation ``` --- ## GitHub Actions Workflows -### `ci.yml` — Continuous Integration (Build, Test, AOT Gate) - -**File**: `.github/workflows/ci.yml` +### 1. `ci.yml` — Continuous Integration Orchestrator +**File**: `.github/workflows/ci.yml` **Triggers**: - Push to `main`, `develop` - Pull Request targeting `main`, `develop` **Jobs**: -| Job | Description | -|-----|-------------| -| `build-and-test` | Calls `dotnet-build-test.yml` (reusable); runs build + tests + SonarCloud + Codecov | -| `aot-gate` | Compiles and runs `samples/NativeAotDapper` as a NativeAOT binary — fails if ILLink warnings appear | - -**Secrets required**: - -| Secret | Used in | Purpose | -|--------|---------|---------| -| `SNK_KEY` | `build-and-test` (via reusable) | Base64-encoded Strong Name key for assembly signing | -| `CODECOV_TOKEN` | `build-and-test` (via reusable) | Codecov upload token | -| `SONAR_TOKEN` | `build-and-test` (via reusable) | SonarCloud analysis token | +| Job | Description | Reusable Workflow Called | +|-----|-------------|--------------------------| +| `build-and-test` | Restores, builds Release, runs full test suite with coverage, analyzes via SonarCloud | `.github/workflows/dotnet-build-test.yml` | +| `aot-smoke-test` | Publishes AotSmokeTest with NativeAOT and runs native binary | `.github/workflows/aot-smoke-test.yml` | -**Artifacts produced**: `test-results` (uploaded by reusable workflow) +**Secrets forwarded**: `SNK_KEY`, `CODECOV_TOKEN`, `SONAR_TOKEN` +**Artifacts produced**: `test-results` --- -### `dotnet-build-test.yml` — Reusable Build & Test - -**File**: `.github/workflows/dotnet-build-test.yml` - -**Type**: Reusable workflow (`workflow_call`) +### 2. `dotnet-build-test.yml` — Reusable .NET Build & Test +**File**: `.github/workflows/dotnet-build-test.yml` +**Type**: Reusable workflow (`workflow_call`) **Inputs**: | Input | Default | Description | |-------|---------|-------------| | `dotnet-version` | `10.0.x` | .NET SDK version | -| `test-filter` | `""` | Test filter expression | +| `test-filter` | `""` | Test filter expression (e.g. `Category!=Integration`) | | `test-project` | `""` | Specific test project path | -| `upload-coverage` | `true` | Upload coverage to Codecov | -| `artifact-name` | `test-results` | Name of artifact | - -**Secrets**: `SNK_KEY`, `CODECOV_TOKEN`, `SONAR_TOKEN` - -**Steps**: - -| Step | Action | Notes | -|------|--------|-------| -| Checkout | `actions/checkout@v4` | `fetch-depth: 0` for full history | -| Setup .NET | `actions/setup-dotnet@v4` | `10.0.x` | -| Restore SNK | inline script | Decodes `SNK_KEY` if present | -| Setup Java | `actions/setup-java@v3` | Java 17 (Zulu) for SonarScanner | -| Install SonarScanner | `dotnet tool install` | `dotnet-sonarscanner` global tool | -| Begin Sonar Analysis | `dotnet sonarscanner begin` | Conditional on `SONAR_TOKEN` present | -| Build | `dotnet build` | `Release` configuration | -| Run tests | `dotnet test` | All test projects, XPlat Code Coverage (opencover + cobertura) | -| End Sonar Analysis | `dotnet sonarscanner end` | Conditional on `SONAR_TOKEN` present | -| Upload test results | `actions/upload-artifact@v4` | Always runs | -| Upload coverage | `codecov/codecov-action@v4` | Conditional on `upload-coverage` input | +| `upload-coverage` | `true` | Whether to upload coverage to Codecov | +| `artifact-name` | `test-results` | Name of test results artifact | + +**Secrets**: `SNK_KEY`, `CODECOV_TOKEN`, `SONAR_TOKEN` +**Key Steps**: +1. Restore Strong Name key (`SNK_KEY` decoded to `EricksonLopez.Specifications.snk`). +2. Setup Java 17 Zulu and install `dotnet-sonarscanner`. +3. Begin SonarCloud analysis (conditional on `SONAR_TOKEN`). +4. Build solution in `Release` configuration. +5. Run tests with `XPlat Code Coverage` (opencover and cobertura formats). +6. End SonarCloud analysis. +7. Upload `test-results.trx` artifact and Codecov coverage reports. --- -### `publish.yml` — Pack & Publish NuGet - -**File**: `.github/workflows/publish.yml` +### 3. `aot-smoke-test.yml` — NativeAOT Smoke Test +**File**: `.github/workflows/aot-smoke-test.yml` **Triggers**: -- Push of tag matching `v*.*.*` (legacy manual tag) -- `workflow_dispatch` (manual or triggered by `release-please.yml`) +- Push / PR to `main`, `develop` +- Reusable call (`workflow_call`) +- Manual dispatch (`workflow_dispatch`) -**Required permissions**: `id-token: write`, `contents: write`, `attestations: write`, `statuses: read`, `actions: read` +**Purpose**: Validates genuine NativeAOT compatibility (`PublishAot=true`). Emits compilation warnings as errors (`DOTNET_EnableAotCompilationWarningsAsErrors=true`). +**Key Steps**: +1. Setup .NET (8.0.x, 9.0.x, 10.0.x). +2. Install NativeAOT build prerequisites (`clang`, `lld`, `zlib1g-dev`). +3. Publish `tests/EricksonLopez.Specification.AotSmokeTest/EricksonLopez.Specification.AotSmokeTest.csproj` with `--runtime linux-x64 --self-contained`. +4. Run `./aot-output/EricksonLopez.Specification.AotSmokeTest` and assert zero exit code. -**Secrets required**: +--- -| Secret | Purpose | -|--------|---------| -| `SNK_KEY` | Base64-encoded Strong Name key for assembly signing | -| `CODECOV_TOKEN` | Codecov upload during publish gate | +### 4. `benchmark-regression-gate.yml` — PR Performance Regression Gate -> **OIDC Note**: NuGet push uses `NuGet/login@v1` (OIDC federated identity). No static NuGet API key secret is required or stored. +**File**: `.github/workflows/benchmark-regression-gate.yml` +**Triggers**: +- Pull Request targeting `main` or `develop` modifying `src/**` or `benchmarks/**` +- Manual dispatch (`workflow_dispatch`) with configurable `threshold` (default `5`%) -**Jobs**: +**Enforced Quality Gates**: +1. **Heap Invariant**: Zero-allocation on hot-path combinators (0 B allocated). +2. **Latency Invariant**: Mean latency regression must not exceed 5% against baseline. -1. **`evaluate-mutation-gate`**: Runs `scripts/verify-mutation-gate.js` in `evaluate` mode. Checks if a valid, fresh (≤ 7 days) mutation testing result exists without production code drift in `src/`. Emits output `needs_stryker: true/false`. -2. **`run-mutation-testing`** *(Conditional)*: Invoked via reusable workflow call to `.github/workflows/mutation-testing.yml` only when `needs_stryker == 'true'`. -3. **`publish`**: Enforces the mutation quality gate (`mode: enforce`, break threshold ≥ 95%), compiles, executes unit tests with coverage, packs 16 packages, generates Sigstore SLSA provenance attestations, authenticates via OIDC, and pushes packages to NuGet.org. +**Execution**: +- Runs BenchmarkDotNet in `Release` configuration under `net10.0` (`--job short --filter "*"`). +- Evaluates results using `scripts/verify-benchmark-gate.ps1` comparing against `benchmarks/results/baseline.json`. +- Uploads `pr-benchmark-results-${{ github.run_id }}` artifact. -**Steps (`publish` job)**: +--- + +### 5. `benchmarks.yml` — On-Demand Benchmarking -| Step | Description | -|------|-------------| -| Checkout | `fetch-depth: 0` | -| Resolve version | From `workflow_dispatch` input → git tag → `Directory.Build.props` fallback | -| Enforce Stryker Quality Gate | Calls `scripts/verify-mutation-gate.js` (mode `enforce`) — ensures passing mutation score (≥ 95%) | -| Setup .NET | `10.0.x` | -| Restore Strong Name key | Decodes `SNK_KEY` to `EricksonLopez.Specification.snk` ephemerally | -| Restore | `dotnet restore EricksonLopez.Specifications.slnx` | -| Build (Release) | `dotnet build --configuration Release` | -| Run tests | Full test suite with code coverage before packing | -| Upload coverage to Codecov | `codecov/codecov-action@v5`, flag `publish-gate` | -| Pack All Packages | Packs 16 packable projects to `./nupkgs/` with version override | -| Generate Sigstore Provenance | `actions/attest-build-provenance@v2` — all `.nupkg` files | -| NuGet login (OIDC) | `NuGet/login@v1` — returns ephemeral API key | -| Push to NuGet.org | `dotnet nuget push --skip-duplicate` | -| Create GitHub Release | `softprops/action-gh-release@v2` — only on tag-triggered runs; includes `prerelease: true` if version contains `-` | +**File**: `.github/workflows/benchmarks.yml` +**Triggers**: Reusable call (`workflow_call`), manual dispatch (`workflow_dispatch`). +**Inputs**: `benchmark-filter` (default `*`). +**Purpose**: Executes BenchmarkDotNet suites, exports JSON and Markdown summaries, syncs artifacts to `benchmarks/results/`, and appends Markdown summaries to `$GITHUB_STEP_SUMMARY`. -**Packages published** (16 packages): +--- -`Abstractions`, `Specification` (Core), `Analyzers`, `Dapper`, `DapperExtensions`, `EntityFrameworkCore`, `Generators`, `Linq`, `MariaDb`, `MongoDB`, `MsSql`, `MySql`, `Oracle`, `PostgreSql`, `Sql`, `Sqlite` +### 6. `weekly-benchmarks.yml` — Weekly Deep Performance Review -> **Note**: `EricksonLopez.Specification.Result` is **not** included in the publish step of this workflow. +**File**: `.github/workflows/weekly-benchmarks.yml` +**Triggers**: +- Weekly schedule: Every Sunday at `02:00 UTC` (`cron: '0 2 * * 0'`) +- Manual dispatch (`workflow_dispatch`) -**Artifacts produced**: `./nupkgs/*.nupkg` (16 files), GitHub Release (tag-triggered only) +**Behavior**: +- Runs BenchmarkDotNet with the full Default Job (statistically rigorous) across `.NET 8.0`, `.NET 9.0`, and `.NET 10.0`. +- Commits updated baseline files to `benchmarks/results/` with `chore(benchmarks): update weekly performance baseline [skip ci]`. +- Uploads 90-day retained artifact `weekly-benchmark-results-${{ github.run_id }}`. --- -### `release-please.yml` — Automated Release Management +### 7. `mutation-testing.yml` — Stryker Mutation Testing -**File**: `.github/workflows/release-please.yml` +**File**: `.github/workflows/mutation-testing.yml` +**Triggers**: +- Weekly schedule: Every Monday at `04:00 UTC` (`cron: '0 4 * * 1'`) +- Reusable call (`workflow_call`) from release orchestration +- Manual dispatch (`workflow_dispatch`) with mutation level selection (`Basic`, `Standard`, `Advanced`) + +**Architecture**: +- Runs 17 parallel matrix jobs covering all 17 projects in `src/`: + - `Core`, `Abstractions`, `Analyzers`, `Dapper`, `DapperExtensions`, `EntityFrameworkCore`, `Generators`, `Linq`, `MariaDb`, `MongoDB`, `MsSql`, `MySql`, `Oracle`, `PostgreSql`, `Sql`, `Sqlite`, `Result`. +- Timeout: 360 minutes (6 hours) per runner. +- Enforced Thresholds (from `stryker-*.json`): + - High: ≥ 100% + - Low: ≥ 98% + - Warn: ≥ 95% + - Break: < 95% (exits with non-zero code, failing the gate) +- Outputs results to `StrykerOutput/` and uploads individual artifacts. -**Trigger**: Push to `main` +--- -**Tool**: [Release Please](https://github.com/googleapis/release-please) +### 8. `publish.yml` — Pack & Publish NuGet -**Behavior**: -1. Scans commit messages for [Conventional Commits](https://www.conventionalcommits.org/) prefixes (`feat:`, `fix:`, `docs:`, etc.) -2. Opens or updates a Release PR with a generated CHANGELOG and version bump -3. When the Release PR is merged, creates a GitHub Release and tag `vX.Y.Z` -4. Dispatches `publish.yml` via `workflow_dispatch` with the new version +**File**: `.github/workflows/publish.yml` +**Triggers**: +- Git tag push matching `v*.*.*` (legacy manual release) +- `workflow_dispatch` with optional `version` input (invoked by Release Please) -**Configuration**: `.release-please-config.json`, `.release-please-manifest.json` +**Permissions**: `id-token: write`, `contents: write`, `attestations: write`, `statuses: read`, `actions: read` +**Jobs**: +1. **`evaluate-mutation-gate`**: Evaluates freshness (≤ 7 days) and checks for code drift in `src/` via `scripts/verify-mutation-gate.js`. +2. **`run-mutation-testing`** *(Conditional)*: Invokes `mutation-testing.yml` if results are missing or code has drifted. +3. **`publish`**: + - Enforces mutation gate threshold (≥ 95%). + - Restores Strong Name key from `SNK_KEY` secret. + - Builds in `Release` configuration and runs full test suite with coverage. + - Packs 16 packages to `./nupkgs/`. + - Generates Sigstore provenance attestations via `actions/attest-build-provenance@v2`. + - Authenticates to NuGet.org via OIDC federated login (`NuGet/login@v1`). + - Pushes packages with `--skip-duplicate`. + - Generates a GitHub Release on tag-triggered runs. + +> **Ecosystem Note**: `EricksonLopez.Specification.Result` is currently excluded from the publish step in `publish.yml` pending upstream synchronization, as documented in `PULL_REQUEST_TEMPLATE.md`. --- -### `mutation-testing.yml` — Mutation Testing +### 9. `release-please.yml` — Automated Release Management + +**File**: `.github/workflows/release-please.yml` +**Triggers**: Push to `main` +**Action**: `googleapis/release-please-action@v4` +**Flow**: +1. Evaluates Conventional Commits on `main`. +2. Creates or updates Release PR with release notes and version bump. +3. Upon merge of Release PR, cuts tag `vX.Y.Z`, generates GitHub Release, and dispatches `publish.yml` via GitHub REST API with the new version. -**File**: `.github/workflows/mutation-testing.yml` +--- -**Triggers**: Manual (`workflow_dispatch`), scheduled (weekly) +### 10. `repo-compliance.yml` — Architecture & Compliance Gate -**Description**: Runs Stryker.NET mutation testing for all 16 packages in parallel (per-package configs: `stryker-config.json`, `stryker-abstractions-config.json`, etc.). Results are uploaded as GitHub Actions artifacts. +**File**: `.github/workflows/repo-compliance.yml` +**Triggers**: Push/PR to `main`, manual dispatch (`workflow_dispatch`) +**Key Steps**: +1. Executes `scripts/verify-compliance.ps1` checking Clean Architecture layer rules, CPM consistency, and naming conventions. +2. Restores and builds with `TreatWarningsAsErrors=true`. +3. Runs unit tests excluding slow integration tests (`--filter "FullyQualifiedName!~IntegrationTests"`). +4. Validates NuGet packaging with `dotnet pack EricksonLopez.Specifications.slnx --no-build -c Release`. --- @@ -179,7 +233,7 @@ flowchart TD | Property | Value | Source | |----------|-------|--------| -| Target Framework | `net10.0` | `Directory.Build.props` | +| Target Frameworks | `net8.0;net10.0` (Libraries)
`netstandard2.0` (Analyzers & Generators) | `Directory.Build.props` | | SDK Version | `10.0.302` | `global.json` | | Language Version | `preview` | `Directory.Build.props` | | Nullable | `enable` | `Directory.Build.props` | @@ -304,10 +358,10 @@ Short-lived branches (`feature/*`, `bugfix/*`, `docs/*`) are merged to `develop` | Aspect | State | |--------|-------| -| Versioning | `VersionPrefix=1.0.0` in `Directory.Build.props` | +| Versioning | `VersionPrefix=2.0.0` in `Directory.Build.props` | | Versioning tool | [Release Please](https://github.com/googleapis/release-please) — reads Conventional Commits | -| Git tags | None published yet | -| NuGet packages | Not published yet | +| Git tags | `v2.0.0` (Official Release — 2026-09-21) | +| NuGet packages | 16 packages configured for publishing via OIDC Trusted Publishing | | Pre-release detection | `contains(version, '-')` → `prerelease: true` in GitHub Release | | Skip-duplicate | `--skip-duplicate` on `dotnet nuget push` | @@ -317,7 +371,7 @@ Short-lived branches (`feature/*`, `bugfix/*`, `docs/*`) are merged to `develop` flowchart LR Commit["Conventional Commit\n(feat:, fix:, etc.)"] --> RPR["Release Please\nOpens/Updates Release PR"] RPR --> Merge["Maintainer\nmerges Release PR"] - Merge --> Tag["Release Please\ncreates tag v1.0.0"] + Merge --> Tag["Release Please\ncreates tag v2.0.0"] Tag --> Dispatch["workflow_dispatch\ntriggers publish.yml"] Dispatch --> Pack["dotnet pack\n16 packages"] Pack --> Attest["Sigstore Attestation"] @@ -344,11 +398,20 @@ flowchart LR ## AOT Publish Gate -The final job of `ci.yml` (`aot-gate`) compiles and runs `samples/NativeAotDapper` as a NativeAOT binary. This is a hard gate that prevents merging if the core library introduces ILLink warnings: +The `aot-smoke-test` job in `ci.yml` invokes `.github/workflows/aot-smoke-test.yml`, compiling and executing `tests/EricksonLopez.Specification.AotSmokeTest` as a genuine NativeAOT binary. This is a hard gate that prevents merging if the core library introduces trimming or reflection warnings: ```bash -dotnet publish samples/NativeAotDapper/NativeAotDapper.csproj -c Release -p:PublishAot=true --no-restore -# Expected: Zero ILLink warnings from EricksonLopez.Specification.* +dotnet publish tests/EricksonLopez.Specification.AotSmokeTest/EricksonLopez.Specification.AotSmokeTest.csproj \ + --configuration Release \ + --runtime linux-x64 \ + --self-contained \ + -p:TreatWarningsAsErrors=true \ + -p:WarningLevel=5 \ + --output ./aot-output + +# Run native binary and assert zero exit code: +./aot-output/EricksonLopez.Specification.AotSmokeTest +# Expected: Zero IL2026/IL3050 warnings and clean exit ``` This validates the AOT compatibility claim on every push and PR. diff --git a/docs/ci-cd-pipelines.md b/docs/ci-cd-pipelines.md index fdf9bda..6d2e246 100644 --- a/docs/ci-cd-pipelines.md +++ b/docs/ci-cd-pipelines.md @@ -11,6 +11,7 @@ This document defines the complete Continuous Integration and Continuous Deploym | **Main CI** | `ci.yml` | `push`, `pull_request` (`main`, `develop`) | Fast PR feedback: builds, tests, coverage, NativeAOT smoke test | | **Reusable Build & Test** | `dotnet-build-test.yml` | `workflow_call` | Build, test, coverage, SonarCloud | | **NativeAOT Smoke Test** | `aot-smoke-test.yml` | `push`/`PR`, `workflow_call`, `workflow_dispatch` | Compile and run a NativeAOT binary (`PublishAot=true`) | +| **Benchmark Regression Gate** | `benchmark-regression-gate.yml` | `pull_request` (`src/**`, `benchmarks/**`), `workflow_dispatch` | Enforces zero-allocation & max 5% latency regression | | **Publish NuGet** | `publish.yml` | `push v*.*.*` tag, `workflow_dispatch` | Pack + sign + publish all packages to NuGet | | **Release Please** | `release-please.yml` | `push` → `main` | Automated release PR + dispatch publish | | **Mutation Testing** | `mutation-testing.yml` | Schedule Mon 04:00 UTC, `workflow_dispatch` | Stryker mutation analysis across all packages | diff --git a/docs/cookbook.md b/docs/cookbook.md index 8cc16c7..78e4645 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -33,6 +33,8 @@ Practical, copy-paste-ready recipes for every production scenario across Domain- 25. [Recipe 25: High-Throughput In-Memory Evaluation with Bounded LRU Cache](#recipe-25-high-throughput-in-memory-evaluation-with-bounded-lru-cache) 26. [Recipe 26: 100% NativeAOT & Dapper.AOT Configuration](#recipe-26-100-nativeaot--dapperaot-configuration) 27. [Recipe 27: OpenTelemetry Metrics & Diagnostic Tracing](#recipe-27-opentelemetry-metrics--diagnostic-tracing) +28. [Recipe 28: Functional Result Pattern (ReadRepositoryResultExtensions)](#recipe-28-functional-result-pattern-readrepositoryresultextensions) +29. [Recipe 29: Fluent MongoDB Querying (MongoSpecificationEvaluator)](#recipe-29-fluent-mongodb-querying-mongospecificationevaluator) --- @@ -593,3 +595,78 @@ var meterProvider = Sdk.CreateMeterProviderBuilder() // - specification.compositions_total // - specification.sql_translations_total ``` + +--- + +## Recipe 28: Functional Result Pattern (ReadRepositoryResultExtensions) + +**Problem**: Execute specifications via `IReadRepository` returning functional `Result` envelopes without throwing expected business exceptions (like not-found or multiple matches). + +```csharp +using System; +using System.Threading; +using System.Threading.Tasks; +using EricksonLopez.Result; +using EricksonLopez.Specification; +using EricksonLopez.Specification.Result; + +public sealed class GetCustomerByIdQueryHandler +{ + private readonly IReadRepository _repository; + + public GetCustomerByIdQueryHandler(IReadRepository repository) + { + _repository = repository; + } + + public async Task> Handle(Guid customerId, CancellationToken ct) + { + // Executes query returning Result.Success(customer) or Result.Failure(Error.NotFound) + Result result = await _repository.GetByIdResultAsync(customerId, ct); + + return result; + } +} +``` + +> **Note**: `OperationCanceledException` is automatically preserved and rethrown to ensure cooperative task cancellation. + +--- + +## Recipe 29: Fluent MongoDB Querying (MongoSpecificationEvaluator) + +**Problem**: Query MongoDB collections using domain specifications and `QuerySpec` directly via fluent drivers. + +```csharp +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; +using EricksonLopez.Specification; +using EricksonLopez.Specification.MongoDB; + +public sealed class MongoCustomerService +{ + private readonly IMongoCollection _mongoCollection; + + public MongoCustomerService(IMongoCollection mongoCollection) + { + _mongoCollection = mongoCollection; + } + + public async Task> GetActiveVipCustomersAsync(CancellationToken ct) + { + var activeSpec = new ActiveCustomerSpecification(); + var querySpec = QuerySpec.Empty + .And(activeSpec) + .OrderByDescending(c => c.TotalPurchases) + .Page(page: 1, pageSize: 20); + + // Fluent query execution on IMongoCollection: + IFindFluent findFluent = _mongoCollection.Find(querySpec); + + return await findFluent.ToListAsync(ct); + } +} +``` + diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..172798b --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,83 @@ +# Frequently Asked Questions (FAQ) + +--- + +### 1. Why another Specification library? How does it differ from Ardalis.Specification? + +| Dimension | Ardalis.Specification | EricksonLopez.Specification | +|---|---|---| +| **Architectural Separation** | Mixes domain rules, EF Core query options, `.Include()`, and tracking in one mutable class | **Strict separation**: Pure Domain `Specification` vs Query Intent `QuerySpec` | +| **Native AOT & Trimming** | Relies on dynamic compilation and reflection-heavy evaluators | **100% Native AOT Compatible** out-of-the-box via `ExpressionInterpreter` | +| **Direct SQL Translation** | Requires EF Core to execute queries | **Built-in SQL AST Translator** supporting 6 database engines without any ORM | +| **Expression Composition** | Uses `Expression.Invoke`, which breaks multiple LINQ providers and query compilers | **`ParameterReplacer` AST rewriting**: Generates clean invoke-free expressions | +| **Immutability & Concurrency** | Mutable builder pattern; instances are not thread-safe | **Purely immutable**: Instances are 100% thread-safe and can be registered as `Singleton` | +| **Database Support** | EF Core only | EF Core, Dapper, PostgreSQL, SQL Server, SQLite, MySQL, MariaDB, Oracle, MongoDB | + +--- + +### 2. Is this library compatible with Native AOT in .NET 8 and .NET 10? + +**Yes.** All assemblies are built with `true` and verified with `EnableTrimAnalyzer`. +- In-memory evaluation (`IsSatisfiedBy`) uses an AST node-walking interpreter (`ExpressionInterpreter`) instead of calling `expression.Compile()`. +- SQL generation is entirely static string and parameter formatting. +- Any JIT-specific dynamic compilation features (such as `ToCompiledPredicate()`) are explicitly decorated with `[RequiresDynamicCode]` to alert developers at build time. + +--- + +### 3. Why are there no `.Include()` or `.ThenInclude()` methods on `Specification`? + +Per **ADR-002** (Architectural Decision Record), entity graph loading (`Eager Loading` / `.Include`) is an **infrastructure persistence concern**, not a domain business rule. +- A business rule ("Is this customer active?") should never dictate relational join trees or foreign key fetching. +- Eager loading concerns belong in repository implementations, projection queries (`QuerySpec`), or specialized application query services. + +--- + +### 4. Can I use this library without Entity Framework Core? + +**Absolutely.** The core library (`EricksonLopez.Specification`) and abstractions (`EricksonLopez.Specification.Abstractions`) have zero third-party dependencies. +- You can evaluate specifications entirely in-memory over `IEnumerable`. +- You can translate specifications directly to raw SQL with `EricksonLopez.Specification.Sql` and execute via `Dapper`. +- You can compile specifications to MongoDB filter definitions with `EricksonLopez.Specification.MongoDB`. + +--- + +### 5. What C# operators can I use to combine specifications? + +The library provides first-class operator overloads for natural C# expressions: +- `&` (logical AND) and `BitwiseAnd` +- `|` (logical OR) and `BitwiseOr` +- `!` (logical NOT) and `LogicalNot` +- `&&` and `||` short-circuiting composition via `operator true` and `operator false` + +```csharp +var spec = activeSpec && (vipSpec || highCreditSpec); +``` + +--- + +### 6. How does keyset / cursor pagination work? + +Offset-based pagination (`OFFSET 100000 ROWS FETCH NEXT 20 ROWS`) forces database engines to scan and discard 100,000 index rows, causing performance degradation. + +Keyset pagination (`SeekAfter` / `SeekBefore`) filters on index bounds: + +```csharp +// WHERE created_at < @cursor ORDER BY created_at DESC LIMIT 20 +var page = QuerySpec.Empty + .OrderByDescending(o => o.CreatedAt) + .SeekAfter(o => o.CreatedAt, lastSeenTimestamp, take: 20); +``` + +This guarantees $O(1)$ query execution regardless of how deep the user paginates. + +--- + +### 7. Are specifications and QuerySpec instances thread-safe? + +**Yes.** All specification classes and `QuerySpec` records are **strictly immutable**. Any method that modifies criteria, ordering, or pagination returns a new instance. They can be safely cached, shared across asynchronous tasks, and registered as DI Singletons. + +--- + +### 8. What is the difference between `Spec.Between` and `Spec.InRange`? + +Both methods create range predicates. `Spec.Between` supports inclusive boundaries for both non-nullable and nullable struct properties (`c => c.DiscountRate, 0.05m, 0.20m`), while `Spec.InRange` is an alias for range containment. Both validate that `lower <= upper` and throw `ArgumentException` if violated. diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index b6a937f..1815f75 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -2,7 +2,7 @@ > **Version**: 1.0 — Post-Audit Execution (2026-08-14) > **Auditor/Architect**: Principal .NET Architect & DDD Specialist -> **Repository**: `EricksonLopez.Specification` (.NET 10 / C# 13) +> **Repository**: `EricksonLopez.Specification` (.NET 8 & 10 / C# 13) --- @@ -40,7 +40,7 @@ Each feature in this matrix is categorized according to strict DDD and Clean Arc |---|---|---|---|---|---|---|---|---|---|---| | Core | `Specification` abstract base | ✅ Implemented | CORE | P0 | Specification | Pure Domain | ⚠️ Conditional | Zero Alloc (Cached) | Low | **KEEP** | | Core | `ISpecification` minimal contract | ✅ Implemented | CORE | P0 | Abstractions | Pure Domain | ✅ Full | Zero Alloc | Low | **KEEP** | -| Core | `IExpressionSpecification` | ✅ Implemented | CORE | P0 | Specification | Pure Domain | ⚠️ Conditional | Zero Alloc | Low | **KEEP** | +| Core | `IExpressionSpecification` | ✅ Implemented | CORE | P0 | Abstractions | Pure Domain | ✅ Full | Zero Alloc | Low | **KEEP (adr-023)** | | Core | `BuildExpression()` lazy cache | ✅ Implemented | CORE | P0 | Specification | Pure Domain | ✅ Full | Zero Alloc | Low | **KEEP** | | Core | `IsSatisfiedBy(T)` (interpreted) | ✅ Implemented | CORE | P0 | Specification | Pure Domain | ⚠️ Conditional (Reflection) | ~500ns / 0 B | High | **KEEP** | | Core | `ToCompiledPredicate()` | ✅ Implemented | CORE | P0 | Specification | Pure Domain | ❌ RequiresDynamicCode | ~15ns / 0 B (Cached) | Medium | **KEEP** | diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d4c7721 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,169 @@ +# Getting Started: EricksonLopez.Specification + +A comprehensive guide for architects and senior engineers adopting **EricksonLopez.Specification** in production .NET enterprise applications. + +--- + +## 🏛️ Architectural Foundations + +The Specification Pattern (Evans & Fowler, DDD) formalizes business logic into first-class domain objects. + +### The Problem: Rule Scattering & Query Coupling + +In typical architectures: +1. **Rule Duplication**: The definition of an "Active Customer" is repeated across EF Core queries, business validation services, background jobs, and test assertions. +2. **Persistence Leaks**: UI or Application layers write inline lambdas that bind directly to database columns or navigation properties. +3. **Untestable Queries**: Inline LINQ queries cannot be unit-tested in isolation without mocking `IQueryable` or standing up a database. + +### The Solution: Isolated, Composable Domain Rules + +With `EricksonLopez.Specification`: +- A business rule lives in a single, sealed, immutable class in the Domain layer (`Domain/Specifications/ActiveCustomerSpecification.cs`). +- The domain class has zero dependencies on Entity Framework, Dapper, SQL, or MongoDB. +- It can be evaluated in memory (`spec.IsSatisfiedBy(entity)`) or translated to server-side SQL/NoSQL filters. +- Multiple rules compose cleanly via boolean algebra (`spec1 & spec2`, `spec1.Or(spec2)`). + +--- + +## 🧩 Clean Architecture Layer Breakdown + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Presentation │ +│ Controllers / Minimal APIs / gRPC Endpoints │ +└──────────────────────────────┬──────────────────────────────┘ + │ Invokes Mediator / Handlers +┌──────────────────────────────▼──────────────────────────────┐ +│ Application (CQRS) │ +│ • Request: GetActiveCustomersQuery(int Page, int PageSize)│ +│ • Handler: GetActiveCustomersHandler │ +│ • Dependency: IReadRepository │ +│ → Combines domain specs with pagination & sorting │ +└──────────────────────────────┬──────────────────────────────┘ + │ Uses Pure Specifications +┌──────────────────────────────▼──────────────────────────────┐ +│ Domain (Core) │ +│ • Entities: Customer, Order │ +│ • Rules: ActiveCustomerSpecification, VipSpecification │ +│ • Pure C# + System.Linq.Expressions │ +│ → ZERO database or ORM dependencies │ +└──────────────────────────────▲──────────────────────────────┘ + │ Implements +┌──────────────────────────────┴──────────────────────────────┐ +│ Infrastructure │ +│ • EfReadRepository │ +│ • MongoReadRepository │ +│ • QuerySpecTranslator & SqlDialect implementations │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## ⚙️ Dependency Injection Setup + +### 1. Registering Domain Specifications + +Domain specifications are **immutable and thread-safe**. Register them as `Singleton` or instantiate them directly: + +```csharp +// Program.cs or DependencyInjection.cs +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +### 2. Entity Framework Core Integration + +Use the official DI extensions from `EricksonLopez.Specification.EntityFrameworkCore`: + +```csharp +using EricksonLopez.Specification.EntityFrameworkCore; + +// Registers EF Core read repositories and evaluators: +builder.Services.AddSpecificationEntityFramework(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); +}); + +// Register specific entity read repositories: +builder.Services.AddEfReadRepository(); +builder.Services.AddEfReadRepository(); +``` + +--- + +## ⚡ CQRS Query Handler Implementation + +Here is a production-ready MediatR / CQRS Query Handler: + +```csharp +public sealed record GetVipCustomersQuery(int Page = 1, int PageSize = 20) + : IRequest>; + +public sealed class GetVipCustomersHandler : IRequestHandler> +{ + private readonly IReadRepository _customerRepository; + private readonly ActiveCustomerSpecification _activeSpec; + private readonly VipCustomerSpecification _vipSpec; + + public GetVipCustomersHandler( + IReadRepository customerRepository, + ActiveCustomerSpecification activeSpec, + VipCustomerSpecification vipSpec) + { + _customerRepository = customerRepository; + _activeSpec = activeSpec; + _vipSpec = vipSpec; + } + + public async Task> Handle( + GetVipCustomersQuery request, + CancellationToken cancellationToken) + { + // Compose domain rules with projection and pagination: + var querySpec = new QuerySpec() + .And(_activeSpec) + .And(_vipSpec) + .OrderByDescending(c => c.TotalPurchases) + .ThenBy(c => c.Name) + .Page(request.Page, request.PageSize) + .Select(c => new CustomerSummary(c.Id, c.Name, c.TotalPurchases)); + + return await _customerRepository.ListAsync(querySpec, cancellationToken); + } +} +``` + +--- + +## 🛡️ Native AOT & Zero Dynamic Code + +Unlike older specification libraries that rely on `Compile()` or Reflection.Emit (which fail under Native AOT with trimming): + +1. **`ExpressionInterpreter`**: In-memory evaluation (`IsSatisfiedBy`) is performed via a dedicated AST tree interpreter that walks expression nodes without generating dynamic IL code. +2. **`QuerySpecTranslator`**: SQL queries are generated through static AST mapping (`QueryModel`) and dialect renders. +3. **Trimmer-Safe Annotations**: All generic methods are annotated with `[DynamicallyAccessedMembers]` to guarantee linker preservation. + +> [!NOTE] +> If your application runs on standard JIT and requires maximum throughput for millions of in-memory evaluations, you can optionally invoke `.ToCompiledPredicate()` to utilize the bounded `ExpressionCompilationCache`. + +--- + +## 📊 Observability (OpenTelemetry) + +The library ships built-in OpenTelemetry instrumentation in `EricksonLopez.Specification.Diagnostics`: + +- **ActivitySource**: `"EricksonLopez.Specification"` +- **Meter**: `"EricksonLopez.Specification"` + - `specification.evaluations`: Total count of evaluated specifications + - `specification.compositions`: Count of composed specifications (And/Or/Not) + - `specification.sql_translations`: Total SQL query translations + - `specification.evaluation_duration`: Histogram measuring in-memory evaluation latency + - `specification.sql_translation_duration`: Histogram measuring AST-to-SQL translation latency + +To enable in your telemetry setup: + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddSource("EricksonLopez.Specification")) + .WithMetrics(metrics => metrics.AddMeter("EricksonLopez.Specification")); +``` diff --git a/docs/migration-from-ardalis.md b/docs/migration-from-ardalis.md index 6d7313f..caac7d0 100644 --- a/docs/migration-from-ardalis.md +++ b/docs/migration-from-ardalis.md @@ -1,4 +1,4 @@ -# Migrating from Ardalis.Specification to EricksonLopez.Specification +# Migrating from Ardalis.Specification to EricksonLopez.Specification This guide maps every Ardalis concept to its EricksonLopez equivalent, and explains the architectural differences. @@ -190,7 +190,7 @@ public sealed class EfCustomerRepository : IReadRepository | EF Core IQueryable | ✅ | ✅ | Via .Apply() | | Include / ThenInclude | ✅ | ❌ (by design) | Move to repository | | Write repository | ✅ | ❌ (by design) | Implement yourself | -| Roslyn analyzers | ❌ | ✅ (SPEC001–010) | Compile-time enforcement | +| Roslyn analyzers | ❌ | ✅ (SPEC001–SPEC011) | Compile-time enforcement (SPEC011 detects Ardalis specs) | | Expression.Invoke-free | ✅ | ✅ | | | Immutable query descriptor | ❌ (mutable) | ✅ (sealed record) | Thread-safe caching | | Dynamic string ordering | ✅ | ❌ (by design) | Type-unsafe anti-pattern | diff --git a/docs/migration-guide.md b/docs/migration-guide.md index d5f116a..16f4172 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -2,7 +2,10 @@ ## From `Ardalis.Specification` to `EricksonLopez.Specification` -If you were using traditional libraries purely focused on Entity Framework Core, the transition will require re-thinking the architectural purpose of your rules. +> [!TIP] +> For an exhaustive, step-by-step migration blueprint covering repositories, ordering, and feature-by-feature comparisons, see [Migrating from Ardalis.Specification](migration-from-ardalis.md). Additionally, the `EricksonLopez.Specification.Analyzers` package includes diagnostic rule **`SPEC011`** which automatically flags legacy Ardalis specifications and offers automated CodeFix refactoring in your IDE. + +If you were using traditional libraries purely focused on Entity Framework Core, the transition will require re-thinking the architectural purpose of your rules: ### 1. Replacing `ISpecification` (Ardalis) In Ardalis, the Specification mixed pure conditional rules (`Where`), along with Infrastructure logic (`Includes`, `AsNoTracking`, `OrderBy`). diff --git a/docs/nuget-packages.md b/docs/nuget-packages.md index 786c3f1..9f4d207 100644 --- a/docs/nuget-packages.md +++ b/docs/nuget-packages.md @@ -12,18 +12,20 @@ All external dependencies are centrally versioned in [`Directory.Packages.props` | Dependency | Pinned Version | Scope | |---|---|---| -| `Dapper` | 2.1.66 | Micro-ORM SQL execution | +| `Dapper` | 2.1.79 | Micro-ORM SQL execution | | `Dapper.AOT` | 1.0.52 | Native AOT code generation for Dapper | -| `Npgsql` | 9.0.3 | PostgreSQL provider driver | +| `Npgsql` | 10.0.3 | PostgreSQL provider driver | | `Microsoft.EntityFrameworkCore` | 9.0.2 | EF Core LINQ and relational engine | | `Microsoft.EntityFrameworkCore.Relational` | 9.0.2 | EF Core relational extensions | | `Microsoft.EntityFrameworkCore.InMemory` | 9.0.2 | In-memory testing provider | | `Microsoft.EntityFrameworkCore.Sqlite` | 9.0.2 | SQLite EF Core provider | -| `MongoDB.Driver` | 3.10.0 | MongoDB document store driver | +| `MongoDB.Driver` | 3.11.1 | MongoDB document store driver | | `Microsoft.Extensions.Hosting` | 10.0.11 | Generic host & runtime integration | -| `Microsoft.CodeAnalysis.CSharp` | 4.14.0 | Roslyn compiler API for analyzers & generators | +| `Microsoft.Extensions.DependencyInjection.Abstractions` | 10.0.2 | Dependency Injection abstractions | +| `Microsoft.CodeAnalysis.CSharp` | 5.9.0 | Roslyn compiler API for analyzers & generators | | `OpenTelemetry.Api` | 1.10.0 | Distributed tracing and metrics | | `System.Diagnostics.DiagnosticSource` | 9.0.0 | Observability ActivitySource & Meter | +| `EricksonLopez.Result` | 1.0.0 | Functional Result pattern integration | --- @@ -53,7 +55,7 @@ graph TD GEN[EricksonLopez.Specification.Generators] CORE --> ABS - LINQ --> CORE + LINQ --> ABS SQL --> CORE PG --> SQL @@ -80,7 +82,7 @@ graph TD ## Package Catalog -All shipping packages target `net10.0` with `LangVersion=preview`. +Shipping library packages multi-target `.NET 8`, `.NET 9`, and `.NET 10` (`TargetFrameworks=net8.0;net9.0;net10.0` with `LangVersion=preview`). Tooling packages (`Analyzers` and `Generators`) target `.NET Standard 2.0` (`netstandard2.0`) for broad Roslyn IDE and build host compatibility. ### 1. `EricksonLopez.Specification.Abstractions` - **Purpose**: Foundational contracts with zero external dependencies. @@ -170,7 +172,7 @@ All shipping packages target `net10.0` with `LangVersion=preview`. ## Compatibility Matrix -| Package | .NET 10 | Native AOT | Entity Framework Core | Dapper | MongoDB | +| Package | .NET 8 / 10 | Native AOT | Entity Framework Core | Dapper | MongoDB | |---|:---:|:---:|:---:|:---:|:---:| | `Abstractions` | ✅ Yes | ✅ Full | ✅ Yes | ✅ Yes | ✅ Yes | | `Specification` (Core) | ✅ Yes | ✅ Full (Interpreted) | ✅ Yes | ✅ Yes | ✅ Yes | @@ -187,5 +189,5 @@ All shipping packages target `net10.0` with `LangVersion=preview`. | `MongoDB` | ✅ Yes | ✅ Full | ❌ N/A | ❌ N/A | ✅ Yes | | `DapperExtensions` | ✅ Yes | ✅ Full | ❌ N/A | ✅ Yes | ❌ N/A | | `Result` | ✅ Yes | ✅ Full | ✅ Yes | ✅ Yes | ✅ Yes | -| `Analyzers` | ✅ Roslyn 4.14 | N/A (Dev) | N/A | N/A | N/A | -| `Generators` | ✅ Roslyn 4.14 | N/A (Dev) | N/A | N/A | N/A | +| `Analyzers` | ✅ Roslyn 5.9 | N/A (Dev) | N/A | N/A | N/A | +| `Generators` | ✅ Roslyn 5.9 | N/A (Dev) | N/A | N/A | N/A | diff --git a/docs/public-api-surface.md b/docs/public-api-surface.md index f405bf3..7c04dec 100644 --- a/docs/public-api-surface.md +++ b/docs/public-api-surface.md @@ -12,6 +12,18 @@ This document provides a comprehensive technical inventory of the public API sur - `Expression> ToExpression();` - `bool IsSatisfiedBy(T entity);` +### `IExpressionSpecification` +- **Contract**: Specification convertible to an expression tree for query providers (inherits `ISpecification`). +- **Signatures**: + - `Expression> ToExpression();` + - `string ToDebugString();` — delegates to `ExpressionDebugFormatterRegistry` + +### `ExpressionDebugFormatterRegistry` +- **Contract**: Cross-layer registry for expression debug formatting. +- **Properties & Methods**: + - `public static Func Formatter { get; set; }` + - `public static string Format(Expression expression)` + ### `QuerySpec` and `QuerySpec` - **Contract**: Immutable record representing a full query intent (filters, ordering, pagination, cursor). - **Core Factory**: `QuerySpec.Empty` @@ -157,7 +169,13 @@ All dialects implement `ISqlDialect` and provide `Default` singleton instances: - `ReadRepositoryDapperExtensions` — executes specifications over internal `IUnitOfWork` sessions. ### `EricksonLopez.Specification.Result` -- `ListResultAsync()`, `FirstOrDefaultResultAsync()` — executes specifications returning functional `Result` envelopes. +- `ReadRepositoryResultExtensions` — extension methods over `IReadRepository` returning functional `Result` envelopes from `EricksonLopez.Result`: + - `Task> FirstOrDefaultResultAsync(this IReadRepository, QuerySpec, CancellationToken)` — maps null to `NotFound` error + - `Task> SingleOrDefaultResultAsync(this IReadRepository, QuerySpec, CancellationToken)` + - `Task>> ListResultAsync(this IReadRepository, QuerySpec, CancellationToken)` + - `Task>> ListResultAsync(this IReadRepository, QuerySpec, CancellationToken)` + - `Task> CountResultAsync(this IReadRepository, QuerySpec, CancellationToken)` + - `Task> AnyResultAsync(this IReadRepository, QuerySpec, CancellationToken)` --- diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..7bbb5b0 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,175 @@ +# Quick Start Guide: EricksonLopez.Specification + +Get up and running with **EricksonLopez.Specification** in under 5 minutes. + +--- + +## 1. Installation + +Install the core abstractions and engine via the .NET CLI or Package Manager: + +```bash +# Core Domain Specification Engine (100% Native AOT Compatible) +dotnet add package EricksonLopez.Specification + +# Core Abstractions (Lightweight interfaces for domain & application layers) +dotnet add package EricksonLopez.Specification.Abstractions +``` + +### Optional Infrastructure Packages + +```bash +# LINQ & IQueryable extensions +dotnet add package EricksonLopez.Specification.Linq + +# Entity Framework Core 9+ Repository & Evaluator +dotnet add package EricksonLopez.Specification.EntityFrameworkCore + +# High-performance SQL AST Translator (PostgreSQL, SQL Server, SQLite, MySQL, MariaDB, Oracle) +dotnet add package EricksonLopez.Specification.Sql +dotnet add package EricksonLopez.Specification.PostgreSql # or MsSql, Sqlite, MySql, MariaDb, Oracle + +# Micro-ORM & NoSQL Integrations +dotnet add package EricksonLopez.Specification.Dapper +dotnet add package EricksonLopez.Specification.MongoDB +dotnet add package EricksonLopez.Specification.Result +``` + +--- + +## 2. Create Your First Domain Specification + +In Domain-Driven Design (DDD), business rules should live in pure domain classes, completely decoupled from persistence frameworks. + +```csharp +using System.Linq.Expressions; +using EricksonLopez.Specification; + +public sealed class ActiveCustomerSpecification : Specification +{ + protected override Expression> BuildExpression() + { + return customer => customer.IsActive; + } +} + +public sealed class PremiumCustomerSpecification : Specification +{ + private readonly decimal _minimumPurchases; + + public PremiumCustomerSpecification(decimal minimumPurchases = 10) + { + _minimumPurchases = minimumPurchases; + } + + protected override Expression> BuildExpression() + { + return customer => customer.TotalPurchases >= _minimumPurchases; + } +} +``` + +--- + +## 3. In-Memory Evaluation (100% Native AOT Safe) + +Evaluate rules directly against entity instances without compiling IL or generating dynamic code: + +```csharp +var customer = new Customer +{ + Name = "Alice", + IsActive = true, + TotalPurchases = 15 +}; + +var activeSpec = new ActiveCustomerSpecification(); + +// Evaluated via the AOT-safe ExpressionInterpreter: +bool isEligible = activeSpec.IsSatisfiedBy(customer); // Returns true +``` + +--- + +## 4. Compose Specifications + +Combine domain rules using boolean logic. The library supports standard methods, C# operators (`&`, `|`, `!`), and short-circuit evaluation (`&&`, `||`): + +```csharp +var activeSpec = new ActiveCustomerSpecification(); +var premiumSpec = new PremiumCustomerSpecification(10); + +// Using standard combinator methods: +Specification targetSpec1 = activeSpec.And(premiumSpec); +Specification targetSpec2 = activeSpec.Or(premiumSpec); +Specification targetSpec3 = activeSpec.Not(); + +// Using natural C# operators: +Specification combined = activeSpec & premiumSpec; +Specification alternative = activeSpec | premiumSpec; +Specification inverted = !activeSpec; + +// Short-circuit composition: +Specification shortCircuit = activeSpec && premiumSpec; + +// Combining multiple specifications via Span / Enumerable: +Specification allOfThese = Spec.All(activeSpec, premiumSpec); +Specification anyOfThese = Spec.Any(activeSpec, premiumSpec); +``` + +--- + +## 5. Build Complete Queries with `QuerySpec` + +`QuerySpec` represents query intent (filters, ordering, pagination, cursor pagination, and projection): + +```csharp +using EricksonLopez.Specification; + +// Declarative query builder +var query = QuerySpec.Empty + .And(new ActiveCustomerSpecification()) + .Where(c => c.CreatedAt >= DateTime.UtcNow.AddDays(-30)) + .OrderByDescending(c => c.TotalPurchases) + .ThenBy(c => c.Name) + .Page(page: 1, pageSize: 20); +``` + +--- + +## 6. Execute with LINQ / EF Core + +Apply specifications directly onto any `IQueryable` DbSet: + +```csharp +using EricksonLopez.Specification.Linq; + +// In your CQRS Query Handler or Repository: +public async Task> Handle(GetActiveCustomersQuery query, CancellationToken ct) +{ + return await _dbContext.Customers + .Apply(query.Spec) + .ToListAsync(ct); +} +``` + +### Direct IQueryable & IEnumerable Extensions + +```csharp +// Direct IQueryable filtering with specification: +IQueryable filtered = _dbContext.Customers.Where(activeSpec); +bool anyVip = _dbContext.Customers.Any(premiumSpec); +int totalActive = _dbContext.Customers.Count(activeSpec); + +// In-Memory IEnumerable filtering: +IEnumerable cachedList = GetCachedCustomers(); +List activeOnly = cachedList.Where(activeSpec).ToList(); +``` + +--- + +## 7. Next Steps + +- Explore the [Getting Started Guide](getting-started.md) for full architecture and DI integration. +- Read the [Cookbook](cookbook.md) for ready-to-use patterns (keyset pagination, projections, MongoDB, Dapper). +- Review the [API Reference](api-reference.md) for complete signature documentation. diff --git a/docs/roadmap.md b/docs/roadmap.md index b245432..0171235 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > **Version**: 2.0 — Post-Audit Architecture Execution (2026-08-14) > **Strategy**: Pure Domain Predicates · Zero-ORM SQL Engine · Native AOT-First · Compile-Time Governance -> **Repository Status**: 1,052 tests passing · 0 warnings (`--warnaserror`) · 11 Roslyn Analyzers · 6 SQL Dialects (PostgreSQL, SQL Server, SQLite, MySQL, MariaDB, Oracle) +> **Repository Status**: 1,057 tests passing · 0 warnings (`--warnaserror`) · 11 Roslyn Analyzers · 6 SQL Dialects (PostgreSQL, SQL Server, SQLite, MySQL, MariaDB, Oracle) --- diff --git a/docs/showcase/diagrams.md b/docs/showcase/diagrams.md new file mode 100644 index 0000000..afda875 --- /dev/null +++ b/docs/showcase/diagrams.md @@ -0,0 +1,299 @@ +# Architecture Diagrams — EricksonLopez.Specification + + +--- + +## 1. General Package Architecture + +```mermaid +graph TD + A["EricksonLopez.Specification.Abstractions\nISpecification, IExpressionSpecification,\nIReadRepository, QuerySpec, ExpressionDebugFormatterRegistry"] + B["EricksonLopez.Specification\n(Core Engine)\nSpecification, Spec, ExpressionComposer,\nExpressionHasher, ExpressionSimplifier,\nExpressionInterpreter, ExpressionCompilationCache,\nSpecificationDiagnostics"] + C["EricksonLopez.Specification.Linq\nQuerySpecLinqExtensions:\nApply, Any, Count"] + D["EricksonLopez.Specification.Sql\nQuerySpecTranslator, ISqlDialect,\nQueryModel, QueryPlanCache,\nIColumnNameResolver"] + E1["PostgreSqlDialect"] + E2["MsSqlDialect"] + E3["SqliteDialect"] + E4["MySqlDialect"] + E5["MariaDbDialect"] + E6["OracleDialect"] + F["EricksonLopez.Specification.Dapper\nQuerySpecDapperExtensions:\nQueryAsync, QueryFirstOrDefaultAsync,\nCountAsync, AnyAsync"] + G["EricksonLopez.Specification.EntityFrameworkCore\nEfSpecificationEvaluator, ISpecificationEvaluator,\nEfReadRepository, QuerySpecEfCoreExtensions,\nSpecificationEntityFrameworkServiceCollectionExtensions"] + H["EricksonLopez.Specification.MongoDB\nMongoSpecificationEvaluator,\nMongoSpecificationExtensions"] + I["EricksonLopez.Specification.Result\nReadRepositoryResultExtensions"] + + A --> B + B --> C + B --> D + D --> E1 + D --> E2 + D --> E3 + D --> E4 + D --> E5 + D --> E6 + E1 --> F + E2 --> F + E3 --> F + A --> G + A --> H + A --> I +``` + +--- + +## 2. Main Workflow: Definition → Evaluation + +```mermaid +sequenceDiagram + participant Domain as Domain Layer + participant Spec as Specification<T> + participant Engine as Expression Engine + participant Repo as IReadRepository<T> + participant Infra as Infrastructure + + Domain->>Spec: new ActiveCustomerSpecification() + Note over Spec: Diagnostics.SpecificationsCreated++ + Spec->>Engine: BuildExpression() lazy cached + Domain->>Spec: IsSatisfiedBy(customer) + Spec->>Engine: ExpressionInterpreter.Evaluate(expr, candidate) + Note over Engine: AOT-safe, no IL emit + Engine-->>Domain: bool + + Domain->>Repo: ListAsync(QuerySpec<T>) + Repo->>Infra: EF Core / Dapper / MongoDB + Infra-->>Domain: IReadOnlyList<T> +``` + +--- + +## 3. QuerySpec Construction Pipeline + +```mermaid +flowchart LR + A["QuerySpec<T>.Empty"] + B[".Where(predicate)"] + C[".And(Specification<T>)"] + D[".OrderBy / .OrderByDescending"] + E[".ThenBy / .ThenByDescending"] + F[".Page(page, pageSize)"] + G[".Take(n) / .Skip(n)"] + H[".Distinct()"] + I[".TagWith(tag)"] + J[".WithCursor / .SeekAfter / .SeekBefore"] + K[".Select() -> QuerySpec<T,TResult>"] + + A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K +``` + +--- + +## 4. SQL Translation Pipeline + +```mermaid +sequenceDiagram + participant App as Application + participant TS as "QuerySpecTranslator<T>" + participant PC as QueryPlanCache + participant AST as QueryModel + participant Dialect as ISqlDialect + participant DB as IDbConnection + + App->>TS: Translate(querySpec) + TS->>PC: TryGetPlan(criteria, tableName) + alt Cache Hit + PC-->>TS: QueryModel cached + else Cache Miss + TS->>TS: Walk expression tree + TS->>TS: Build SqlPredicateNode AST + TS->>AST: QueryModel with Filters/Orders/Skip/Take + TS->>PC: SetPlan(criteria, tableName, model) + end + App->>Dialect: Render(model) + Dialect-->>App: SqlQuery { Sql, Parameters } + App->>DB: QueryAsync<T>(sql, parameters) + DB-->>App: IEnumerable<T> +``` + +--- + +## 5. SQL Predicate AST + +```mermaid +classDiagram + class SqlPredicateNode { + abstract + } + class BinaryPredicateNode { + string Column + SqlBinaryOperator Operator + object Value + } + class InPredicateNode { + string Column + IEnumerable Values + } + class BetweenPredicateNode { + string Column + object Lower + object Upper + } + class FullTextPredicateNode { + string Column + string SearchTerm + } + class RangePredicateNode { + string Column + object Lower + object Upper + } + class AndPredicateNode { + SqlPredicateNode Left + SqlPredicateNode Right + } + class OrPredicateNode { + SqlPredicateNode Left + SqlPredicateNode Right + } + class NotPredicateNode { + SqlPredicateNode Inner + } + + SqlPredicateNode <|-- BinaryPredicateNode + SqlPredicateNode <|-- InPredicateNode + SqlPredicateNode <|-- BetweenPredicateNode + SqlPredicateNode <|-- FullTextPredicateNode + SqlPredicateNode <|-- RangePredicateNode + SqlPredicateNode <|-- AndPredicateNode + SqlPredicateNode <|-- OrPredicateNode + SqlPredicateNode <|-- NotPredicateNode +``` + +--- + +## 6. Specification Composition Hierarchy + +```mermaid +classDiagram + class ISpecification~T~ { + interface + bool IsSatisfiedBy(T) + } + class IExpressionSpecification~T~ { + interface + Expression ToExpression() + string ToDebugString() + } + class Specification~T~ { + abstract + BuildExpression() + IsSatisfiedBy(T) + ToExpression() + ToCompiledPredicate() + ToQuerySpec() + And(other) + Or(other) + Not() + operator and + operator or + operator not + } + class LambdaSpecification~T~ + class CompositeSpecification~T~ + class NegatedSpecification~T~ + + ISpecification~T~ <|.. IExpressionSpecification~T~ + IExpressionSpecification~T~ <|.. Specification~T~ + Specification~T~ <|-- LambdaSpecification~T~ + Specification~T~ <|-- CompositeSpecification~T~ + Specification~T~ <|-- NegatedSpecification~T~ +``` + +--- + +## 7. Error Handling Flow + +```mermaid +flowchart TB + A["QuerySpec.Where(null)"] --> B["ArgumentNullException"] + C["QuerySpec.Page(0, pageSize)"] --> D["ArgumentOutOfRangeException"] + E["QuerySpec.Skip(-1)"] --> F["ArgumentOutOfRangeException"] + G["Spec.Between(lower:50, upper:10)"] --> H["ArgumentException"] + I["Translator.Translate(unsupported expr)"] --> J["NotSupportedException"] + K["repo.SingleOrDefaultAsync() - multiple matches"] --> L["InvalidOperationException"] + M["IReadRepository null result"] --> N["ReadRepositoryResultExtensions\nResult Error.NotFound"] +``` + +--- + +## 8. Clean Architecture Integration + +```mermaid +graph TB + subgraph Domain["Domain Layer"] + E["Entity: Customer"] + S1["ActiveCustomerSpecification"] + S2["VipCustomerSpecification"] + end + + subgraph Application["Application Layer (CQRS)"] + H["GetTopCustomersHandler"] + R["IReadRepository<Customer>"] + end + + subgraph Infrastructure["Infrastructure Layer"] + EF["EfReadRepository<TCtx,Customer>"] + DP["Dapper + QuerySpecTranslator<Customer>"] + MG["MongoSpecificationEvaluator"] + end + + subgraph Presentation["Presentation Layer"] + API["Controller / gRPC / Worker"] + end + + API --> H + H --> R + H --> S1 + H --> S2 + R --> EF + R --> DP + R --> MG +``` + +--- + +## 9. QueryPlanCache States (LRU) + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Populated : SetPlan + Populated --> CacheHit : TryGetPlan found + Populated --> CacheMiss : TryGetPlan not found + CacheMiss --> Populated : SetPlan new entry + Populated --> Evicted : Capacity exceeded LRU + Evicted --> Populated : SetPlan replaces LRU entry + Populated --> Empty : Clear() + CacheHit --> Populated +``` + +--- + +## 10. In-Memory Evaluation Flow (AOT-Safe) + +```mermaid +flowchart TB + A["Specification.IsSatisfiedBy(candidate)"] + B["ExpressionInterpreter.Evaluate(expr, candidate)"] + C{"Expression NodeType?"} + D["BinaryExpression AND/OR/NOT -> recurse"] + E["MemberExpression -> property access"] + F["ConstantExpression -> return value"] + G["MethodCallExpression -> dispatch table"] + H["bool result"] + + A --> B --> C + C --> D --> C + C --> E --> H + C --> F --> H + C --> G --> H +``` diff --git a/docs/showcase/level-04-advanced-integration.md b/docs/showcase/level-04-advanced-integration.md new file mode 100644 index 0000000..fee6973 --- /dev/null +++ b/docs/showcase/level-04-advanced-integration.md @@ -0,0 +1,40 @@ +# Level 04: Advanced Integration — SQL Translation Pipeline + +## Overview + +Level 4 demonstrates the SQL translation pipeline that converts QuerySpec into parameterized SQL queries without ORM dependencies. This is the core of the Dapper integration. + +## Key Components + +- **QuerySpecTranslator**: Traverses QuerySpec expression trees and builds a QueryModel (SQL AST). +- **QueryModel**: Provider-agnostic record: TableName, Filters, Orders, Skip, Take, IsDistinct, QueryType, Parameters, Projections. +- **ISqlDialect**: Renders QueryModel into dialect-specific SQL with parameters. +- **IColumnNameResolver**: Maps C# property names to database column names. +- **QueryPlanCache**: Thread-safe bounded LRU cache for translated plans. +- **SqlQuery**: Result record containing Sql string and Parameters dictionary. + +## Running Example (Level4_AdvancedIntegration.cs) + +See [Level4_AdvancedIntegration.cs](../../samples/Showcase/Levels/Level4_AdvancedIntegration.cs) for the complete executable demonstration of: + +1. QuerySpecTranslator("customers", SnakeCaseColumnNameResolver.Default) +2. Rendering with 6 dialects: PostgreSqlDialect, MsSqlDialect, SqliteDialect, MySqlDialect, MariaDbDialect, OracleDialect +3. QueryPlanCache — LRU cache with TryGetPlan, SetPlan, Clear, Count, Capacity +4. SqlPredicateNode AST inspection: AndPredicateNode, BinaryPredicateNode, InPredicateNode, BetweenPredicateNode +5. QuerySpec with .Select() projection +6. SeekAfter / SeekBefore keyset pagination with WithCursor +7. TagWith() for diagnostic query labeling + +## Supported Expressions + +The translator handles: member access, constants, binary comparisons (==, !=, <, >, <=, >=), Contains, StartsWith, EndsWith, boolean AND/OR/NOT, null comparisons. + +**Unsupported**: string.IsNullOrEmpty(), complex method chains → use equivalent expressions instead. + +## Column Name Resolvers + +| Resolver | IsActive → | Usage | +|---|---|---| +| SnakeCaseColumnNameResolver.Default | is_active | PostgreSQL, standard SQL | +| VerbatimColumnNameResolver.Default | IsActive | EF Core shadow properties | +| Custom IColumnNameResolver | Any mapping | Legacy databases | diff --git a/docs/showcase/level-05-processing.md b/docs/showcase/level-05-processing.md new file mode 100644 index 0000000..e4ff1c1 --- /dev/null +++ b/docs/showcase/level-05-processing.md @@ -0,0 +1,52 @@ +# Level 05: Processing — Batch, Conditional, and Concurrent + +## Overview + +Level 5 demonstrates how specifications interact with batch processing, conditional filtering, and concurrent execution. + +## Key APIs Demonstrated + +- **QuerySpec.Page(page, pageSize)**: 1-based pagination for batch iteration. +- **Spec.True()**: Identity element for AND chains — no filtering effect. Use as default when a filter is disabled. +- **Spec.False()**: Identity element for OR chains — no filtering effect. Use as base for dynamic OR accumulation. +- **QuerySpec immutability**: Thread-safe by design. Multiple workers can share a single QuerySpec instance. +- **CancellationToken**: Passes through all async paths. + +## Patterns + +### Batch Processing Pattern + +`csharp +var baseSpec = QuerySpec.Empty + .Where(c => c.IsActive) + .OrderBy(c => c.Name); + +int page = 1; +while (!ct.IsCancellationRequested) +{ + var batch = await repo.ListAsync(baseSpec.Page(page, pageSize), ct); + if (batch.Count == 0) break; + // process batch... + page++; +} +` + +### Dynamic AND Filter Pattern (Spec.True) + +`csharp +Specification filter = filterByActive + ? new ActiveCustomerSpecification() + : Spec.True(); // pass-through — no filtering +` + +### Dynamic OR Chain Pattern (Spec.False) + +`csharp +Specification nameFilter = Spec.False(); // neutral base +foreach (var name in segments) + nameFilter = nameFilter.Or(Spec.For(c => c.Name.Contains(name))); +` + +## Running Example + +See [Level5_Processing.cs](../../samples/Showcase/Levels/Level5_Processing.cs). diff --git a/docs/showcase/level-06-error-handling.md b/docs/showcase/level-06-error-handling.md new file mode 100644 index 0000000..ed2aa3c --- /dev/null +++ b/docs/showcase/level-06-error-handling.md @@ -0,0 +1,48 @@ +# Level 06: Error Handling — Exceptions and Result Pattern + +## Overview + +Level 6 covers runtime exceptions, pre-execution validation, and the functional Result Pattern. + +## Exception Types + +| Exception | Trigger | Solution | +|---|---|---| +| ArgumentNullException | QuerySpec.Where(null) | Validate before calling | +| ArgumentOutOfRangeException | Page(0, n) or Skip(-1) | Use page >= 1, skip >= 0 | +| ArgumentException | Spec.Between(lower:50, upper:10) | Ensure lower <= upper | +| NotSupportedException | Translator receives unsupported expression | Rewrite expression | +| InvalidOperationException | SingleOrDefaultAsync() finds multiple | Narrow criteria or use FirstOrDefaultAsync | + +## Pre-Execution Validation + +Use QuerySpecExtensions to validate before executing: + +`csharp +if (!spec.HasCriteria()) + _logger.LogWarning("Unbounded query — no filter criteria."); +if (!spec.HasPagination()) + _logger.LogWarning("Unbounded query — no Take limit."); +if (spec.HasOrdering() && !spec.HasPagination()) + _logger.LogWarning("Sorting without pagination can be expensive."); +` + +## Result Pattern + +ReadRepositoryResultExtensions transforms null returns into typed Result: + +`csharp +var result = await repo.FirstOrDefaultResultAsync(spec); +if (result.IsFailure) + return Error.NotFound; // null → Result.Failure(Error.NotFound) +` + +Available methods: +- FirstOrDefaultResultAsync +- SingleOrDefaultResultAsync +- ListResultAsync +- GetByIdResultAsync + +## Running Example + +See [Level6_ErrorHandling.cs](../../samples/Showcase/Levels/Level6_ErrorHandling.cs). diff --git a/docs/showcase/level-07-scalability.md b/docs/showcase/level-07-scalability.md new file mode 100644 index 0000000..933b09a --- /dev/null +++ b/docs/showcase/level-07-scalability.md @@ -0,0 +1,79 @@ +# Level 07: Scalability and Performance — Expression Engine + +## Overview + +Level 7 exposes the internal performance engine that powers the library's AOT-safe, high-throughput expression evaluation. + +## Engine Components + +### ExpressionHasher +Computes structural hash of expression trees. Two syntactically identical expressions produce the same hash regardless of parameter names. + +`csharp +int hash = ExpressionHasher.ComputeHash(expr); +` + +### ExpressionSimplifier +Performs constant folding: rue && x → x, alse || x → x, NOT(NOT(x)) → x. + +`csharp +var simplified = ExpressionSimplifier.Simplify(expr); +// or: var simplified = ExpressionSimplifier.Default.Visit(expr); +` + +### ExpressionCompilationCache +Caches compiled IL delegates by expression structural equality. **JIT only** ([RequiresDynamicCode]). + +`csharp +var compiled = ExpressionCompilationCache.GetOrCompile(expr); +int cached = ExpressionCompilationCache.CachedCount; +` + +### ExpressionInterpreter +AOT-safe evaluator. Walks expression tree at runtime without IL emit. + +`csharp +bool result = ExpressionInterpreter.Evaluate(expr, candidate); +` + +### ExpressionComposer +Compose expressions without Expression.Invoke (avoids nested parameter issues): + +`csharp +var and = ExpressionComposer.And(expr1, expr2); +var or = ExpressionComposer.Or(expr1, expr2); +var not = ExpressionComposer.Not(expr1); +var andAll = ExpressionComposer.AndAll(predicates.AsSpan()); +var orAny = ExpressionComposer.OrAny(predicates.AsSpan()); +` + +### ExpressionEqualityComparer +Structural equality ignoring parameter names: + +`csharp +bool equal = ExpressionEqualityComparer.Default.Equals(expr1, expr2); +` + +### ExpressionDebugFormatter +Human-readable expression formatting: + +`csharp +string formatted = ExpressionDebugFormatter.Format(expr); +` + +### SpecificationDiagnostics (OpenTelemetry) + +`csharp +// Activity Source for distributed tracing +using var activity = SpecificationDiagnostics.ActivitySource.StartActivity("MyOp"); +activity?.SetTag("specification.hash", hash); + +// Meters for metrics +// specification.created, specification.evaluated, specification.composed, +// specification.compiled, specification.expression.cache.hits, +// specification.expression.cache.misses, specification.sql.translations +` + +## Running Example + +See [Level7_Scalability.cs](../../samples/Showcase/Levels/Level7_Scalability.cs). diff --git a/docs/showcase/level-08-customization.md b/docs/showcase/level-08-customization.md new file mode 100644 index 0000000..03391cb --- /dev/null +++ b/docs/showcase/level-08-customization.md @@ -0,0 +1,63 @@ +# Level 08: Customization — Implementing Public Interfaces + +## Overview + +Level 8 demonstrates how to extend the library by implementing its public interfaces without modifying library source code. + +## IColumnNameResolver + +Maps C# property names to SQL column names. + +### Built-in Resolvers + +| Resolver | IsActive → | TotalPurchases → | +|---|---|---| +| SnakeCaseColumnNameResolver.Default | is_active | otal_purchases | +| VerbatimColumnNameResolver.Default | IsActive | TotalPurchases | + +### Custom Resolver Example + +`csharp +public sealed class LegacyDbColumnNameResolver : IColumnNameResolver +{ + public string Resolve(string propertyName) + => "TBL_COL_" + propertyName.ToUpperInvariant(); +} +// IsActive → TBL_COL_ISACTIVE +` + +### Explicit Mapping Resolver + +`csharp +var resolver = new ExplicitMappingColumnNameResolver(new Dictionary +{ + { "IsActive", "ACTIVE" }, + { "TotalPurchases", "TOTAL_PURCHASES" } +}); +` + +## ISqlDialect + +Full custom SQL generation engine: + +`csharp +public sealed class LegacyReportingDialect : ISqlDialect +{ + public string DialectName => "LegacyReporting"; + public string ParameterPrefix => ":"; + public string QuoteIdentifier(string identifier) => identifier.ToUpperInvariant(); + public SqlQuery Render(QueryModel model) { /* build SQL */ } +} +` + +## Usage with QuerySpecTranslator + +`csharp +var translator = new QuerySpecTranslator("CUSTOMERS", new LegacyDbColumnNameResolver()); +var model = translator.Translate(spec); +var sql = new LegacyReportingDialect().Render(model); +` + +## Running Example + +See [Level8_Customization.cs](../../samples/Showcase/Levels/Level8_Customization.cs). diff --git a/docs/showcase/level-09-official-extensions.md b/docs/showcase/level-09-official-extensions.md new file mode 100644 index 0000000..c0f7bdc --- /dev/null +++ b/docs/showcase/level-09-official-extensions.md @@ -0,0 +1,79 @@ +# Level 09: Official Extensions — Dapper, EF Core, MongoDB + +## Overview + +Level 9 demonstrates all official library integrations: Dapper SQL execution, EF Core helpers, and MongoDB. + +## QuerySpecDapperExtensions + +Executes QuerySpec directly against IDbConnection: + +`csharp +// QueryAsync — list with filters, ordering, pagination +IEnumerable customers = await connection.QueryAsync( + spec, translator, dialect); + +// QueryFirstOrDefaultAsync — single entity +Customer? first = await connection.QueryFirstOrDefaultAsync( + spec, translator, dialect); + +// CountAsync — scalar count +int count = await connection.CountAsync( + spec, translator, dialect); + +// AnyAsync — existence check +bool exists = await connection.AnyAsync( + spec, translator, dialect); +` + +## QuerySpecLinqExtensions / QuerySpecEfCoreExtensions + +`csharp +// LINQ Apply — works with EF Core DbSet or any IQueryable +IQueryable query = dbContext.Customers.Apply(spec); + +// EF Core specific options +IQueryable splitQuery = dbContext.Customers + .Apply(spec, asSplitQuery: true, ignoreAutoIncludes: true); +` + +## EF Core Dependency Injection + +`csharp +services.AddSpecificationEntityFramework(); +services.AddEfReadRepository(); +// Registers EfReadRepository : IReadRepository +` + +## MongoSpecificationEvaluator + +`csharp +// Build FilterDefinition and SortDefinition +FilterDefinition filter = MongoSpecificationEvaluator.GetFilter(spec); +SortDefinition? sort = MongoSpecificationEvaluator.GetSort(spec); + +// Fluent find +IFindFluent find = collection.Find(spec); + +// Apply to existing find +findFluent.ApplySpecification(spec); + +// Async execution +IReadOnlyList results = await collection.FindAsync(spec); +long count = await collection.CountDocumentsAsync(spec); +` + +## Dialect Selection + +| Database | Dialect | NuGet | +|---|---|---| +| PostgreSQL | PostgreSqlDialect.Default | EricksonLopez.Specification.PostgreSql | +| SQL Server | MsSqlDialect.Default | EricksonLopez.Specification.MsSql | +| SQLite | SqliteDialect.Default | EricksonLopez.Specification.Sqlite | +| MySQL | MySqlDialect.Default | EricksonLopez.Specification.MySql | +| MariaDB | MariaDbDialect.Default | EricksonLopez.Specification.MariaDb | +| Oracle | OracleDialect.Default | EricksonLopez.Specification.Oracle | + +## Running Example + +See [Level9_Extensions.cs](../../samples/Showcase/Levels/Level9_Extensions.cs). diff --git a/docs/showcase/level-10-enterprise-architecture.md b/docs/showcase/level-10-enterprise-architecture.md new file mode 100644 index 0000000..e5aef72 --- /dev/null +++ b/docs/showcase/level-10-enterprise-architecture.md @@ -0,0 +1,63 @@ +# Level 10: Enterprise Architecture — Clean Architecture, CQRS, and DDD + +## Overview + +Level 10 demonstrates how the Specification Pattern integrates with Clean Architecture, CQRS, and DDD. All IReadRepository methods are exercised. + +## IReadRepository — Complete API + +| Method | Description | +|---|---| +| ListAsync(QuerySpec) | All matching entities | +| ListAsync(QuerySpec) | Typed projections | +| FirstOrDefaultAsync(QuerySpec) | First match or null | +| SingleOrDefaultAsync(QuerySpec) | Exactly one or null | +| GetByIdAsync(id) | By primary key | +| CountAsync(QuerySpec) | Scalar count | +| AnyAsync(QuerySpec) | Existence check | + +## EfSpecificationEvaluator + +`csharp +// Build IQueryable from QuerySpec +IQueryable query = EfSpecificationEvaluator.GetQuery(dbContext.Customers, spec); + +// Via interface +ISpecificationEvaluator evaluator = EfSpecificationEvaluator.Default; +` + +## EfReadRepository Variants + +`csharp +// Single DbContext (simple applications) +public class CustomerRepository : EfReadRepository +{ + public CustomerRepository(AppDbContext context) : base(context) {} +} + +// Two-parameter (multiple DbContexts in one app) +public class CustomerRepo : EfReadRepository { } +` + +## Projection Pattern + +`csharp +var spec = new QuerySpec() + .Where(c => c.IsActive) + .OrderByDescending(c => c.TotalPurchases) + .Select(c => new CustomerSummary(c.Id, c.Name, c.TotalPurchases)); + +IReadOnlyList summaries = await repo.ListAsync(spec); +` + +## DI Registration Best Practices + +| Component | Lifetime | Reason | +|---|---|---| +| Specification subclasses | Singleton | Immutable, thread-safe | +| IReadRepository / EfReadRepository | Scoped | Matches DbContext lifetime | +| QueryPlanCache | Singleton (static) | Process-wide LRU cache | + +## Running Example + +See [Level10_EnterpriseArchitecture.cs](../../samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs). diff --git a/docs/system-overview.md b/docs/system-overview.md index 34e2ca5..22552a2 100644 --- a/docs/system-overview.md +++ b/docs/system-overview.md @@ -8,7 +8,7 @@ This document provides a comprehensive overview of the `EricksonLopez.Specificat ## 1. Executive Summary -EricksonLopez.Specification is a **composable, AOT-first, provider-agnostic predicate and query descriptor library** for .NET 10+. +EricksonLopez.Specification is a **composable, AOT-first, provider-agnostic predicate and query descriptor library** multi-targeting .NET 8 and .NET 10. **Three non-negotiable design constraints**: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..7869ad5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,143 @@ +# Troubleshooting Guide: EricksonLopez.Specification + +Common issues, compiler errors, analyzer warnings, and runtime diagnostics when working with **EricksonLopez.Specification**. + +--- + +## 1. Runtime Exceptions + +### `NotSupportedException` in `QuerySpecTranslator` + +#### Symptom +```text +System.NotSupportedException: Method 'Boolean IsNullOrEmpty(System.String)' is not supported in SQL translation. +``` + +#### Root Cause +`QuerySpecTranslator` translates expression trees into relational SQL AST nodes (`QueryModel`). Non-mapped .NET methods like `string.IsNullOrEmpty` or arbitrary user methods cannot be automatically converted to SQL. + +#### Solution +Rewrite using translatable primitive comparisons: + +```csharp +// ❌ FAILS: +var spec = QuerySpec.Empty.Where(c => string.IsNullOrEmpty(c.Email)); + +// ✅ WORKS: +var spec = QuerySpec.Empty.Where(c => c.Email == null || c.Email == string.Empty); +``` + +--- + +### `ArgumentException: Lower bound cannot be greater than upper bound` + +#### Symptom +```text +System.ArgumentException: Lower bound '50' cannot be greater than upper bound '10'. (Parameter 'lower') +``` + +#### Root Cause +In `Spec.Between`, the lower boundary value is greater than the upper boundary value. + +#### Solution +Ensure `lower <= upper`: + +```csharp +// ❌ FAILS: +var spec = Spec.Between(c => c.TotalPurchases, 50, 10); + +// ✅ WORKS: +var spec = Spec.Between(c => c.TotalPurchases, 10, 50); +``` + +--- + +### `ArgumentOutOfRangeException` on `Page` or `Skip` + +#### Symptom +```text +System.ArgumentOutOfRangeException: Page number must be greater than or equal to 1. (Parameter 'page') +``` + +#### Root Cause +`QuerySpec.Page(page, pageSize)` is **1-based**: +- `page < 1` throws `ArgumentOutOfRangeException` +- `pageSize < 1` throws `ArgumentOutOfRangeException` +- `Skip(count)` with `count < 0` throws `ArgumentOutOfRangeException` +- `Take(count)` with `count < 0` throws `ArgumentOutOfRangeException` + +#### Solution +Sanitize UI / HTTP input parameters before calling `.Page()`: + +```csharp +int safePage = Math.Max(1, request.Page); +int safePageSize = Math.Clamp(request.PageSize, 1, 100); + +var query = QuerySpec.Empty.Page(safePage, safePageSize); +``` + +--- + +## 2. Roslyn Analyzer Warnings & Errors + +| Analyzer ID | Severity | Problem | Fix | +|---|:---:|---|---| +| **SPEC001** | Warning | Specification class is not `sealed` | Add `sealed` keyword: `public sealed class ActiveCustomerSpecification : Specification` | +| **SPEC002** | Warning | Specification contains mutable fields or public setters | Ensure all specifications are immutable; pass parameters via constructor | +| **SPEC003** | Error | `Expression.Invoke` detected inside `BuildExpression` | Use `ExpressionComposer.And()` or combinators (`spec1.And(spec2)`) instead of invoking lambdas | +| **SPEC004** | Info | QuerySpec lacks pagination limits | Add `.Take(n)` or `.Page(p, s)` to prevent unbounded table scans | +| **SPEC007** | Warning | Non-translatable method in `BuildExpression` | Replace method calls with translatable member comparisons | +| **SPEC009** | Error | Async lambda inside `BuildExpression` | Domain specifications must be synchronous and pure | +| **SPEC011** | Warning | Inheritance from legacy `Ardalis.Specification` | Inherit from `EricksonLopez.Specification.Specification` | + +--- + +## 3. Query Performance & Diagnostic Warnings + +### Unbounded Table Scan Warnings + +#### Symptom +Diagnostic log entry: +```text +[Validation] WARNING: QuerySpec without filter criteria will return ALL records. +[Validation] WARNING: QuerySpec without pagination (Take) may produce an unbounded query. +``` + +#### Prevention +Use `QuerySpecExtensions` inspection helpers in query pipelines: + +```csharp +if (!querySpec.HasCriteria()) +{ + _logger.LogWarning("Execution attempted on unbounded query: {Tag}", querySpec.Tag); +} + +if (!querySpec.HasPagination()) +{ + // Apply safety default + querySpec = querySpec.Take(100); +} +``` + +--- + +## 4. Cooperative Task Cancellation + +### CancellationTokens in Repositories + +When using `ReadRepositoryResultExtensions` (`ListResultAsync`, `FirstOrDefaultResultAsync`): +- Any caught `OperationCanceledException` is **never swallowed into a Result failure**. +- It is immediately rethrown so that ASP.NET Core request timeouts, background worker cancellations, and client disconnections abort gracefully. + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + +try +{ + var result = await repo.ListResultAsync(querySpec, cts.Token); +} +catch (OperationCanceledException) +{ + // Normal cancellation handled by framework pipeline +} +``` diff --git a/docs/when-to-use-specification.md b/docs/when-to-use-specification.md new file mode 100644 index 0000000..1125c05 --- /dev/null +++ b/docs/when-to-use-specification.md @@ -0,0 +1,146 @@ + +# When to Use the Specification Pattern in DDD + +## Overview + +In Domain-Driven Design (DDD), the **Specification Pattern** encapsulates business rules as first-class domain objects that can be combined, evaluated in-memory, and translated into persistent queries. + +However, a common architectural defect in enterprise software is overusing specifications for concerns better served by other DDD building blocks. This document outlines clear architectural boundaries between **Specifications**, **Entity Invariants**, **Value Objects**, **Domain Services**, and **Application Policies**. + +--- + +## Strategic Decision Matrix + +| Architectural Concern | Primary Responsibility | Primary DDD Pattern | Should You Use a `Specification`? | +|---|---|---|:---:| +| **Entity Invariants** | Ensuring an entity never enters an invalid state at construction or mutation time | Encapsulation & Guard Clauses in Entity | ❌ **No** (Violates entity encapsulation) | +| **Attribute Validation** | Structural validation of properties (email format, string lengths, currency bounds) | **Value Object** self-validation | ❌ **No** (Belongs inside Value Object constructor) | +| **Domain Predicate** | Business rule stating whether an entity satisfies a specific domain condition | **`Specification`** | ✅ **Yes** (Ideal use case) | +| **Query Filtering** | Filtering database sets by domain business conditions | **`QuerySpec`** + `Specification` | ✅ **Yes** (Translates to parameterized SQL / LINQ) | +| **Complex Multi-Entity Operation** | State mutation involving multiple aggregates or external infrastructure | **Domain Service** | ❌ **No** (Specifications are pure side-effect-free predicates) | +| **Workflow / Orchestration** | Security checks, permissions, UI workflows, temporal jobs | **Application Policy / Pipeline** | ❌ **No** (Belongs in Application Layer) | + +--- + +## Architectural Decision Flowchart + +```mermaid +flowchart TD + Start["New Business Rule / Check Needed"] --> Q1{"Is it maintaining entity integrity\n(e.g., non-negative price, required name)?"} + Q1 -- Yes --> Invariant["Implement as Entity Invariant / Guard Clause\nin Entity constructor or mutator"] + Q1 -- No --> Q2{"Is it validating the internal format\nof a single conceptual attribute?"} + Q2 -- Yes --> VO["Implement as a Value Object\n(e.g., EmailAddress, Money, ZipCode)"] + Q2 -- No --> Q3{"Does it perform side-effects, state mutations,\nor external I/O (APIs, payment gateways)?"} + Q3 -- Yes --> DS["Implement as a Domain Service or Application Service\n(e.g., OrderProcessingService)"] + Q3 -- No --> Q4{"Is it a pure, composable predicate evaluating\nwhether an entity satisfies business criteria?"} + Q4 -- Yes --> Spec["✅ Implement as a Specification\n(Pure, immutable, composable expression tree)"] + Q4 -- No --> AppPolicy["Implement as Application Policy / Middleware"] +``` + +--- + +## Detailed Comparisons + +### 1. Specification vs. Entity Invariant + +An **invariant** is a business rule that must always hold true for an aggregate or entity throughout its entire lifecycle. + +- **Entity Invariant**: An order line must never have a quantity $\le 0$. If an order line has quantity $\le 0$, the aggregate is invalid and corrupted. Invariants must be enforced inside the entity's constructor and mutating methods. +- **Specification**: An order qualifies for express priority shipping if total amount $> \$500$ and customer tier is VIP. An order with total amount of $\$100$ is perfectly valid; it simply does not satisfy the priority specification. + +```csharp +// ❌ WRONG: Using a specification to enforce an entity invariant +public sealed class ValidOrderLineQuantitySpec : Specification +{ + protected override Expression> BuildExpression() + => line => line.Quantity > 0; +} + +// ✅ CORRECT: Invariant enforced directly within the Entity +public sealed class OrderLine +{ + public int Quantity { get; private set; } + + public OrderLine(int quantity) + { + if (quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be greater than zero."); + Quantity = quantity; + } +} +``` + +--- + +### 2. Specification vs. Value Object + +A **Value Object** is defined by its attributes and carries intrinsic validation for structural integrity and format. + +- **Value Object**: Validating whether an email contains an `@` sign or whether a PostalCode matches a regulatory format belongs in the `EmailAddress` or `PostalCode` Value Object. +- **Specification**: Checking whether an existing `Customer` with a valid `EmailAddress` belongs to an approved enterprise domain (`@acme.corp`) for a discount promotion belongs in a `Specification`. + +```csharp +// ❌ WRONG: Validating value object formatting with a specification +public sealed class ValidEmailAddressSpec : Specification +{ + protected override Expression> BuildExpression() + => email => email.Contains("@"); +} + +// ✅ CORRECT: Value object self-validates on construction +public sealed record EmailAddress +{ + public string Value { get; } + + public EmailAddress(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + if (!value.Contains('@')) + throw new ArgumentException("Invalid email format.", nameof(value)); + Value = value; + } +} +``` + +--- + +### 3. Specification vs. Domain Service + +A **Domain Service** performs domain operations that do not naturally belong to a single entity or that coordinate operations across multiple aggregates. + +- **Domain Service**: Rebalancing bank accounts, calculating tax via an external lookup service, or debiting credit cards. +- **Specification**: A pure boolean condition evaluated over entity state (e.g. `Spec.For(a => a.Balance >= minimumBalance)`). Specifications must never trigger network requests, database side-effects, or mutate entity state. + +```csharp +// ❌ WRONG: Specification executing side-effects or external calls +public sealed class AccountCanWithdrawSpec : Specification +{ + protected override Expression> BuildExpression() + { + // Calling an external service or mutating state inside a specification is strictly prohibited! + return a => ExternalFraudDetectionApi.IsClear(a.Id) && a.Balance > 0; + } +} + +// ✅ CORRECT: Pure domain predicate in Specification; orchestration in Domain Service +public sealed class AccountSufficientFundsSpec : Specification +{ + private readonly decimal _amount; + public AccountSufficientFundsSpec(decimal amount) => _amount = amount; + + protected override Expression> BuildExpression() + => account => account.Balance >= _amount && account.Status == AccountStatus.Active; +} +``` + +--- + +## When to Choose `Specification` in EricksonLopez.Specification + +Choose `Specification` when: + +1. **Reusability**: The same business predicate is needed in multiple application use cases or repository queries. +2. **Composition**: The rule needs to be combined dynamically using boolean logic (`&`, `|`, `!`, `Spec.All`, `Spec.Any`). +3. **Dual Execution**: The same rule must execute both in database SQL queries (`IQueryable.Apply`) and in-memory (`specification.IsSatisfiedBy(entity)`). +4. **Native AOT Safety**: In-memory evaluation must run on Native AOT without generating runtime IL or relying on reflection. +5. **Architectural Clarity**: The rule represents a distinct business concept that deserves a descriptive, sealed class name (e.g., `EligibleForAnnualLoyaltyDiscountSpecification`). diff --git a/samples/NativeAotDapper/NativeAotDapper.csproj b/samples/NativeAotDapper/NativeAotDapper.csproj index 76cb4b9..90a499c 100644 --- a/samples/NativeAotDapper/NativeAotDapper.csproj +++ b/samples/NativeAotDapper/NativeAotDapper.csproj @@ -12,6 +12,7 @@ + diff --git a/samples/Showcase/Domain/Customer.cs b/samples/Showcase/Domain/Customer.cs index 68e7b89..d19a24e 100644 --- a/samples/Showcase/Domain/Customer.cs +++ b/samples/Showcase/Domain/Customer.cs @@ -30,6 +30,9 @@ public class Customer /// Gets or sets the credit limit granted to the customer. public decimal CreditLimit { get; set; } + /// Gets or sets the optional promotional discount rate. + public decimal? DiscountRate { get; set; } + /// Gets or sets the collection of orders associated with the customer. public ICollection Orders { get; set; } = new List(); } diff --git a/samples/Showcase/EricksonLopez.Specification.Showcase.csproj b/samples/Showcase/EricksonLopez.Specification.Showcase.csproj index 2335f2b..bab191b 100644 --- a/samples/Showcase/EricksonLopez.Specification.Showcase.csproj +++ b/samples/Showcase/EricksonLopez.Specification.Showcase.csproj @@ -21,6 +21,9 @@ + + + diff --git a/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs b/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs index e32e979..fd5f9de 100644 --- a/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs +++ b/samples/Showcase/Levels/Level10_EnterpriseArchitecture.cs @@ -11,8 +11,8 @@ namespace EricksonLopez.Specification.Showcase.Levels; // ───────────────────────────────────────────────────────────────────────────── -// CAPA DE DOMINIO / APLICACIÓN -// Contratos definidos en el core del dominio — sin dependencias de infraestructura. +// DOMAIN / APPLICATION LAYER +// Contracts defined in the domain core — without infrastructure dependencies. // ───────────────────────────────────────────────────────────────────────────── /// @@ -58,8 +58,12 @@ public async Task ExecuteAsync() var listSpec = QuerySpec.Empty .Where(c => c.IsActive) .Where(c => c.TotalPurchases > 10) - .OrderByDescending(c => c.TotalPurchases) - .Page(page: 1, pageSize: 5); + .OrderByDescending(c => c.TotalPurchases); + + var dummyQueryable = new List().AsQueryable(); + var efQuery = EfSpecificationEvaluator.GetQuery(dummyQueryable, listSpec); + _logger.LogInformation("[EfSpecificationEvaluator.GetQuery] Generated query: {Q}", efQuery); + listSpec.Page(page: 1, pageSize: 5); var customers = await repo.ListAsync(listSpec); _logger.LogInformation("[ListAsync] Active VIP customers: {N}", customers.Count); diff --git a/samples/Showcase/Levels/Level1_QuickStart.cs b/samples/Showcase/Levels/Level1_QuickStart.cs index c0f5f28..84be31f 100644 --- a/samples/Showcase/Levels/Level1_QuickStart.cs +++ b/samples/Showcase/Levels/Level1_QuickStart.cs @@ -1,5 +1,6 @@ // Copyright © Erickson Lopez. MIT License. using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; @@ -83,39 +84,77 @@ public Task ExecuteAsync() // ───────────────────────────────────────────────────────────────── Expression> expr = activeSpec.ToExpression(); string debugStr = activeSpec.ToDebugString(); + string formattedViaRegistry = ExpressionDebugFormatterRegistry.Format(expr); _logger.LogInformation("[ToExpression] Expression: {Expr}", expr); _logger.LogInformation("[ToDebugString] Readable format: {Debug}", debugStr); + _logger.LogInformation("[RegistryFormat] Formatted via registry: {Formatted}", formattedViaRegistry); // ───────────────────────────────────────────────────────────────── - // 5. Boolean composition: And / Or / Not - // Specification provides these combinators directly. + // 5. Boolean composition: And / Or / Not & C# Language Operators + // Specification provides combinator methods and operator overloads: + // &, |, !, and short-circuiting && and || via operator true/false. // ───────────────────────────────────────────────────────────────── - var activeAndVip = activeSpec.And(vipSpec); // AND - var activeOrVip = activeSpec.Or(vipSpec); // OR + var activeAndVip = activeSpec.And(vipSpec); // AND method + var activeOrVip = activeSpec.Or(vipSpec); // OR method var notActive = activeSpec.Not(); // NOT (NegatedSpecification) - _logger.LogInformation("[And] Alice (active && vip): {R}", activeAndVip.IsSatisfiedBy(alice)); - _logger.LogInformation("[Or ] Bob (active || vip) : {R}", activeOrVip.IsSatisfiedBy(bob)); - _logger.LogInformation("[Not] Alice NOT active : {R}", notActive.IsSatisfiedBy(alice)); + // C# Language Operators & Named Alternates + var opAnd = activeSpec & vipSpec; // operator & + var opOr = activeSpec | vipSpec; // operator | + var opNot = !activeSpec; // operator ! + var opShortAnd = activeSpec && vipSpec; // short-circuit && (via operator false) + var opShortOr = activeSpec || vipSpec; // short-circuit || (via operator true) + + var bitwiseAndSpec = Specification.BitwiseAnd(activeSpec, vipSpec); + var bitwiseOrSpec = Specification.BitwiseOr(activeSpec, vipSpec); + var logicalNotSpec = Specification.LogicalNot(activeSpec); + + _logger.LogInformation("[Operators] op&: {A}, op|: {O}, op!: {N}, op&&: {SA}, op||: {SO}", + opAnd.IsSatisfiedBy(alice), opOr.IsSatisfiedBy(alice), opNot.IsSatisfiedBy(alice), + opShortAnd.IsSatisfiedBy(alice), opShortOr.IsSatisfiedBy(alice)); + _logger.LogInformation("[And] Alice (active && vip): {R} | BitwiseAnd: {B}", activeAndVip.IsSatisfiedBy(alice), bitwiseAndSpec.IsSatisfiedBy(alice)); + _logger.LogInformation("[Or ] Bob (active || vip) : {R} | BitwiseOr: {B}", activeOrVip.IsSatisfiedBy(bob), bitwiseOrSpec.IsSatisfiedBy(bob)); + _logger.LogInformation("[Not] Alice NOT active : {R} | LogicalNot: {B}", notActive.IsSatisfiedBy(alice), logicalNotSpec.IsSatisfiedBy(alice)); // ───────────────────────────────────────────────────────────────── // 6. Spec.All and Spec.Any — Multi-specification composition + // Supports both params arrays and IEnumerable> collections. // ───────────────────────────────────────────────────────────────── var highCreditSpec = new HighCreditCustomerSpecification(5_000m); var allSpec = Spec.All(activeSpec, vipSpec, highCreditSpec); var anySpec = Spec.Any(vipSpec, highCreditSpec); - _logger.LogInformation("[Spec.All] Satisfies all: {R}", allSpec.IsSatisfiedBy(alice)); - _logger.LogInformation("[Spec.Any] Satisfies any: {R}", anySpec.IsSatisfiedBy(alice)); + // IEnumerable overloads + var specCollection = new List> { activeSpec, vipSpec, highCreditSpec }; + var allFromEnumerable = Spec.All(specCollection); + var anyFromEnumerable = Spec.Any(specCollection); + + _logger.LogInformation("[Spec.All] Satisfies all (params): {R} | (IEnumerable): {E}", allSpec.IsSatisfiedBy(alice), allFromEnumerable.IsSatisfiedBy(alice)); + _logger.LogInformation("[Spec.Any] Satisfies any (params): {R} | (IEnumerable): {E}", anySpec.IsSatisfiedBy(alice), anyFromEnumerable.IsSatisfiedBy(alice)); // ───────────────────────────────────────────────────────────────── - // 7. Spec.Between, Spec.Search, and Spec.FullText + // 7. Spec.Between, Spec.InRange, Spec.Search, Spec.FullText, Spec.MatchesFullText + // Between supports both non-nullable and nullable struct properties. + // Spec.InRange is a direct alias for Spec.Between — same semantics. + // Spec.MatchesFullText is a direct alias for Spec.FullText — same semantics. // ───────────────────────────────────────────────────────────────── var betweenSpec = Spec.Between(c => c.TotalPurchases, 10, 50); + alice.DiscountRate = 0.15m; + var nullableBetweenSpec = Spec.Between(c => c.DiscountRate, 0.05m, 0.20m); + + // Spec.InRange — alias for Between, prefer when semantics are "inclusive range containment". + var inRangeSpec = Spec.InRange(c => c.TotalPurchases, 10, 50); + _logger.LogInformation("[Spec.InRange] Same as Between — Alice in [10,50]: {R}", inRangeSpec.IsSatisfiedBy(alice)); + var searchSpec = Spec.Search("Ali", c => c.Name, c => c.Email); var fullTextSpec = Spec.FullText(c => c.Name, "Alice"); + // Spec.MatchesFullText — alias for FullText (case-sensitive Contains). + var matchesFullTextSpec = Spec.MatchesFullText(c => c.Name, "Ali"); + _logger.LogInformation("[Spec.MatchesFullText] Alias for FullText — Alice matches 'Ali': {R}", matchesFullTextSpec.IsSatisfiedBy(alice)); + _logger.LogInformation("[Spec.Between] Alice purchases in [10,50]: {R}", betweenSpec.IsSatisfiedBy(alice)); + _logger.LogInformation("[Spec.Between (Nullable)] Alice discount in [0.05, 0.20]: {R}", nullableBetweenSpec.IsSatisfiedBy(alice)); _logger.LogInformation("[Spec.Search] Alice matches 'Ali': {R}", searchSpec.IsSatisfiedBy(alice)); _logger.LogInformation("[Spec.FullText] Alice matches 'Alice': {R}", fullTextSpec.IsSatisfiedBy(alice)); diff --git a/samples/Showcase/Levels/Level3_RealUseCases.cs b/samples/Showcase/Levels/Level3_RealUseCases.cs index 75bc45f..93f4243 100644 --- a/samples/Showcase/Levels/Level3_RealUseCases.cs +++ b/samples/Showcase/Levels/Level3_RealUseCases.cs @@ -140,6 +140,29 @@ public Task ExecuteAsync() foreach (var c in cursorResults) _logger.LogInformation(" - {Name}: {Purchases} purchases", c.Name, c.TotalPurchases); + // ───────────────────────────────────────────────────────────────── + // 7. IQueryable Direct Specification Extensions (Where, All, FirstOrDefault) + // ───────────────────────────────────────────────────────────────── + var queryableWhere = customers.Where(activeSpec).ToList(); + bool queryableAll = customers.All(activeSpec); + var queryableFirst = customers.FirstOrDefault(vipSpec); + + _logger.LogInformation("[IQueryable Extensions] Where: {W} items | All active: {A} | FirstOrDefault VIP: {F}", + queryableWhere.Count, queryableAll, queryableFirst?.Name ?? "None"); + + // ───────────────────────────────────────────────────────────────── + // 8. IEnumerable In-Memory Specification Extensions (Where, Any, All, Count, FirstOrDefault) + // ───────────────────────────────────────────────────────────────── + var inMemoryList = customers.ToList(); + var memWhere = inMemoryList.Where(activeSpec).ToList(); + bool memAny = inMemoryList.Any(vipSpec); + bool memAll = inMemoryList.All(activeSpec); + int memCount = inMemoryList.Count(activeSpec); + var memFirst = inMemoryList.FirstOrDefault(vipSpec); + + _logger.LogInformation("[IEnumerable Extensions] Where: {W} | Any: {A} | All: {All} | Count: {C} | First: {F}", + memWhere.Count, memAny, memAll, memCount, memFirst?.Name ?? "None"); + return Task.CompletedTask; } } diff --git a/samples/Showcase/Levels/Level6_ErrorHandling.cs b/samples/Showcase/Levels/Level6_ErrorHandling.cs index 1ffd08f..41c154b 100644 --- a/samples/Showcase/Levels/Level6_ErrorHandling.cs +++ b/samples/Showcase/Levels/Level6_ErrorHandling.cs @@ -120,6 +120,16 @@ public async Task ExecuteAsync() _logger.LogWarning("[ArgumentOutOfRangeException] Skip < 0: {Msg}", ex.ParamName); } + // 3b. ArgumentException — lower bound > upper bound in Spec.Between + try + { + var _ = Spec.Between(c => c.TotalPurchases, lower: 50, upper: 10); + } + catch (ArgumentException ex) + { + _logger.LogWarning("[ArgumentException] Spec.Between lower > upper: {Msg}", ex.Message); + } + // ───────────────────────────────────────────────────────────────── // 4. In-Memory evaluation with ExpressionInterpreter (AOT-safe) // ───────────────────────────────────────────────────────────────── diff --git a/samples/Showcase/Levels/Level8_Customization.cs b/samples/Showcase/Levels/Level8_Customization.cs index 8053268..5309d3d 100644 --- a/samples/Showcase/Levels/Level8_Customization.cs +++ b/samples/Showcase/Levels/Level8_Customization.cs @@ -56,9 +56,9 @@ public Task ExecuteAsync() var explicit_ = new ExplicitMappingColumnNameResolver( new Dictionary { - { "IsActive", "ACTIVO" }, - { "TotalPurchases", "TOT_COMPRAS" }, - { "Name", "NOMBRE" } + { "IsActive", "ACTIVE" }, + { "TotalPurchases", "TOTAL_PURCHASES" }, + { "Name", "CUSTOMER_NAME" } }); string[] props = ["IsActive", "TotalPurchases", "Name", "CreditLimit"]; diff --git a/samples/Showcase/Levels/Level9_Extensions.cs b/samples/Showcase/Levels/Level9_Extensions.cs index 6dfde51..0f4b9bf 100644 --- a/samples/Showcase/Levels/Level9_Extensions.cs +++ b/samples/Showcase/Levels/Level9_Extensions.cs @@ -40,7 +40,7 @@ public Level9_Extensions(ILogger logger) } /// - public Task ExecuteAsync() + public async Task ExecuteAsync() { _logger.LogInformation("--- {Name} ---", Name); @@ -101,7 +101,57 @@ public Task ExecuteAsync() d.DialectName, d.ParameterPrefix, d.QuoteIdentifier("customers")); } - return Task.CompletedTask; + // ───────────────────────────────────────────────────────────────── + // 5. EF Core DI Extensions + // ───────────────────────────────────────────────────────────────── + var efServices = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + Microsoft.Extensions.DependencyInjection.SpecificationEntityFrameworkServiceCollectionExtensions.AddSpecificationEntityFramework(efServices); + Microsoft.Extensions.DependencyInjection.SpecificationEntityFrameworkServiceCollectionExtensions.AddEfReadRepository(efServices); + _logger.LogInformation("[EF Core DI] AddSpecificationEntityFramework & AddEfReadRepository registered."); + + // ───────────────────────────────────────────────────────────────── + // 6. QueryPlanCache.Clear & WithCursor pagination + // ───────────────────────────────────────────────────────────────── + EricksonLopez.Specification.Sql.QueryPlanCache.Clear(); + var cursorSpec = spec.WithCursor(c => c.TotalPurchases, 100, CursorDirection.After, 20); + _logger.LogInformation("[QueryPlanCache & Cursor] QueryPlanCache.Clear executed, spec.WithCursor configured (Take={Take}).", cursorSpec.TakeCount); + + // ───────────────────────────────────────────────────────────────── + // 7. Dapper QueryFirstOrDefaultAsync + // ───────────────────────────────────────────────────────────────── + using var sqliteConn = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + sqliteConn.Open(); + using var createTableCmd = sqliteConn.CreateCommand(); + createTableCmd.CommandText = "CREATE TABLE customers (id INT, total_purchases INT, is_active INT);"; + createTableCmd.ExecuteNonQuery(); + var firstCustomer = await sqliteConn.QueryFirstOrDefaultAsync(spec, translator, SqliteDialect.Default); + _logger.LogInformation("[Dapper] QueryFirstOrDefaultAsync executed successfully against SQLite."); + + // ───────────────────────────────────────────────────────────────── + // 8. MongoDB Extensions & Contracts + // ───────────────────────────────────────────────────────────────── + try + { + global::MongoDB.Driver.IMongoCollection mongoCol = null!; + await EricksonLopez.Specification.MongoDB.MongoSpecificationExtensions.FindAsync(mongoCol, spec); + await EricksonLopez.Specification.MongoDB.MongoSpecificationExtensions.CountDocumentsAsync(mongoCol, spec); + EricksonLopez.Specification.MongoDB.MongoSpecificationEvaluator.Find(mongoCol, spec); + } + catch (ArgumentNullException) + { + // Expected argument null validation + } + + try + { + global::MongoDB.Driver.IFindFluent findFluent = null!; + EricksonLopez.Specification.MongoDB.MongoSpecificationEvaluator.ApplySpecification(findFluent, spec); + } + catch (ArgumentNullException) + { + // Expected argument null validation + } + _logger.LogInformation("[MongoDB] Find, FindAsync, CountDocumentsAsync & ApplySpecification contracts verified."); } private void DemonstrateQuerySql( @@ -154,6 +204,7 @@ public void Dispose() { } } } - - - +public sealed class ShowcaseDbContext : Microsoft.EntityFrameworkCore.DbContext +{ + public ShowcaseDbContext(Microsoft.EntityFrameworkCore.DbContextOptions options) : base(options) { } +} diff --git a/samples/Showcase/README.md b/samples/Showcase/README.md new file mode 100644 index 0000000..6b7507d --- /dev/null +++ b/samples/Showcase/README.md @@ -0,0 +1,118 @@ +# EricksonLopez.Specification — Official Showcase + +The **Showcase** is the official executable reference implementation of the `EricksonLopez.Specification` library. It serves as executable documentation, an integration cookbook, and a progressive learning path through the library's entire public API surface. + +--- + +## 🎯 Architecture & Educational Roadmap + +The Showcase is organized into 11 strictly graduated levels (`Levels/` folder), demonstrating the library from foundational domain concepts to enterprise Clean Architecture with CQRS: + +| Level | Class | Core Responsibility | Public APIs Demonstrated | +|:---:|---|---|---| +| **0** | `Level0_Conceptual` | Architectural foundations & problem space | Theoretical DDD Specification, comparisons with alternatives, AOT guarantees | +| **1** | `Level1_QuickStart` | Minimal setup, basic composition & operators | `Specification`, `Spec.For`, `Spec.True/False`, `Spec.All`, `Spec.Any`, `Spec.Between` (nullable & struct), `operator &`, `operator \|`, `operator !`, `operator true/false`, `BitwiseAnd`, `BitwiseOr`, `LogicalNot`, `ExpressionDebugFormatterRegistry` | +| **2** | `Level2_Configuration` | Comprehensive QuerySpec capabilities | `QuerySpec`, `QuerySpec`, `Where`, `TagWith`, `Search`, `OrderBy`, `OrderByDescending`, `ThenBy`, `ThenByDescending`, `Page`, `Take`, `Skip`, `Distinct`, `SeekAfter`, `SeekBefore`, `WithCursor`, `ExpressionSimplifier` | +| **3** | `Level3_RealUseCases` | LINQ & in-memory evaluation | `QuerySpecLinqExtensions.Apply`, `Any`, `Count`, `Where`, `All`, `FirstOrDefault` across `IQueryable` and `IEnumerable` | +| **4** | `Level4_AdvancedIntegration` | Provider-agnostic SQL translation | `QuerySpecTranslator`, `ISqlDialect`, `PostgreSqlDialect`, `MsSqlDialect`, `SqliteDialect`, `MySqlDialect`, `MariaDbDialect`, `OracleDialect`, `SnakeCaseColumnNameResolver`, `VerbatimColumnNameResolver` | +| **5** | `Level5_Processing` | Batching, pagination & cancellation | Batch processing via `Page()`, `CancellationToken` flow, conditional filtering with `Spec.True()`, thread-safety verification | +| **6** | `Level6_ErrorHandling` | Error prevention & Result pattern | `NotSupportedException` avoidance, argument fast-fail validation, defensive `Spec.Between` checks, `ReadRepositoryResultExtensions` (`ListResultAsync`, `FirstOrDefaultResultAsync`, `SingleOrDefaultResultAsync`, `GetByIdResultAsync`) | +| **7** | `Level7_Scalability` | Low-level performance & AST engine | `ExpressionHasher`, `ExpressionEqualityComparer`, `ExpressionCompilationCache`, `ExpressionInterpreter`, `ExpressionComposer` (`AndAll`, `OrAny`), `SpecificationDiagnostics` (OpenTelemetry metrics & activity) | +| **8** | `Level8_Customization` | Custom interfaces & extensibility | Custom `IColumnNameResolver` (Legacy & Explicit mappings), custom `ISqlDialect` implementation | +| **9** | `Level9_Extensions` | Ecosystem integrations | `QuerySpecDapperExtensions` (Dapper), `EfSpecificationEvaluator` & `QuerySpecEfCoreExtensions` (EF Core), `MongoSpecificationEvaluator` & `MongoReadRepository` (MongoDB), `SpecificationDapperExtensions` (UnitOfWork) | +| **10** | `Level10_EnterpriseArchitecture` | Clean Architecture + DDD + CQRS | `IReadRepository`, `EfReadRepository`, CQRS Query handlers, ServiceCollection DI extensions | + +--- + +## 🚀 How to Run + +### Interactive Mode (Step-by-Step) + +```bash +dotnet run --project samples/Showcase/EricksonLopez.Specification.Showcase.csproj +``` +*Prompts to press `` after each level, allowing step-by-step console inspection.* + +### Automated / CI Mode (Unattended) + +```bash +# In PowerShell: +"" | dotnet run --project samples/Showcase/EricksonLopez.Specification.Showcase.csproj + +# In Bash / Linux: +dotnet run --project samples/Showcase/EricksonLopez.Specification.Showcase.csproj < /dev/null +``` + +--- + +## 🛠️ Dependency Injection & Host Setup + +The Showcase uses the standard Microsoft Generic Host (`Microsoft.Extensions.Hosting`): + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) + .ConfigureServices(services => + { + // 1. Register domain specifications (Singleton: immutable and thread-safe) + services.AddSingleton(); + services.AddSingleton(); + + // 2. Register Showcase levels + services.AddTransient(); + services.AddTransient(); + // ... Levels 2 to 10 + }) + .Build(); + +await host.RunAsync(); +``` + +--- + +## 📐 Clean Architecture Integration Pattern + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Presentation │ +│ API Controllers / Minimal APIs / gRPC │ +└──────────────────────────────┬──────────────────────────────┘ + │ Dispatches Queries / Commands +┌──────────────────────────────▼──────────────────────────────┐ +│ Application (CQRS) │ +│ • GetActiveCustomersQuery │ +│ • GetActiveCustomersHandler(IReadRepository) │ +│ → Strictly depends on IReadRepository & QuerySpec │ +└──────────────────────────────┬──────────────────────────────┘ + │ Uses Domain Rules +┌──────────────────────────────▼──────────────────────────────┐ +│ Domain (Core) │ +│ • Customer, Order (Entities) │ +│ • ActiveCustomerSpecification : Specification │ +│ • VipCustomerSpecification : Specification │ +│ → ZERO external dependencies. Pure C# + Expression Trees. │ +└──────────────────────────────▲──────────────────────────────┘ + │ Implements Repositories +┌──────────────────────────────┴──────────────────────────────┐ +│ Infrastructure │ +│ • EfReadRepository │ +│ • MongoReadRepository │ +│ • QuerySpecTranslator & Dapper extensions │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔒 Source Code Guarantee + +1. **Strictly Verified Public APIs**: Every method, property, overload, operator, and interface used in this Showcase exists in `src/`. +2. **Native AOT Compatible**: Uses `ExpressionInterpreter` for in-memory evaluation and static SQL AST generation without IL emit. +3. **Always Compilable**: Continuously verified via solution build and unit tests. diff --git a/scripts/run-targeted-mutation.ps1 b/scripts/run-targeted-mutation.ps1 new file mode 100644 index 0000000..5997a1d --- /dev/null +++ b/scripts/run-targeted-mutation.ps1 @@ -0,0 +1,174 @@ +# Copyright © Erickson Lopez. MIT License. +<# +.SYNOPSIS + Executes targeted Stryker.NET mutation testing on projects affected by current git changes. +.DESCRIPTION + Maps changed files from git diff (against target branch, default 'origin/main') to their + corresponding source projects and Stryker configurations. Executes mutation testing exclusively + on affected components to prevent CI bottlenecks during Pull Requests while upholding quality gates. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$TargetBranch = "origin/main", + + [Parameter(Mandatory = $false)] + [string]$MutationLevel = "Standard" +) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir "..") + +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host " TARGETED STRYKER.NET MUTATION RUNNER (PR MODE) " -ForegroundColor Cyan +Write-Host " Target Branch: $TargetBranch " -ForegroundColor Cyan +Write-Host "==================================================" -ForegroundColor Cyan + +# Project-to-config mapping +$ProjectMap = @{ + "src/EricksonLopez.Specification" = "stryker-config.json" + "src/EricksonLopez.Specification.Abstractions" = "stryker-abstractions-config.json" + "src/EricksonLopez.Specification.Analyzers" = "stryker-analyzers-config.json" + "src/EricksonLopez.Specification.Dapper" = "stryker-dapper-config.json" + "src/EricksonLopez.Specification.DapperExtensions" = "stryker-dapperextensions-config.json" + "src/EricksonLopez.Specification.EntityFrameworkCore" = "stryker-efcore-config.json" + "src/EricksonLopez.Specification.Generators" = "stryker-generators-config.json" + "src/EricksonLopez.Specification.Linq" = "stryker-linq-config.json" + "src/EricksonLopez.Specification.MariaDb" = "stryker-mariadb-config.json" + "src/EricksonLopez.Specification.MongoDB" = "stryker-mongodb-config.json" + "src/EricksonLopez.Specification.MsSql" = "stryker-mssql-config.json" + "src/EricksonLopez.Specification.MySql" = "stryker-mysql-config.json" + "src/EricksonLopez.Specification.Oracle" = "stryker-oracle-config.json" + "src/EricksonLopez.Specification.PostgreSql" = "stryker-postgresql-config.json" + "src/EricksonLopez.Specification.Result" = "stryker-result-config.json" + "src/EricksonLopez.Specification.Sql" = "stryker-sql-config.json" + "src/EricksonLopez.Specification.Sqlite" = "stryker-sqlite-config.json" +} + +# Also map test projects to corresponding source configs +$TestToConfigMap = @{ + "tests/EricksonLopez.Specification.Tests" = "stryker-config.json" + "tests/EricksonLopez.Specification.Analyzers.Tests" = "stryker-analyzers-config.json" + "tests/EricksonLopez.Specification.Dapper.Tests" = "stryker-dapper-config.json" + "tests/EricksonLopez.Specification.DapperExtensions.Tests" = "stryker-dapperextensions-config.json" + "tests/EricksonLopez.Specification.EntityFrameworkCore.Tests" = "stryker-efcore-config.json" + "tests/EricksonLopez.Specification.Generators.Tests" = "stryker-generators-config.json" + "tests/EricksonLopez.Specification.MariaDb.Tests" = "stryker-mariadb-config.json" + "tests/EricksonLopez.Specification.MongoDB.Tests" = "stryker-mongodb-config.json" + "tests/EricksonLopez.Specification.MsSql.Tests" = "stryker-mssql-config.json" + "tests/EricksonLopez.Specification.MySql.Tests" = "stryker-mysql-config.json" + "tests/EricksonLopez.Specification.Oracle.Tests" = "stryker-oracle-config.json" + "tests/EricksonLopez.Specification.PostgreSql.Tests" = "stryker-postgresql-config.json" + "tests/EricksonLopez.Specification.Sql.Tests" = "stryker-sql-config.json" + "tests/EricksonLopez.Specification.Sqlite.Tests" = "stryker-sqlite-config.json" +} + +# Determine changed files safely without triggering PowerShell NativeCommandError on stderr +$prevEAP = $ErrorActionPreference +$ErrorActionPreference = "SilentlyContinue" + +$hasBranch = $false +& git rev-parse --verify "$TargetBranch" 2>$null +if ($LASTEXITCODE -eq 0) { + $hasBranch = $true +} + +$rawDiff = @() +if ($hasBranch) { + $rawDiff = & git diff --name-only "$TargetBranch...HEAD" 2>$null +} +if (-not $rawDiff -or $rawDiff.Count -eq 0) { + $rawDiff = & git diff --name-only "HEAD" 2>$null +} +if (-not $rawDiff -or $rawDiff.Count -eq 0) { + $statusLines = & git status --porcelain 2>$null + if ($statusLines) { + $rawDiff = $statusLines | ForEach-Object { + if ($_.Length -ge 3) { $_.Substring(3).Trim() } + } + } +} +$ErrorActionPreference = $prevEAP + +$changedFiles = $rawDiff | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and $_ -notmatch '^\s*fatal:' } + +Write-Host "Detected $($changedFiles.Count) changed file(s):" -ForegroundColor Yellow +$changedFiles | ForEach-Object { Write-Host " • $_" -ForegroundColor DarkGray } + +$affectedConfigs = [System.Collections.Generic.HashSet[string]]::new() + +foreach ($file in $changedFiles) { + $normalized = $file.Replace("\", "/") + + foreach ($sourceKey in $ProjectMap.Keys) { + if ($normalized.StartsWith($sourceKey)) { + $config = $ProjectMap[$sourceKey] + [void]$affectedConfigs.Add($config) + Write-Host " -> Affected component: $sourceKey ($config)" -ForegroundColor Green + } + } + + foreach ($testKey in $TestToConfigMap.Keys) { + if ($normalized.StartsWith($testKey)) { + if ($testKey -eq "tests/EricksonLopez.Specification.Tests") { + if ($normalized -match "Result") { + [void]$affectedConfigs.Add("stryker-result-config.json") + Write-Host " -> Affected test suite: $normalized (stryker-result-config.json)" -ForegroundColor Green + } + if ($normalized -match "Linq") { + [void]$affectedConfigs.Add("stryker-linq-config.json") + Write-Host " -> Affected test suite: $normalized (stryker-linq-config.json)" -ForegroundColor Green + } + if ($normalized -match "Abstractions|Contract") { + [void]$affectedConfigs.Add("stryker-abstractions-config.json") + Write-Host " -> Affected test suite: $normalized (stryker-abstractions-config.json)" -ForegroundColor Green + } + [void]$affectedConfigs.Add("stryker-config.json") + Write-Host " -> Affected test suite: $testKey (stryker-config.json)" -ForegroundColor Green + } else { + $config = $TestToConfigMap[$testKey] + [void]$affectedConfigs.Add($config) + Write-Host " -> Affected test suite: $testKey ($config)" -ForegroundColor Green + } + } + } +} + +if ($affectedConfigs.Count -eq 0) { + Write-Host "`n✅ No production C# source or test files changed in this changeset." -ForegroundColor Green + Write-Host "Targeted mutation testing skipped. Zero CI cycle penalty." -ForegroundColor Green + exit 0 +} + +Write-Host "`nExecuting Stryker for $($affectedConfigs.Count) affected package(s):" -ForegroundColor Cyan +$failedCount = 0 + +foreach ($config in $affectedConfigs) { + $configPath = Join-Path $RepoRoot $config + if (-not (Test-Path $configPath)) { + Write-Host "⚠️ Warning: Config file $config not found, skipping." -ForegroundColor Yellow + continue + } + + Write-Host "`n--------------------------------------------------" -ForegroundColor Cyan + Write-Host "Running Stryker with config: $config (Level: $MutationLevel)" -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor Cyan + + & dotnet stryker --config-file $config --mutation-level $MutationLevel + if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Stryker mutation test failed for $config (Exit code: $LASTEXITCODE)" -ForegroundColor Red + $failedCount++ + } else { + Write-Host "✅ Stryker passed for $config" -ForegroundColor Green + } +} + +if ($failedCount -gt 0) { + Write-Host "`n❌ Targeted mutation testing failed for $failedCount package(s)." -ForegroundColor Red + exit 1 +} + +Write-Host "`n✅ All targeted mutation tests passed successfully." -ForegroundColor Green +exit 0 diff --git a/scripts/verify-benchmark-gate.ps1 b/scripts/verify-benchmark-gate.ps1 new file mode 100644 index 0000000..fff3cc0 --- /dev/null +++ b/scripts/verify-benchmark-gate.ps1 @@ -0,0 +1,296 @@ +# Copyright © Erickson Lopez. MIT License. +# ───────────────────────────────────────────────────────────────────────────── +# Automated Benchmark Regression Quality Gate +# Validates BenchmarkDotNet JSON reports against strict ecosystem invariants: +# 1. Heap Invariant (Zero-Allocation): Hot-path combinators must allocate 0 B. +# 2. Latency Threshold: Mean latency must not regress > 5% vs baseline.json. +# ───────────────────────────────────────────────────────────────────────────── + +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [string]$ReportDir = "benchmarks/pr-results", + + [Parameter(Mandatory = $false)] + [string]$BaselinePath = "benchmarks/results/baseline.json", + + [Parameter(Mandatory = $false)] + [double]$MaxLatencyRegressionPercent = 5.0, + + [Parameter(Mandatory = $false)] + [string]$ZeroAllocPattern = "^(Bind|Map|Tap|ValidateAll|Success|Failure|ZeroAlloc|.*_TState.*)", + + [Parameter(Mandatory = $false)] + [switch]$FailOnMissingReports +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Write-Host "============================================================" -ForegroundColor Magenta +Write-Host " AUTOMATED BENCHMARK REGRESSION QUALITY GATE" -ForegroundColor Magenta +Write-Host "============================================================" -ForegroundColor Magenta +Write-Host "Report Directory : $ReportDir" +Write-Host "Baseline File : $BaselinePath" +Write-Host "Max Latency Regression : +$MaxLatencyRegressionPercent%" +Write-Host "Zero-Allocation Pattern : $ZeroAllocPattern" +Write-Host "============================================================`n" + +# Helper for strict mode safe property extraction +function Get-PropValue($obj, [string]$propName, $defaultValue = $null) { + if ($null -eq $obj) { return $defaultValue } + if ($obj.PSObject.Properties[$propName]) { + return $obj.$propName + } + return $defaultValue +} + +# 1. Locate Report JSON Files +if (-not (Test-Path $ReportDir)) { + # Fallback to benchmarks/results if pr-results does not exist + if (Test-Path "benchmarks/results") { + Write-Host "[INFO] ReportDir '$ReportDir' not found, falling back to 'benchmarks/results'." -ForegroundColor Cyan + $ReportDir = "benchmarks/results" + } +} + +$reportFiles = @() +if (Test-Path $ReportDir) { + $reportFiles = Get-ChildItem -Path $ReportDir -Filter "*report*.json" -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch "baseline\.json$" } +} + +if ($reportFiles.Count -eq 0) { + $msg = "No BenchmarkDotNet JSON reports (*report*.json) found in '$ReportDir'." + if ($FailOnMissingReports) { + Write-Host "::error::$msg" + Write-Error $msg + exit 1 + } else { + Write-Host "::warning::$msg Skipping benchmark assertion (no reports generated)." -ForegroundColor Yellow + exit 0 + } +} + +Write-Host "[INFO] Found $($reportFiles.Count) benchmark report file(s)." -ForegroundColor Cyan + +# 2. Parse Current Benchmark Results +$currentBenchmarks = @{} + +foreach ($file in $reportFiles) { + try { + $json = Get-Content $file.FullName -Raw -Encoding utf8 | ConvertFrom-Json + $benchList = Get-PropValue $json "Benchmarks" + if ($null -eq $benchList) { continue } + + foreach ($b in $benchList) { + $method = Get-PropValue $b "Method" (Get-PropValue $b "MethodTitle" "") + $fullName = Get-PropValue $b "FullName" $method + if ([string]::IsNullOrWhiteSpace($method)) { continue } + + $stats = Get-PropValue $b "Statistics" + $mean = $null + if ($null -ne $stats) { + $mean = Get-PropValue $stats "Mean" + } + + $memory = Get-PropValue $b "Memory" + $allocBytes = $null + if ($null -ne $memory) { + $allocBytes = Get-PropValue $memory "BytesAllocatedPerOperation" + } + + # Key by Method and FullName + $entry = [PSCustomObject]@{ + Method = $method + FullName = $fullName + MeanNs = if ($null -ne $mean) { [double]$mean } else { $null } + AllocatedBytes = if ($null -ne $allocBytes) { [int64]$allocBytes } else { $null } + File = $file.Name + } + + $currentBenchmarks[$method] = $entry + if ($fullName -ne $method) { + $currentBenchmarks[$fullName] = $entry + } + } + } catch { + Write-Host "::warning::Failed to parse $($file.FullName): $($_.Exception.Message)" -ForegroundColor Yellow + } +} + +Write-Host "[INFO] Parsed $($currentBenchmarks.Keys.Count / 2) distinct benchmark operation(s).`n" -ForegroundColor Cyan + +# 3. Load Baseline if present +$baseline = @{} +$hasBaseline = $false + +if (Test-Path $BaselinePath) { + try { + $baseJson = Get-Content $BaselinePath -Raw -Encoding utf8 | ConvertFrom-Json + $baseZeroAllocPattern = Get-PropValue $baseJson "ZeroAllocPattern" + if ($null -ne $baseZeroAllocPattern -and -not [string]::IsNullOrWhiteSpace($baseZeroAllocPattern)) { + $ZeroAllocPattern = "$ZeroAllocPattern|$baseZeroAllocPattern" + } + $baseBench = Get-PropValue $baseJson "Benchmarks" + if ($null -ne $baseBench) { + if ($baseBench -is [System.Collections.IDictionary] -or $baseBench -is [System.Management.Automation.PSCustomObject]) { + foreach ($prop in $baseBench.PSObject.Properties) { + $bObj = $prop.Value + $baseline[$prop.Name] = [PSCustomObject]@{ + MeanNs = Get-PropValue $bObj "MeanNs" (Get-PropValue $bObj "Mean") + AllocatedBytes = Get-PropValue $bObj "AllocatedBytes" + ZeroAlloc = Get-PropValue $bObj "ZeroAlloc" + } + } + } elseif ($baseBench -is [System.Collections.IEnumerable]) { + foreach ($b in $baseBench) { + $m = Get-PropValue $b "Method" (Get-PropValue $b "MethodTitle" "") + $fn = Get-PropValue $b "FullName" $m + $stats = Get-PropValue $b "Statistics" + $mean = if ($null -ne $stats) { Get-PropValue $stats "Mean" } else { Get-PropValue $b "MeanNs" } + $mem = Get-PropValue $b "Memory" + $bytes = if ($null -ne $mem) { Get-PropValue $mem "BytesAllocatedPerOperation" } else { Get-PropValue $b "AllocatedBytes" } + $zeroAlloc = Get-PropValue $b "ZeroAlloc" + + $record = [PSCustomObject]@{ + MeanNs = if ($null -ne $mean) { [double]$mean } else { $null } + AllocatedBytes = if ($null -ne $bytes) { [int64]$bytes } else { $null } + ZeroAlloc = $zeroAlloc + } + if (-not [string]::IsNullOrWhiteSpace($m)) { $baseline[$m] = $record } + if (-not [string]::IsNullOrWhiteSpace($fn)) { $baseline[$fn] = $record } + } + } + $hasBaseline = ($baseline.Keys.Count -gt 0) + } + } catch { + Write-Host "::warning::Could not parse baseline file $BaselinePath: $($_.Exception.Message)" -ForegroundColor Yellow + } +} + +if ($hasBaseline) { + Write-Host "[INFO] Loaded baseline with $($baseline.Keys.Count) benchmark records." -ForegroundColor Green +} else { + Write-Host "[INFO] No baseline found at '$BaselinePath'. Latency regression comparison will be skipped (zero-allocation invariants are still enforced)." -ForegroundColor Yellow +} + +# 4. Evaluate Invariants & Assertions +$violations = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() +$evaluatedResults = [System.Collections.Generic.List[PSCustomObject]]::new() + +$uniqueMethods = $currentBenchmarks.Values | Sort-Object -Property Method -Unique + +foreach ($item in $uniqueMethods) { + $methodName = $item.Method + $allocBytes = $item.AllocatedBytes + $meanNs = $item.MeanNs + $baseRecord = if ($baseline.ContainsKey($methodName)) { $baseline[$methodName] } elseif ($baseline.ContainsKey($item.FullName)) { $baseline[$item.FullName] } else { $null } + $isZeroAllocRequired = ($methodName -match $ZeroAllocPattern) -or + ($null -ne $baseRecord -and ($baseRecord.AllocatedBytes -eq 0 -or $baseRecord.ZeroAlloc -eq $true)) -or + ($methodName -match '(?i)(ZeroAlloc|Stackalloc|Span|TryFormat)') + + $status = "PASS" + $details = "" + + # Rule 1: Heap Invariant (Zero-Allocation on Hot Path) + if ($isZeroAllocRequired) { + if ($null -eq $allocBytes) { + # Memory was not tracked for this benchmark + $details += "MemoryDiagnoser not captured. " + } elseif ($allocBytes -gt 0) { + $violMsg = "❌ Zero-allocation invariant VIOLATED: Method '$methodName' allocated $allocBytes B (Expected: 0 B)." + $violations.Add($violMsg) + $status = "FAIL (Allocations > 0B)" + $details += "Allocated: $allocBytes B (Expected: 0 B). " + } else { + $details += "Allocations: 0 B (PASS). " + } + } else { + if ($null -ne $allocBytes) { + $details += "Allocated: $allocBytes B. " + } + } + + # Rule 2: Latency Regression vs Baseline + if ($hasBaseline -and ($baseline.ContainsKey($methodName) -or $baseline.ContainsKey($item.FullName))) { + $baseRecord = if ($baseline.ContainsKey($methodName)) { $baseline[$methodName] } else { $baseline[$item.FullName] } + $baseMean = Get-PropValue $baseRecord "MeanNs" + + if ($null -ne $baseMean -and $null -ne $meanNs -and $baseMean -gt 0) { + $deltaPct = (($meanNs - $baseMean) / $baseMean) * 100.0 + + if ($deltaPct -gt $MaxLatencyRegressionPercent) { + $regMsg = "⚠️ Latency regressed: '$methodName' took $($meanNs.ToString('F2')) ns vs baseline $($baseMean.ToString('F2')) ns (+$(($deltaPct).ToString('F1'))% > +$MaxLatencyRegressionPercent%)." + $violations.Add($regMsg) + $status = "FAIL (Latency Regression)" + $details += "Latency: +$(($deltaPct).ToString('F1'))% (+$MaxLatencyRegressionPercent% limit). " + } else { + $details += "Latency delta: $(($deltaPct).ToString('+0.0;-0.0;0.0'))% vs baseline. " + } + } + } + + $evaluatedResults.Add([PSCustomObject]@{ + Method = $methodName + ZeroAllocReq = if ($isZeroAllocRequired) { "Yes (0 B)" } else { "No" } + AllocatedBytes = if ($null -ne $allocBytes) { "$allocBytes B" } else { "N/A" } + MeanNs = if ($null -ne $meanNs) { "$($meanNs.ToString('F2')) ns" } else { "N/A" } + Status = $status + Details = $details.Trim() + }) +} + +# 5. Output Console Table +Write-Host "`n============================================================" -ForegroundColor Magenta +Write-Host " BENCHMARK QUALITY GATE EVALUATION REPORT" -ForegroundColor Magenta +Write-Host "============================================================" -ForegroundColor Magenta + +$evaluatedResults | Format-Table -Property Method, ZeroAllocReq, AllocatedBytes, MeanNs, Status -AutoSize + +# 6. Generate GitHub Step Summary if applicable +$summaryFile = $env:GITHUB_STEP_SUMMARY +if (-not [string]::IsNullOrWhiteSpace($summaryFile) -and (Test-Path (Split-Path -Parent $summaryFile) -ErrorAction SilentlyContinue)) { + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("## ⚡ Benchmark Regression Quality Gate Report") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("| Rule | Invariant | Policy | Result |") + [void]$sb.AppendLine("|---|---|---|---|") + [void]$sb.AppendLine("| **Rule 1 (Heap)** | Zero-Allocation on Hot Path (`$ZeroAllocPattern`) | Must allocate **0 B** | $(if ($violations | Where-Object { $_ -match "Zero-allocation" }) { "❌ **FAILED**" } else { "✅ **PASSED**" }) |") + [void]$sb.AppendLine("| **Rule 2 (Latency)** | Nanosecond Regression vs Baseline | Max **+$MaxLatencyRegressionPercent%** | $(if ($violations | Where-Object { $_ -match "Latency" }) { "❌ **FAILED**" } else { "✅ **PASSED**" }) |") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("### Evaluated Benchmark Results ($($evaluatedResults.Count) operations)") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("| Method | Zero-Alloc Expected | Allocated | Mean Latency | Gate Status |") + [void]$sb.AppendLine("|---|:---:|:---:|:---:|:---:|") + + foreach ($res in $evaluatedResults) { + $statusIcon = if ($res.Status -eq "PASS") { "✅ PASS" } else { "❌ $($res.Status)" } + [void]$sb.AppendLine("| `$($res.Method)` | $($res.ZeroAllocReq) | $($res.AllocatedBytes) | $($res.MeanNs) | $statusIcon |") + } + + if ($violations.Count -gt 0) { + [void]$sb.AppendLine("") + [void]$sb.AppendLine("### ❌ Violations Detected") + foreach ($v in $violations) { + [void]$sb.AppendLine("- $v") + } + } + + [System.IO.File]::AppendAllText($summaryFile, $sb.ToString(), [System.Text.Encoding]::UTF8) +} + +# 7. Final Verdict and Exit Code +Write-Host "============================================================" -ForegroundColor Magenta +if ($violations.Count -gt 0) { + Write-Host "❌ BENCHMARK GATE FAILED: $($violations.Count) violation(s) detected:`n" -ForegroundColor Red + foreach ($v in $violations) { + Write-Host " $v" -ForegroundColor Red + Write-Host "::error::$v" + } + exit 1 +} else { + Write-Host "✅ BENCHMARK GATE PASSED: All zero-allocation invariants and latency thresholds verified successfully." -ForegroundColor Green + exit 0 +} diff --git a/scripts/verify-benchmark-gate.test.ps1 b/scripts/verify-benchmark-gate.test.ps1 new file mode 100644 index 0000000..e520758 --- /dev/null +++ b/scripts/verify-benchmark-gate.test.ps1 @@ -0,0 +1,166 @@ +# Copyright © Erickson Lopez. MIT License. +# Unit tests for verify-benchmark-gate.ps1 + +$scriptPath = Join-Path $PSScriptRoot "verify-benchmark-gate.ps1" +$testTempDir = Join-Path $PSScriptRoot "temp-bench-test" + +if (Test-Path $testTempDir) { Remove-Item -Path $testTempDir -Recurse -Force } +New-Item -ItemType Directory -Path (Join-Path $testTempDir "reports") -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $testTempDir "baseline") -Force | Out-Null + +$reportsDir = Join-Path $testTempDir "reports" +$baselineDir = Join-Path $testTempDir "baseline" +$baselineFile = Join-Path $baselineDir "baseline.json" + +function Run-Gate($repDir, $baseFile, [double]$threshold = 5.0) { + $pinfo = New-Object System.Diagnostics.ProcessStartInfo + $pinfo.FileName = "powershell.exe" + $pinfo.Arguments = "-ExecutionPolicy Bypass -File `"$scriptPath`" -ReportDir `"$repDir`" -BaselinePath `"$baseFile`" -MaxLatencyRegressionPercent $threshold" + $pinfo.RedirectStandardOutput = $true + $pinfo.RedirectStandardError = $true + $pinfo.UseShellExecute = $false + + $p = New-Object System.Diagnostics.Process + $p.StartInfo = $pinfo + $p.Start() | Out-Null + $stdout = $p.StandardOutput.ReadToEnd() + $stderr = $p.StandardError.ReadToEnd() + $p.WaitForExit() + return [PSCustomObject]@{ + ExitCode = $p.ExitCode + Stdout = $stdout + Stderr = $stderr + } +} + +Write-Host "Running tests for verify-benchmark-gate.ps1...`n" -ForegroundColor Cyan + +# Test 1: No reports found (should exit 0 with warning) +{ + $emptyDir = Join-Path $testTempDir "empty" + New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null + $res = Run-Gate $emptyDir $baselineFile + if ($res.ExitCode -eq 0 -and $res.Stdout -match "No BenchmarkDotNet JSON reports") { + Write-Host "✅ Test 1 Passed: Missing reports directory exits 0 with warning." -ForegroundColor Green + } else { + Write-Error "❌ Test 1 Failed: Expected exit 0 with warning, got exit $($res.ExitCode)" + } +} + +# Test 2: Clean 0B and within baseline +{ + $baselineData = @{ + Benchmarks = @{ + "Bind_Success_TState" = @{ MeanNs = 10.0; AllocatedBytes = 0 } + "Map_Success_TState" = @{ MeanNs = 5.0; AllocatedBytes = 0 } + "Success_NonGeneric" = @{ MeanNs = 1.0; AllocatedBytes = 0 } + } + } | ConvertTo-Json -Depth 5 + Set-Content -Path $baselineFile -Value $baselineData -Encoding utf8 + + $reportData = @{ + Title = "CleanReport" + Benchmarks = @( + @{ + Method = "Bind_Success_TState" + Statistics = @{ Mean = 10.2 } # +2% (<= 5%) + Memory = @{ BytesAllocatedPerOperation = 0 } + }, + @{ + Method = "Map_Success_TState" + Statistics = @{ Mean = 4.9 } # -2% + Memory = @{ BytesAllocatedPerOperation = 0 } + }, + @{ + Method = "Success_NonGeneric" + Statistics = @{ Mean = 1.03 } # +3% + Memory = @{ BytesAllocatedPerOperation = 0 } + } + ) + } | ConvertTo-Json -Depth 5 + Set-Content -Path (Join-Path $reportsDir "BenchmarkRun-clean-report.json") -Value $reportData -Encoding utf8 + + $res = Run-Gate $reportsDir $baselineFile + if ($res.ExitCode -eq 0 -and $res.Stdout -match "BENCHMARK GATE PASSED") { + Write-Host "✅ Test 2 Passed: Clean 0B and valid latency passes gate." -ForegroundColor Green + } else { + Write-Error "❌ Test 2 Failed: Expected pass, got exit $($res.ExitCode). Stdout: $($res.Stdout)" + } +} + +# Test 3: Zero-Allocation violation (AllocatedBytes > 0 on hot path) +{ + $badAllocReport = @{ + Title = "BadAllocReport" + Benchmarks = @( + @{ + Method = "Bind_Success_TState" + Statistics = @{ Mean = 10.0 } + Memory = @{ BytesAllocatedPerOperation = 24 } # VIOLATION! + } + ) + } | ConvertTo-Json -Depth 5 + Set-Content -Path (Join-Path $reportsDir "BenchmarkRun-clean-report.json") -Value $badAllocReport -Encoding utf8 + + $res = Run-Gate $reportsDir $baselineFile + if ($res.ExitCode -eq 1 -and $res.Stdout -match "Zero-allocation invariant VIOLATED") { + Write-Host "✅ Test 3 Passed: Hot-path allocation (> 0 B) triggers gate failure." -ForegroundColor Green + } else { + Write-Error "❌ Test 3 Failed: Expected failure on allocation > 0, got exit $($res.ExitCode)" + } +} + +# Test 4: Latency regression violation (> 5% slower than baseline) +{ + $badLatencyReport = @{ + Title = "BadLatencyReport" + Benchmarks = @( + @{ + Method = "Success_NonGeneric" + Statistics = @{ Mean = 1.15 } # +15% vs baseline 1.0 (threshold 5%) + Memory = @{ BytesAllocatedPerOperation = 0 } + } + ) + } | ConvertTo-Json -Depth 5 + Set-Content -Path (Join-Path $reportsDir "BenchmarkRun-clean-report.json") -Value $badLatencyReport -Encoding utf8 + + $res = Run-Gate $reportsDir $baselineFile -threshold 5.0 + if ($res.ExitCode -eq 1 -and $res.Stdout -match "Latency regressed") { + Write-Host "✅ Test 4 Passed: Latency regression (+15% > +5%) triggers gate failure." -ForegroundColor Green + } else { + Write-Error "❌ Test 4 Failed: Expected failure on latency regression, got exit $($res.ExitCode)" + } +} + +# Test 5: Domain-specific / baseline-declared ZeroAlloc method violation (non-Result method) +{ + $domainBaseline = @{ + Benchmarks = @{ + "CustomCrypto_Hash" = @{ MeanNs = 100.0; AllocatedBytes = 0; ZeroAlloc = $true } + } + } | ConvertTo-Json -Depth 5 + Set-Content -Path $baselineFile -Value $domainBaseline -Encoding utf8 + + $domainReport = @{ + Title = "DomainReport" + Benchmarks = @( + @{ + Method = "CustomCrypto_Hash" + Statistics = @{ Mean = 100.0 } + Memory = @{ BytesAllocatedPerOperation = 16 } # VIOLATION! + } + ) + } | ConvertTo-Json -Depth 5 + Set-Content -Path (Join-Path $reportsDir "BenchmarkRun-clean-report.json") -Value $domainReport -Encoding utf8 + + $res = Run-Gate $reportsDir $baselineFile + if ($res.ExitCode -eq 1 -and $res.Stdout -match "Zero-allocation invariant VIOLATED: Method 'CustomCrypto_Hash'") { + Write-Host "✅ Test 5 Passed: Baseline-declared zero-alloc method triggers failure when allocating > 0 B." -ForegroundColor Green + } else { + Write-Error "❌ Test 5 Failed: Expected failure for CustomCrypto_Hash, got exit $($res.ExitCode)" + } +} + +# Cleanup +Remove-Item -Path $testTempDir -Recurse -Force +Write-Host "`nAll verify-benchmark-gate tests passed successfully!" -ForegroundColor Green diff --git a/scripts/verify-compliance.ps1 b/scripts/verify-compliance.ps1 index 43435f6..4ca7dbf 100644 --- a/scripts/verify-compliance.ps1 +++ b/scripts/verify-compliance.ps1 @@ -1,17 +1,17 @@ +# Copyright © Erickson Lopez. MIT License. <# -// Copyright © Erickson Lopez. MIT License. .SYNOPSIS - Architecture & Quality Standards Compliance Verification Script for EricksonLopez.Specification. +Architecture & Quality Standards Compliance Verification Script for EricksonLopez.Specification. .DESCRIPTION - Validates architectural invariants: - 1. Kebab-case naming for all markdown documentation repo-wide (excluding standard GitHub files). - 2. Zero [Obsolete] usages in production and test code. - 3. Presence of canonical MIT copyright header across all source files. - 4. Single top-level type per file in src/. - 5. Valid GitHub repository links referencing ericksonlopezf/dotnet-specification. - 6. Official support and security email normalization (ericksonlopezf@gmail.com). - 7. Consistent ImplicitUsings (enable) across Directory.Build.props and all .csproj. - 8. Zero CS1591 / CS1573 suppressions in src projects. +Validates architectural invariants: +1. Kebab-case naming for all markdown documentation repo-wide (excluding standard GitHub files). +2. Zero [Obsolete] usages in production and test code. +3. Presence of canonical MIT copyright header across all source files. +4. Single top-level type per file in src/. +5. Valid GitHub repository links referencing ericksonlopezf/dotnet-specification. +6. Official support and security email normalization (ericksonlopezf@gmail.com). +7. Consistent ImplicitUsings (disable) across Directory.Build.props and all .csproj. +8. Zero CS1591 / CS1573 suppressions in src projects. #> [CmdletBinding()] @@ -42,7 +42,7 @@ $standardFiles = @( # 1. Kebab-case documentation verification across entire repo Write-Host "`n[1/8] Checking documentation file naming (kebab-case repo-wide)..." -ForegroundColor Yellow $allMdFiles = Get-ChildItem -Path $RootDirectory -Recurse -Filter "*.md" | Where-Object { - $_.FullName -notmatch "[\\/](bin|obj|\.git|node_modules|BenchmarkDotNet\.Artifacts)[\\/]" + $_.FullName -notmatch "[\\/](bin|obj|\.git|node_modules|BenchmarkDotNet\.Artifacts|StrykerOutput|MEGA-AUDITORIA|results)[\\/]" } $badDocNames = 0 foreach ($doc in $allMdFiles) { @@ -81,8 +81,8 @@ Write-Host "`n[3/8] Checking canonical MIT copyright headers..." -ForegroundColo $srcCsFiles = $allCsFiles | Where-Object { $_.FullName -match "[\\/]src[\\/]" } $missingHeaders = 0 foreach ($cs in $srcCsFiles) { - $firstLine = (Get-Content $cs.FullName -TotalCount 1) - if ($firstLine -notmatch "Copyright © Erickson Lopez\. MIT License\.") { + $firstLine = (Get-Content $cs.FullName -TotalCount 1 -Encoding UTF8) + if ($firstLine -notmatch "Copyright .* Erickson Lopez.* MIT License") { Write-Host " ❌ Missing MIT header in $($cs.FullName)" -ForegroundColor Red $violations++ $missingHeaders++ @@ -115,15 +115,24 @@ $csprojs = Get-ChildItem -Path $RootDirectory -Recurse -Filter "*.csproj" | Wher $_.FullName -notmatch "[\\/](obj|bin|\.git)[\\/]" } $badImplicit = 0 +$propsFile = Join-Path $RootDirectory "Directory.Build.props" +if (Test-Path $propsFile) { + $propsContent = [System.IO.File]::ReadAllText($propsFile) + if ($propsContent -match "enable" -or $propsContent -notmatch "disable") { + Write-Host " ❌ ImplicitUsings must be disabled in Directory.Build.props" -ForegroundColor Red + $violations++ + $badImplicit++ + } +} foreach ($proj in $csprojs) { $content = [System.IO.File]::ReadAllText($proj.FullName) - if ($content -match "disable") { - Write-Host " ❌ ImplicitUsings is disabled in $($proj.FullName)" -ForegroundColor Red + if ($content -match "enable") { + Write-Host " ❌ ImplicitUsings is enabled in $($proj.FullName)" -ForegroundColor Red $violations++ $badImplicit++ } } -if ($badImplicit -eq 0) { Write-Host " ✅ ImplicitUsings is consistently enabled." -ForegroundColor Green } +if ($badImplicit -eq 0) { Write-Host " ✅ ImplicitUsings is consistently disabled across Directory.Build.props and all projects." -ForegroundColor Green } # 6. Zero CS1591 Suppressions in src/ Write-Host "`n[6/8] Checking for prohibited CS1591 / CS1573 suppressions in src/..." -ForegroundColor Yellow @@ -176,13 +185,558 @@ foreach ($meta in $metaFiles) { } if ($badEmails -eq 0) { Write-Host " ✅ Official contact emails normalized to ericksonlopezf@gmail.com." -ForegroundColor Green } +# ----------------------------------------------------------------------------- +# Stryker.NET Configuration, Concurrency, Anti-Gaming Blacklist & Matrix Synchronization +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: Stryker] Validating Stryker.NET configuration, concurrency, anti-gaming blacklist & package matrix..." -ForegroundColor Yellow +$strykerErrors = 0 +$targetRoot = if (Get-Variable -Name "RootDirectory" -Scope 0 -ErrorAction SilentlyContinue) { $RootDirectory } elseif (Get-Variable -Name "WorkspaceRoot" -Scope 0 -ErrorAction SilentlyContinue) { $WorkspaceRoot } elseif (Get-Variable -Name "repoRoot" -Scope 0 -ErrorAction SilentlyContinue) { $repoRoot } elseif (Get-Variable -Name "RepoRoot" -Scope 0 -ErrorAction SilentlyContinue) { $RepoRoot } else { (Resolve-Path (Join-Path $PSScriptRoot "..")).Path } + +$strykerConfigFiles = Get-ChildItem -Path $targetRoot -Recurse -Filter "stryker*.json" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj|StrykerOutput|BenchmarkDotNet\.Artifacts|node_modules)[\\/]' -and + $_.Name -ne "stryker-config.master.json" +} + +if (-not $strykerConfigFiles -or $strykerConfigFiles.Count -eq 0) { + Write-Host " ❌ Zero Stryker configuration files found in repository." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Zero Stryker configuration files found.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ +} +else { + foreach ($sf in $strykerConfigFiles) { + $json = Get-Content $sf.FullName -Raw | ConvertFrom-Json + $cfg = if ($json.PSObject.Properties['stryker-config']) { $json.'stryker-config' } else { $json } + + if ($cfg.PSObject.Properties['thresholds']) { + $th = $cfg.thresholds + if ($th.high -ne 100 -or $th.low -ne 98 -or $th.break -ne 95) { + Write-Host " ❌ Non-compliant mutation thresholds in $($sf.FullName): high=$($th.high), low=$($th.low), break=$($th.break). Required: high=100, low=98, break=95." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Non-compliant mutation thresholds in $($sf.FullName)") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + + if ($cfg.PSObject.Properties['concurrency']) { + if ($cfg.concurrency -ne 2) { + Write-Host " ❌ Non-compliant Stryker concurrency in $($sf.FullName): $($cfg.concurrency). Required: 2." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Non-compliant Stryker concurrency in $($sf.FullName)") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + + if ($cfg.PSObject.Properties['ignore-methods'] -and $cfg.'ignore-methods') { + foreach ($m in $cfg.'ignore-methods') { + if ($m -match 'ThrowIf|Exception|Guard|ScrubEphemeralMemory') { + Write-Host " ❌ Prohibited anti-gaming method exclusion '$m' detected in $($sf.FullName)." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Prohibited anti-gaming exclusion '$m' in $($sf.FullName)") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + } + } + + $strykerProfiles = Get-ChildItem -Path $targetRoot -Filter "stryker*.json" -File -ErrorAction SilentlyContinue | Where-Object { + $_.Name -ne "stryker-config.master.json" -and + ($_.Name -match '^stryker(-.+)?-config\.json$' -or $_.Name -eq "stryker-config.json") + } + + $srcProjects = Get-ChildItem -Path (Join-Path $targetRoot "src") -Recurse -Filter "*.csproj" -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\/](bin|obj)[\/]' + } + + # Verify exact 1:1 count parity between Stryker profile configs and src/ projects + if ($strykerProfiles.Count -ne $srcProjects.Count) { + Write-Host " ❌ Stryker profile count ($($strykerProfiles.Count)) does not match exactly the number of projects in src/ ($($srcProjects.Count))." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Stryker profile count ($($strykerProfiles.Count)) does not match project count in src/ ($($srcProjects.Count)).") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + + foreach ($proj in $srcProjects) { + $projName = $proj.Name + $matched = $false + foreach ($sf in $strykerProfiles) { + $raw = Get-Content $sf.FullName -Raw + if ($raw -match [regex]::Escape($projName) -or $sf.Name -match [regex]::Escape($proj.BaseName)) { + $matched = $true + break + } + } + + if (-not $matched) { + Write-Host " ❌ Project '$projName' has no corresponding Stryker configuration profile." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Project '$projName' has no corresponding Stryker configuration profile.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + + foreach ($sf in $strykerProfiles) { + $raw = Get-Content $sf.FullName -Raw + $matchedProj = $false + foreach ($proj in $srcProjects) { + if ($raw -match [regex]::Escape($proj.Name) -or $sf.Name -match [regex]::Escape($proj.BaseName)) { + $matchedProj = $true + break + } + } + if (-not $matchedProj) { + Write-Host " ❌ Stryker profile '$($sf.Name)' does not correspond to any project in src/ (orphaned profile)." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Stryker profile '$($sf.Name)' does not correspond to any project in src/.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + + $mutationWfPath = Join-Path $targetRoot ".github/workflows/mutation-testing.yml" + if (-not (Test-Path $mutationWfPath)) { + Write-Host " ❌ Missing .github/workflows/mutation-testing.yml" -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing .github/workflows/mutation-testing.yml") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + else { + $wfContent = Get-Content $mutationWfPath -Raw + if ($wfContent -match '--concurrency\s*[:\s]\s*([3-9]|\d{2,})') { + Write-Host " ❌ Mutation workflow overrides concurrency with value > 2 in CLI arguments." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Mutation workflow overrides concurrency > 2") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + if ($wfContent -match '--break-at\s*[:\s]\s*([0-8]\d|\d{1})(?!\d)') { + Write-Host " ❌ Mutation workflow overrides break threshold with value < 90 in CLI arguments." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Mutation workflow overrides break threshold < 90") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + + foreach ($sf in $strykerConfigFiles) { + if ($sf.Name -eq "stryker-config.json" -and $strykerConfigFiles.Count -gt 1) { + continue + } + if ($sf.Name -eq "stryker-config-unit.json") { + continue + } + $pkgIdent = if ($sf.Name -match '^stryker-(.+)-config\.json$') { $Matches[1] } else { $sf.Name } + if ($wfContent -notmatch [regex]::Escape($sf.Name) -and $wfContent -notmatch "(?i)name:\s*$pkgIdent" -and $wfContent -notmatch "(?i)working-dir:.*$pkgIdent") { + Write-Host " ❌ Stryker configuration '$($sf.Name)' is missing from .github/workflows/mutation-testing.yml matrix." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Stryker configuration '$($sf.Name)' is missing from matrix") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $strykerErrors++ + } + } + } +} + +if ($strykerErrors -eq 0) { + Write-Host " ✅ Stryker.NET configuration, 100/98/95 thresholds, concurrency 2, anti-gaming blacklist, and package matrix synchronization verified." -ForegroundColor Green +} + +# ----------------------------------------------------------------------------- +# Mutation Testing Release Gate Scripts, Workflow Gate & docs/testing-roadmap.md +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: Release Gate] Validating mutation release gate scripts, workflow enforcement & docs/testing-roadmap.md..." -ForegroundColor Yellow +$gateErrors = 0 + +$gateScriptPath = Join-Path $targetRoot "scripts/verify-mutation-gate.js" +if (-not (Test-Path $gateScriptPath)) { + Write-Host " ❌ Missing scripts/verify-mutation-gate.js release gate script." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing scripts/verify-mutation-gate.js") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $gateErrors++ +} + +$gateTestPath = Join-Path $targetRoot "scripts/verify-mutation-gate.test.js" +if (-not (Test-Path $gateTestPath)) { + Write-Host " ❌ Missing scripts/verify-mutation-gate.test.js unit tests." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing scripts/verify-mutation-gate.test.js") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $gateErrors++ +} + +$roadmapPath = Join-Path $targetRoot "docs/testing-roadmap.md" +if (-not (Test-Path $roadmapPath)) { + Write-Host " ❌ Missing docs/testing-roadmap.md governance document." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing docs/testing-roadmap.md") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $gateErrors++ +} + +$publishWfPath = Join-Path $targetRoot ".github/workflows/publish.yml" +if (Test-Path $publishWfPath) { + $pubContent = Get-Content $publishWfPath -Raw + if ($pubContent -notmatch "verify-mutation-gate\.js") { + Write-Host " ❌ .github/workflows/publish.yml does not enforce verify-mutation-gate.js before publishing." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("publish.yml does not enforce verify-mutation-gate.js") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $gateErrors++ + } + foreach ($sp in $srcProjects) { + if ($pubContent -notmatch [regex]::Escape($sp.Name)) { + Write-Host " ❌ Project '$($sp.Name)' is missing from .github/workflows/publish.yml pack steps." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Project '$($sp.Name)' missing from publish.yml") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $gateErrors++ + } + } +} + +if ($gateErrors -eq 0) { + Write-Host " ✅ Mutation release gate scripts, publish pipeline gate, and docs/testing-roadmap.md verified." -ForegroundColor Green +} + +# ----------------------------------------------------------------------------- +# Benchmark Regression Quality Gate & CI Enforcement +# ----------------------------------------------------------------------------- +$hasBenchProject = (Get-ChildItem -Path $targetRoot -Filter "*Benchmark*.csproj" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '[\\/](obj|bin|MEGA-AUDITORIA|StrykerOutput)[\\/]' } | Select-Object -First 1) -ne $null +if ($hasBenchProject) { + Write-Host "`n[Gate: Benchmark Gate] Validating benchmark regression scripts & workflow enforcement..." -ForegroundColor Yellow + $benchGateErrors = 0 + + $benchScriptPath = Join-Path $targetRoot "scripts/verify-benchmark-gate.ps1" + if (-not (Test-Path $benchScriptPath)) { + Write-Host " ❌ Missing scripts/verify-benchmark-gate.ps1 regression assertion script." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing scripts/verify-benchmark-gate.ps1") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $benchGateErrors++ + } + + $benchTestScriptPath = Join-Path $targetRoot "scripts/verify-benchmark-gate.test.ps1" + if (-not (Test-Path $benchTestScriptPath)) { + Write-Host " ❌ Missing scripts/verify-benchmark-gate.test.ps1 unit tests." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing scripts/verify-benchmark-gate.test.ps1") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $benchGateErrors++ + } + + $benchWfPath = Join-Path $targetRoot ".github/workflows/benchmark-regression-gate.yml" + if (-not (Test-Path $benchWfPath)) { + Write-Host " ❌ Missing .github/workflows/benchmark-regression-gate.yml CI workflow." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing benchmark-regression-gate.yml") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $benchGateErrors++ + } + else { + $benchWfContent = Get-Content $benchWfPath -Raw -Encoding utf8 + if ($benchWfContent -notmatch "verify-benchmark-gate\.ps1" -or $benchWfContent -notmatch "--exporters json") { + Write-Host " ❌ .github/workflows/benchmark-regression-gate.yml does not enforce verify-benchmark-gate.ps1 and --exporters json." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Invalid benchmark-regression-gate.yml") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $benchGateErrors++ + } + } + + $baselinePath = Join-Path $targetRoot "benchmarks/results/baseline.json" + if (-not (Test-Path $baselinePath)) { + Write-Host " ❌ Missing benchmarks/results/baseline.json baseline file." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing benchmarks/results/baseline.json") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $benchGateErrors++ + } + + if ($benchGateErrors -eq 0) { + Write-Host " ✅ Benchmark regression assertion script, baseline, and CI workflow verified." -ForegroundColor Green + } +} + +# ----------------------------------------------------------------------------- +# SourceLink & Central Package Management Integration Gate +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: SourceLink] Validating centralized Microsoft.SourceLink.GitHub integration..." -ForegroundColor Yellow +$sourceLinkErrors = 0 + +$pkgPropsPath = Join-Path $targetRoot "Directory.Packages.props" +$bldPropsPath = Join-Path $targetRoot "Directory.Build.props" + +if (-not (Test-Path $pkgPropsPath)) { + Write-Host " ❌ Missing Directory.Packages.props." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing Directory.Packages.props") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ +} +else { + $pkgContent = Get-Content $pkgPropsPath -Raw -Encoding utf8 + if ($pkgContent -notmatch 'PackageVersion\s+Include="Microsoft\.SourceLink\.GitHub"') { + Write-Host " ❌ Directory.Packages.props must declare 'Microsoft.SourceLink.GitHub' instead of generic or missing package." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Directory.Packages.props missing Microsoft.SourceLink.GitHub") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ + } + if ($pkgContent -match 'PackageVersion\s+Include="Microsoft\.SourceLink\.Common"' -and $pkgContent -notmatch 'PackageVersion\s+Include="Microsoft\.SourceLink\.GitHub"') { + Write-Host " ❌ Directory.Packages.props uses generic Microsoft.SourceLink.Common without GitHub provider." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Generic Microsoft.SourceLink.Common used") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ + } +} + +if (-not (Test-Path $bldPropsPath)) { + Write-Host " ❌ Missing Directory.Build.props." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing Directory.Build.props") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ +} +else { + $bldContent = Get-Content $bldPropsPath -Raw -Encoding utf8 + if ($bldContent -notmatch 'PackageReference\s+Include="Microsoft\.SourceLink\.GitHub"') { + Write-Host " ❌ Directory.Build.props must centralize ''." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Directory.Build.props missing Microsoft.SourceLink.GitHub PackageReference") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ + } + if ($bldContent -notmatch '\s*true\s*' -and $bldContent -notmatch 'true'." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Directory.Build.props missing PublishRepositoryUrl") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $sourceLinkErrors++ + } +} + +if ($sourceLinkErrors -eq 0) { + Write-Host " ✅ SourceLink integration (Microsoft.SourceLink.GitHub) verified in Directory.Packages.props & Directory.Build.props." -ForegroundColor Green +} + +# ----------------------------------------------------------------------------- +# Native AOT Test Gate & Compilation Smoke Test Invariants +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: Native AOT] Validating Native AOT compilation smoke tests & workflow enforcement..." -ForegroundColor Yellow +$aotErrors = 0 + +$allSrcProjs = Get-ChildItem -Path (Join-Path $targetRoot "src") -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' +} + +# 1. Discover AOT-applicable projects in src/ +$aotApplicableProjects = @() +foreach ($proj in $allSrcProjs) { + $projName = $proj.Name + $projDir = $proj.DirectoryName + + # Exclude Roslyn Analyzers and Source Generators + if ($projName -match '(Analyzers?|Generators?)\.csproj$' -or $projDir -match '[\\/](Analyzers?|Generators?)[\\/]?$') { + continue + } + # Exclude API endpoints / applications if applicable + if ($projName -match '\.Api\.csproj$') { + continue + } + + $projContent = Get-Content $proj.FullName -Raw + # Exclude projects explicitly marked as non-AOT compatible + if ($projContent -match '\s*false\s*' -or + $projContent -match '\s*false\s*') { + continue + } + + $aotApplicableProjects += $proj +} + +if ($aotApplicableProjects.Count -gt 0) { + Write-Host " [INFO] Detected $($aotApplicableProjects.Count) Native AOT applicable project(s) in src/." -ForegroundColor Gray + + # 2. Check for dedicated Native AOT smoke test project in tests/ or samples/ + $aotTestProjects = @() + foreach ($searchDir in @("tests", "samples")) { + $dirPath = Join-Path $targetRoot $searchDir + if (Test-Path $dirPath) { + $candidateTests = Get-ChildItem -Path $dirPath -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' + } + foreach ($t in $candidateTests) { + $content = Get-Content $t.FullName -Raw + if ($content -match '\s*true\s*' -or $t.Name -match 'AotSmokeTest|AotTest|NativeAot') { + $aotTestProjects += $t + } + } + } + } + + if ($aotTestProjects.Count -eq 0) { + Write-Host " ❌ Missing Native AOT smoke test project in tests/ or samples/ for $($aotApplicableProjects.Count) AOT-applicable project(s)." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing Native AOT smoke test project in tests/ or samples/.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $aotErrors++ + } else { + $hasValidExecutable = $false + foreach ($aotProj in $aotTestProjects) { + $aotContent = Get-Content $aotProj.FullName -Raw + if ($aotContent -match '\s*Exe\s*' -and ($aotContent -match '\s*true\s*' -or $aotContent -match 'PublishAot')) { + $hasValidExecutable = $true + break + } + } + if (-not $hasValidExecutable) { + Write-Host " ❌ At least one AOT smoke test project must declare OutputType=Exe and PublishAot=true." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("AOT smoke test project must declare OutputType=Exe and PublishAot=true.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $aotErrors++ + } + } + + # 3. Check for CI workflow .github/workflows/aot-smoke-test.yml + $aotWorkflowPath = Join-Path $targetRoot ".github/workflows/aot-smoke-test.yml" + if (-not (Test-Path $aotWorkflowPath)) { + Write-Host " ❌ Missing .github/workflows/aot-smoke-test.yml CI workflow." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing .github/workflows/aot-smoke-test.yml CI workflow.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $aotErrors++ + } else { + $wfContent = Get-Content $aotWorkflowPath -Raw + if ($wfContent -notmatch 'dotnet publish' -or ($wfContent -notmatch 'linux-x64|win-x64' -and $wfContent -notmatch 'PublishAot')) { + Write-Host " ❌ Workflow .github/workflows/aot-smoke-test.yml does not execute a valid Native AOT publish step." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Invalid aot-smoke-test.yml workflow.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $aotErrors++ + } + } +} else { + Write-Host " [INFO] Zero Native AOT applicable projects in src/ (pure analyzer/generator repository). Native AOT test gate skipped." -ForegroundColor Gray +} + +if ($aotErrors -eq 0) { + Write-Host " ✅ Native AOT test project(s) and CI workflow verified." -ForegroundColor Green +} + + + +# ----------------------------------------------------------------------------- +# README Package Table Parity Gate +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: README Package Table Parity] Validating documentation package table synchronization..." -ForegroundColor Yellow +$readmeErrors = 0 +$readmePath = Join-Path $targetRoot "README.md" + +if (-not (Test-Path $readmePath)) { + Write-Host " ❌ Missing README.md in repository root." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Missing README.md in repository root.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $readmeErrors++ +} else { + $readmeContent = Get-Content $readmePath -Raw -Encoding utf8 + $allSrcProjs = Get-ChildItem -Path (Join-Path $targetRoot "src") -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' + } + + foreach ($proj in $allSrcProjs) { + $projName = $proj.Name + $baseName = $proj.BaseName + + # Check if project appears in README.md inside a table or package reference + $escapedBase = [regex]::Escape($baseName) + $isDocumented = ($readmeContent -match ('\|\s*`?' + $escapedBase + '`?\s*\|')) -or + ($readmeContent -match ('\[`?' + $escapedBase + '`?\]')) -or + ($readmeContent -match "/packages/$escapedBase") -or + ($readmeContent -match ('\|\s*\[`?' + $escapedBase + '`?\]')) + + if (-not $isDocumented) { + Write-Host " ❌ Project '$projName' is missing from the packages table in README.md." -ForegroundColor Red + if (Get-Variable -Name "violations" -Scope 0 -ErrorAction SilentlyContinue) { $violations++ } + if (Get-Variable -Name "Violations" -Scope 0 -ErrorAction SilentlyContinue) { if ($Violations -is [System.Collections.IList]) { $Violations.Add("Project '$projName' is missing from the packages table in README.md.") } else { $Violations++ } } + if (Get-Variable -Name "FailedChecks" -Scope 0 -ErrorAction SilentlyContinue) { $FailedChecks++ } + $readmeErrors++ + } + } + + if ($readmeErrors -eq 0) { + Write-Host " ✅ All $($allSrcProjs.Count) project(s) in src/ verified in README.md package table." -ForegroundColor Green + } +} + +# ----------------------------------------------------------------------------- +# Test Suite Symmetry & Coverage Gate (Principle 12) +# ----------------------------------------------------------------------------- +Write-Host "`n[Gate: Test Suite Symmetry] Validating test project symmetry & project references across tests/..." -ForegroundColor Yellow +$testSymErrors = 0 +$testsDir = Join-Path $targetRoot "tests" + +$allSrcProjs = Get-ChildItem -Path (Join-Path $targetRoot "src") -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' +} + +$allTestProjs = @() +$testProjectReferences = @{} +if (Test-Path $testsDir) { + $allTestProjs = Get-ChildItem -Path $testsDir -Recurse -Filter "*.csproj" -File -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' + } + foreach ($tp in $allTestProjs) { + $tContent = Get-Content $tp.FullName -Raw -Encoding utf8 + $refs = [regex]::Matches($tContent, ' - net10.0 EricksonLopez.Specification.Abstractions EricksonLopez.Specification EricksonLopez.Specification.Abstractions Pure domain-level specification abstractions. Zero infrastructure dependencies. AOT-safe interfaces for ISpecification<T>, IExpressionSpecification<T>, and QuerySpec<T>. - $(CommonPackageTags);abstractions;contracts;domain-model;query-spec;repository-pattern;aot + $(CommonPackageTags);abstractions;contracts;interfaces;domain-model;repository-pattern;query-specification;native-aot;trimming diff --git a/src/EricksonLopez.Specification.Abstractions/ExpressionDebugFormatterRegistry.cs b/src/EricksonLopez.Specification.Abstractions/ExpressionDebugFormatterRegistry.cs new file mode 100644 index 0000000..3093be0 --- /dev/null +++ b/src/EricksonLopez.Specification.Abstractions/ExpressionDebugFormatterRegistry.cs @@ -0,0 +1,34 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Linq.Expressions; + +namespace EricksonLopez.Specification; + +/// +/// Provides a registry for expression debug formatting across architectural layers. +/// +public static class ExpressionDebugFormatterRegistry +{ + private static Func _formatter = expr => expr.ToString(); + + /// + /// Gets or sets the debug formatter delegate. + /// + public static Func Formatter + { + get => _formatter; + set => _formatter = value ?? (expr => expr.ToString()); + } + + /// + /// Formats an expression using the registered formatter. + /// + /// The expression to format. + /// A formatted string representation of the expression. + /// is + public static string Format(Expression expression) + { + ArgumentNullException.ThrowIfNull(expression); + return _formatter(expression); + } +} diff --git a/src/EricksonLopez.Specification/IExpressionSpecification.cs b/src/EricksonLopez.Specification.Abstractions/IExpressionSpecification.cs similarity index 90% rename from src/EricksonLopez.Specification/IExpressionSpecification.cs rename to src/EricksonLopez.Specification.Abstractions/IExpressionSpecification.cs index df57710..0512c72 100644 --- a/src/EricksonLopez.Specification/IExpressionSpecification.cs +++ b/src/EricksonLopez.Specification.Abstractions/IExpressionSpecification.cs @@ -1,7 +1,6 @@ // Copyright © Erickson Lopez. MIT License. using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Linq.Expressions; namespace EricksonLopez.Specification; @@ -26,9 +25,8 @@ public interface IExpressionSpecification< /// /// A string representation of the specification expression. /// - /// The default implementation delegates to . + /// The default implementation delegates to . /// - string ToDebugString() => ExpressionDebugFormatter.Format(ToExpression()); + string ToDebugString() => ExpressionDebugFormatterRegistry.Format(ToExpression()); } - diff --git a/src/EricksonLopez.Specification.Abstractions/IReadRepository.cs b/src/EricksonLopez.Specification.Abstractions/IReadRepository.cs index bcce550..9f43469 100644 --- a/src/EricksonLopez.Specification.Abstractions/IReadRepository.cs +++ b/src/EricksonLopez.Specification.Abstractions/IReadRepository.cs @@ -103,6 +103,18 @@ Task> ListAsync( /// A task representing the asynchronous operation. /// The task result contains the matching entity, or if not found. /// + /// + /// + /// The default interface implementation always returns (equivalent to + /// Task.FromResult<T?>(default)). + /// + /// + /// Implementors must override this method to provide actual entity lookup by identifier. + /// If not overridden, any call to GetByIdAsync will silently return + /// regardless of whether the entity exists, which can cause + /// in callers that do not expect a result. + /// + /// Task GetByIdAsync( TId id, CancellationToken cancellationToken = default) where TId : notnull diff --git a/src/EricksonLopez.Specification.Abstractions/ISpecification.cs b/src/EricksonLopez.Specification.Abstractions/ISpecification.cs index 2481bfc..bc265b8 100644 --- a/src/EricksonLopez.Specification.Abstractions/ISpecification.cs +++ b/src/EricksonLopez.Specification.Abstractions/ISpecification.cs @@ -21,7 +21,7 @@ public interface ISpecification< /// /// The candidate to evaluate. /// if the candidate satisfies the specification; otherwise, . - /// is . + /// is bool IsSatisfiedBy(T candidate); } diff --git a/src/EricksonLopez.Specification.Analyzers/EricksonLopez.Specification.Analyzers.csproj b/src/EricksonLopez.Specification.Analyzers/EricksonLopez.Specification.Analyzers.csproj index f8fab95..070e619 100644 --- a/src/EricksonLopez.Specification.Analyzers/EricksonLopez.Specification.Analyzers.csproj +++ b/src/EricksonLopez.Specification.Analyzers/EricksonLopez.Specification.Analyzers.csproj @@ -5,11 +5,11 @@ EricksonLopez.Specification.Analyzers EricksonLopez.Specification.Analyzers Roslyn analyzers for EricksonLopez.Specification. Detects specification anti-patterns at compile time: unbounded queries, mutable state, unsafe closures, and AOT-incompatible constructs. - $(CommonPackageTags);roslyn;roslyn-analyzers;static-analysis;code-analysis;codefix;developer-tools + $(CommonPackageTags);roslyn;roslyn-analyzers;static-analysis;code-analysis;codefix;developer-tools;linter netstandard2.0 latest enable - enable + disable false false true diff --git a/src/EricksonLopez.Specification.Analyzers/SpecificationDiagnosticDescriptors.cs b/src/EricksonLopez.Specification.Analyzers/SpecificationDiagnosticDescriptors.cs index f90c870..21779fa 100644 --- a/src/EricksonLopez.Specification.Analyzers/SpecificationDiagnosticDescriptors.cs +++ b/src/EricksonLopez.Specification.Analyzers/SpecificationDiagnosticDescriptors.cs @@ -14,7 +14,7 @@ public static class SpecificationDiagnosticDescriptors private const string Category = "Specification"; private const string BaseUrl = "https://github.com/ericksonlopezf/dotnet-specification/docs/analyzers/"; - /// Diagnostic descriptor for SPEC001: Specification class should be sealed or abstract. + /// Gets the diagnostic descriptor for SPEC001: Specification class should be sealed or abstract. public static readonly DiagnosticDescriptor SpecificationShouldBeSealedOrAbstract = new( id: "SPEC001", title: "Specification class should be sealed or abstract", @@ -27,7 +27,7 @@ public static class SpecificationDiagnosticDescriptors "Seal concrete specifications to make them clearly final, or mark them abstract if they are intended as base classes.", helpLinkUri: BaseUrl + "SPEC001"); - /// Diagnostic descriptor for SPEC002: Mutable state in specification. + /// Gets the diagnostic descriptor for SPEC002: Mutable state in specification. public static readonly DiagnosticDescriptor MutableStateInSpecification = new( id: "SPEC002", title: "Specification contains mutable state", @@ -40,7 +40,7 @@ public static class SpecificationDiagnosticDescriptors "thread-safety issues if shared across requests. Consider making fields readonly or using immutable types.", helpLinkUri: BaseUrl + "SPEC002"); - /// Diagnostic descriptor for SPEC003: Expression.Invoke detected in specification expression. + /// Gets the diagnostic descriptor for SPEC003: Expression.Invoke detected in specification expression. public static readonly DiagnosticDescriptor ExpressionInvokeDetected = new( id: "SPEC003", title: "Expression.Invoke detected in specification expression", @@ -53,7 +53,7 @@ public static class SpecificationDiagnosticDescriptors "Use the ExpressionComposer helpers which use parameter rebinding instead.", helpLinkUri: BaseUrl + "SPEC003"); - /// Diagnostic descriptor for SPEC004: QuerySpec has no Take limit — potential unbounded query. + /// Gets the diagnostic descriptor for SPEC004: QuerySpec has no Take limit — potential unbounded query. public static readonly DiagnosticDescriptor UnboundedQuery = new( id: "SPEC004", title: "QuerySpec has no Take limit — potential unbounded query", @@ -66,7 +66,7 @@ public static class SpecificationDiagnosticDescriptors "returning all results is intentional (e.g., export scenarios).", helpLinkUri: BaseUrl + "SPEC004"); - /// Diagnostic descriptor for SPEC005: QuerySpec has ordering but no pagination. + /// Gets the diagnostic descriptor for SPEC005: QuerySpec has ordering but no pagination. public static readonly DiagnosticDescriptor OrderingWithoutPagination = new( id: "SPEC005", title: "QuerySpec has ordering but no pagination", @@ -79,7 +79,7 @@ public static class SpecificationDiagnosticDescriptors "on the full dataset.", helpLinkUri: BaseUrl + "SPEC005"); - /// Diagnostic descriptor for SPEC006: Domain specification defined outside Domain layer. + /// Gets the diagnostic descriptor for SPEC006: Domain specification defined outside Domain layer. public static readonly DiagnosticDescriptor DomainSpecificationOutsideDomain = new( id: "SPEC006", title: "Domain specification defined outside Domain layer", @@ -91,7 +91,7 @@ public static class SpecificationDiagnosticDescriptors description: "Domain specifications should be defined in the Domain project to maintain Clean Architecture boundaries.", helpLinkUri: BaseUrl + "SPEC006"); - /// Diagnostic descriptor for SPEC007: Expression contains non-translatable method call. + /// Gets the diagnostic descriptor for SPEC007: Expression contains non-translatable method call. public static readonly DiagnosticDescriptor PotentialClientSideEvaluation = new( id: "SPEC007", title: "Expression contains non-translatable method call — potential client-side evaluation", @@ -104,7 +104,7 @@ public static class SpecificationDiagnosticDescriptors "does not support them, potentially loading the entire table into memory.", helpLinkUri: BaseUrl + "SPEC007"); - /// Diagnostic descriptor for SPEC008: Infrastructure service injected into domain specification constructor. + /// Gets the diagnostic descriptor for SPEC008: Infrastructure service injected into domain specification constructor. public static readonly DiagnosticDescriptor InfrastructureServiceInSpecification = new( id: "SPEC008", title: "Infrastructure service injected into domain specification constructor", @@ -118,7 +118,7 @@ public static class SpecificationDiagnosticDescriptors "Pass only primitive values or domain objects required to define the predicate.", helpLinkUri: BaseUrl + "SPEC008"); - /// Diagnostic descriptor for SPEC009: Async lambda or await expression inside BuildExpression. + /// Gets the diagnostic descriptor for SPEC009: Async lambda or await expression inside BuildExpression. public static readonly DiagnosticDescriptor AsyncLambdaInExpression = new( id: "SPEC009", title: "Async lambda or await expression inside BuildExpression", @@ -131,7 +131,7 @@ public static class SpecificationDiagnosticDescriptors "Remove async lambdas from BuildExpression(). If async data is needed, resolve it before constructing the specification.", helpLinkUri: BaseUrl + "SPEC009"); - /// Diagnostic descriptor for SPEC010: IsSatisfiedBy called inside BuildExpression. + /// Gets the diagnostic descriptor for SPEC010: IsSatisfiedBy called inside BuildExpression. public static readonly DiagnosticDescriptor IsSatisfiedByInsideBuildExpression = new( id: "SPEC010", title: "IsSatisfiedBy called inside BuildExpression", @@ -144,7 +144,7 @@ public static class SpecificationDiagnosticDescriptors "Use And() / Or() composition methods instead to combine specification logic at the expression tree level.", helpLinkUri: BaseUrl + "SPEC010"); - /// Diagnostic descriptor for SPEC011: Legacy Ardalis.Specification usage detected. + /// Gets the diagnostic descriptor for SPEC011: Legacy Ardalis.Specification usage detected. public static readonly DiagnosticDescriptor LegacyArdalisSpecificationDetected = new( id: "SPEC011", title: "Legacy Ardalis.Specification usage detected", diff --git a/src/EricksonLopez.Specification.Dapper/EricksonLopez.Specification.Dapper.csproj b/src/EricksonLopez.Specification.Dapper/EricksonLopez.Specification.Dapper.csproj index 67ce5d1..a6aa175 100644 --- a/src/EricksonLopez.Specification.Dapper/EricksonLopez.Specification.Dapper.csproj +++ b/src/EricksonLopez.Specification.Dapper/EricksonLopez.Specification.Dapper.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.Dapper EricksonLopez.Specification.Dapper Dapper integration for EricksonLopez.Specification. Execute QuerySpec<T> against IDbConnection using parameterized SQL generated by the SQL translation layer. - $(CommonPackageTags);dapper;micro-orm;repository-pattern;data-access;sql + $(CommonPackageTags);dapper;micro-orm;repository-pattern;data-access;sql;idbconnection false diff --git a/src/EricksonLopez.Specification.DapperExtensions/EricksonLopez.Specification.DapperExtensions.csproj b/src/EricksonLopez.Specification.DapperExtensions/EricksonLopez.Specification.DapperExtensions.csproj index 93b65c7..500f203 100644 --- a/src/EricksonLopez.Specification.DapperExtensions/EricksonLopez.Specification.DapperExtensions.csproj +++ b/src/EricksonLopez.Specification.DapperExtensions/EricksonLopez.Specification.DapperExtensions.csproj @@ -1,7 +1,7 @@ DapperExtensions integration for EricksonLopez.Specification. Enables executing domain specifications over IDbConnection and IUnitOfWork. - $(CommonPackageTags);dapper;dapper-extensions;unit-of-work;repository-pattern;aot + $(CommonPackageTags);dapper;dapper-extensions;micro-orm;unit-of-work;repository-pattern;data-access;native-aot diff --git a/src/EricksonLopez.Specification.EntityFrameworkCore/EricksonLopez.Specification.EntityFrameworkCore.csproj b/src/EricksonLopez.Specification.EntityFrameworkCore/EricksonLopez.Specification.EntityFrameworkCore.csproj index c3dd2ce..976744b 100644 --- a/src/EricksonLopez.Specification.EntityFrameworkCore/EricksonLopez.Specification.EntityFrameworkCore.csproj +++ b/src/EricksonLopez.Specification.EntityFrameworkCore/EricksonLopez.Specification.EntityFrameworkCore.csproj @@ -1,7 +1,7 @@ Entity Framework Core integration for EricksonLopez.Specification. Provides specification evaluation and asynchronous read repository implementations for EF Core. - $(CommonPackageTags);efcore;entity-framework-core;orm;repository-pattern;aot + $(CommonPackageTags);efcore;entity-framework-core;orm;dbcontext;repository-pattern;data-access;native-aot $(NoWarn);CA1515 @@ -13,5 +13,6 @@ + diff --git a/src/EricksonLopez.Specification.EntityFrameworkCore/QuerySpecEfCoreExtensions.cs b/src/EricksonLopez.Specification.EntityFrameworkCore/QuerySpecEfCoreExtensions.cs index 65379d0..88a6293 100644 --- a/src/EricksonLopez.Specification.EntityFrameworkCore/QuerySpecEfCoreExtensions.cs +++ b/src/EricksonLopez.Specification.EntityFrameworkCore/QuerySpecEfCoreExtensions.cs @@ -20,6 +20,7 @@ public static class QuerySpecEfCoreExtensions /// If , configures the query to use multiple SQL queries (AsSplitQuery). /// If , configures the query to ignore auto-included navigations (IgnoreAutoIncludes). /// The configured queryable. + /// or is public static IQueryable Apply( this IQueryable source, QuerySpec specification, @@ -54,6 +55,7 @@ public static IQueryable Apply( /// If , configures the query to use multiple SQL queries (AsSplitQuery). /// If , configures the query to ignore auto-included navigations (IgnoreAutoIncludes). /// The configured queryable. + /// or is public static IQueryable Apply( this IQueryable source, QuerySpec specification, diff --git a/src/EricksonLopez.Specification.Generators/EricksonLopez.Specification.Generators.csproj b/src/EricksonLopez.Specification.Generators/EricksonLopez.Specification.Generators.csproj index 3ecc660..b1e8918 100644 --- a/src/EricksonLopez.Specification.Generators/EricksonLopez.Specification.Generators.csproj +++ b/src/EricksonLopez.Specification.Generators/EricksonLopez.Specification.Generators.csproj @@ -5,11 +5,11 @@ EricksonLopez.Specification.Generators EricksonLopez.Specification.Generators Roslyn source generators for EricksonLopez.Specification. Provides [Spec] attribute for strongly-typed ordering and AOT-safe expression metadata. - $(CommonPackageTags);roslyn;source-generator;code-generation;metaprogramming;developer-tools + $(CommonPackageTags);roslyn;source-generator;incremental-generator;code-generation;compile-time;metaprogramming;developer-tools netstandard2.0 latest enable - enable + disable false false true diff --git a/src/EricksonLopez.Specification.Linq/EricksonLopez.Specification.Linq.csproj b/src/EricksonLopez.Specification.Linq/EricksonLopez.Specification.Linq.csproj index 7ae3572..690af28 100644 --- a/src/EricksonLopez.Specification.Linq/EricksonLopez.Specification.Linq.csproj +++ b/src/EricksonLopez.Specification.Linq/EricksonLopez.Specification.Linq.csproj @@ -5,12 +5,12 @@ EricksonLopez.Specification.Linq EricksonLopez.Specification.Linq LINQ/IQueryable integration for EricksonLopez.Specification. Applies QuerySpec<T> to IQueryable<T> sources. Compatible with EF Core, LINQ to Objects, and any IQueryable provider. - $(CommonPackageTags);linq;iqueryable;queryable;query-evaluation;aot + $(CommonPackageTags);linq;iqueryable;filtering;sorting;pagination;query-evaluation;native-aot;trimming
- +
diff --git a/src/EricksonLopez.Specification.Linq/QuerySpecLinqExtensions.cs b/src/EricksonLopez.Specification.Linq/QuerySpecLinqExtensions.cs index 8c72d9c..3e65d6c 100644 --- a/src/EricksonLopez.Specification.Linq/QuerySpecLinqExtensions.cs +++ b/src/EricksonLopez.Specification.Linq/QuerySpecLinqExtensions.cs @@ -1,5 +1,7 @@ // Copyright © Erickson Lopez. MIT License. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; @@ -37,10 +39,11 @@ public static IQueryable Apply(this IQueryable source, QuerySpec spe var query = source; - // Apply filter criteria (AND-combined) - var predicate = spec.BuildCombinedPredicate(); - if (predicate is not null) - query = query.Where(predicate); + // Apply filter criteria + foreach (var criterion in spec.Criteria) + { + query = query.Where(criterion); + } // Apply cursor pagination filter if (spec.Cursor is not null) @@ -105,9 +108,10 @@ public static IQueryable Apply( var query = source; // Apply filter criteria - var predicate = spec.BuildCombinedPredicate(); - if (predicate is not null) - query = query.Where(predicate); + foreach (var criterion in spec.Criteria) + { + query = query.Where(criterion); + } // Apply cursor pagination filter if (spec.Cursor is not null) @@ -153,23 +157,11 @@ public static IQueryable Apply( if (spec.Selector is not null) return query.Select(spec.Selector); - // Stryker disable once String : Trivial exception message throw new InvalidOperationException( - "QuerySpec requires a projection defined via .Select(x => ...). " + + "Projected QuerySpec must define a Selector expression. " + "Use QuerySpec if no projection is needed."); } - /// - /// Returns the combined AND predicate from a projected . - /// - internal static System.Linq.Expressions.Expression>? BuildCombinedPredicate( - this QuerySpec spec) - { - if (spec.Criteria.IsEmpty) return null; - if (spec.Criteria.Length == 1) return spec.Criteria[0]; - return ExpressionComposer.AndAll(spec.Criteria.AsSpan()); - } - internal static System.Linq.Expressions.Expression> BuildCursorPredicate(CursorClause cursor) { var param = cursor.KeySelector.Parameters[0]; @@ -184,6 +176,14 @@ internal static System.Linq.Expressions.Expression> BuildCursorPre } } + if (!typeof(IComparable).IsAssignableFrom(actualExpr.Type) && + Nullable.GetUnderlyingType(actualExpr.Type) is null) + { + throw new NotSupportedException( + $"Keyset cursor pagination on type '{actualExpr.Type.Name}' is not supported. " + + "Cursor key selector must target a comparable scalar property."); + } + var valueConstant = System.Linq.Expressions.Expression.Constant(cursor.Value, actualExpr.Type); var comparison = cursor.Direction == CursorDirection.After @@ -201,7 +201,7 @@ internal static System.Linq.Expressions.Expression> BuildCursorPre /// The specification whose predicate to test. /// if any element satisfies the predicate; otherwise, . /// or is - public static bool Any(this IQueryable source, IExpressionSpecification specification) + public static bool Any<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IQueryable source, IExpressionSpecification specification) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(specification); @@ -216,12 +216,132 @@ public static bool Any(this IQueryable source, IExpressionSpecification /// The specification whose predicate to test. /// The number of elements satisfying the specification. /// or is - public static int Count(this IQueryable source, IExpressionSpecification specification) + public static int Count<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IQueryable source, IExpressionSpecification specification) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(specification); return source.Count(specification.ToExpression()); } + + /// + /// Filters a sequence of values based on a predicate defined by an expression specification. + /// + /// The entity type. + /// The source queryable. + /// The specification whose predicate to test. + /// An that contains elements from the input sequence that satisfy the specification. + /// or is + public static IQueryable Where<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IQueryable source, IExpressionSpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.Where(specification.ToExpression()); + } + + /// + /// Determines whether all elements in the source satisfy the specified specification predicate. + /// + /// The entity type. + /// The source queryable. + /// The specification whose predicate to test. + /// if every element satisfies the predicate, or if the source is empty; otherwise, . + /// or is + public static bool All<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IQueryable source, IExpressionSpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.All(specification.ToExpression()); + } + + /// + /// Returns the first element of a sequence that satisfies an expression specification, or a default value if no such element is found. + /// + /// The entity type. + /// The source queryable. + /// The specification whose predicate to test. + /// () if is empty or if no element passes the test; otherwise, the first matching element. + /// or is + public static T? FirstOrDefault<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IQueryable source, IExpressionSpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.FirstOrDefault(specification.ToExpression()); + } + + /// + /// Filters an in-memory sequence of values based on a domain specification. + /// + /// The entity type. + /// An to filter. + /// A domain specification to test each element for a condition. + /// An that contains elements from the input sequence that satisfy the specification. + /// or is + public static IEnumerable Where<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IEnumerable source, ISpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.Where(specification.IsSatisfiedBy); + } + + /// + /// Determines whether any element of an in-memory sequence satisfies a domain specification. + /// + /// The entity type. + /// An to evaluate. + /// A domain specification to test each element for a condition. + /// if any elements in the source sequence satisfy the condition; otherwise, . + /// or is + public static bool Any<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IEnumerable source, ISpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.Any(specification.IsSatisfiedBy); + } + + /// + /// Determines whether all elements of an in-memory sequence satisfy a domain specification. + /// + /// The entity type. + /// An to evaluate. + /// A domain specification to test each element for a condition. + /// if every element passes the test in the specified specification, or if the sequence is empty; otherwise, . + /// or is + public static bool All<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IEnumerable source, ISpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.All(specification.IsSatisfiedBy); + } + + /// + /// Returns the number of elements in an in-memory sequence that satisfy a domain specification. + /// + /// The entity type. + /// An to evaluate. + /// A domain specification to test each element for a condition. + /// A number that represents how many elements in the sequence satisfy the condition in the specification. + /// or is + public static int Count<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IEnumerable source, ISpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.Count(specification.IsSatisfiedBy); + } + + /// + /// Returns the first element of an in-memory sequence that satisfies a domain specification, or a default value if no such element is found. + /// + /// The entity type. + /// An to return an element from. + /// A domain specification to test each element for a condition. + /// () if is empty or if no element passes the test; otherwise, the first matching element. + /// or is + public static T? FirstOrDefault<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>(this IEnumerable source, ISpecification specification) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(specification); + return source.FirstOrDefault(specification.IsSatisfiedBy); + } } diff --git a/src/EricksonLopez.Specification.MariaDb/EricksonLopez.Specification.MariaDb.csproj b/src/EricksonLopez.Specification.MariaDb/EricksonLopez.Specification.MariaDb.csproj index 843217b..1417994 100644 --- a/src/EricksonLopez.Specification.MariaDb/EricksonLopez.Specification.MariaDb.csproj +++ b/src/EricksonLopez.Specification.MariaDb/EricksonLopez.Specification.MariaDb.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.MariaDb EricksonLopez.Specification.MariaDb MariaDB dialect for EricksonLopez.Specification. Translates QueryModel to MariaDB-compatible SQL (backtick quoting, @parameters, LIMIT/OFFSET, expanded IN). - $(CommonPackageTags);mariadb;sql-dialect;rdbms;aot + $(CommonPackageTags);mariadb;sql-dialect;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification.MongoDB/EricksonLopez.Specification.MongoDB.csproj b/src/EricksonLopez.Specification.MongoDB/EricksonLopez.Specification.MongoDB.csproj index 19f7d77..7cd1563 100644 --- a/src/EricksonLopez.Specification.MongoDB/EricksonLopez.Specification.MongoDB.csproj +++ b/src/EricksonLopez.Specification.MongoDB/EricksonLopez.Specification.MongoDB.csproj @@ -1,7 +1,7 @@ MongoDB driver integration for EricksonLopez.Specification. Compiles ISpecification and QuerySpec descriptors into native MongoDB FilterDefinition and SortDefinition. - $(CommonPackageTags);mongodb;nosql;document-database;repository-pattern;aot + $(CommonPackageTags);mongodb;nosql;document-database;mongo-csharp-driver;repository-pattern;data-access;native-aot $(NoWarn);CA1515 diff --git a/src/EricksonLopez.Specification.MongoDB/MongoSpecificationEvaluator.cs b/src/EricksonLopez.Specification.MongoDB/MongoSpecificationEvaluator.cs index de29451..fec3507 100644 --- a/src/EricksonLopez.Specification.MongoDB/MongoSpecificationEvaluator.cs +++ b/src/EricksonLopez.Specification.MongoDB/MongoSpecificationEvaluator.cs @@ -82,6 +82,14 @@ public static IFindFluent ApplySpecification( // Stryker disable once Statement : Delegated null validation to GetSort ArgumentNullException.ThrowIfNull(specification); + var filter = GetFilter(specification); + if (filter != Builders.Filter.Empty) + { + findFluent.Filter = (findFluent.Filter is null || findFluent.Filter == Builders.Filter.Empty) + ? filter + : Builders.Filter.And(findFluent.Filter, filter); + } + var sort = GetSort(specification); if (sort is not null) { @@ -100,4 +108,24 @@ public static IFindFluent ApplySpecification( return findFluent; } + + /// + /// Executes a find query on the specified collection using the criteria, ordering, and pagination from a . + /// + /// The MongoDB document type. + /// The source MongoDB collection. + /// The query specification. + /// A configured fluent find instance. + /// or is + public static IFindFluent Find( + this IMongoCollection collection, + QuerySpec specification) + { + ArgumentNullException.ThrowIfNull(collection); + ArgumentNullException.ThrowIfNull(specification); + + var filter = GetFilter(specification); + var findFluent = collection.Find(filter); + return findFluent.ApplySpecification(specification); + } } diff --git a/src/EricksonLopez.Specification.MsSql/EricksonLopez.Specification.MsSql.csproj b/src/EricksonLopez.Specification.MsSql/EricksonLopez.Specification.MsSql.csproj index 329f70e..bf2e9dd 100644 --- a/src/EricksonLopez.Specification.MsSql/EricksonLopez.Specification.MsSql.csproj +++ b/src/EricksonLopez.Specification.MsSql/EricksonLopez.Specification.MsSql.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.MsSql EricksonLopez.Specification.MsSql Microsoft SQL Server (T-SQL) dialect for EricksonLopez.Specification. Translates QueryModel to SQL Server-compatible SQL (OFFSET/FETCH, TOP N, bracket quoting, @parameters, IN expansion). - $(CommonPackageTags);sql-server;mssql;tsql;sql-dialect;rdbms;aot + $(CommonPackageTags);sql-server;mssql;tsql;sql-dialect;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification.MySql/EricksonLopez.Specification.MySql.csproj b/src/EricksonLopez.Specification.MySql/EricksonLopez.Specification.MySql.csproj index 92f66f7..61824dd 100644 --- a/src/EricksonLopez.Specification.MySql/EricksonLopez.Specification.MySql.csproj +++ b/src/EricksonLopez.Specification.MySql/EricksonLopez.Specification.MySql.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.MySql EricksonLopez.Specification.MySql MySQL and MariaDB dialect for EricksonLopez.Specification. Translates QueryModel to MySQL-compatible SQL (LIMIT/OFFSET, backtick quoting, @parameters, IN expansion). - $(CommonPackageTags);mysql;sql-dialect;rdbms;aot + $(CommonPackageTags);mysql;sql-dialect;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification.Oracle/EricksonLopez.Specification.Oracle.csproj b/src/EricksonLopez.Specification.Oracle/EricksonLopez.Specification.Oracle.csproj index bc920ac..2be94f0 100644 --- a/src/EricksonLopez.Specification.Oracle/EricksonLopez.Specification.Oracle.csproj +++ b/src/EricksonLopez.Specification.Oracle/EricksonLopez.Specification.Oracle.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.Oracle EricksonLopez.Specification.Oracle Oracle Database dialect for EricksonLopez.Specification. Translates QueryModel to Oracle-compatible SQL (OFFSET/FETCH, double quote quoting, :parameters, IN expansion). - $(CommonPackageTags);oracle;oracle-db;plsql;sql-dialect;rdbms;aot + $(CommonPackageTags);oracle;oracle-db;plsql;sql-dialect;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification.PostgreSql/EricksonLopez.Specification.PostgreSql.csproj b/src/EricksonLopez.Specification.PostgreSql/EricksonLopez.Specification.PostgreSql.csproj index f62be75..6c13bff 100644 --- a/src/EricksonLopez.Specification.PostgreSql/EricksonLopez.Specification.PostgreSql.csproj +++ b/src/EricksonLopez.Specification.PostgreSql/EricksonLopez.Specification.PostgreSql.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.PostgreSql EricksonLopez.Specification.PostgreSql PostgreSQL dialect for EricksonLopez.Specification. Translates QueryModel to PostgreSQL-compatible SQL (LIMIT/OFFSET, ILIKE, $n parameters, etc.). - $(CommonPackageTags);postgresql;postgres;npgsql;sql-dialect;rdbms;aot + $(CommonPackageTags);postgresql;postgres;npgsql;sql-dialect;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification.Result/EricksonLopez.Specification.Result.csproj b/src/EricksonLopez.Specification.Result/EricksonLopez.Specification.Result.csproj index e97abdf..95eda7f 100644 --- a/src/EricksonLopez.Specification.Result/EricksonLopez.Specification.Result.csproj +++ b/src/EricksonLopez.Specification.Result/EricksonLopez.Specification.Result.csproj @@ -1,10 +1,10 @@ - net10.0 + net8.0;net9.0;net10.0 enable true - $(CommonPackageTags);result-pattern;railway-oriented-programming;functional-programming;error-handling;aot + $(CommonPackageTags);result-pattern;railway-oriented-programming;functional-programming;error-handling;repository-pattern;native-aot true diff --git a/src/EricksonLopez.Specification.Result/ReadRepositoryResultExtensions.cs b/src/EricksonLopez.Specification.Result/ReadRepositoryResultExtensions.cs index 148f13f..2c27732 100644 --- a/src/EricksonLopez.Specification.Result/ReadRepositoryResultExtensions.cs +++ b/src/EricksonLopez.Specification.Result/ReadRepositoryResultExtensions.cs @@ -38,6 +38,10 @@ public static async Task> FirstOrDefaultResultAsync( ? EricksonLopez.Result.Result.Failure(Error.NotFound($"{typeof(T).Name}.NotFound", $"No {typeof(T).Name} found matching the specification.")) : EricksonLopez.Result.Result.Success(result); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { return EricksonLopez.Result.Result.Failure(Error.Failure("Database.Error", ex.Message)); @@ -65,6 +69,10 @@ public static async Task>> ListResultAsync( var result = await repository.ListAsync(specification, cancellationToken).ConfigureAwait(false); return EricksonLopez.Result.Result.Success(result); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { return EricksonLopez.Result.Result.Failure>(Error.Failure("Database.Error", ex.Message)); @@ -95,6 +103,10 @@ public static async Task> SingleOrDefaultResultAsync( ? EricksonLopez.Result.Result.Failure(Error.NotFound($"{typeof(T).Name}.NotFound", $"No {typeof(T).Name} found matching the specification.")) : EricksonLopez.Result.Result.Success(result); } + catch (OperationCanceledException) + { + throw; + } catch (InvalidOperationException) { return EricksonLopez.Result.Result.Failure(Error.Conflict("Database.MultipleMatches", "Multiple entities found matching the specification.")); @@ -130,6 +142,10 @@ public static async Task> GetByIdResultAsync( ? EricksonLopez.Result.Result.Failure(Error.NotFound($"{typeof(T).Name}.NotFound", $"No {typeof(T).Name} found with ID {id}.")) : EricksonLopez.Result.Result.Success(result); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { return EricksonLopez.Result.Result.Failure(Error.Failure("Database.Error", ex.Message)); diff --git a/src/EricksonLopez.Specification.Sql/EricksonLopez.Specification.Sql.csproj b/src/EricksonLopez.Specification.Sql/EricksonLopez.Specification.Sql.csproj index 0cc4161..48aa24b 100644 --- a/src/EricksonLopez.Specification.Sql/EricksonLopez.Specification.Sql.csproj +++ b/src/EricksonLopez.Specification.Sql/EricksonLopez.Specification.Sql.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.Sql EricksonLopez.Specification.Sql SQL generation layer for EricksonLopez.Specification. Translates QuerySpec<T> to a provider-agnostic SQL AST and parameterized SQL strings. No EF Core or Dapper dependency. - $(CommonPackageTags);sql;sql-generation;sql-translator;sql-ast;query-builder;aot + $(CommonPackageTags);sql;sql-generation;sql-translator;sql-ast;sql-dialect;query-builder;native-aot diff --git a/src/EricksonLopez.Specification.Sql/QuerySpecTranslator.cs b/src/EricksonLopez.Specification.Sql/QuerySpecTranslator.cs index f9a8e0c..5f2a887 100644 --- a/src/EricksonLopez.Specification.Sql/QuerySpecTranslator.cs +++ b/src/EricksonLopez.Specification.Sql/QuerySpecTranslator.cs @@ -81,9 +81,36 @@ public QueryModel Translate(QuerySpec spec) { if (spec.SkipCount == null && spec.TakeCount == null && spec.OrderClauses.IsEmpty && !spec.IsDistinct && spec.Cursor == null) { + var currentParameters = new List(); + var counter = 0; + TranslateFilters(spec.Criteria, currentParameters, ref counter); + + if (cachedPlan.Parameters.Length == 0 && currentParameters.Count == 0) + { + Diagnostics.SpecificationDiagnostics.SqlTranslations.Add(1); + Diagnostics.SpecificationDiagnostics.SqlTranslationDuration.Record(sw.Elapsed.TotalMilliseconds); + return cachedPlan; + } + + var parametersMatch = cachedPlan.Parameters.Length == currentParameters.Count; + if (parametersMatch) + { + for (var i = 0; i < cachedPlan.Parameters.Length; i++) + { + if (!Equals(cachedPlan.Parameters[i].Value, currentParameters[i].Value) || + cachedPlan.Parameters[i].Name != currentParameters[i].Name) + { + parametersMatch = false; + break; + } + } + } + + var reparameterizedPlan = parametersMatch ? cachedPlan : cachedPlan with { Parameters = [.. currentParameters] }; + Diagnostics.SpecificationDiagnostics.SqlTranslations.Add(1); Diagnostics.SpecificationDiagnostics.SqlTranslationDuration.Record(sw.Elapsed.TotalMilliseconds); - return cachedPlan; + return reparameterizedPlan; } } diff --git a/src/EricksonLopez.Specification.Sql/RawPredicateNode.cs b/src/EricksonLopez.Specification.Sql/RawPredicateNode.cs index c3a7736..5f21a5f 100644 --- a/src/EricksonLopez.Specification.Sql/RawPredicateNode.cs +++ b/src/EricksonLopez.Specification.Sql/RawPredicateNode.cs @@ -3,7 +3,7 @@ namespace EricksonLopez.Specification.Sql; /// -/// A raw SQL predicate for unsupported expression patterns. Internal to dialect implementations. +/// Represents a raw SQL predicate for unsupported expression patterns. /// /// The raw SQL string. internal sealed record RawPredicateNode(string Sql) : SqlPredicateNode; diff --git a/src/EricksonLopez.Specification.Sql/SqlQueryType.cs b/src/EricksonLopez.Specification.Sql/SqlQueryType.cs index 02ce4df..41fbfba 100644 --- a/src/EricksonLopez.Specification.Sql/SqlQueryType.cs +++ b/src/EricksonLopez.Specification.Sql/SqlQueryType.cs @@ -5,10 +5,10 @@ namespace EricksonLopez.Specification.Sql; /// Specifies the type of SQL query to generate. public enum SqlQueryType { - /// A standard SELECT query. + /// Specifies a standard SELECT query returning rows. Select, - /// A COUNT(*) query. + /// Specifies a COUNT(*) query returning the row count. Count, - /// An EXISTS query (e.g. SELECT 1 ... LIMIT 1). + /// Specifies an EXISTS query returning whether matching rows exist. Exists } diff --git a/src/EricksonLopez.Specification.Sqlite/EricksonLopez.Specification.Sqlite.csproj b/src/EricksonLopez.Specification.Sqlite/EricksonLopez.Specification.Sqlite.csproj index de2b743..9a9bb2c 100644 --- a/src/EricksonLopez.Specification.Sqlite/EricksonLopez.Specification.Sqlite.csproj +++ b/src/EricksonLopez.Specification.Sqlite/EricksonLopez.Specification.Sqlite.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification.Sqlite EricksonLopez.Specification.Sqlite SQLite dialect for EricksonLopez.Specification. Translates QueryModel to SQLite-compatible SQL (LIMIT/OFFSET, IN expansion). Primarily used for integration testing without an external server. - $(CommonPackageTags);sqlite;sql-dialect;embedded-database;aot + $(CommonPackageTags);sqlite;sql-dialect;embedded-database;rdbms;sql;native-aot diff --git a/src/EricksonLopez.Specification/Diagnostics/SpecificationVersion.cs b/src/EricksonLopez.Specification/Diagnostics/SpecificationVersion.cs index 3c2837a..f844747 100644 --- a/src/EricksonLopez.Specification/Diagnostics/SpecificationVersion.cs +++ b/src/EricksonLopez.Specification/Diagnostics/SpecificationVersion.cs @@ -2,8 +2,8 @@ namespace EricksonLopez.Specification.Diagnostics; -/// Contains the current library version for telemetry. +/// Provides the current library version for telemetry. internal static class SpecificationVersion { - internal const string Current = "1.0.0"; + internal const string Current = "2.0.0"; } diff --git a/src/EricksonLopez.Specification/Engine/ExpressionDebugFormatter.cs b/src/EricksonLopez.Specification/Engine/ExpressionDebugFormatter.cs index 43ffea2..4f4a405 100644 --- a/src/EricksonLopez.Specification/Engine/ExpressionDebugFormatter.cs +++ b/src/EricksonLopez.Specification/Engine/ExpressionDebugFormatter.cs @@ -29,23 +29,37 @@ public sealed class ExpressionDebugFormatter : ExpressionVisitor /// Gets the singleton instance of the formatter. public static readonly ExpressionDebugFormatter Default = new(); + static ExpressionDebugFormatter() + { + ExpressionDebugFormatterRegistry.Formatter = Format; + } + private ExpressionDebugFormatter() { } /// /// Formats the specified expression tree as a human-readable string. /// - /// The predicate input type. /// The expression tree to format. /// A formatted string representation of the expression. /// is - public static string Format(Expression> expression) + public static string Format(Expression expression) { ArgumentNullException.ThrowIfNull(expression); var builder = new FormatVisitor(); - builder.Visit(expression.Body); + var body = expression is LambdaExpression lambda ? lambda.Body : expression; + builder.Visit(body); return builder.ToString(); } + /// + /// Formats the specified expression tree as a human-readable string. + /// + /// The predicate input type. + /// The expression tree to format. + /// A formatted string representation of the expression. + /// is + public static string Format(Expression> expression) => Format((Expression)expression); + private sealed class FormatVisitor : ExpressionVisitor { private readonly StringBuilder _sb = new(); diff --git a/src/EricksonLopez.Specification/Engine/ExpressionEqualityComparer.cs b/src/EricksonLopez.Specification/Engine/ExpressionEqualityComparer.cs index 87bcfd9..4d58cbb 100644 --- a/src/EricksonLopez.Specification/Engine/ExpressionEqualityComparer.cs +++ b/src/EricksonLopez.Specification/Engine/ExpressionEqualityComparer.cs @@ -20,31 +20,48 @@ public sealed class ExpressionEqualityComparer : IEqualityComparer private ExpressionEqualityComparer() { } + private const int MaxDepth = 512; + + [ThreadStatic] + private static int s_depth; + /// + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Guards against StackOverflowException DoS attack on deeply nested ASTs.")] public bool Equals(Expression? x, Expression? y) { - if (ReferenceEquals(x, y)) return true; - if (x is null || y is null) return false; - if (x.NodeType != y.NodeType || x.Type != y.Type) return false; + if (s_depth > MaxDepth) + throw new InvalidOperationException($"Expression tree exceeds maximum supported equality depth of {MaxDepth}."); - return x switch + s_depth++; + try { - BinaryExpression b => EqualsBinary(b, (BinaryExpression)y), - UnaryExpression u => EqualsUnary(u, (UnaryExpression)y), - MethodCallExpression m => EqualsMethodCall(m, (MethodCallExpression)y), - MemberExpression m => EqualsMember(m, (MemberExpression)y), - ConstantExpression c => EqualsConstant(c, (ConstantExpression)y), - ParameterExpression p => EqualsParameter(p, (ParameterExpression)y), - LambdaExpression l => EqualsLambda(l, (LambdaExpression)y), - ConditionalExpression c => EqualsConditional(c, (ConditionalExpression)y), - InvocationExpression i => EqualsInvocation(i, (InvocationExpression)y), - NewExpression n => EqualsNew(n, (NewExpression)y), - NewArrayExpression n => EqualsNewArray(n, (NewArrayExpression)y), - MemberInitExpression m => EqualsMemberInit(m, (MemberInitExpression)y), - ListInitExpression l => EqualsListInit(l, (ListInitExpression)y), - TypeBinaryExpression t => EqualsTypeBinary(t, (TypeBinaryExpression)y), - _ => false - }; + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + if (x.NodeType != y.NodeType || x.Type != y.Type) return false; + + return x switch + { + BinaryExpression b => EqualsBinary(b, (BinaryExpression)y), + UnaryExpression u => EqualsUnary(u, (UnaryExpression)y), + MethodCallExpression m => EqualsMethodCall(m, (MethodCallExpression)y), + MemberExpression m => EqualsMember(m, (MemberExpression)y), + ConstantExpression c => EqualsConstant(c, (ConstantExpression)y), + ParameterExpression p => EqualsParameter(p, (ParameterExpression)y), + LambdaExpression l => EqualsLambda(l, (LambdaExpression)y), + ConditionalExpression c => EqualsConditional(c, (ConditionalExpression)y), + InvocationExpression i => EqualsInvocation(i, (InvocationExpression)y), + NewExpression n => EqualsNew(n, (NewExpression)y), + NewArrayExpression n => EqualsNewArray(n, (NewArrayExpression)y), + MemberInitExpression m => EqualsMemberInit(m, (MemberInitExpression)y), + ListInitExpression l => EqualsListInit(l, (ListInitExpression)y), + TypeBinaryExpression t => EqualsTypeBinary(t, (TypeBinaryExpression)y), + _ => false + }; + } + finally + { + s_depth--; + } } /// @@ -73,9 +90,28 @@ private bool EqualsMethodCall(MethodCallExpression x, MethodCallExpression y) => Equals(x.Object, y.Object) && EqualsReadOnlyCollection(x.Arguments, y.Arguments); - private bool EqualsMember(MemberExpression x, MemberExpression y) => - x.Member == y.Member && - Equals(x.Expression, y.Expression); + private bool EqualsMember(MemberExpression x, MemberExpression y) + { + if (x.Member != y.Member) return false; + + if (x.Expression is null && y.Expression is null) + { + var valX = GetStaticMemberValue(x.Member); + var valY = GetStaticMemberValue(y.Member); + return Equals(valX, valY); + } + + return Equals(x.Expression, y.Expression); + } + + private static object? GetStaticMemberValue(MemberInfo member) + { + if (member is PropertyInfo pi && (pi.GetMethod?.IsStatic ?? false)) + return pi.GetValue(null); + if (member is FieldInfo fi && fi.IsStatic) + return fi.GetValue(null); + return null; + } private static bool EqualsConstant(ConstantExpression x, ConstantExpression y) => Equals(x.Value, y.Value); @@ -181,5 +217,3 @@ private static bool EqualsReadOnlyCollection(System.Collections.ObjectModel.Read return true; } } - - diff --git a/src/EricksonLopez.Specification/Engine/ExpressionHasher.cs b/src/EricksonLopez.Specification/Engine/ExpressionHasher.cs index 611f447..05967ea 100644 --- a/src/EricksonLopez.Specification/Engine/ExpressionHasher.cs +++ b/src/EricksonLopez.Specification/Engine/ExpressionHasher.cs @@ -33,24 +33,36 @@ public static int ComputeHash(Expression expression) private sealed class HashVisitor : ExpressionVisitor { + private const int MaxDepth = 512; + private int _depth; private HashCode _hash; internal int Hash => _hash.ToHashCode(); public override Expression? Visit(Expression? node) { - if (node is null) + if (++_depth > MaxDepth) + throw new InvalidOperationException($"Expression tree exceeds maximum supported hashing depth of {MaxDepth}."); + + try { - // Stryker disable once Statement : Hash collision for missing nodes is astronomically unlikely - _hash.Add(0); - return null; - } + if (node is null) + { + // Stryker disable once Statement : Hash collision for missing nodes is astronomically unlikely + _hash.Add(0); + return null; + } - _hash.Add((int)node.NodeType); - // Stryker disable once Statement : Node types usually disambiguate anyway - _hash.Add(node.Type.GetHashCode()); + _hash.Add((int)node.NodeType); + // Stryker disable once Statement : Node types usually disambiguate anyway + _hash.Add(node.Type.GetHashCode()); - return base.Visit(node); + return base.Visit(node); + } + finally + { + _depth--; + } } protected override Expression VisitConstant(ConstantExpression node) @@ -62,9 +74,23 @@ protected override Expression VisitConstant(ConstantExpression node) protected override Expression VisitMember(MemberExpression node) { _hash.Add(node.Member.GetHashCode()); + if (node.Expression is null) + { + var val = GetStaticMemberValue(node.Member); + _hash.Add(val?.GetHashCode() ?? 0); + } return base.VisitMember(node); } + private static object? GetStaticMemberValue(System.Reflection.MemberInfo member) + { + if (member is System.Reflection.PropertyInfo pi && (pi.GetMethod?.IsStatic ?? false)) + return pi.GetValue(null); + if (member is System.Reflection.FieldInfo fi && fi.IsStatic) + return fi.GetValue(null); + return null; + } + protected override Expression VisitMethodCall(MethodCallExpression node) { _hash.Add(node.Method.GetHashCode()); diff --git a/src/EricksonLopez.Specification/Engine/ExpressionInterpreter.cs b/src/EricksonLopez.Specification/Engine/ExpressionInterpreter.cs index 83d73f7..86c3d00 100644 --- a/src/EricksonLopez.Specification/Engine/ExpressionInterpreter.cs +++ b/src/EricksonLopez.Specification/Engine/ExpressionInterpreter.cs @@ -19,6 +19,8 @@ namespace EricksonLopez.Specification; /// public static class ExpressionInterpreter { + private const int MaxDepth = 512; + /// /// Evaluates whether a predicate expression is satisfied by a candidate value without runtime IL compilation. /// @@ -36,7 +38,7 @@ public static bool Evaluate< { ArgumentNullException.ThrowIfNull(expression); ArgumentNullException.ThrowIfNull(candidate); - var result = EvaluateNode(expression.Body, expression.Parameters[0], candidate); + var result = EvaluateNode(expression.Body, expression.Parameters[0], candidate, 0); return (bool)result!; } @@ -46,18 +48,22 @@ private static object? EvaluateNode< DynamicallyAccessedMemberTypes.PublicFields)] T>( Expression node, ParameterExpression param, - T candidate) + T candidate, + int depth) { + if (depth > MaxDepth) + throw new InvalidOperationException($"Expression tree exceeds maximum supported evaluation depth of {MaxDepth}."); + return node switch { ConstantExpression c => c.Value, ParameterExpression p when ReferenceEquals(p, param) => candidate, - MemberExpression m => EvaluateMember(m, param, candidate), - BinaryExpression b => EvaluateBinary(b, param, candidate), - UnaryExpression u => EvaluateUnary(u, param, candidate), - ConditionalExpression cond => EvaluateConditional(cond, param, candidate), - TypeBinaryExpression tb => EvaluateTypeBinary(tb, param, candidate), - MethodCallExpression mc => EvaluateMethodCall(mc, param, candidate), + MemberExpression m => EvaluateMember(m, param, candidate, depth + 1), + BinaryExpression b => EvaluateBinary(b, param, candidate, depth + 1), + UnaryExpression u => EvaluateUnary(u, param, candidate, depth + 1), + ConditionalExpression cond => EvaluateConditional(cond, param, candidate, depth + 1), + TypeBinaryExpression tb => EvaluateTypeBinary(tb, param, candidate, depth + 1), + MethodCallExpression mc => EvaluateMethodCall(mc, param, candidate, depth + 1), _ => throw new NotSupportedException( $"Expression node type '{node.NodeType}' is not supported by the interpreted evaluator. " + $"Use ToCompiledPredicate() in JIT environments for full expression support.") @@ -74,9 +80,10 @@ private static object? EvaluateMember< DynamicallyAccessedMemberTypes.PublicFields)] T>( MemberExpression m, ParameterExpression param, - T candidate) + T candidate, + int depth) { - var instance = m.Expression is null ? null : EvaluateNode(m.Expression, param, candidate); + var instance = m.Expression is null ? null : EvaluateNode(m.Expression, param, candidate, depth); return m.Member switch { System.Reflection.PropertyInfo p => p.GetValue(instance), @@ -92,13 +99,14 @@ private static object? EvaluateBinary< DynamicallyAccessedMemberTypes.PublicFields)] T>( BinaryExpression b, ParameterExpression param, - T candidate) + T candidate, + int depth) { if (b.NodeType == ExpressionType.AndAlso) { - var left = EvaluateNode(b.Left, param, candidate); + var left = EvaluateNode(b.Left, param, candidate, depth); if (left is false) return false; - var right = EvaluateNode(b.Right, param, candidate); + var right = EvaluateNode(b.Right, param, candidate, depth); if (right is false) return false; if (left is null || right is null) return null; return true; @@ -106,9 +114,9 @@ private static object? EvaluateBinary< if (b.NodeType == ExpressionType.OrElse) { - var left = EvaluateNode(b.Left, param, candidate); + var left = EvaluateNode(b.Left, param, candidate, depth); if (left is true) return true; - var right = EvaluateNode(b.Right, param, candidate); + var right = EvaluateNode(b.Right, param, candidate, depth); if (right is true) return true; if (left is null || right is null) return null; return false; @@ -116,12 +124,26 @@ private static object? EvaluateBinary< if (b.NodeType == ExpressionType.Coalesce) { - var left = EvaluateNode(b.Left, param, candidate); - return left ?? EvaluateNode(b.Right, param, candidate); + var left = EvaluateNode(b.Left, param, candidate, depth); + return left ?? EvaluateNode(b.Right, param, candidate, depth); } - var leftVal = EvaluateNode(b.Left, param, candidate); - var rightVal = EvaluateNode(b.Right, param, candidate); + var leftVal = EvaluateNode(b.Left, param, candidate, depth); + var rightVal = EvaluateNode(b.Right, param, candidate, depth); + + if (b.Left is not ConstantExpression && (leftVal is null || rightVal is null)) + { + return b.NodeType switch + { + ExpressionType.Equal => Equals(leftVal, rightVal), + ExpressionType.NotEqual => !Equals(leftVal, rightVal), + ExpressionType.GreaterThan or + ExpressionType.GreaterThanOrEqual or + ExpressionType.LessThan or + ExpressionType.LessThanOrEqual => false, + _ => throw new NotSupportedException($"Binary operator '{b.NodeType}' is not supported by the interpreted evaluator.") + }; + } return b.NodeType switch { @@ -141,12 +163,13 @@ private static object? EvaluateConditional< DynamicallyAccessedMemberTypes.PublicFields)] T>( ConditionalExpression cond, ParameterExpression param, - T candidate) + T candidate, + int depth) { - var test = EvaluateNode(cond.Test, param, candidate); + var test = EvaluateNode(cond.Test, param, candidate, depth); return test is true - ? EvaluateNode(cond.IfTrue, param, candidate) - : EvaluateNode(cond.IfFalse, param, candidate); + ? EvaluateNode(cond.IfTrue, param, candidate, depth) + : EvaluateNode(cond.IfFalse, param, candidate, depth); } private static object? EvaluateTypeBinary< @@ -155,11 +178,12 @@ private static object? EvaluateTypeBinary< DynamicallyAccessedMemberTypes.PublicFields)] T>( TypeBinaryExpression tb, ParameterExpression param, - T candidate) + T candidate, + int depth) { if (tb.NodeType == ExpressionType.TypeIs) { - var operand = EvaluateNode(tb.Expression, param, candidate); + var operand = EvaluateNode(tb.Expression, param, candidate, depth); return operand != null && tb.TypeOperand.IsAssignableFrom(operand.GetType()); } @@ -172,37 +196,93 @@ private static object? EvaluateUnary< DynamicallyAccessedMemberTypes.PublicFields)] T>( UnaryExpression u, ParameterExpression param, - T candidate) + T candidate, + int depth) { - var operand = EvaluateNode(u.Operand, param, candidate); + var operand = EvaluateNode(u.Operand, param, candidate, depth); return u.NodeType switch { ExpressionType.Not => operand is false || operand is null, - ExpressionType.Convert => Convert.ChangeType(operand, u.Type, System.Globalization.CultureInfo.InvariantCulture), + ExpressionType.Convert or ExpressionType.ConvertChecked => ConvertOperand(operand, u.Type), _ => throw new NotSupportedException($"Unary operator '{u.NodeType}' is not supported by the interpreted evaluator.") }; } + private static object? ConvertOperand(object? operand, Type targetType) + { + if (operand is null) + return null; + + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlying.IsInstanceOfType(operand)) + return operand; + + if (underlying.IsEnum) + return Enum.ToObject(underlying, operand); + + return Convert.ChangeType(operand, underlying, System.Globalization.CultureInfo.InvariantCulture); + } + private static object? EvaluateMethodCall< [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] T>( MethodCallExpression mc, ParameterExpression param, - T candidate) + T candidate, + int depth) { - var instance = mc.Object is null ? null : EvaluateNode(mc.Object, param, candidate); + EnsureSafeMethod(mc.Method); + + var instance = mc.Object is null ? null : EvaluateNode(mc.Object, param, candidate, depth); var args = new object?[mc.Arguments.Count]; for (var i = 0; i < mc.Arguments.Count; i++) - args[i] = EvaluateNode(mc.Arguments[i], param, candidate); + args[i] = EvaluateNode(mc.Arguments[i], param, candidate, depth); return mc.Method.Invoke(instance, args); } + private static void EnsureSafeMethod(System.Reflection.MethodInfo method) + { + var declaringType = method.DeclaringType; + if (declaringType is null) return; + + var ns = declaringType.Namespace ?? string.Empty; + if (ns.StartsWith("System.Diagnostics", StringComparison.Ordinal) || + ns.StartsWith("System.IO", StringComparison.Ordinal) || + ns.StartsWith("System.Reflection", StringComparison.Ordinal) || + declaringType == typeof(Environment)) + { + throw new InvalidOperationException($"Method '{method.Name}' on type '{declaringType.FullName}' is not permitted in interpreted specification evaluation for security reasons."); + } + } + private static int CompareValues(object? left, object? right) { if (left is IComparable comparable) + { + if (right is not null && right.GetType() != left.GetType()) + { + try + { + right = Convert.ChangeType(right, left.GetType(), System.Globalization.CultureInfo.InvariantCulture); + } + catch (InvalidCastException) + { + // Fall back to direct comparison + } + catch (FormatException) + { + // Fall back to direct comparison + } + catch (OverflowException) + { + // Fall back to direct comparison + } + } return comparable.CompareTo(right); + } // Stryker disable once all : Non-IComparable comparison is guarded by Expression tree construction throw new NotSupportedException( diff --git a/src/EricksonLopez.Specification/EricksonLopez.Specification.csproj b/src/EricksonLopez.Specification/EricksonLopez.Specification.csproj index b2ec43f..98573db 100644 --- a/src/EricksonLopez.Specification/EricksonLopez.Specification.csproj +++ b/src/EricksonLopez.Specification/EricksonLopez.Specification.csproj @@ -5,7 +5,7 @@ EricksonLopez.Specification EricksonLopez.Specification Core Specification Pattern implementation for .NET 10+. AOT-first, allocation-conscious, DDD-compatible. Includes expression engine, composition, simplification, and structural hashing. - $(CommonPackageTags);expression-tree;expression;composition;boolean-algebra;interpreter;aot;native-aot;high-performance + $(CommonPackageTags);expression-tree;composition;boolean-algebra;interpreter;predicate;query-specification;native-aot;trimming diff --git a/src/EricksonLopez.Specification/Spec.cs b/src/EricksonLopez.Specification/Spec.cs index 3797552..ce98633 100644 --- a/src/EricksonLopez.Specification/Spec.cs +++ b/src/EricksonLopez.Specification/Spec.cs @@ -1,5 +1,6 @@ // Copyright © Erickson Lopez. MIT License. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; @@ -90,6 +91,47 @@ public static Specification All< return new LambdaSpecification(ExpressionComposer.AndAll(expressions.AsSpan())); } + /// + /// Composes a sequence of specifications using logical AND. + /// + /// The entity type. + /// The specifications sequence to compose with AND. + /// A specification that is satisfied only when all of are satisfied. + /// is + /// + /// + /// var specs = new List<Specification<Product>> { new ActiveSpec(), new InStockSpec() }; + /// var composite = Spec.All(specs); + /// + /// + public static Specification All< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] T>(IEnumerable> specifications) + { + ArgumentNullException.ThrowIfNull(specifications); + + if (specifications is Specification[] array) + { + return All(array); + } + + if (specifications is IReadOnlyList> list) + { + if (list.Count == 0) return True(); + if (list.Count == 1) return list[0]; + + var expressions = new Expression>[list.Count]; + for (var i = 0; i < list.Count; i++) + { + expressions[i] = list[i].ToExpression(); + } + return new LambdaSpecification(ExpressionComposer.AndAll(expressions.AsSpan())); + } + + return All(specifications.ToArray()); + } + /// /// Composes all specifications using logical OR. /// @@ -117,6 +159,47 @@ public static Specification Any< return new LambdaSpecification(ExpressionComposer.OrAny(expressions.AsSpan())); } + /// + /// Composes a sequence of specifications using logical OR. + /// + /// The entity type. + /// The specifications sequence to compose with OR. + /// A specification that is satisfied when any of is satisfied. + /// is + /// + /// + /// var specs = new List<Specification<Product>> { new PremiumSpec(), new VipSpec() }; + /// var composite = Spec.Any(specs); + /// + /// + public static Specification Any< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] T>(IEnumerable> specifications) + { + ArgumentNullException.ThrowIfNull(specifications); + + if (specifications is Specification[] array) + { + return Any(array); + } + + if (specifications is IReadOnlyList> list) + { + if (list.Count == 0) return False(); + if (list.Count == 1) return list[0]; + + var expressions = new Expression>[list.Count]; + for (var i = 0; i < list.Count; i++) + { + expressions[i] = list[i].ToExpression(); + } + return new LambdaSpecification(ExpressionComposer.OrAny(expressions.AsSpan())); + } + + return Any(specifications.ToArray()); + } + /// /// Creates a specification that determines whether a property value is inclusively between the lower and upper bounds. /// @@ -127,6 +210,7 @@ public static Specification Any< /// The inclusive upper bound. /// A specification checking the range. /// is + /// is greater than public static Specification Between< [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicProperties | @@ -137,6 +221,8 @@ public static Specification Between< where TProperty : IComparable { ArgumentNullException.ThrowIfNull(propertySelector); + if (lower.CompareTo(upper) > 0) + throw new ArgumentException($"Lower bound '{lower}' cannot be greater than upper bound '{upper}'.", nameof(lower)); var param = propertySelector.Parameters[0]; var propExpr = propertySelector.Body; @@ -152,6 +238,48 @@ public static Specification Between< return new LambdaSpecification(lambda); } + /// + /// Creates a specification that determines whether a nullable property value is inclusively between the lower and upper bounds. + /// + /// The entity type. + /// The underlying property type. + /// The property selector expression returning a nullable value. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// A specification checking the range. + /// is + /// is greater than + public static Specification Between< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicFields)] T, TProperty>( + Expression> propertySelector, + TProperty lower, + TProperty upper) + where TProperty : struct, IComparable + { + ArgumentNullException.ThrowIfNull(propertySelector); + if (lower.CompareTo(upper) > 0) + throw new ArgumentException($"Lower bound '{lower}' cannot be greater than upper bound '{upper}'.", nameof(lower)); + + var param = propertySelector.Parameters[0]; + var propExpr = propertySelector.Body; + + var nullConstant = Expression.Constant(null, typeof(TProperty?)); + var notNull = Expression.NotEqual(propExpr, nullConstant); + + var lowerConstant = Expression.Constant((TProperty?)lower, typeof(TProperty?)); + var upperConstant = Expression.Constant((TProperty?)upper, typeof(TProperty?)); + + var gte = Expression.GreaterThanOrEqual(propExpr, lowerConstant); + var lte = Expression.LessThanOrEqual(propExpr, upperConstant); + var inRange = Expression.AndAlso(gte, lte); + var and = Expression.AndAlso(notNull, inRange); + + var lambda = Expression.Lambda>(and, param); + return new LambdaSpecification(lambda); + } + /// /// Creates a specification that determines whether a property value is inclusively between the lower and upper bounds. /// diff --git a/src/EricksonLopez.Specification/Specification.cs b/src/EricksonLopez.Specification/Specification.cs index 6528eb0..97b7b07 100644 --- a/src/EricksonLopez.Specification/Specification.cs +++ b/src/EricksonLopez.Specification/Specification.cs @@ -26,6 +26,11 @@ public abstract class Specification< { private readonly Lazy>> _expression; + static Specification() + { + ExpressionDebugFormatterRegistry.Formatter = ExpressionDebugFormatter.Format; + } + /// /// Initializes a new instance of the class. /// @@ -135,6 +140,90 @@ public static implicit operator QuerySpec(Specification specification) ArgumentNullException.ThrowIfNull(specification); return specification.ToQuerySpec(); } + + /// + /// Combines two specifications using logical AND. + /// + /// The left specification. + /// The right specification. + /// A new composite specification representing (left AND right). + /// or is + public static Specification operator &(Specification left, Specification right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + return left.And(right); + } + + /// + /// Combines two specifications using logical AND as a named alternative to . + /// + /// The left specification. + /// The right specification. + /// A new composite specification representing ( AND ). + /// or is + public static Specification BitwiseAnd(Specification left, Specification right) => left & right; + + /// + /// Combines two specifications using logical OR. + /// + /// The left specification. + /// The right specification. + /// A new composite specification representing (left OR right). + /// or is + public static Specification operator |(Specification left, Specification right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + return left.Or(right); + } + + /// + /// Combines two specifications using logical OR as a named alternative to . + /// + /// The left specification. + /// The right specification. + /// A new composite specification representing ( OR ). + /// or is + public static Specification BitwiseOr(Specification left, Specification right) => left | right; + + /// + /// Negates the specified specification. + /// + /// The specification to negate. + /// A new negated specification representing (NOT specification). + /// is + public static Specification operator !(Specification specification) + { + ArgumentNullException.ThrowIfNull(specification); + return specification.Not(); + } + + /// + /// Negates the specified specification as a named alternative to . + /// + /// The specification to negate. + /// A new negated specification representing (NOT ). + /// is + public static Specification LogicalNot(Specification specification) => !specification; + + /// + /// Determines whether the specification evaluates to false for short-circuit evaluation in logical AND expressions. + /// + /// The specification to evaluate. + /// Always to ensure both operands are evaluated. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", + Justification = "Short-circuit operator pair (true/false) is an internal C# language idiom without a standard named alternative.")] + public static bool operator false(Specification specification) => false; + + /// + /// Determines whether the specification evaluates to true for short-circuit evaluation in logical OR expressions. + /// + /// The specification to evaluate. + /// Always to ensure both operands are evaluated. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", + Justification = "Short-circuit operator pair (true/false) is an internal C# language idiom without a standard named alternative.")] + public static bool operator true(Specification specification) => false; } diff --git a/src/EricksonLopez.Specification/TypeForwarders.cs b/src/EricksonLopez.Specification/TypeForwarders.cs new file mode 100644 index 0000000..4613a2c --- /dev/null +++ b/src/EricksonLopez.Specification/TypeForwarders.cs @@ -0,0 +1,4 @@ +// Copyright © Erickson Lopez. MIT License. +using System.Runtime.CompilerServices; + +[assembly: TypeForwardedTo(typeof(EricksonLopez.Specification.IExpressionSpecification<>))] diff --git a/stryker-abstractions-config.json b/stryker-abstractions-config.json index 487c64a..f325d3c 100644 --- a/stryker-abstractions-config.json +++ b/stryker-abstractions-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-analyzers-config.json b/stryker-analyzers-config.json index bd44e8b..0c154ae 100644 --- a/stryker-analyzers-config.json +++ b/stryker-analyzers-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-config.json b/stryker-config.json index 28e4aaf..fe933ea 100644 --- a/stryker-config.json +++ b/stryker-config.json @@ -25,6 +25,7 @@ "ignore-methods": [ "ConfigureAwait", "Dispose" - ] + ], + "concurrency": 2 } } diff --git a/stryker-dapper-config.json b/stryker-dapper-config.json index 8bdae47..31adc98 100644 --- a/stryker-dapper-config.json +++ b/stryker-dapper-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-dapperextensions-config.json b/stryker-dapperextensions-config.json index f101509..ccf8779 100644 --- a/stryker-dapperextensions-config.json +++ b/stryker-dapperextensions-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-efcore-config.json b/stryker-efcore-config.json index 594be32..247d305 100644 --- a/stryker-efcore-config.json +++ b/stryker-efcore-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-generators-config.json b/stryker-generators-config.json index 0af0256..d91894e 100644 --- a/stryker-generators-config.json +++ b/stryker-generators-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-linq-config.json b/stryker-linq-config.json index 76d04ab..7b9162d 100644 --- a/stryker-linq-config.json +++ b/stryker-linq-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-mariadb-config.json b/stryker-mariadb-config.json index 7a9a1f9..3960182 100644 --- a/stryker-mariadb-config.json +++ b/stryker-mariadb-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-mongodb-config.json b/stryker-mongodb-config.json index a22834b..8ad211f 100644 --- a/stryker-mongodb-config.json +++ b/stryker-mongodb-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-mssql-config.json b/stryker-mssql-config.json index e89da10..8dee1db 100644 --- a/stryker-mssql-config.json +++ b/stryker-mssql-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-mysql-config.json b/stryker-mysql-config.json index 8bf85e2..5bc96e2 100644 --- a/stryker-mysql-config.json +++ b/stryker-mysql-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-oracle-config.json b/stryker-oracle-config.json index 9bad8f6..22c1d5b 100644 --- a/stryker-oracle-config.json +++ b/stryker-oracle-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-postgresql-config.json b/stryker-postgresql-config.json index 3c0bf4e..75281c7 100644 --- a/stryker-postgresql-config.json +++ b/stryker-postgresql-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-result-config.json b/stryker-result-config.json new file mode 100644 index 0000000..61627d5 --- /dev/null +++ b/stryker-result-config.json @@ -0,0 +1,27 @@ +{ + "stryker-config": { + "project": "EricksonLopez.Specification.Result.csproj", + "test-projects": [ + "EricksonLopez.Specification.Tests.csproj" + ], + "mutate": [ + "**/*.cs", + "!bin/**", + "!obj/**", + "!**/*.g.cs", + "!**/*.AssemblyInfo.cs" + ], + "thresholds": { + "high": 100, + "low": 98, + "break": 95 + }, + "reporters": [ + "html", + "json", + "cleartext", + "progress" + ], + "concurrency": 2 + } +} diff --git a/stryker-sql-config.json b/stryker-sql-config.json index e82c016..bd0a262 100644 --- a/stryker-sql-config.json +++ b/stryker-sql-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/stryker-sqlite-config.json b/stryker-sqlite-config.json index 6f9c614..70e1169 100644 --- a/stryker-sqlite-config.json +++ b/stryker-sqlite-config.json @@ -21,6 +21,7 @@ "json", "cleartext", "progress" - ] + ], + "concurrency": 2 } } diff --git a/tests/EricksonLopez.Specification.Dapper.Tests/EricksonLopez.Specification.Dapper.Tests.csproj b/tests/EricksonLopez.Specification.Dapper.Tests/EricksonLopez.Specification.Dapper.Tests.csproj index 6d8f107..fb25cc4 100644 --- a/tests/EricksonLopez.Specification.Dapper.Tests/EricksonLopez.Specification.Dapper.Tests.csproj +++ b/tests/EricksonLopez.Specification.Dapper.Tests/EricksonLopez.Specification.Dapper.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/EricksonLopez.Specification.MongoDB.Tests/MongoSpecificationEvaluatorTests.cs b/tests/EricksonLopez.Specification.MongoDB.Tests/MongoSpecificationEvaluatorTests.cs index 026d126..3ff2fa3 100644 --- a/tests/EricksonLopez.Specification.MongoDB.Tests/MongoSpecificationEvaluatorTests.cs +++ b/tests/EricksonLopez.Specification.MongoDB.Tests/MongoSpecificationEvaluatorTests.cs @@ -206,5 +206,19 @@ public void ApplySpecification_WithoutSortOrPagination_DoesNotCallSortSkipOrLimi findFluent.DidNotReceive().Limit(Arg.Any()); } + [Fact] + public void ApplySpecification_WithCriteria_CombinesCriteriaWithFilter() + { + var spec = QuerySpec.Empty.Where(d => d.IsActive); + var findFluent = Substitute.For>(); + findFluent.Filter = Builders.Filter.Empty; + + var result = findFluent.ApplySpecification(spec); + + result.Should().BeSameAs(findFluent); + findFluent.Filter.Should().NotBeNull(); + findFluent.Filter.Should().NotBe(Builders.Filter.Empty); + } + #endregion } diff --git a/tests/EricksonLopez.Specification.PostgreSql.IntegrationTests/EricksonLopez.Specification.PostgreSql.IntegrationTests.csproj b/tests/EricksonLopez.Specification.PostgreSql.IntegrationTests/EricksonLopez.Specification.PostgreSql.IntegrationTests.csproj index 4fb6745..b4efaf4 100644 --- a/tests/EricksonLopez.Specification.PostgreSql.IntegrationTests/EricksonLopez.Specification.PostgreSql.IntegrationTests.csproj +++ b/tests/EricksonLopez.Specification.PostgreSql.IntegrationTests/EricksonLopez.Specification.PostgreSql.IntegrationTests.csproj @@ -11,6 +11,7 @@ + all diff --git a/tests/EricksonLopez.Specification.Tests/AdversarialRegressionTests.cs b/tests/EricksonLopez.Specification.Tests/AdversarialRegressionTests.cs new file mode 100644 index 0000000..f7479c9 --- /dev/null +++ b/tests/EricksonLopez.Specification.Tests/AdversarialRegressionTests.cs @@ -0,0 +1,226 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Linq; +using System.Linq.Expressions; +using AwesomeAssertions; +using EricksonLopez.Specification; +using EricksonLopez.Specification.Sql; +using Xunit; + +namespace EricksonLopez.Specification.Tests; + +public static class SecurityContext +{ + public static int CurrentTenantId { get; set; } = 1; +} + +public sealed class TenantRecord +{ + public int Id { get; init; } + public int TenantId { get; init; } + public int? NullableScore { get; init; } + public string Data { get; init; } = string.Empty; +} + +public sealed class AdversarialRegressionTests : IDisposable +{ + public AdversarialRegressionTests() + { + QueryPlanCache.Clear(); + ExpressionCompilationCache.Clear(); + } + + public void Dispose() + { + QueryPlanCache.Clear(); + ExpressionCompilationCache.Clear(); + } + + [Fact] + public void Regression_SEC01_QueryPlanCache_IsolatesParametersAcrossContexts() + { + // ARRANGE: Tenant 1 executes query with TenantId = 100 + SecurityContext.CurrentTenantId = 100; + var translator = new QuerySpecTranslator("records"); + + Expression> filter1 = r => r.TenantId == SecurityContext.CurrentTenantId; + var spec1 = Spec.For(filter1).ToQuerySpec(); + var plan1 = translator.Translate(spec1); + + plan1.Parameters.Should().ContainSingle(); + ((int)plan1.Parameters[0].Value!).Should().Be(100); + + // ACT: Tenant 2 executes query with TenantId = 200 + SecurityContext.CurrentTenantId = 200; + Expression> filter2 = r => r.TenantId == SecurityContext.CurrentTenantId; + var spec2 = Spec.For(filter2).ToQuerySpec(); + var plan2 = translator.Translate(spec2); + + // ASSERT: Tenant 2 MUST receive parameter value 200 (NOT Tenant 1's 100) + plan2.Parameters.Should().ContainSingle(); + var paramVal2 = (int)plan2.Parameters[0].Value!; + paramVal2.Should().Be(200, "QueryPlanCache must not leak parameters across different execution contexts."); + } + + [Fact] + public void Regression_BUG_EXP01_ExpressionInterpreter_HandlesNullableConversions() + { + // ARRANGE: Candidate records with null and non-null values + var recordNull = new TenantRecord { Id = 10, NullableScore = null }; + var recordMatch = new TenantRecord { Id = 10, NullableScore = 80 }; + var recordOther = new TenantRecord { Id = 20, NullableScore = 30 }; + + // ACT & ASSERT: Must not throw InvalidCastException when evaluating Nullable expressions (SEC-01 / BUG-EXP-01 exploit) + var specCast = Spec.For(r => (int?)r.Id == (int?)10); + specCast.IsSatisfiedBy(recordNull).Should().BeTrue(); + specCast.IsSatisfiedBy(recordOther).Should().BeFalse(); + + var specCoalesce = Spec.For(r => (r.NullableScore ?? 0) > 50); + specCoalesce.IsSatisfiedBy(recordNull).Should().BeFalse(); + specCoalesce.IsSatisfiedBy(recordMatch).Should().BeTrue(); + specCoalesce.IsSatisfiedBy(recordOther).Should().BeFalse(); + } + + [Fact] + public void Regression_SEC02_ExpressionInterpreter_GuardsRecursionDepth() + { + // ARRANGE: Create deeply nested expression tree > 512 nodes + ParameterExpression param = Expression.Parameter(typeof(TenantRecord), "x"); + Expression current = Expression.Equal(param, Expression.Constant(null, typeof(TenantRecord))); + + for (int i = 0; i < 550; i++) + { + current = Expression.OrElse(current, Expression.Constant(false)); + } + + var lambda = Expression.Lambda>(current, param); + + // ACT & ASSERT: Should throw InvalidOperationException instead of StackOverflowException + var act = () => ExpressionInterpreter.Evaluate(lambda, new TenantRecord()); + act.Should().Throw() + .WithMessage("*depth*"); + } + + [Fact] + public void Regression_API01_Spec_Between_WithNullableTypes() + { + // ARRANGE & ACT: Spec.Between on Nullable + var spec = Spec.Between(r => r.NullableScore, 20, 80); + + // ASSERT + var match = new TenantRecord { NullableScore = 50 }; + var outside = new TenantRecord { NullableScore = 90 }; + var nullVal = new TenantRecord { NullableScore = null }; + + spec.IsSatisfiedBy(match).Should().BeTrue(); + spec.IsSatisfiedBy(outside).Should().BeFalse(); + spec.IsSatisfiedBy(nullVal).Should().BeFalse(); + } + + [Fact] + public void Regression_API01_Spec_Between_InvalidBounds_ThrowsArgumentException() + { + // ACT & ASSERT: lower > upper should throw ArgumentException + var act = () => Spec.Between(r => r.Id, 100, 10); + act.Should().Throw() + .WithMessage("*cannot be greater than*"); + } + + [Fact] + public void Regression_API02_Specification_BooleanOperators() + { + var specA = Spec.For(r => r.Id > 5); + var specB = Spec.For(r => r.Id < 20); + + // Operator & + var andSpec = specA & specB; + andSpec.IsSatisfiedBy(new TenantRecord { Id = 10 }).Should().BeTrue(); + andSpec.IsSatisfiedBy(new TenantRecord { Id = 25 }).Should().BeFalse(); + + // Operator | + var orSpec = specA | Spec.For(r => r.Id == 0); + orSpec.IsSatisfiedBy(new TenantRecord { Id = 0 }).Should().BeTrue(); + orSpec.IsSatisfiedBy(new TenantRecord { Id = 10 }).Should().BeTrue(); + orSpec.IsSatisfiedBy(new TenantRecord { Id = 2 }).Should().BeFalse(); + + // Operator ! + var notSpec = !specA; + notSpec.IsSatisfiedBy(new TenantRecord { Id = 2 }).Should().BeTrue(); + notSpec.IsSatisfiedBy(new TenantRecord { Id = 10 }).Should().BeFalse(); + } + + [Fact] + public void Regression_EXP10_AndSpecification_ShortCircuitsWhenLeftIsFalse() + { + var falseSpec = Spec.For(r => false); + var explosiveSpec = new ExplosiveSpec(); + + var composite = falseSpec.And(explosiveSpec); + + // Should NOT evaluate explosiveSpec + bool result = composite.IsSatisfiedBy(new TenantRecord()); + result.Should().BeFalse(); + explosiveSpec.WasEvaluated.Should().BeFalse(); + } + + [Fact] + public void Regression_SEC03_ExpressionInterpreter_BlocksUnsafeMethods() + { + var method = typeof(Environment).GetMethod(nameof(Environment.GetEnvironmentVariable), [typeof(string)])!; + var param = Expression.Parameter(typeof(TenantRecord), "r"); + var call = Expression.Call(method, Expression.Constant("PATH")); + var notNull = Expression.NotEqual(call, Expression.Constant(null, typeof(string))); + var lambda = Expression.Lambda>(notNull, param); + + var act = () => ExpressionInterpreter.Evaluate(lambda, new TenantRecord()); + act.Should().Throw() + .WithMessage("*not permitted*security*"); + } + + [Fact] + public void Regression_MUT02_QuerySpec_Skip_Negative_ThrowsArgumentOutOfRangeException() + { + var spec = QuerySpec.Empty; + var act = () => spec.Skip(-1); + act.Should().Throw(); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void Regression_MUT03_QuerySpec_Take_ZeroOrNegative_ThrowsArgumentOutOfRangeException(int invalidTake) + { + var spec = QuerySpec.Empty; + var act = () => spec.Take(invalidTake); + act.Should().Throw(); + } + + [Fact] + public void Regression_MUT04_ExpressionInterpreter_NegationOfNullOrFalse() + { + // !false => true + var specFalse = Spec.For(r => !false); + specFalse.IsSatisfiedBy(new TenantRecord()).Should().BeTrue(); + + // !(null == null) => false + var specNullNeg = Spec.For(r => !(r.NullableScore == null)); + specNullNeg.IsSatisfiedBy(new TenantRecord { NullableScore = null }).Should().BeFalse(); + specNullNeg.IsSatisfiedBy(new TenantRecord { NullableScore = 42 }).Should().BeTrue(); + } + + private sealed class ExplosiveSpec< + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers( + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicFields)] T> : Specification + { + public bool WasEvaluated { get; private set; } + + protected override Expression> BuildExpression() => x => true; + + public new bool IsSatisfiedBy(T entity) + { + WasEvaluated = true; + throw new InvalidOperationException("ExplosiveSpec was evaluated!"); + } + } +} diff --git a/tests/EricksonLopez.Specification.Tests/ConcurrencyAuditTests.cs b/tests/EricksonLopez.Specification.Tests/ConcurrencyAuditTests.cs new file mode 100644 index 0000000..eb9b078 --- /dev/null +++ b/tests/EricksonLopez.Specification.Tests/ConcurrencyAuditTests.cs @@ -0,0 +1,74 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using AwesomeAssertions; +using EricksonLopez.Specification; +using EricksonLopez.Specification.Sql; +using Xunit; + +namespace EricksonLopez.Specification.Tests; + +public sealed class ConcurrencyCustomer +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public bool IsActive { get; init; } +} + +[CollectionDefinition("ConcurrencyTestCollection", DisableParallelization = true)] +public sealed class ConcurrencyTestCollection { } + +[Collection("ConcurrencyTestCollection")] +public sealed class ConcurrencyAuditTests : IDisposable +{ + public ConcurrencyAuditTests() + { + QueryPlanCache.Clear(); + ExpressionCompilationCache.Clear(); + } + + public void Dispose() + { + QueryPlanCache.Clear(); + ExpressionCompilationCache.Clear(); + } + + [Theory] + [InlineData(10)] + [InlineData(100)] + [InlineData(1_000)] + [InlineData(10_000)] + public void ConcurrentEvaluations_ProducesConsistentResults(int iterations) + { + var spec = Spec.For(c => c.IsActive && c.Id > 5); + var valid = new ConcurrencyCustomer { Id = 10, IsActive = true }; + var invalid = new ConcurrencyCustomer { Id = 2, IsActive = true }; + + Parallel.For(0, iterations, _ => + { + spec.IsSatisfiedBy(valid).Should().BeTrue(); + spec.IsSatisfiedBy(invalid).Should().BeFalse(); + }); + } + + [Theory] + [InlineData(10)] + [InlineData(100)] + [InlineData(1_000)] + public void ConcurrentQueryPlanCache_AccessDoesNotCorruptState(int operations) + { + var translator = new QuerySpecTranslator("customers"); + + Parallel.For(0, operations, i => + { + var spec = Spec.For(c => c.IsActive).ToQuerySpec(); + var plan = translator.Translate(spec); + plan.Should().NotBeNull(); + plan.TableName.Should().Be("customers"); + }); + + QueryPlanCache.Count.Should().BeLessThanOrEqualTo(QueryPlanCache.Capacity); + } +} diff --git a/tests/EricksonLopez.Specification.Tests/EricksonLopez.Specification.Tests.csproj b/tests/EricksonLopez.Specification.Tests/EricksonLopez.Specification.Tests.csproj index cb7b76c..2f75caa 100644 --- a/tests/EricksonLopez.Specification.Tests/EricksonLopez.Specification.Tests.csproj +++ b/tests/EricksonLopez.Specification.Tests/EricksonLopez.Specification.Tests.csproj @@ -29,6 +29,8 @@ + + diff --git a/tests/EricksonLopez.Specification.Tests/FuzzingEngineTests.cs b/tests/EricksonLopez.Specification.Tests/FuzzingEngineTests.cs new file mode 100644 index 0000000..694776b --- /dev/null +++ b/tests/EricksonLopez.Specification.Tests/FuzzingEngineTests.cs @@ -0,0 +1,83 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Linq; +using System.Linq.Expressions; +using AwesomeAssertions; +using EricksonLopez.Specification; +using EricksonLopez.Specification.Sql; +using Xunit; + +namespace EricksonLopez.Specification.Tests; + +public sealed class FuzzEntity +{ + public int? NullableInt { get; init; } + public string? Text { get; init; } + public decimal? Amount { get; init; } + public bool Flag { get; init; } +} + +public sealed class FuzzingEngineTests +{ + [Fact] + public void Fuzz_ExtremeDepth_CompositionStackLimit() + { + // Compose a tree of depth 500 + var spec = Spec.True(); + for (int i = 0; i < 500; i++) + { + spec = spec.And(Spec.For(e => e.Flag == (i % 2 == 0))); + } + + var candidate = new FuzzEntity { Flag = true }; + + // Test if expression evaluates without crashing + var act = () => spec.IsSatisfiedBy(candidate); + act.Should().NotThrow(); + } + + [Fact] + public void Fuzz_NullPropertyPredicates_DoesNotThrowUnexpectedExceptions() + { + var entityWithNulls = new FuzzEntity + { + NullableInt = null, + Text = null, + Amount = null, + Flag = false + }; + + var spec1 = Spec.For(e => e.Text == null); + var spec2 = Spec.For(e => e.NullableInt == null); + var spec3 = Spec.For(e => e.Amount > 0m); + + spec1.IsSatisfiedBy(entityWithNulls).Should().BeTrue(); + spec2.IsSatisfiedBy(entityWithNulls).Should().BeTrue(); + spec3.IsSatisfiedBy(entityWithNulls).Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("'; DROP TABLE Users; --")] + [InlineData("\0\uFFFF\uD800")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")] + public void Fuzz_SqlTranslator_PathologicalStringsInSearch(string fuzzInput) + { + var translator = new QuerySpecTranslator("fuzz_entities"); + + if (string.IsNullOrWhiteSpace(fuzzInput)) + { + // TagWith throws on whitespace + var act = () => QuerySpec.Empty.TagWith(fuzzInput); + act.Should().Throw(); + } + else + { + var querySpec = QuerySpec.Empty.Search(fuzzInput, e => e.Text); + var plan = translator.Translate(querySpec); + plan.Should().NotBeNull(); + plan.Parameters.Should().ContainSingle(p => (string)p.Value! == $"%{fuzzInput}%"); + } + } +} diff --git a/tests/EricksonLopez.Specification.Tests/ReadRepositoryResultExtensionsTests.cs b/tests/EricksonLopez.Specification.Tests/ReadRepositoryResultExtensionsTests.cs new file mode 100644 index 0000000..df53e81 --- /dev/null +++ b/tests/EricksonLopez.Specification.Tests/ReadRepositoryResultExtensionsTests.cs @@ -0,0 +1,130 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using EricksonLopez.Specification; +using EricksonLopez.Specification.Result; +using Xunit; + +namespace EricksonLopez.Specification.Tests; + +public sealed class ReadRepositoryResultExtensionsTests +{ + private sealed class FakeRepository : IReadRepository where T : class + { + public T? SingleItem { get; set; } + public IReadOnlyList ListItems { get; set; } = []; + public bool ThrowCancellation { get; set; } + public bool ThrowGeneric { get; set; } + + public Task GetByIdAsync(TId id, CancellationToken cancellationToken = default) where TId : notnull + { + if (ThrowCancellation) throw new OperationCanceledException(); + if (ThrowGeneric) throw new InvalidOperationException("DB connection failed"); + return Task.FromResult(SingleItem); + } + + public Task FirstOrDefaultAsync(QuerySpec specification, CancellationToken cancellationToken = default) + { + if (ThrowCancellation) throw new OperationCanceledException(); + if (ThrowGeneric) throw new InvalidOperationException("DB connection failed"); + return Task.FromResult(SingleItem); + } + + public Task SingleOrDefaultAsync(QuerySpec specification, CancellationToken cancellationToken = default) + { + if (ThrowCancellation) throw new OperationCanceledException(); + if (ThrowGeneric) throw new InvalidOperationException("DB connection failed"); + return Task.FromResult(SingleItem); + } + + public Task> ListAsync(QuerySpec specification, CancellationToken cancellationToken = default) + { + if (ThrowCancellation) throw new OperationCanceledException(); + if (ThrowGeneric) throw new InvalidOperationException("DB connection failed"); + return Task.FromResult(ListItems); + } + + public Task> ListAsync(QuerySpec specification, CancellationToken cancellationToken = default) + { + if (ThrowCancellation) throw new OperationCanceledException(); + if (ThrowGeneric) throw new InvalidOperationException("DB connection failed"); + return Task.FromResult>([]); + } + + public Task CountAsync(QuerySpec specification, CancellationToken cancellationToken = default) + => Task.FromResult(ListItems.Count); + + public Task AnyAsync(QuerySpec specification, CancellationToken cancellationToken = default) + => Task.FromResult(ListItems.Count > 0); + } + + public sealed class Item + { + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + } + + [Fact] + public async Task FirstOrDefaultResultAsync_Found_ReturnsSuccess() + { + var repo = new FakeRepository { SingleItem = new Item { Id = 1, Name = "Item 1" } }; + var spec = QuerySpec.Empty; + + var result = await repo.FirstOrDefaultResultAsync(spec); + + result.IsSuccess.Should().BeTrue(); + result.Value.Name.Should().Be("Item 1"); + } + + [Fact] + public async Task FirstOrDefaultResultAsync_NotFound_ReturnsNotFoundFailure() + { + var repo = new FakeRepository { SingleItem = null }; + var spec = QuerySpec.Empty; + + var result = await repo.FirstOrDefaultResultAsync(spec); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Item.NotFound"); + } + + [Fact] + public async Task FirstOrDefaultResultAsync_OperationCanceled_RethrowsException() + { + var repo = new FakeRepository { ThrowCancellation = true }; + var spec = QuerySpec.Empty; + + var act = () => repo.FirstOrDefaultResultAsync(spec); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task FirstOrDefaultResultAsync_GenericException_ReturnsFailure() + { + var repo = new FakeRepository { ThrowGeneric = true }; + var spec = QuerySpec.Empty; + + var result = await repo.FirstOrDefaultResultAsync(spec); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("Database.Error"); + } + + [Fact] + public async Task ListResultAsync_ReturnsList() + { + var repo = new FakeRepository + { + ListItems = new List { new() { Id = 1 }, new() { Id = 2 } } + }; + + var result = await repo.ListResultAsync(QuerySpec.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + } +} diff --git a/tests/EricksonLopez.Specification.Tests/SpecTests.cs b/tests/EricksonLopez.Specification.Tests/SpecTests.cs index b3e62a4..be5c352 100644 --- a/tests/EricksonLopez.Specification.Tests/SpecTests.cs +++ b/tests/EricksonLopez.Specification.Tests/SpecTests.cs @@ -1,5 +1,6 @@ // Copyright © Erickson Lopez. MIT License. using System; +using System.Collections.Generic; using System.Linq.Expressions; using AwesomeAssertions; using Xunit; @@ -320,6 +321,86 @@ public void False_Or_Specification_SimplifiesToOriginalExpression() composed.IsSatisfiedBy(new Customer { IsActive = true }).Should().BeTrue(); composed.IsSatisfiedBy(new Customer { IsActive = false }).Should().BeFalse(); } + + // ────────────────────────────────────────────────────────────────────────── + // IEnumerable> Overloads + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void All_WithNullEnumerable_ThrowsArgumentNullException() + { + IEnumerable> specs = null!; + var act = () => Spec.All(specs); + act.Should().Throw(); + } + + [Fact] + public void All_WithEmptyList_ReturnsAlwaysTrueSpecification() + { + var list = new System.Collections.Generic.List>(); + var spec = Spec.All(list); + spec.IsSatisfiedBy(new Customer()).Should().BeTrue(); + } + + [Fact] + public void All_WithSingleItemList_ReturnsOriginalSpecification() + { + var single = Spec.For(c => c.IsActive); + var list = new System.Collections.Generic.List> { single }; + var spec = Spec.All(list); + spec.Should().BeSameAs(single); + } + + [Fact] + public void All_WithMultipleItemList_ComposesWithAnd() + { + var spec1 = Spec.For(c => c.IsActive); + var spec2 = Spec.For(c => c.CreditLimit > 100m); + var list = new System.Collections.Generic.List> { spec1, spec2 }; + + var composed = Spec.All(list); + composed.IsSatisfiedBy(new Customer { IsActive = true, CreditLimit = 200m }).Should().BeTrue(); + composed.IsSatisfiedBy(new Customer { IsActive = true, CreditLimit = 50m }).Should().BeFalse(); + composed.IsSatisfiedBy(new Customer { IsActive = false, CreditLimit = 200m }).Should().BeFalse(); + } + + [Fact] + public void Any_WithNullEnumerable_ThrowsArgumentNullException() + { + IEnumerable> specs = null!; + var act = () => Spec.Any(specs); + act.Should().Throw(); + } + + [Fact] + public void Any_WithEmptyList_ReturnsAlwaysFalseSpecification() + { + var list = new System.Collections.Generic.List>(); + var spec = Spec.Any(list); + spec.IsSatisfiedBy(new Customer()).Should().BeFalse(); + } + + [Fact] + public void Any_WithSingleItemList_ReturnsOriginalSpecification() + { + var single = Spec.For(c => c.IsActive); + var list = new System.Collections.Generic.List> { single }; + var spec = Spec.Any(list); + spec.Should().BeSameAs(single); + } + + [Fact] + public void Any_WithMultipleItemList_ComposesWithOr() + { + var spec1 = Spec.For(c => c.IsActive); + var spec2 = Spec.For(c => c.CreditLimit > 1000m); + var list = new System.Collections.Generic.List> { spec1, spec2 }; + + var composed = Spec.Any(list); + composed.IsSatisfiedBy(new Customer { IsActive = true, CreditLimit = 50m }).Should().BeTrue(); + composed.IsSatisfiedBy(new Customer { IsActive = false, CreditLimit = 2000m }).Should().BeTrue(); + composed.IsSatisfiedBy(new Customer { IsActive = false, CreditLimit = 50m }).Should().BeFalse(); + } } diff --git a/tests/EricksonLopez.Specification.Tests/SpecificationLinqExtensionsTests.cs b/tests/EricksonLopez.Specification.Tests/SpecificationLinqExtensionsTests.cs new file mode 100644 index 0000000..1bfa927 --- /dev/null +++ b/tests/EricksonLopez.Specification.Tests/SpecificationLinqExtensionsTests.cs @@ -0,0 +1,177 @@ +// Copyright © Erickson Lopez. MIT License. +using System; +using System.Collections.Generic; +using System.Linq; +using AwesomeAssertions; +using EricksonLopez.Specification.Linq; +using Xunit; + +namespace EricksonLopez.Specification.Tests; + +public sealed class SpecificationLinqExtensionsTests +{ + private sealed class ActiveCustomerSpec : Specification + { + protected override System.Linq.Expressions.Expression> BuildExpression() + => c => c.IsActive; + } + + private sealed class HighCreditCustomerSpec : Specification + { + private readonly decimal _threshold; + public HighCreditCustomerSpec(decimal threshold) => _threshold = threshold; + + protected override System.Linq.Expressions.Expression> BuildExpression() + => c => c.CreditLimit >= _threshold; + } + + private static List CreateSampleCustomers() => + [ + new Customer { Id = 1, Name = "Alice", IsActive = true, CreditLimit = 500m }, + new Customer { Id = 2, Name = "Bob", IsActive = false, CreditLimit = 200m }, + new Customer { Id = 3, Name = "Charlie", IsActive = true, CreditLimit = 1500m }, + new Customer { Id = 4, Name = "Diana", IsActive = false, CreditLimit = 3000m } + ]; + + // ────────────────────────────────────────────────────────────────────────── + // IEnumerable Extension Tests + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Where_OnEnumerable_FiltersMatchingEntities() + { + var customers = CreateSampleCustomers(); + var spec = new ActiveCustomerSpec(); + + var result = customers.Where(spec).ToList(); + + result.Should().HaveCount(2); + result.Should().OnlyContain(c => c.IsActive); + } + + [Fact] + public void Where_OnEnumerable_WithNullArguments_ThrowsArgumentNullException() + { + IEnumerable customers = CreateSampleCustomers(); + ISpecification spec = new ActiveCustomerSpec(); + + Action act1 = () => ((IEnumerable)null!).Where(spec); + Action act2 = () => customers.Where((ISpecification)null!); + + act1.Should().Throw(); + act2.Should().Throw(); + } + + [Fact] + public void Any_OnEnumerable_ReturnsCorrectBoolean() + { + var customers = CreateSampleCustomers(); + var specHigh = new HighCreditCustomerSpec(2000m); + var specImpossible = new HighCreditCustomerSpec(10000m); + + customers.Any(specHigh).Should().BeTrue(); + customers.Any(specImpossible).Should().BeFalse(); + } + + [Fact] + public void All_OnEnumerable_ReturnsCorrectBoolean() + { + var customers = CreateSampleCustomers(); + var activeSpec = new ActiveCustomerSpec(); + var positiveCreditSpec = new HighCreditCustomerSpec(0m); + + customers.All(activeSpec).Should().BeFalse(); + customers.All(positiveCreditSpec).Should().BeTrue(); + } + + [Fact] + public void Count_OnEnumerable_ReturnsAccurateCount() + { + var customers = CreateSampleCustomers(); + var activeSpec = new ActiveCustomerSpec(); + + customers.Count(activeSpec).Should().Be(2); + } + + [Fact] + public void FirstOrDefault_OnEnumerable_ReturnsFirstOrNull() + { + var customers = CreateSampleCustomers(); + var spec = new HighCreditCustomerSpec(1000m); + var impossible = new HighCreditCustomerSpec(50000m); + + customers.FirstOrDefault(spec)?.Name.Should().Be("Charlie"); + customers.FirstOrDefault(impossible).Should().BeNull(); + } + + // ────────────────────────────────────────────────────────────────────────── + // IQueryable Extension Tests + // ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Where_OnQueryable_FiltersMatchingEntities() + { + var queryable = CreateSampleCustomers().AsQueryable(); + var spec = new ActiveCustomerSpec(); + + var result = queryable.Where(spec).ToList(); + + result.Should().HaveCount(2); + result.Should().OnlyContain(c => c.IsActive); + } + + [Fact] + public void Where_OnQueryable_WithNullArguments_ThrowsArgumentNullException() + { + IQueryable queryable = CreateSampleCustomers().AsQueryable(); + IExpressionSpecification spec = new ActiveCustomerSpec(); + + Action act1 = () => ((IQueryable)null!).Where(spec); + Action act2 = () => queryable.Where((IExpressionSpecification)null!); + + act1.Should().Throw(); + act2.Should().Throw(); + } + + [Fact] + public void Any_OnQueryable_ReturnsCorrectBoolean() + { + var queryable = CreateSampleCustomers().AsQueryable(); + var specHigh = new HighCreditCustomerSpec(2000m); + var specImpossible = new HighCreditCustomerSpec(10000m); + + queryable.Any(specHigh).Should().BeTrue(); + queryable.Any(specImpossible).Should().BeFalse(); + } + + [Fact] + public void All_OnQueryable_ReturnsCorrectBoolean() + { + var queryable = CreateSampleCustomers().AsQueryable(); + var activeSpec = new ActiveCustomerSpec(); + var positiveCreditSpec = new HighCreditCustomerSpec(0m); + + queryable.All(activeSpec).Should().BeFalse(); + queryable.All(positiveCreditSpec).Should().BeTrue(); + } + + [Fact] + public void Count_OnQueryable_ReturnsAccurateCount() + { + var queryable = CreateSampleCustomers().AsQueryable(); + var activeSpec = new ActiveCustomerSpec(); + + queryable.Count(activeSpec).Should().Be(2); + } + + [Fact] + public void FirstOrDefault_OnQueryable_ReturnsFirstOrNull() + { + var queryable = CreateSampleCustomers().AsQueryable(); + var spec = new HighCreditCustomerSpec(1000m); + var impossible = new HighCreditCustomerSpec(50000m); + + queryable.FirstOrDefault(spec)?.Name.Should().Be("Charlie"); + queryable.FirstOrDefault(impossible).Should().BeNull(); + } +} diff --git a/tests/tests_tree.txt b/tests/tests_tree.txt index a11b646..ef13f0f 100644 --- a/tests/tests_tree.txt +++ b/tests/tests_tree.txt @@ -1,5 +1,5 @@ -Listado de rutas de carpetas para el volumen WorkDisc -El número de serie del volumen es 000000EF FC5B:A535 +Folder PATH listing for volume WorkDisc +Volume serial number is 000000EF FC5B:A535 D:. | docker-compose.yml | README.md From 80981b9e5ed2b1d67b2ef24f3c55ccd66d583788 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:29:28 +0000 Subject: [PATCH 2/3] chore(release): release 2.0.0 --- .release-please-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 37fcefa..895bf0e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "2.0.0" } From 7589435600d5ce7a69dfc599f3bb2dc3891cc432 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:34:06 +0000 Subject: [PATCH 3/3] Bump Microsoft.EntityFrameworkCore.Sqlite from 9.0.2 to 9.0.20 --- updated-dependencies: - dependency-name: Microsoft.EntityFrameworkCore.Sqlite dependency-version: 9.0.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3c17074..a65a8fe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,7 +42,7 @@ - +