diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 0000000..354e7b5
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,114 @@
+name: Tests & Coverage
+
+on:
+ pull_request:
+ branches:
+ - '**'
+ push:
+ branches:
+ - master
+
+jobs:
+ test:
+ name: Tests & Code Coverage
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+
+ permissions:
+ contents: read
+ pull-requests: write
+ checks: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore src/Dime.Repositories.slnx
+
+ - name: Build solution
+ run: dotnet build src/Dime.Repositories.slnx -c Release --no-restore
+
+ - name: Run unit tests with coverage
+ run: |
+ dotnet test src/test/Dime.Repositories.Sql.EntityFramework.Tests/Dime.Repositories.Sql.EntityFramework.Tests.csproj \
+ -c Release --no-build \
+ --logger "trx;LogFileName=unit-tests.trx" \
+ --results-directory TestResults/unit \
+ --collect:"XPlat Code Coverage" \
+ --settings src/coverlet.runsettings
+
+ - name: Run integration tests with coverage
+ timeout-minutes: 15
+ run: |
+ dotnet test src/test/Dime.Repositories.Sql.EntityFramework.IntegrationTests/Dime.Repositories.Sql.EntityFramework.IntegrationTests.csproj \
+ -c Release --no-build \
+ --logger "trx;LogFileName=integration-tests.trx" \
+ --results-directory TestResults/integration \
+ --collect:"XPlat Code Coverage" \
+ --settings src/coverlet.runsettings
+
+ - name: Install ReportGenerator
+ if: always()
+ run: dotnet tool install -g dotnet-reportgenerator-globaltool
+
+ - name: Merge coverage and generate report
+ if: always()
+ run: |
+ reportgenerator \
+ "-reports:TestResults/**/coverage.cobertura.xml" \
+ "-targetdir:CoverageReport" \
+ "-reporttypes:Html;MarkdownSummaryGithub;Cobertura;TextSummary" \
+ "-title:Dime.Repositories Coverage"
+
+ - name: Append coverage to job summary
+ if: always()
+ run: |
+ if [ -f CoverageReport/SummaryGithub.md ]; then
+ cat CoverageReport/SummaryGithub.md >> $GITHUB_STEP_SUMMARY
+ fi
+ if [ -f CoverageReport/Summary.txt ]; then
+ echo "::group::Coverage summary"
+ cat CoverageReport/Summary.txt
+ echo "::endgroup::"
+ fi
+
+ - name: Post coverage comment on PR
+ if: always() && github.event_name == 'pull_request'
+ uses: irongut/CodeCoverageSummary@v1.3.0
+ with:
+ filename: CoverageReport/Cobertura.xml
+ badge: true
+ fail_below_min: false
+ format: markdown
+ hide_branch_rate: false
+ hide_complexity: true
+ indicators: true
+ output: both
+ thresholds: '60 90'
+
+ - name: Attach coverage comment to PR
+ if: always() && github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ recreate: true
+ path: code-coverage-results.md
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: TestResults
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: CoverageReport
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 0000000..c926d76
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,40 @@
+name: Unit Tests
+
+on:
+ pull_request:
+ branches:
+ - '**'
+ push:
+ branches:
+ - master
+
+jobs:
+ unit:
+ name: Unit tests (SQLite in-memory)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore src/Dime.Repositories.slnx
+
+ - name: Build unit test project
+ run: dotnet build src/test/Dime.Repositories.Sql.EntityFramework.Tests/Dime.Repositories.Sql.EntityFramework.Tests.csproj -c Release --no-restore
+
+ - name: Run unit tests
+ run: dotnet test src/test/Dime.Repositories.Sql.EntityFramework.Tests/Dime.Repositories.Sql.EntityFramework.Tests.csproj -c Release --no-build --logger trx --results-directory TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: unit-test-results
+ path: TestResults
diff --git a/.gitignore b/.gitignore
index 1b66a78..0e0c346 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
*.vs
**/packages
**/*.coverage
+**/BenchmarkDotNet.Artifacts/
+CoverageReport/
+TestResults/
\ No newline at end of file
diff --git a/src/Dime.Repositories.slnx b/src/Dime.Repositories.slnx
index 0cd41f4..abf85ed 100644
--- a/src/Dime.Repositories.slnx
+++ b/src/Dime.Repositories.slnx
@@ -4,6 +4,10 @@
+
+
+
+
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/BenchmarkFixture.cs b/src/benchmarks/Dime.Repositories.Benchmarks/BenchmarkFixture.cs
new file mode 100644
index 0000000..4edaeb8
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/BenchmarkFixture.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.EntityFrameworkCore;
+
+namespace Dime.Repositories.Benchmarks
+{
+ ///
+ /// File-based SQLite fixture. Seeds 1,000 Blogs x ~10 Posts x ~15 Tags
+ /// (10k Posts + 15k Tags; Cartesian fan-out of 150 join rows per Blog
+ /// when both collections are joined in a single query).
+ ///
+ internal sealed class BenchmarkFixture : IDisposable
+ {
+ public const int BlogCount = 1000;
+ public const int PostsPerBlog = 10;
+ public const int TagsPerBlog = 15;
+
+ public string DbPath { get; }
+ public string ConnectionString { get; }
+ public DbContextOptions Options { get; }
+
+ public BenchmarkFixture()
+ {
+ DbPath = Path.Combine(
+ Path.GetTempPath(),
+ $"dime-repo-bench-{Guid.NewGuid():N}.db");
+ ConnectionString = $"Data Source={DbPath}";
+
+ Options = new DbContextOptionsBuilder()
+ .UseSqlite(ConnectionString)
+ .Options;
+
+ Seed();
+ }
+
+ private void Seed()
+ {
+ using BloggingContext context = new(Options);
+ context.Database.EnsureDeleted();
+ context.Database.EnsureCreated();
+
+ // Disable change tracking on the seed context — pure inserts.
+ context.ChangeTracker.AutoDetectChangesEnabled = false;
+
+ List blogs = new(BlogCount);
+ for (int i = 0; i < BlogCount; i++)
+ blogs.Add(new Blog { Url = $"http://sample.com/blog/{i}" });
+
+ context.Blogs.AddRange(blogs);
+ context.SaveChanges();
+
+ // Seed Posts and Tags in batches to keep memory bounded.
+ const int batchSize = 200;
+ for (int start = 0; start < blogs.Count; start += batchSize)
+ {
+ int end = Math.Min(start + batchSize, blogs.Count);
+
+ for (int i = start; i < end; i++)
+ {
+ int blogId = blogs[i].BlogId;
+
+ for (int p = 0; p < PostsPerBlog; p++)
+ {
+ context.Posts.Add(new Post
+ {
+ BlogId = blogId,
+ Title = $"Post {p} for blog {blogId}",
+ Content = $"Content body for post {p} of blog {blogId}. Padded so allocations are noticeable."
+ });
+ }
+
+ for (int t = 0; t < TagsPerBlog; t++)
+ {
+ context.Tags.Add(new Tag
+ {
+ BlogId = blogId,
+ Name = $"tag-{t}-blog-{blogId}"
+ });
+ }
+ }
+
+ context.SaveChanges();
+ context.ChangeTracker.Clear();
+ }
+ }
+
+ public BloggingContext CreateContext() => new(Options);
+
+ public void Dispose()
+ {
+ try
+ {
+ // Force SQLite to release the file handle on Windows.
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ if (File.Exists(DbPath))
+ File.Delete(DbPath);
+ }
+ catch
+ {
+ // Best-effort cleanup; benchmark teardown shouldn't fail the run.
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/BloggingContext.cs b/src/benchmarks/Dime.Repositories.Benchmarks/BloggingContext.cs
new file mode 100644
index 0000000..16b4e1b
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/BloggingContext.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+
+namespace Dime.Repositories.Benchmarks
+{
+ ///
+ /// Copy of the Blog/Post/Tag model used in the unit tests. Duplicated here so the benchmark
+ /// project doesn't take a dependency on the test project (which carries MSTest/Test SDK and
+ /// would otherwise pollute the benchmark's runtime/build). Keep the schema in sync with
+ /// src/test/Dime.Repositories.Sql.EntityFramework.Tests/Helpers/BloggingContext.cs.
+ ///
+ public class BloggingContext : DbContext
+ {
+ public BloggingContext()
+ { }
+
+ public BloggingContext(DbContextOptions options)
+ : base(options)
+ { }
+
+ public DbSet Blogs { get; set; }
+ public DbSet Posts { get; set; }
+ public DbSet Tags { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().HasKey(c => c.BlogId);
+ modelBuilder.Entity().HasKey(c => c.TagId);
+ }
+ }
+
+ public class Blog
+ {
+ public int BlogId { get; set; }
+ public string Url { get; set; }
+
+ public List Posts { get; set; }
+ public List Tags { get; set; }
+ }
+
+ public class Post
+ {
+ public int PostId { get; set; }
+ public string Title { get; set; }
+ public string Content { get; set; }
+
+ public int BlogId { get; set; }
+ public Blog Blog { get; set; }
+ }
+
+ public class Tag
+ {
+ public int TagId { get; set; }
+ public string Name { get; set; }
+
+ public int BlogId { get; set; }
+ public Blog Blog { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/Dime.Repositories.Benchmarks.csproj b/src/benchmarks/Dime.Repositories.Benchmarks/Dime.Repositories.Benchmarks.csproj
new file mode 100644
index 0000000..8a1f070
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/Dime.Repositories.Benchmarks.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+ latest
+ disable
+ false
+
+ false
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/Program.cs b/src/benchmarks/Dime.Repositories.Benchmarks/Program.cs
new file mode 100644
index 0000000..d00abcd
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/Program.cs
@@ -0,0 +1,10 @@
+using BenchmarkDotNet.Running;
+
+namespace Dime.Repositories.Benchmarks
+{
+ public static class Program
+ {
+ public static void Main(string[] args)
+ => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+ }
+}
\ No newline at end of file
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/README.md b/src/benchmarks/Dime.Repositories.Benchmarks/README.md
new file mode 100644
index 0000000..7e63491
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/README.md
@@ -0,0 +1,92 @@
+# Dime.Repositories.Benchmarks
+
+BenchmarkDotNet harness for the `splitQuery: bool` opt-in added in `3.2.0-beta.3` on
+`FindAllPagedAsync` (and `FindAllAsync`).
+
+## What this measures
+
+Six benchmarks comparing `splitQuery: false` (single SQL JOIN with Cartesian fan-out) vs
+`splitQuery: true` (separate `SELECT` per collection nav) on a seeded fixture:
+
+| Scenario | Page | Includes |
+| -------- | ---- | -------- |
+| `Page1Of50_*` | 1 of 50 | `Posts` + `Tags` |
+| `Page1Of10_*` | 1 of 10 | `Posts` + `Tags` |
+| `FullScan_*` | all 1,000 Blogs | `Posts` + `Tags` |
+
+Fixture seed (in `[GlobalSetup]`, reused across iterations):
+
+- **1,000 Blogs**
+- **10 Posts per Blog** (10,000 Posts total)
+- **15 Tags per Blog** (15,000 Tags total)
+- Cartesian fan-out when both collections are joined: **150 join rows per Blog** (150,000 for the full set)
+
+`[MemoryDiagnoser]` is on — allocations matter at least as much as wall time for this comparison.
+
+## Running
+
+From the repo root:
+
+```powershell
+dotnet run -c Release --project src/benchmarks/Dime.Repositories.Benchmarks
+```
+
+Filter to a subset:
+
+```powershell
+dotnet run -c Release --project src/benchmarks/Dime.Repositories.Benchmarks -- --filter '*Page1Of50*'
+```
+
+List benchmarks without running:
+
+```powershell
+dotnet run -c Release --project src/benchmarks/Dime.Repositories.Benchmarks -- --list flat
+```
+
+Results are written to `BenchmarkDotNet.Artifacts/results/` (markdown + CSV + log).
+
+## Honesty: SQLite is not SQL Server
+
+The benchmark uses file-based SQLite. This is **not** representative of SQL Server's wire
+protocol, query planner, or join algorithms. What you can reasonably draw from these numbers:
+
+- **Relative deltas** between split and single mode on identical seeds: indicative of the
+ materialization / traversal / allocation cost EF Core itself pays for the Cartesian product.
+ This part of the cost is provider-agnostic.
+- **Absolute timings, throughput, or SQL Server-specific cost** (logical reads, tempdb
+ spills, parameter sniffing, plan cache effects): **do not infer**. Use a real SQL Server.
+
+If a clear delta does not show up at this seed size on SQLite, scale `BlogCount`,
+`PostsPerBlog`, `TagsPerBlog` in `BenchmarkFixture.cs` and re-run.
+
+## Pointing at a real SQL Server
+
+In `BenchmarkFixture.cs`, swap the options builder:
+
+```csharp
+Options = new DbContextOptionsBuilder()
+ .UseSqlServer("Server=...;Database=...;...") // was: .UseSqlite(ConnectionString)
+ .Options;
+```
+
+Notes when you do this:
+
+- Drop the `Dispose()` SQLite-specific file cleanup (or guard it).
+- Increase `BlogCount` / `PostsPerBlog` / `TagsPerBlog` — SQL Server's planner is happy
+ to JOIN tiny tables; you want enough rows that the fan-out actually hurts (try 10,000
+ Blogs × 20 Posts × 25 Tags as a starting point).
+- Consider adding `MultipleActiveResultSets=True` and a warm-up benchmark that runs
+ outside the measured section so plan cache / first-call costs are not in the timing.
+- Run on a quiet machine; SQL Server numbers move around a lot with concurrent load.
+
+## Project layout
+
+| File | Purpose |
+| ---- | ------- |
+| `Program.cs` | `BenchmarkSwitcher.FromAssembly(...).Run(args)` entry point |
+| `BloggingContext.cs` | Duplicate of the test project's Blog/Post/Tag model (keeps benchmark independent of the test SDK dependency chain) |
+| `BenchmarkFixture.cs` | `[GlobalSetup]` seeder — file-based SQLite, 1000/10/15 |
+| `SplitQueryBenchmarks.cs` | Six benchmarks: 3 scenarios × {split=true, split=false} |
+
+`IsPackable=false` and `IsTestProject=false` — this project is excluded from `dotnet test`
+discovery and never produces a NuGet package.
diff --git a/src/benchmarks/Dime.Repositories.Benchmarks/SplitQueryBenchmarks.cs b/src/benchmarks/Dime.Repositories.Benchmarks/SplitQueryBenchmarks.cs
new file mode 100644
index 0000000..be5ef00
--- /dev/null
+++ b/src/benchmarks/Dime.Repositories.Benchmarks/SplitQueryBenchmarks.cs
@@ -0,0 +1,135 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Engines;
+
+namespace Dime.Repositories.Benchmarks
+{
+ ///
+ /// Benchmarks the splitQuery: true vs splitQuery: false branch of
+ /// EfRepository.FindAllPagedAsync for the entity overload, against a fixture
+ /// where each Blog has 10 Posts and 15 Tags (150 join-row Cartesian per Blog when joined).
+ ///
+ /// SQLite caveat: numbers here are NOT representative of SQL Server. The *relative* delta
+ /// between split and single mode should still be visible (materialization + traversal cost
+ /// scales with the Cartesian product regardless of driver), but treat absolute numbers as
+ /// indicative only. See README.md.
+ ///
+ [MemoryDiagnoser]
+ [MarkdownExporter]
+ [SimpleJob(RunStrategy.Throughput, launchCount: 1, warmupCount: 1, iterationCount: 3, invocationCount: 1)]
+ public class SplitQueryBenchmarks
+ {
+ private BenchmarkFixture _fixture;
+
+ private static readonly IEnumerable> OrderByUrl = [new Order(nameof(Blog.Url), true)];
+
+ private static readonly string[] BothIncludes = [nameof(Blog.Posts), nameof(Blog.Tags)];
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _fixture = new BenchmarkFixture();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _fixture?.Dispose();
+ _fixture = null;
+ }
+
+ private EfRepository NewRepo()
+ => new(_fixture.CreateContext());
+
+ // ---------- Page 1, size 50 (production-shaped) ----------
+
+ [Benchmark(Description = "Page1Of50 + Posts+Tags includes, splitQuery=false")]
+ public async Task Page1Of50_Single()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: 50,
+ splitQuery: false,
+ includes: BothIncludes);
+ return page.Total;
+ }
+
+ [Benchmark(Description = "Page1Of50 + Posts+Tags includes, splitQuery=true")]
+ public async Task Page1Of50_Split()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: 50,
+ splitQuery: true,
+ includes: BothIncludes);
+ return page.Total;
+ }
+
+ // ---------- Page 1, size 10 (small page, roundtrip-sensitive) ----------
+
+ [Benchmark(Description = "Page1Of10 + Posts+Tags includes, splitQuery=false")]
+ public async Task Page1Of10_Single()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: 10,
+ splitQuery: false,
+ includes: BothIncludes);
+ return page.Total;
+ }
+
+ [Benchmark(Description = "Page1Of10 + Posts+Tags includes, splitQuery=true")]
+ public async Task Page1Of10_Split()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: 10,
+ splitQuery: true,
+ includes: BothIncludes);
+ return page.Total;
+ }
+
+ // ---------- Full scan, all 1,000 Blogs (worst-case fan-out) ----------
+
+ [Benchmark(Description = "FullScan(1000) + Posts+Tags includes, splitQuery=false")]
+ public async Task FullScan_Single()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: BenchmarkFixture.BlogCount,
+ splitQuery: false,
+ includes: BothIncludes);
+ return page.Total;
+ }
+
+ [Benchmark(Description = "FullScan(1000) + Posts+Tags includes, splitQuery=true")]
+ public async Task FullScan_Split()
+ {
+ using EfRepository repo = NewRepo();
+ IPage page = await repo.FindAllPagedAsync(
+ where: x => true,
+ orderBy: OrderByUrl,
+ page: 1,
+ pageSize: BenchmarkFixture.BlogCount,
+ splitQuery: true,
+ includes: BothIncludes);
+ return page.Total;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/core/Dime.Repositories.Sql/Dime.Repositories.Sql.csproj b/src/core/Dime.Repositories.Sql/Dime.Repositories.Sql.csproj
index b58c91f..8d929ed 100644
--- a/src/core/Dime.Repositories.Sql/Dime.Repositories.Sql.csproj
+++ b/src/core/Dime.Repositories.Sql/Dime.Repositories.Sql.csproj
@@ -1,15 +1,14 @@
- 3.1.0.0
- 3.1.0.0
+ 3.2.0.0
+ 3.2.0.0
Dime Software
- 3.1.0.0
+ 3.2.0
Dime Software
net10.0
Dime.Repositories.Sql
Dime.Repositories.Sql
- https://cdn.dime-software.com/dime-software/logo-shape.png
false
false
false
@@ -24,8 +23,13 @@
https://github.com/dimesoftware/repository
git
MIT
+ logo.png
+
+
+
+
diff --git a/src/core/Dime.Repositories.Sql/ISqlRepository.cs b/src/core/Dime.Repositories.Sql/ISqlRepository.cs
index 785a43f..8204477 100644
--- a/src/core/Dime.Repositories.Sql/ISqlRepository.cs
+++ b/src/core/Dime.Repositories.Sql/ISqlRepository.cs
@@ -1,5 +1,7 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Data.Common;
+using System.Linq.Expressions;
using System.Threading.Tasks;
namespace Dime.Repositories
@@ -62,5 +64,27 @@ public interface ISqlRepository : IRepository, IStoredProcedureRepository
/// The parameters to pass to the stored procedure.
/// A task that represents the asynchronous operation. The task result contains a collection of results.
Task> ExecuteStoredProcedureAsync(string name, string schema = "dbo", params DbParameter[] parameters);
+
+ ///
+ /// Executes a SQL Server inline table-valued function and projects results.
+ /// SQL emitted: SELECT * FROM [schema].[name](@p1, @p2, ...)
+ ///
+ /// The projected result type.
+ /// The name of the inline table-valued function.
+ /// The schema of the function. Defaults to "dbo".
+ /// The parameters to pass to the function.
+ /// Optional projection expression. When null, the implementation requires to equal .
+ ///
+ /// Calls AsSplitQuery() on the underlying FromSqlRaw query.
+ ///
+ /// In practice this is a no-op for typical TVF usage: this overload does not expose
+ /// Include, and AsSplitQuery only takes effect when EF Core has
+ /// collection navigations to split. The parameter is kept for symmetry with
+ /// FindAllAsync / FindAllPagedAsync and reserved for future use if
+ /// include support is added here.
+ ///
+ ///
+ /// A task that represents the asynchronous operation. The task result contains the materialized rows.
+ Task> QueryFunctionAsync(string name, string schema = "dbo", DbParameter[] parameters = null, Expression> select = null, bool splitQuery = false) where TResult : class;
}
}
\ No newline at end of file
diff --git a/src/core/Dime.Repositories/Dime.Repositories.csproj b/src/core/Dime.Repositories/Dime.Repositories.csproj
index 859f8ce..35a33a0 100644
--- a/src/core/Dime.Repositories/Dime.Repositories.csproj
+++ b/src/core/Dime.Repositories/Dime.Repositories.csproj
@@ -1,13 +1,12 @@
- 3.1.0.0
- 3.1.0.0
+ 3.2.0.0
+ 3.2.0.0
Dime Software
- 3.1.0.0
+ 3.2.0
net10.0
Dime.Repositories
Dime.Repositories
- https://cdn.dime-software.com/dime-software/logo-shape.png
false
false
false
@@ -21,7 +20,13 @@
https://github.com/dimesoftware/repository
git
latest
+ annotations
MIT
+ logo.png
+
+
+
+
diff --git a/src/core/Dime.Repositories/Interfaces/Repository/IDeleteRepository.cs b/src/core/Dime.Repositories/Interfaces/Repository/IDeleteRepository.cs
index bb1c570..4db5244 100644
--- a/src/core/Dime.Repositories/Interfaces/Repository/IDeleteRepository.cs
+++ b/src/core/Dime.Repositories/Interfaces/Repository/IDeleteRepository.cs
@@ -27,7 +27,7 @@ public interface IDeleteRepository : IDisposable where TEntity : class
///
/// Removes all records
- ///
+ ///
/// Task
Task DeleteAsync();
diff --git a/src/core/Dime.Repositories/Interfaces/Repository/IPagedQueryRepositoryAsync.cs b/src/core/Dime.Repositories/Interfaces/Repository/IPagedQueryRepositoryAsync.cs
index 34c676b..b446d03 100644
--- a/src/core/Dime.Repositories/Interfaces/Repository/IPagedQueryRepositoryAsync.cs
+++ b/src/core/Dime.Repositories/Interfaces/Repository/IPagedQueryRepositoryAsync.cs
@@ -28,6 +28,26 @@ Task> FindAllPagedAsync(
int? pageSize = null,
params string[] includes);
+ ///
+ /// Same as the five-arg FindAllPagedAsync but with EF Core split-query opt-in on the data query.
+ /// When is true, the data query uses AsSplitQuery() to avoid
+ /// Cartesian fan-out on multi-collection includes. The Count query is unaffected.
+ ///
+ /// The expression to execute against the data store
+ /// The sorting expression to execute against the data store
+ /// The page number which is multiplied by the page size to calculate the amount of items to skip
+ /// Size of the page.
+ /// When true, opts the data query into AsSplitQuery
+ /// The optional list of related entities that should be eagerly loaded
+ ///
+ Task> FindAllPagedAsync(
+ Expression> where,
+ IEnumerable> orderBy,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes);
+
///
/// Finds all paged asynchronous.
///
@@ -143,6 +163,38 @@ Task> FindAllPagedAsync(
params string[] includes)
where TResult : class;
+ ///
+ /// Same as the nine-arg FindAllPagedAsync<TResult> (count + IEnumerable<IOrder> ordering)
+ /// but with EF Core split-query opt-in on the data query. When
+ /// is true, the data query uses AsSplitQuery() to avoid Cartesian fan-out across
+ /// multi-collection projections. The Count query is always a separate SELECT COUNT(*) and is
+ /// unaffected.
+ ///
+ /// The projected class
+ /// The expression to execute against the data store
+ /// A separate predicate for the COUNT(*) query; defaults to when null
+ /// The expression for the projection of type
+ /// The sorting expressions to execute against the data store
+ /// The optional grouping expression
+ /// Whether the sort is ascending (true) or descending (false)
+ /// The page number, multiplied by page size to compute skip
+ /// The size of each page
+ /// When true, opts the data query into AsSplitQuery
+ /// The optional list of related entities that should be eagerly loaded
+ /// A page of
+ Task> FindAllPagedAsync(
+ Expression> where,
+ Expression> count,
+ Expression> select,
+ IEnumerable> orderBy,
+ Expression> groupBy,
+ bool? ascending,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes)
+ where TResult : class;
+
///
/// Finds all asynchronous.
///
@@ -164,6 +216,33 @@ Task> FindAllPagedAsync(
params string[] includes)
where TResult : class;
+ ///
+ /// Same as the seven-arg FindAllPagedAsync<TResult> but with EF Core split-query opt-in.
+ /// When is true, the data query for the page uses
+ /// AsSplitQuery() to avoid Cartesian fan-out across multi-collection projections.
+ /// The Count query is always a separate SELECT COUNT(*) and is not affected.
+ ///
+ /// The projected class
+ /// The expression to execute against the data store
+ /// The expression for the projection of type
+ /// The sorting expression to execute against the data store
+ /// Whether the sort is ascending (true) or descending (false)
+ /// The page number, multiplied by page size to compute skip
+ /// The size of each page
+ /// When true, opts the data query into AsSplitQuery
+ /// The optional list of related entities that should be eagerly loaded
+ /// A page of
+ Task> FindAllPagedAsync(
+ Expression> where,
+ Expression> select,
+ Expression> orderBy,
+ bool? ascending,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes)
+ where TResult : class;
+
///
/// Finds all asynchronous.
///
diff --git a/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepository.cs b/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepository.cs
new file mode 100644
index 0000000..5243212
--- /dev/null
+++ b/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepository.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+
+namespace Dime.Repositories
+{
+ ///
+ /// Synchronous read-side surface of a repository. The asynchronous counterparts
+ /// live in ; this interface intentionally
+ /// holds only the blocking variants so callers can be explicit about what they use.
+ ///
+ /// The entity type managed by the repository.
+ public partial interface IQueryRepository : IDisposable where TEntity : class
+ {
+ ///
+ /// Gets the record by its identifier.
+ ///
+ /// The identifier of the entity.
+ /// The record of type that matches the id.
+ TEntity FindById(object? id);
+
+ ///
+ /// Checks if a record matching the predicate exists.
+ ///
+ /// The expression to execute against the data store.
+ bool Exists(Expression> where);
+
+ ///
+ /// Gets the first record from the data store that matches the predicate.
+ ///
+ TEntity FindOne(Expression> where);
+
+ ///
+ /// Gets the first record from the data store that matches the predicate,
+ /// eagerly loading the supplied navigation property paths.
+ ///
+ TEntity FindOne(Expression> where, params string[] includes);
+
+ ///
+ /// Finds entities based on the provided predicate.
+ ///
+ IEnumerable FindAll(Expression> where);
+
+ ///
+ /// Finds entities based on the provided predicate with paging and includes.
+ ///
+ IEnumerable FindAll(Expression> where, int? page, int? pageSize, string[] includes);
+
+ ///
+ /// Finds entities based on the provided predicate, optionally eager-loading every navigation
+ /// or just the supplied ones.
+ ///
+ IEnumerable FindAll(Expression> where, bool includeAll, params string[] includes);
+
+ ///
+ /// Retrieves a collection of projected, sorted, paged and filtered items in a flat list.
+ ///
+ /// The projected class.
+ IEnumerable FindAll(
+ Expression> where = null,
+ Expression> select = null,
+ Expression> orderBy = null,
+ bool? ascending = null,
+ int? page = null,
+ int? pageSize = null,
+ params string[] includes);
+
+ ///
+ /// Counts every record in the table that corresponds to .
+ ///
+ long Count();
+
+ ///
+ /// Counts every record in the table that corresponds to
+ /// matching the supplied predicate.
+ ///
+ long Count(Expression> where);
+ }
+}
\ No newline at end of file
diff --git a/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepositoryAsync.cs b/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepositoryAsync.cs
index a955931..500065d 100644
--- a/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepositoryAsync.cs
+++ b/src/core/Dime.Repositories/Interfaces/Repository/IQueryRepositoryAsync.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
@@ -6,27 +6,21 @@
namespace Dime.Repositories
{
///
- ///
+ /// Asynchronous read-side surface of a repository. The blocking counterparts live in
+ /// .
///
- ///
+ /// The entity type managed by the repository.
public partial interface IQueryRepositoryAsync : IDisposable where TEntity : class
{
///
- /// Gets the record by its identifier
- ///
- /// The identifier of the entity
- /// The record of type that matches the id
- TEntity FindById(object? id);
-
- ///
- /// Gets the record by its identifier
+ /// Gets the record by its identifier.
///
/// The identifier of the entity
/// The record of type that matches the id
Task FindByIdAsync(object? id);
///
- /// Gets the record by its identifier
+ /// Gets the record by its identifier, eagerly loading the supplied navigation paths.
///
/// The identifier of the entity
/// The optional list of related entities that should be eagerly loaded
@@ -34,61 +28,29 @@ public partial interface IQueryRepositoryAsync : IDisposable where TEnt
Task FindByIdAsync(object? id, params string[] includes);
///
- /// Checks if the record exists
- ///
- /// The expression to execute against the data store
- /// The first record of type that matches the query
- bool Exists(Expression> where);
-
- ///
- /// Checks if the record exists
+ /// Checks if a record matching the predicate exists.
///
/// The expression to execute against the data store
- /// The first record of type that matches the query
Task ExistsAsync(Expression> where);
///
- /// Gets the first record from the data store that matches the parameter
+ /// Gets the first record from the data store that matches the parameter.
///
/// The expression to execute against the data store
- /// The first record of type that matches the query
- TEntity FindOne(Expression> where);
-
- ///
- /// Gets the first record from the data store that matches the parameter
- ///
- /// The expression to execute against the data store
- /// The first record of type that matches the query
Task FindOneAsync(Expression> where);
///
- /// Gets the first record from the data store that matches the parameter
- ///
- /// The expression to execute against the data store
- /// The optional list of related entities that should be eagerly loaded
- /// The first record of type that matches the query
- TEntity FindOne(Expression> where, params string[] includes);
-
- ///
- /// Gets the first record from the data store that matches the parameter
+ /// Gets the first record from the data store that matches the parameter,
+ /// eagerly loading the supplied navigation paths.
///
/// The expression to execute against the data store
/// The optional list of related entities that should be eagerly loaded
- /// The first record of type that matches the query
Task FindOneAsync(Expression> where, params string[] includes);
///
- /// Gets the first record from the data store that matches the parameter
+ /// Gets the first record from the data store that matches the parameter.
///
/// The projected class
- /// The expression to execute against the data store
- /// The expression for the projection of type that should be executed against the data store
- /// The sorting expression to execute against the data store
- /// Indicates whether the sorting is ascending (true) or descending (false)
- /// The page number which is multiplied by the pagesize to calculate the amount of items to skip
- /// The size of the batch of items that must be retrieved
- /// The optional list of related entities that should be eagerly loaded
- /// An instance of with the mapped data from the record that matched all filters.
Task FindOneAsync(
Expression> where = null,
Expression> select = null,
@@ -103,28 +65,12 @@ Task FindOneAsync(
/// Finds entities based on provided criteria.
///
/// The expression to execute against the data store
- IEnumerable FindAll(Expression> where);
-
- ///
- /// Finds entities based on provided criteria.
- ///
- /// The expression to execute against the data store
- ///
- ///
- ///
- IEnumerable FindAll(Expression> where, int? page, int? pageSize, string[] includes);
-
- ///
- ///
- ///
- /// The expression to execute against the data store
- ///
- ///
- ///
- IEnumerable FindAll(Expression> where, bool includeAll, params string[] includes);
+ /// The optional list of related entities that should be eagerly loaded
+ /// An collection of that matched all filters.
+ Task> FindAllAsync(Expression> where, params string[] includes);
///
- /// Retrieves a collection of projected,sorted, paged and filtered items in a flat list
+ /// Retrieves a collection of projected, sorted, paged and filtered items in a flat list.
///
/// The projected class
/// The expression to execute against the data store
@@ -134,7 +80,7 @@ Task FindOneAsync(
/// The size of the batch of items that must be retrieved
/// The optional list of related entities that should be eagerly loaded
/// An collection of with the mapped data from the records that matched all filters.
- IEnumerable FindAll(
+ Task> FindAllAsync(
Expression> where = null,
Expression> select = null,
Expression> orderBy = null,
@@ -144,44 +90,34 @@ IEnumerable FindAll(
params string[] includes);
///
- /// Finds entities based on provided criteria.
- ///
- /// The expression to execute against the data store
- /// The optional list of related entities that should be eagerly loaded
- /// An collection of that matched all filters.
- Task> FindAllAsync(Expression> where, params string[] includes);
-
- ///
- /// Retrieves a collection of projected,sorted, paged and filtered items in a flat list
+ /// Same as the seven-arg FindAllAsync but with EF Core split-query opt-in.
+ /// When is true, the EF provider runs separate
+ /// SQL statements for each collection navigation in the projection — avoiding
+ /// the Cartesian fan-out that explodes logical reads on multi-collection projections.
///
/// The projected class
/// The expression to execute against the data store
/// The expression for the projection of type that should be executed against the data store
/// The sorting expression to execute against the data store
+ /// Indicates whether the sorting is ascending (true) or descending (false)
/// The page number which is multiplied by the pagesize to calculate the amount of items to skip
/// The size of the batch of items that must be retrieved
+ /// When true, opts into EF Core's AsSplitQuery() to avoid Cartesian fan-out on multi-collection projections.
/// The optional list of related entities that should be eagerly loaded
- /// An collection of with the mapped data from the records that matched all filters.
Task> FindAllAsync(
- Expression> where = null,
- Expression> select = null,
- Expression> orderBy = null,
- bool? ascending = null,
- int? page = null,
- int? pageSize = null,
- params string[] includes);
+ Expression> where,
+ Expression> select,
+ Expression> orderBy,
+ bool? ascending,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes)
+ where TResult : class;
///
- /// Retrieves a collection of paged, sorted and filtered items in a flat list
+ /// Retrieves a collection of paged, sorted and filtered items in a flat list.
///
- /// The projected class
- /// The expression to execute against the data store
- /// The expression for the projection of type that should be executed against the data store
- /// The order by.
- /// The page number which is multiplied by the pagesize to calculate the amount of items to skip
- /// The size of the batch of items that must be retrieved
- /// The optional list of related entities that should be eagerly loaded
- /// An collection of with the mapped data from the records that matched all filters.
Task> FindAllAsync(
Expression> where = null,
Expression> orderBy = null,
@@ -191,31 +127,14 @@ Task> FindAllAsync(
params string[] includes);
///
- /// Counts the amount of records in the data store for the table that corresponds to the entity type .
+ /// Counts every record in the table that corresponds to .
///
- /// A number of the amount of records
Task CountAsync();
///
- /// Counts the amount of records in the data store for the table that corresponds to the entity type .
+ /// Counts every record in the table that corresponds to
+ /// matching the supplied predicate.
///
- /// A number of the amount of records
- long Count();
-
- ///
- /// Counts the amount of records in the data store for the table that corresponds to the entity type .
- ///
- /// A number of the amount of records
- /// The expression to execute against the data store
- ///
Task CountAsync(Expression> where);
-
- ///
- /// Counts the amount of records in the data store for the table that corresponds to the entity type .
- ///
- /// A number of the amount of records
- /// The expression to execute against the data store
- ///
- long Count(Expression> where);
}
-}
\ No newline at end of file
+}
diff --git a/src/core/Dime.Repositories/Interfaces/Repository/IRepositoryAsync.cs b/src/core/Dime.Repositories/Interfaces/Repository/IRepositoryAsync.cs
index 3ae4c18..abae45b 100644
--- a/src/core/Dime.Repositories/Interfaces/Repository/IRepositoryAsync.cs
+++ b/src/core/Dime.Repositories/Interfaces/Repository/IRepositoryAsync.cs
@@ -7,6 +7,7 @@ namespace Dime.Repositories
///
/// The collection type
public partial interface IRepository :
+ IQueryRepository,
IQueryRepositoryAsync,
IPagedQueryRepositoryAsync,
ICommandRepositoryAsync,
diff --git a/src/core/Dime.Repositories/Models/Page.cs b/src/core/Dime.Repositories/Models/Page.cs
index 6d9f291..8f0562b 100644
--- a/src/core/Dime.Repositories/Models/Page.cs
+++ b/src/core/Dime.Repositories/Models/Page.cs
@@ -32,7 +32,7 @@ public Page(IEnumerable data, int total, string message)
public Page(IEnumerable data, int total, string message, IEnumerable summary)
: this(data, total, message)
{
- Summary = summary != null ? summary.ToList() : [];
+ Summary = summary != null ? [.. summary] : [];
}
public IEnumerable Data { get; set; }
diff --git a/src/coverlet.runsettings b/src/coverlet.runsettings
new file mode 100644
index 0000000..3a06928
--- /dev/null
+++ b/src/coverlet.runsettings
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ cobertura
+ [Dime.Repositories]*,[Dime.Repositories.Sql]*,[Dime.Repositories.Sql.EntityFramework]*
+ [*.Tests]*,[*.IntegrationTests]*,[*.Benchmarks]*
+ Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
+ true
+ true
+
+
+
+
+
diff --git a/src/providers/EntityFramework/Dime.Repositories.Sql.EntityFramework.csproj b/src/providers/EntityFramework/Dime.Repositories.Sql.EntityFramework.csproj
index 8482959..2577744 100644
--- a/src/providers/EntityFramework/Dime.Repositories.Sql.EntityFramework.csproj
+++ b/src/providers/EntityFramework/Dime.Repositories.Sql.EntityFramework.csproj
@@ -1,10 +1,11 @@
-
- 3.1.0.0
- 3.1.0.0
- 3.1.0.01
+
+ 3.2.0.0
+ 3.2.0.0
+ 3.2.0
latest
+ annotations
@@ -23,19 +24,27 @@
Dime.Repositories.Sql.EntityFramework
Dime.Repositories.Sql.EntityFramework
Entity Framework;Repository;SQL
- https://cdn.dime-software.com/dime-software/logo-shape.png
Implementation of the repository pattern with Microsoft SQL using Entity Framework Core
Copyright © 2025
https://github.com/dimesoftware/repository
https://github.com/dimesoftware/repository
git
+ logo.png
+
+
+
+
+
+
+
+
diff --git a/src/providers/EntityFramework/Exceptions/ConstraintViolationException.cs b/src/providers/EntityFramework/Exceptions/ConstraintViolationException.cs
index a194fd0..9ef2609 100644
--- a/src/providers/EntityFramework/Exceptions/ConstraintViolationException.cs
+++ b/src/providers/EntityFramework/Exceptions/ConstraintViolationException.cs
@@ -1,6 +1,5 @@
-using System;
+using System;
using System.Diagnostics.CodeAnalysis;
-using System.Runtime.Serialization;
namespace Dime.Repositories
{
@@ -19,9 +18,5 @@ public ConstraintViolationException(string message) : base(message)
public ConstraintViolationException(string message, Exception innerException) : base(message, innerException)
{
}
-
- protected ConstraintViolationException(SerializationInfo info, StreamingContext context) : base(info, context)
- {
- }
}
-}
\ No newline at end of file
+}
diff --git a/src/providers/EntityFramework/Exceptions/DatabaseAccessException.cs b/src/providers/EntityFramework/Exceptions/DatabaseAccessException.cs
index 706f8ef..88fd7ad 100644
--- a/src/providers/EntityFramework/Exceptions/DatabaseAccessException.cs
+++ b/src/providers/EntityFramework/Exceptions/DatabaseAccessException.cs
@@ -1,6 +1,5 @@
-using System;
+using System;
using System.Diagnostics.CodeAnalysis;
-using System.Runtime.Serialization;
namespace Dime.Repositories
{
@@ -21,10 +20,5 @@ public DatabaseAccessException(string message, Exception innerException)
: base(message, innerException)
{
}
-
- protected DatabaseAccessException(SerializationInfo info, StreamingContext context)
- : base(info, context)
- {
- }
}
-}
\ No newline at end of file
+}
diff --git a/src/providers/EntityFramework/Repository/Async/GetRepositoryAsync.cs b/src/providers/EntityFramework/Repository/Async/GetRepositoryAsync.cs
index 4808295..c010a9a 100644
--- a/src/providers/EntityFramework/Repository/Async/GetRepositoryAsync.cs
+++ b/src/providers/EntityFramework/Repository/Async/GetRepositoryAsync.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
@@ -33,24 +33,24 @@ public virtual async Task FindByIdAsync(object? id, params string[] inc
public virtual async Task FindOneAsync(Expression> where)
{
TContext ctx = Context;
- TEntity query = ctx.Set()
+ return await ctx.Set()
.AsNoTracking()
.With(where)
- .FirstOrDefault();
-
- return await Task.Run(() => query);
+ .FirstOrDefaultAsync();
}
public virtual async Task FindOneAsync(Expression> where, params string[] includes)
{
TContext ctx = Context;
- return ctx.Set()
+ IQueryable query = ctx.Set()
.Include(ctx, includes)
.AsNoTracking()
- .WithFirst(where);
+ .With(where);
+
+ return await query.FirstOrDefaultAsync();
}
- public Task FindOneAsync(
+ public async Task FindOneAsync(
Expression> where = null,
Expression> select = null,
Expression> orderBy = null,
@@ -61,16 +61,15 @@ public Task FindOneAsync(
{
using TContext ctx = Context;
IQueryable query = ctx.Set()
- .AsQueryable()
.AsNoTracking()
.With(where)
.WithOrder(orderBy, ascending ?? true)
.With(page, pageSize, orderBy)
.With(pageSize)
.WithSelect(select)
- .Include(Context, includes);
+ .Include(ctx, includes);
- return Task.FromResult(query.FirstOrDefault());
+ return await query.FirstOrDefaultAsync();
}
public virtual async Task> FindAllAsync(Expression> where, params string[] includes)
@@ -78,14 +77,13 @@ public virtual async Task> FindAllAsync(Expression query = ctx.Set()
.Include(ctx, includes)
- .AsQueryable()
.AsNoTracking()
.With(where);
- return await Task.FromResult(query.ToList());
+ return await query.ToListAsync();
}
- public virtual Task> FindAllAsync(
+ public virtual async Task> FindAllAsync(
Expression> where = null,
Expression> select = null,
Expression> orderBy = null,
@@ -98,14 +96,41 @@ public virtual Task> FindAllAsync(
IQueryable query = ctx.Set()
.Include(ctx, includes)
.AsNoTracking()
- .AsQueryable()
.With(where)
.WithOrder(orderBy, ascending ?? true)
.With(page, pageSize, orderBy)
.With(pageSize)
.WithSelect(select);
- return Task.FromResult(query.ToList() as IEnumerable);
+ return await query.ToListAsync();
+ }
+
+ public virtual async Task> FindAllAsync(
+ Expression> where,
+ Expression> select,
+ Expression> orderBy,
+ bool? ascending,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes)
+ where TResult : class
+ {
+ using TContext ctx = Context;
+ IQueryable baseQuery = ctx.Set()
+ .Include(ctx, includes)
+ .AsNoTracking()
+ .With(where)
+ .WithOrder(orderBy, ascending ?? true)
+ .With(page, pageSize, orderBy)
+ .With(pageSize);
+
+ if (splitQuery)
+ baseQuery = baseQuery.AsSplitQuery();
+
+ IQueryable query = baseQuery.WithSelect(select);
+
+ return await query.ToListAsync();
}
public virtual async Task> FindAllAsync(
@@ -125,7 +150,7 @@ public virtual async Task> FindAllAsync(
.With(page, pageSize, orderBy)
.With(pageSize);
- return await Task.FromResult(query.ToList());
+ return await query.ToListAsync();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/providers/EntityFramework/Repository/Async/PagedQueryRunner.cs b/src/providers/EntityFramework/Repository/Async/PagedQueryRunner.cs
new file mode 100644
index 0000000..a21d101
--- /dev/null
+++ b/src/providers/EntityFramework/Repository/Async/PagedQueryRunner.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Dime.Repositories
+{
+ public partial class EfRepository
+ {
+ ///
+ /// Runs a paged query: materializes the data list and computes the total in one call.
+ ///
+ /// When the repository was constructed with an ,
+ /// two independent instances are minted so the data
+ /// SELECT and the COUNT(*) can run concurrently via
+ /// . This halves the wall-clock latency of a paged
+ /// query at the cost of holding two DB connections briefly.
+ ///
+ ///
+ /// In non-factory mode the repository owns a single DbContext; EF Core forbids
+ /// concurrent operations on one context, so this method falls back to running the two
+ /// queries sequentially against the same context.
+ ///
+ ///
+ /// The projected row type.
+ /// Builds the data query against the supplied context.
+ ///
+ /// Optional WHERE predicate for the COUNT(*). When null, all rows are counted.
+ ///
+ protected async Task<(List Data, int Total)> RunPagedAsync(
+ Func> buildDataQuery,
+ Expression> countPredicate)
+ {
+ if (HasContextFactory)
+ {
+ // Two freshly-minted contexts → genuine parallel execution. We own their
+ // lifetime, so we dispose them at the end of the method.
+ using TContext dataCtx = CreateContext();
+ using TContext countCtx = CreateContext();
+
+ Task> dataTask = buildDataQuery(dataCtx).ToListAsync();
+ Task countTask = countCtx.CountAsync(countPredicate);
+ await Task.WhenAll(dataTask, countTask);
+ return (dataTask.Result, countTask.Result);
+ }
+
+ // Single shared context owned by the caller / repository's own Dispose.
+ // We MUST NOT dispose here — callers commonly inspect the context after the
+ // call (e.g. ctx.ChangeTracker, ctx.Entry(...)). EF also forbids concurrent
+ // operations on one context, so the two queries run sequentially.
+ TContext ctx = Context;
+ List data = await buildDataQuery(ctx).ToListAsync();
+ int total = await ctx.CountAsync(countPredicate);
+ return (data, total);
+ }
+ }
+}
diff --git a/src/providers/EntityFramework/Repository/Async/PagedRepositoryAsync.cs b/src/providers/EntityFramework/Repository/Async/PagedRepositoryAsync.cs
index 37639d5..9841d7f 100644
--- a/src/providers/EntityFramework/Repository/Async/PagedRepositoryAsync.cs
+++ b/src/providers/EntityFramework/Repository/Async/PagedRepositoryAsync.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
@@ -10,18 +10,9 @@ namespace Dime.Repositories
public partial class EfRepository
{
///
- /// Finds all asynchronous.
+ /// Projected page (7-arg, single-expression orderBy).
///
- /// The type of the result.
- /// The where.
- /// The select.
- /// The order by.
- ///
- /// The page.
- /// Size of the page.
- /// The includes.
- ///
- public virtual Task> FindAllPagedAsync(
+ public virtual async Task> FindAllPagedAsync(
Expression> where = null,
Expression> select = null,
Expression> orderBy = null,
@@ -31,38 +22,57 @@ public virtual Task> FindAllPagedAsync(
params string[] includes)
where TResult : class
{
- using TContext ctx = Context;
- IQueryable query =
- ctx.Set()
+ var (data, total) = await RunPagedAsync(
+ ctx => ctx.Set()
.AsNoTracking()
.With(where)
.WithOrder(orderBy, ascending ?? true)
.With(page, pageSize, orderBy)
.With(pageSize)
.WithSelect(select)
- .Include(Context, includes);
-
- Page dataPage = new(
- query.ToList(),
- ctx.Set().AsNoTracking().Count(where));
+ .Include(ctx, includes),
+ where);
+ return new Page(data, total);
+ }
- return Task.FromResult((IPage)dataPage);
+ ///
+ /// Projected page (7-arg) with EF Core split-query opt-in on the data query.
+ ///
+ public virtual async Task> FindAllPagedAsync(
+ Expression> where,
+ Expression> select,
+ Expression> orderBy,
+ bool? ascending,
+ int? page,
+ int? pageSize,
+ bool splitQuery,
+ params string[] includes)
+ where TResult : class
+ {
+ var (data, total) = await RunPagedAsync(
+ ctx =>
+ {
+ IQueryable baseQuery = ctx.Set()
+ .Include(ctx, includes)
+ .AsNoTracking()
+ .With(where)
+ .WithOrder(orderBy, ascending ?? true)
+ .With(page, pageSize, orderBy)
+ .With(pageSize);
+ if (splitQuery)
+ baseQuery = baseQuery.AsSplitQuery();
+ return baseQuery.WithSelect(select);
+ },
+ where);
+ return new Page(data, total);
}
///
- ///
+ /// Projected, grouped page. GroupBy projections are LINQ-to-Objects only, so the data
+ /// query stays synchronous-to-list; the count, however, is a pure DB call and is
+ /// parallelised with the materialisation in factory mode.
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public Task> FindAllPagedAsync(
+ public async Task> FindAllPagedAsync(
Expression> where = null,
Func groupBy = null,
Expression, IEnumerable>> select = null,
@@ -82,28 +92,16 @@ public Task> FindAllPagedAsync(
.With(pageSize)
.WithGroup(groupBy)
.WithSelect(select)
- .Include(Context, includes);
-
- Page p = new(
- query.ToList(),
- ctx.Set().AsNoTracking().Count(where));
+ .Include(ctx, includes);
- return Task.FromResult((IPage)p);
+ List data = [.. query];
+ int total = await ctx.CountAsync(where);
+ return new Page(data, total);
}
///
- ///
+ /// Projected page (8-arg, IOrder ordering, optional groupBy).
///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
public async Task> FindAllPagedAsync(
Expression> where = null,
Expression> select = null,
@@ -114,36 +112,22 @@ public async Task> FindAllPagedAsync(
int? pageSize = default,
params string[] includes) where TResult : class
{
- TContext ctx = Context;
- IQueryable