diff --git a/Directory.Packages.props b/Directory.Packages.props
index 235fddd..799bb8a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,5 +12,8 @@
+
+
+
diff --git a/request.slnx b/request.slnx
index cebc37c..65e276c 100644
--- a/request.slnx
+++ b/request.slnx
@@ -5,4 +5,8 @@
+
+
+
+
diff --git a/src/request.persistence.entityframeworkcore.tests/.editorconfig b/src/request.persistence.entityframeworkcore.tests/.editorconfig
new file mode 100644
index 0000000..2300467
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/.editorconfig
@@ -0,0 +1,8 @@
+
+[*.{cs,vb}]
+dotnet_diagnostic.CA1822.severity = none
+dotnet_diagnostic.CA1707.severity = none
+dotnet_diagnostic.IDE0060.severity = none
+dotnet_diagnostic.IDE0005.severity = none
+dotnet_diagnostic.IDE0390.severity = none
+dotnet_diagnostic.IDE0391.severity = none
diff --git a/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs b/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs
new file mode 100644
index 0000000..97f8e11
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs
@@ -0,0 +1,507 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class EvaluationTests
+{
+ private static async Task CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
+ .EnableServiceProviderCaching(false)
+ .Options;
+
+ var context = new FakeDbContext(options);
+ await context.Database.EnsureCreatedAsync();
+ return context;
+ }
+
+ private static async Task> CreateRepo()
+ {
+ var context = await CreateDbContext();
+ return new Repository(context);
+ }
+
+ private static async Task SeedData(Repository repo, params FakeEntity[] entities)
+ {
+ foreach (var entity in entities)
+ {
+ await repo.AddAsync(entity);
+ }
+ }
+
+ [Test]
+ public async Task Where_filters_entities_by_equality()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Gamma" });
+
+ var result = await repo.ListAsync(new NameEqualSpec("Beta"));
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task Where_multiple_filters_combine_as_and()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "AlphaBeta" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new MultipleWhereSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("AlphaBeta");
+ }
+
+ [Test]
+ public async Task Where_no_match_returns_empty_list()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var result = await repo.ListAsync(new NameEqualSpec("NonExistent"));
+
+ await Assert.That(result).IsEmpty();
+ }
+
+ [Test]
+ public async Task Where_with_FirstOrDefaultAsync_returns_matching()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("Alpha"));
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task Where_with_FirstOrDefaultAsync_no_match_returns_null()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var result = await repo.FirstOrDefaultAsync(new NameEqualSpec("NonExistent"));
+
+ await Assert.That(result).IsNull();
+ }
+
+ [Test]
+ public async Task Where_with_CountAsync_returns_correct_count()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var count = await repo.CountAsync(new NameEqualSpec("Alpha"));
+
+ await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Where_with_AnyAsync_returns_true_when_match_exists()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var any = await repo.AnyAsync(new NameEqualSpec("Alpha"));
+
+ await Assert.That(any).IsTrue();
+ }
+
+ [Test]
+ public async Task Where_with_AnyAsync_returns_false_when_no_match()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var any = await repo.AnyAsync(new NameEqualSpec("Beta"));
+
+ await Assert.That(any).IsFalse();
+ }
+
+ [Test]
+ public async Task Search_filters_by_like_pattern()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Albatross" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new SearchSpec("Al%"));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Search_exact_match_works_without_wildcard()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Albatross" });
+
+ var result = await repo.ListAsync(new SearchSpec("Alpha"));
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task Search_multiple_terms_in_same_group_combine_as_or()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Gamma" });
+
+ var result = await repo.ListAsync(new MultiSearchSpec("Alpha", "Beta"));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task OrderBy_returns_ascending()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Charlie" },
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new OrderByNameSpec());
+
+ await Assert.That(result).Count().IsEqualTo(3);
+ await Assert.That(result[0].Name).IsEqualTo("Alpha");
+ await Assert.That(result[1].Name).IsEqualTo("Beta");
+ await Assert.That(result[2].Name).IsEqualTo("Charlie");
+ }
+
+ [Test]
+ public async Task OrderByDescending_returns_descending()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Charlie" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new OrderByNameDescSpec());
+
+ await Assert.That(result).Count().IsEqualTo(3);
+ await Assert.That(result[0].Name).IsEqualTo("Charlie");
+ await Assert.That(result[1].Name).IsEqualTo("Beta");
+ await Assert.That(result[2].Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task ThenBy_chains_after_order_by()
+ {
+ var repo = await CreateRepo();
+ var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
+ var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
+ var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
+
+ await SeedData(repo,
+ new FakeEntity { Id = id2, Name = "Alpha" },
+ new FakeEntity { Id = id1, Name = "Alpha" },
+ new FakeEntity { Id = id3, Name = "Beta" });
+
+ var result = await repo.ListAsync(new OrderByNameThenByIdSpec());
+
+ await Assert.That(result).Count().IsEqualTo(3);
+ await Assert.That(result[0].Id).IsEqualTo(id1);
+ await Assert.That(result[1].Id).IsEqualTo(id2);
+ await Assert.That(result[2].Name).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task ThenByDescending_chains_after_order_by()
+ {
+ var repo = await CreateRepo();
+ var id1 = Guid.Parse("00000000-0000-0000-0000-000000000001");
+ var id2 = Guid.Parse("00000000-0000-0000-0000-000000000002");
+ var id3 = Guid.Parse("00000000-0000-0000-0000-000000000003");
+
+ await SeedData(repo,
+ new FakeEntity { Id = id2, Name = "Alpha" },
+ new FakeEntity { Id = id1, Name = "Alpha" },
+ new FakeEntity { Id = id3, Name = "Beta" });
+
+ var result = await repo.ListAsync(new OrderByNameThenByIdDescSpec());
+
+ await Assert.That(result).Count().IsEqualTo(3);
+ await Assert.That(result[0].Id).IsEqualTo(id2);
+ await Assert.That(result[1].Id).IsEqualTo(id1);
+ await Assert.That(result[2].Name).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task Duplicate_order_chain_throws()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ await Assert.That(async () => await repo.ListAsync(new DuplicateOrderSpec()))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Take_limits_results()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Gamma" });
+
+ var result = await repo.ListAsync(new OrderedTakeSpec(2));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ await Assert.That(result[0].Name).IsEqualTo("Alpha");
+ await Assert.That(result[1].Name).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task Skip_skips_elements()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Gamma" });
+
+ var result = await repo.ListAsync(new OrderedSkipSpec(1));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ await Assert.That(result[0].Name).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task Skip_and_take_paginates_correctly()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Charlie" },
+ new FakeEntity { Name = "Delta" });
+
+ var result = await repo.ListAsync(new OrderedSkipTakeSpec(1, 2));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ await Assert.That(result[0].Name).IsEqualTo("Beta");
+ await Assert.That(result[1].Name).IsEqualTo("Charlie");
+ }
+
+ [Test]
+ public async Task Skip_zero_returns_all()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new SkipOnlySpec(0));
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Pagination_is_ignored_by_CountAsync()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" },
+ new FakeEntity { Name = "Gamma" });
+
+ var count = await repo.CountAsync(new OrderedSkipTakeSpec(1, 1));
+
+ await Assert.That(count).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Pagination_is_ignored_by_AnyAsync()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var any = await repo.AnyAsync(new OrderedSkipSpec(10));
+
+ await Assert.That(any).IsTrue();
+ }
+
+ [Test]
+ public async Task Select_projects_entity_to_new_type()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new NameProjectionSpec());
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ await Assert.That(result[0]).IsEqualTo("Alpha");
+ await Assert.That(result[1]).IsEqualTo("Beta");
+ }
+
+ [Test]
+ public async Task Select_with_FirstOrDefaultAsync_returns_projected_result()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var result = await repo.FirstOrDefaultAsync(new NameProjectionSpec());
+
+ await Assert.That(result).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task No_selector_on_projected_spec_throws()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ await Assert.That(async () => await repo.ListAsync(new NoSelectorSpec()))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Concurrent_selectors_throws()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ await Assert.That(async () => await repo.ListAsync(new ConcurrentSelectorSpec()))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Where_orderby_and_take_combine_correctly()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Charlie" },
+ new FakeEntity { Name = "Alice" },
+ new FakeEntity { Name = "Bob" },
+ new FakeEntity { Name = "David" });
+
+ var result = await repo.ListAsync(new CombinedWhereOrderTakeSpec());
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ await Assert.That(result[0].Name).IsEqualTo("Alice");
+ await Assert.That(result[1].Name).IsEqualTo("Bob");
+ }
+
+ [Test]
+ public async Task PostProcessing_applies_after_query()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "AlphaBeta" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new PostProcessingSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task Where_only_returns_filtered()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.ListAsync(new WhereSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task FirstOrDefaultAsync_returns_first_matching()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var result = await repo.FirstOrDefaultAsync(new FirstOrderedSpec());
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Name).IsEqualTo("Alpha");
+ }
+
+ [Test]
+ public async Task CountAsync_without_spec_returns_all()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var count = await repo.CountAsync();
+
+ await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task AnyAsync_without_spec_returns_true_when_entities_exist()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo, new FakeEntity { Name = "Alpha" });
+
+ var any = await repo.AnyAsync();
+
+ await Assert.That(any).IsTrue();
+ }
+
+ [Test]
+ public async Task AnyAsync_without_spec_returns_false_when_empty()
+ {
+ var repo = await CreateRepo();
+
+ var any = await repo.AnyAsync();
+
+ await Assert.That(any).IsFalse();
+ }
+
+ [Test]
+ public async Task AsAsyncEnumerable_streams_entities()
+ {
+ var repo = await CreateRepo();
+ await SeedData(repo,
+ new FakeEntity { Name = "Alpha" },
+ new FakeEntity { Name = "Beta" });
+
+ var count = 0;
+
+ await foreach (var _ in repo.AsAsyncEnumerable(new OrderByNameSpec()))
+ {
+ count++;
+ }
+
+ await Assert.That(count).IsEqualTo(2);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj b/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj
new file mode 100644
index 0000000..90df1c0
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs b/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs
new file mode 100644
index 0000000..e10d098
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs
@@ -0,0 +1,403 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using System.Linq.Expressions;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class IncludeEvaluatorTests
+{
+ private static async Task CreateDbContext()
+ {
+ var context = new FakeDbContext(new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
+ .EnableServiceProviderCaching(false)
+ .Options);
+ await context.Database.EnsureCreatedAsync();
+ return context;
+ }
+
+ // --- Default evaluator (non-cached, reflection-based) ---
+
+ [Test]
+ public async Task Default_include_reference_nav()
+ {
+ var repo = await CreateRepo();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ Child = new FakeChildEntity
+ {
+ Value = "child",
+ },
+ };
+ await repo.AddAsync(entity);
+
+ var result = await repo.ListAsync(new IncludeChildSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Test");
+ }
+
+ [Test]
+ public async Task Default_include_collection_nav()
+ {
+ var repo = await CreateRepo();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ };
+ entity.Details.Add(new FakeDetailEntity
+ {
+ Value = "detail",
+ });
+ await repo.AddAsync(entity);
+
+ var result = await repo.ListAsync(new IncludeDetailsSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Test");
+ }
+
+ [Test]
+ public async Task Default_then_include_after_reference()
+ {
+ var repo = await CreateRepo();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ Child = new FakeChildEntity
+ {
+ Value = "child",
+ GrandChildren =
+ {
+ new FakeGrandChildEntity
+ {
+ Value = "grand",
+ },
+ },
+ },
+ };
+ await repo.AddAsync(entity);
+
+ var result = await repo.ListAsync(new IncludeChildThenGrandChildrenSpec());
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Default_empty_includes()
+ {
+ var repo = await CreateRepo();
+ await repo.AddAsync(new FakeEntity
+ {
+ Name = "Alpha",
+ });
+ await repo.AddAsync(new FakeEntity
+ {
+ Name = "Beta",
+ });
+
+ var builder = new SpecificationBuilder();
+ var spec = new IncludeOnlySpec(builder);
+ var result = await repo.ListAsync(spec);
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Default_then_include_after_collection()
+ {
+ var dbContext = await CreateDbContext();
+ var repo = new Repository(dbContext);
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ };
+ entity.Details.Add(new FakeDetailEntity
+ {
+ Value = "detail",
+ DetailChildren =
+ {
+ new FakeDetailChildEntity
+ {
+ Value = "childDetail",
+ },
+ },
+ });
+ await repo.AddAsync(entity);
+
+ var builder = new SpecificationBuilder();
+ Expression>> expr = static d => d.DetailChildren;
+ builder.IncludeExpressions.Add(new IncludeExpressionInfo(
+ expr, typeof(FakeEntity), typeof(List), typeof(IEnumerable)));
+ var spec = new IncludeOnlySpec(builder);
+ var result = await repo.ListAsync(spec);
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ }
+
+ // --- Cached evaluator (delegate-based) ---
+
+ [Test]
+ public async Task Cached_include_reference_nav()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ Child = new FakeChildEntity
+ {
+ Value = "child",
+ },
+ };
+ dbContext.FakeEntities.Add(entity);
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child);
+ var spec = new IncludeOnlySpec(builder);
+
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ await Assert.That(result[0].Name).IsEqualTo("Test");
+ }
+
+ [Test]
+ public async Task Cached_include_collection_nav()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ };
+ entity.Details.Add(new FakeDetailEntity
+ {
+ Value = "detail",
+ });
+ dbContext.FakeEntities.Add(entity);
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Details);
+ var spec = new IncludeOnlySpec(builder);
+
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Cached_then_include_after_reference()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ Child = new FakeChildEntity
+ {
+ Value = "child",
+ GrandChildren =
+ {
+ new FakeGrandChildEntity
+ {
+ Value = "grand",
+ },
+ },
+ },
+ };
+ dbContext.FakeEntities.Add(entity);
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
+ var spec = new IncludeOnlySpec(builder);
+
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Cached_then_include_after_collection()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ var entity = new FakeEntity
+ {
+ Name = "Test",
+ };
+ entity.Details.Add(new FakeDetailEntity
+ {
+ Value = "detail",
+ DetailChildren =
+ {
+ new FakeDetailChildEntity
+ {
+ Value = "childDetail",
+ },
+ },
+ });
+ dbContext.FakeEntities.Add(entity);
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ Expression>> expr = static d => d.DetailChildren;
+ builder.IncludeExpressions.Add(new IncludeExpressionInfo(
+ expr, typeof(FakeEntity), typeof(List), typeof(IEnumerable)));
+ var spec = new IncludeOnlySpec(builder);
+
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Cached_empty_includes()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ dbContext.FakeEntities.AddRange(
+ new FakeEntity
+ {
+ Name = "Alpha",
+ },
+ new FakeEntity
+ {
+ Name = "Beta",
+ });
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ var spec = new IncludeOnlySpec(builder);
+
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ // --- Comparison ---
+
+ [Test]
+ public async Task Default_and_cached_produce_same_results()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ dbContext.FakeEntities.Add(new FakeEntity
+ {
+ Name = "Test",
+ Child = new FakeChildEntity
+ {
+ Value = "child",
+ },
+ });
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child);
+ var spec = new IncludeOnlySpec(builder);
+
+ var resultDefault = IncludeEvaluator.Default.Evaluate(query, spec).ToList();
+ var resultCached = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(resultDefault).Count().IsEqualTo(1);
+ await Assert.That(resultCached).Count().IsEqualTo(1);
+ await Assert.That(resultDefault[0].Name).IsEqualTo(resultCached[0].Name);
+ }
+
+ // --- Cache reuse ---
+
+ [Test]
+ public async Task Cached_include_reuses_delegate()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ dbContext.FakeEntities.AddRange(
+ new FakeEntity
+ {
+ Name = "A",
+ Child = new FakeChildEntity
+ {
+ Value = "a",
+ },
+ },
+ new FakeEntity
+ {
+ Name = "B",
+ Child = new FakeChildEntity
+ {
+ Value = "b",
+ },
+ });
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child);
+ var spec = new IncludeOnlySpec(builder);
+
+ _ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Cached_then_include_reuses_delegate()
+ {
+ var dbContext = await CreateDbContext();
+ var query = dbContext.FakeEntities.AsQueryable();
+ dbContext.FakeEntities.AddRange(
+ new FakeEntity
+ {
+ Name = "A",
+ Child = new FakeChildEntity
+ {
+ Value = "a",
+ GrandChildren =
+ {
+ new FakeGrandChildEntity
+ {
+ Value = "g1",
+ },
+ },
+ },
+ },
+ new FakeEntity
+ {
+ Name = "B",
+ Child = new FakeChildEntity
+ {
+ Value = "b",
+ GrandChildren =
+ {
+ new FakeGrandChildEntity
+ {
+ Value = "g2",
+ },
+ },
+ },
+ });
+ await dbContext.SaveChangesAsync();
+
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
+ var spec = new IncludeOnlySpec(builder);
+
+ _ = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+ var result = IncludeEvaluator.Cached.Evaluate(query, spec).ToList();
+
+ await Assert.That(result).Count().IsEqualTo(2);
+ }
+
+ private static async Task> CreateRepo()
+ {
+ var context = await CreateDbContext();
+ return new Repository(context);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs b/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs
new file mode 100644
index 0000000..481b4bf
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs
@@ -0,0 +1,119 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class RepositoryTests
+{
+ private static async Task CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
+ .EnableServiceProviderCaching(false)
+ .Options;
+
+ var context = new FakeDbContext(options);
+ await context.Database.EnsureCreatedAsync();
+ return context;
+ }
+
+ [Test]
+ public async Task AddAsync_persists_entity()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ var entity = new FakeEntity { Name = "Test" };
+ await repo.AddAsync(entity);
+
+ await Assert.That(entity.Id).IsNotEqualTo(Guid.Empty);
+ await Assert.That(entity.Name).IsEqualTo("Test");
+ }
+
+ [Test]
+ public async Task ListAsync_returns_all_entities()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ await repo.AddAsync(new FakeEntity { Name = "A" });
+ await repo.AddAsync(new FakeEntity { Name = "B" });
+
+ var list = await repo.ListAsync();
+
+ await Assert.That(list).Count().IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task UpdateAsync_modifies_entity()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ var entity = new FakeEntity { Name = "Original" };
+ await repo.AddAsync(entity);
+
+ entity.Name = "Updated";
+ await repo.UpdateAsync(entity);
+
+ var all = await repo.ListAsync();
+ var found = all.FirstOrDefault(e => e.Id == entity.Id);
+ await Assert.That(found).IsNotNull();
+ await Assert.That(found!.Name).IsEqualTo("Updated");
+ }
+
+ [Test]
+ public async Task DeleteAsync_removes_entity()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ var entity = new FakeEntity { Name = "Test" };
+ await repo.AddAsync(entity);
+ await repo.DeleteAsync(entity);
+
+ var all = await repo.ListAsync();
+ var found = all.FirstOrDefault(e => e.Id == entity.Id);
+ await Assert.That(found).IsNull();
+ }
+
+ [Test]
+ public async Task CountAsync_returns_correct_count()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ await repo.AddAsync(new FakeEntity { Name = "A" });
+ await repo.AddAsync(new FakeEntity { Name = "B" });
+
+ var count = await repo.CountAsync();
+
+ await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task AnyAsync_returns_true_when_entities_exist()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ await repo.AddAsync(new FakeEntity { Name = "A" });
+
+ var any = await repo.AnyAsync();
+
+ await Assert.That(any).IsTrue();
+ }
+
+ [Test]
+ public async Task AnyAsync_returns_false_when_no_entities()
+ {
+ var context = await CreateDbContext();
+ var repo = new Repository(context);
+
+ var any = await repo.AnyAsync();
+
+ await Assert.That(any).IsFalse();
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs
new file mode 100644
index 0000000..b51af0a
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs
@@ -0,0 +1,15 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class CombinedWhereOrderTakeSpec : Specification
+{
+ public CombinedWhereOrderTakeSpec()
+ {
+ Query.Where(e => e.Name != "David")
+ .OrderBy(e => e.Name)
+ .Builder
+ .Take(2);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs
new file mode 100644
index 0000000..160a5ef
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class ConcurrentSelectorSpec : Specification
+{
+ public ConcurrentSelectorSpec()
+ {
+ Query.Select(e => e.Name);
+ Query.SelectMany(e => e.Name.Split(' '));
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs
new file mode 100644
index 0000000..dad2040
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class DuplicateOrderSpec : Specification
+{
+ public DuplicateOrderSpec()
+ {
+ Query.OrderBy(e => e.Name);
+ Query.OrderBy(e => e.Id);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs
new file mode 100644
index 0000000..24a8e02
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeEntityId { get; set; }
+ public FakeEntity FakeEntity { get; set; } = null!;
+ public List GrandChildren { get; set; } = [];
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs
new file mode 100644
index 0000000..c06fcb9
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs
@@ -0,0 +1,60 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet FakeEntities => Set();
+ public DbSet FakeChildEntities => Set();
+ public DbSet FakeDetailEntities => Set();
+ public DbSet FakeGrandChildEntities => Set();
+ public DbSet FakeDetailChildEntities => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(static e => e.Id);
+ entity.Property(static e => e.Name).IsRequired();
+ entity.HasOne(static e => e.Child)
+ .WithOne(static c => c.FakeEntity)
+ .HasForeignKey(static c => c.FakeEntityId);
+ entity.HasMany(static e => e.Details)
+ .WithOne(static d => d.FakeEntity)
+ .HasForeignKey(static d => d.FakeEntityId);
+ });
+
+ modelBuilder.Entity(child =>
+ {
+ child.HasKey(static c => c.Id);
+ child.Property(static c => c.Value).IsRequired();
+ child.HasMany(static c => c.GrandChildren)
+ .WithOne(static g => g.FakeChildEntity)
+ .HasForeignKey(static g => g.FakeChildEntityId);
+ });
+
+ modelBuilder.Entity(grandChild =>
+ {
+ grandChild.HasKey(static g => g.Id);
+ grandChild.Property(static g => g.Value).IsRequired();
+ });
+
+ modelBuilder.Entity(detail =>
+ {
+ detail.HasKey(static d => d.Id);
+ detail.Property(static d => d.Value).IsRequired();
+ detail.HasMany(static d => d.DetailChildren)
+ .WithOne(static dc => dc.FakeDetailEntity)
+ .HasForeignKey(static dc => dc.FakeDetailEntityId);
+ });
+
+ modelBuilder.Entity(detailChild =>
+ {
+ detailChild.HasKey(static d => d.Id);
+ detailChild.Property(static d => d.Value).IsRequired();
+ });
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs
new file mode 100644
index 0000000..7226cdc
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeDetailChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeDetailEntityId { get; set; }
+ public FakeDetailEntity FakeDetailEntity { get; set; } = null!;
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs
new file mode 100644
index 0000000..652c605
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeDetailEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeEntityId { get; set; }
+ public FakeEntity FakeEntity { get; set; } = null!;
+ public List DetailChildren { get; set; } = [];
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs
new file mode 100644
index 0000000..40feea0
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeEntity
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public FakeChildEntity Child { get; set; } = null!;
+ public List Details { get; set; } = [];
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs
new file mode 100644
index 0000000..c5bba1c
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FakeGrandChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeChildEntityId { get; set; }
+ public FakeChildEntity FakeChildEntity { get; set; } = null!;
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs
new file mode 100644
index 0000000..9d8cf10
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class FirstOrderedSpec : Specification
+{
+ public FirstOrderedSpec()
+ {
+ Query.OrderBy(e => e.Name);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs
new file mode 100644
index 0000000..f918b06
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class IncludeChildSpec : Specification
+{
+ public IncludeChildSpec()
+ {
+ Query.Include(static e => e.Child);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs
new file mode 100644
index 0000000..cd02049
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class IncludeChildThenGrandChildrenSpec : Specification
+{
+ public IncludeChildThenGrandChildrenSpec()
+ {
+ Query.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs
new file mode 100644
index 0000000..2587e48
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class IncludeDetailsSpec : Specification
+{
+ public IncludeDetailsSpec()
+ {
+ Query.Include(static e => e.Details);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs
new file mode 100644
index 0000000..5075d2b
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs
@@ -0,0 +1,11 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class IncludeOnlySpec : Specification
+{
+ public IncludeOnlySpec(SpecificationBuilder builder) : base(builder)
+ {
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs
new file mode 100644
index 0000000..6419704
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class MultiSearchSpec : Specification
+{
+ public MultiSearchSpec(string term1, string term2)
+ {
+ Query.Search(e => e.Name, term1, searchGroup: 1)
+ .Search(e => e.Name, term2, searchGroup: 1);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs
new file mode 100644
index 0000000..d20f0a5
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class MultipleWhereSpec : Specification
+{
+ public MultipleWhereSpec()
+ {
+ Query.Where(e => e.Name.StartsWith("A"))
+ .Where(e => e.Name.EndsWith("Beta"));
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs
new file mode 100644
index 0000000..76e8765
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class NameEqualSpec : Specification
+{
+ public NameEqualSpec(string name)
+ {
+ Query.Where(e => e.Name == name);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs
new file mode 100644
index 0000000..6baa0ae
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class NameProjectionSpec : Specification
+{
+ public NameProjectionSpec()
+ {
+ Query.Select(e => e.Name);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs
new file mode 100644
index 0000000..2860d86
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs
@@ -0,0 +1,8 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class NoSelectorSpec : Specification
+{
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs
new file mode 100644
index 0000000..fa8b4dc
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderByNameDescSpec : Specification
+{
+ public OrderByNameDescSpec()
+ {
+ Query.OrderByDescending(e => e.Name);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs
new file mode 100644
index 0000000..be79f3b
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderByNameSpec : Specification
+{
+ public OrderByNameSpec()
+ {
+ Query.OrderBy(e => e.Name);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs
new file mode 100644
index 0000000..a808164
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderByNameThenByIdDescSpec : Specification
+{
+ public OrderByNameThenByIdDescSpec()
+ {
+ Query.OrderBy(e => e.Name).ThenByDescending(e => e.Id);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs
new file mode 100644
index 0000000..1dab453
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderByNameThenByIdSpec : Specification
+{
+ public OrderByNameThenByIdSpec()
+ {
+ Query.OrderBy(e => e.Name).ThenBy(e => e.Id);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs
new file mode 100644
index 0000000..1969144
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderedSkipSpec : Specification
+{
+ public OrderedSkipSpec(int skip)
+ {
+ Query.OrderBy(e => e.Name);
+ Query.Skip(skip);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs
new file mode 100644
index 0000000..49aca1c
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderedSkipTakeSpec : Specification
+{
+ public OrderedSkipTakeSpec(int skip, int take)
+ {
+ Query.OrderBy(e => e.Name);
+ Query.Skip(skip).Take(take);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs
new file mode 100644
index 0000000..387d640
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class OrderedTakeSpec : Specification
+{
+ public OrderedTakeSpec(int take)
+ {
+ Query.OrderBy(e => e.Name);
+ Query.Take(take);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs
new file mode 100644
index 0000000..fe189e2
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class PostProcessingSpec : Specification
+{
+ public PostProcessingSpec()
+ {
+ Query.Where(e => e.Name.StartsWith("A"))
+ .PostProcessing(items => items.Take(1));
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs
new file mode 100644
index 0000000..99fc073
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class SearchSpec : Specification
+{
+ public SearchSpec(string term, int searchGroup = 1)
+ {
+ Query.Search(e => e.Name, term, searchGroup: searchGroup);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs
new file mode 100644
index 0000000..b762bff
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class SkipOnlySpec : Specification
+{
+ public SkipOnlySpec(int skip)
+ {
+ Query.Skip(skip);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs b/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs
new file mode 100644
index 0000000..235dc1e
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore.Tests;
+
+internal sealed class WhereSpec : Specification
+{
+ public WhereSpec()
+ {
+ Query.Where(e => e.Name == "Alpha");
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj b/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj
new file mode 100644
index 0000000..120069c
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj
@@ -0,0 +1,40 @@
+
+
+
+ Library
+ net10.0
+ true
+
+
+
+ true
+
+
+
+
+
+
+
+ package-readme.md
+ Entity Framework Core implementation of the generic repository and specification pattern.
+ package-icon.png
+ https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence.entityframeworkcore
+ EUPL-1.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/request.persistence.entityframeworkcore/Repository.cs b/src/request.persistence.entityframeworkcore/Repository.cs
new file mode 100644
index 0000000..0d4767e
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/Repository.cs
@@ -0,0 +1,174 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+///
+/// Represents a repository for .
+///
+/// The type of entity.
+public partial class Repository : IRepository
+ where TEntity : class
+{
+ private readonly SpecificationEvaluator _evaluator = SpecificationEvaluator.Default;
+
+ ///
+ /// Creates a new repository.
+ ///
+ /// The used by the repository.
+ public Repository(DbContext dbContext)
+ {
+ DbContext = dbContext;
+ }
+
+ ///
+ /// Gets the used by the repository.
+ ///
+ protected DbContext DbContext { get; }
+
+ ///
+ public virtual async Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task> ListAsync(CancellationToken cancellationToken = default)
+ {
+ return await DbContext.Set().ToListAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken);
+
+ return specification.PostProcessingAction is null
+ ? queryResult
+ : [.. specification.PostProcessingAction(queryResult)];
+ }
+
+ ///
+ public virtual async Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ var queryResult = await ApplySpecification(specification).ToListAsync(cancellationToken);
+
+ return specification.PostProcessingAction is null
+ ? queryResult
+ : [.. specification.PostProcessingAction(queryResult)];
+ }
+
+ ///
+ public virtual async Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ return await ApplySpecification(specification, true).CountAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task CountAsync(CancellationToken cancellationToken = default)
+ {
+ return await DbContext.Set().CountAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task AnyAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ return await ApplySpecification(specification, true).AnyAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task AnyAsync(CancellationToken cancellationToken = default)
+ {
+ return await DbContext.Set().AnyAsync(cancellationToken);
+ }
+
+ ///
+ public virtual IAsyncEnumerable AsAsyncEnumerable(ISpecification specification)
+ {
+ return ApplySpecification(specification).AsAsyncEnumerable();
+ }
+
+ protected virtual IQueryable ApplySpecification(ISpecification specification, bool evaluateCriteriaOnly = false)
+ {
+ return _evaluator.Evaluate(DbContext.Set(), specification, evaluateCriteriaOnly);
+ }
+
+ protected virtual IQueryable ApplySpecification(ISpecification specification)
+ {
+ return _evaluator.Evaluate(DbContext.Set(), specification);
+ }
+}
+
+public partial class Repository
+{
+ ///
+ public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().Add(entity);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().AddRange(entities);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().Update(entity);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().UpdateRange(entities);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().Remove(entity);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default)
+ {
+ DbContext.Set().RemoveRange(entities);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task DeleteRangeAsync(ISpecification specification, CancellationToken cancellationToken = default)
+ {
+ var query = ApplySpecification(specification);
+ DbContext.Set().RemoveRange(query);
+
+ await SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public virtual async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return await DbContext.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs
new file mode 100644
index 0000000..cecdf32
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs
@@ -0,0 +1,60 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class SpecificationEvaluator
+{
+ private readonly IReadOnlyList _evaluators;
+
+ public static SpecificationEvaluator Default { get; } = new();
+
+ private SpecificationEvaluator(bool cache = false)
+ {
+ _evaluators =
+ [
+ WhereEvaluator.Instance,
+ cache ? IncludeEvaluator.Cached : IncludeEvaluator.Default,
+ SearchEvaluator.Instance,
+ OrderEvaluator.Instance,
+ PaginationEvaluator.Instance
+ ];
+ }
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(specification);
+
+ if (specification.Selector is null && specification.SelectorMany is null)
+ {
+ throw new SelectorNotFoundException();
+ }
+
+ if (specification.Selector is not null && specification.SelectorMany is not null)
+ {
+ throw new ConcurrentSelectorsException();
+ }
+
+ query = Evaluate(query, specification);
+
+ return specification.Selector is not null
+ ? query.Select(specification.Selector)
+ : query.SelectMany(specification.SelectorMany!);
+ }
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification, bool evaluateCriteriaOnly = false)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(specification);
+
+ var evaluators = evaluateCriteriaOnly ? _evaluators.Where(static instance => instance.IsCriteriaEvaluator) : _evaluators;
+
+ foreach (var evaluator in evaluators)
+ {
+ query = evaluator.Evaluate(query, specification);
+ }
+
+ return query;
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs
new file mode 100644
index 0000000..39d230a
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs
@@ -0,0 +1,25 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+///
+/// Defines an evaluator that applies a specific aspect of a specification to a query.
+///
+public interface IEvaluator
+{
+ ///
+ /// Gets a value indicating whether this evaluator filters the result set (criteria)
+ /// rather than shaping the query (e.g., includes, ordering, pagination).
+ ///
+ bool IsCriteriaEvaluator { get; }
+
+ ///
+ /// Applies the evaluator's logic to the specified query based on the given specification.
+ ///
+ /// The type of entity being queried.
+ /// The query to evaluate.
+ /// The specification containing the expression to apply.
+ /// The modified query.
+ IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class;
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs
new file mode 100644
index 0000000..12cfef7
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs
@@ -0,0 +1,173 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Reflection;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Query;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class IncludeEvaluator : IEvaluator
+{
+ private static readonly MethodInfo IncludeMethodInfo
+ = typeof(EntityFrameworkQueryableExtensions)
+ .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.Include))
+ .Single(static mi =>
+ mi.GetGenericArguments().Length is 2 &&
+ mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) &&
+ mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>));
+
+ private static readonly MethodInfo ThenIncludeAfterReferenceMethodInfo
+ = typeof(EntityFrameworkQueryableExtensions)
+ .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude))
+ .Where(static mi => mi.GetGenericArguments().Length is 3)
+ .Single(static mi =>
+ mi.GetParameters()[0].ParameterType.GenericTypeArguments[1].IsGenericParameter &&
+ mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) &&
+ mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>));
+
+ private static readonly MethodInfo ThenIncludeAfterEnumerableMethodInfo
+ = typeof(EntityFrameworkQueryableExtensions)
+ .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude))
+ .Where(static mi => mi.GetGenericArguments().Length is 3)
+ .Single(static mi =>
+ {
+ var typeInfo = mi.GetParameters()[0].ParameterType.GenericTypeArguments[1];
+
+ return typeInfo.IsGenericType &&
+ typeInfo.GetGenericTypeDefinition() == typeof(IEnumerable<>) &&
+ mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) &&
+ mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>);
+ });
+
+ private static readonly ConcurrentDictionary<(Type EntityType, Type PropertyType, Type? PreviousPropertyType), Lazy>> DelegatesCache = new();
+
+ private readonly bool _cacheEnabled;
+
+ private IncludeEvaluator(bool cacheEnabled)
+ {
+ _cacheEnabled = cacheEnabled;
+ }
+
+ public static IncludeEvaluator Default { get; } = new(false);
+
+ public static IncludeEvaluator Cached { get; } = new(true);
+
+ public bool IsCriteriaEvaluator => false;
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class
+ {
+ foreach (var includeInfo in specification.IncludeExpressions)
+ {
+ if (includeInfo.Type == IncludeType.Include)
+ {
+ query = BuildInclude(query, includeInfo);
+ }
+ else if (includeInfo.Type == IncludeType.ThenInclude)
+ {
+ query = BuildThenInclude(query, includeInfo);
+ }
+ }
+
+ return query;
+ }
+
+ private IQueryable BuildInclude(IQueryable query, IncludeExpressionInfo includeInfo)
+ {
+ if (!_cacheEnabled)
+ {
+ var result = IncludeMethodInfo.MakeGenericMethod(includeInfo.EntityType, includeInfo.PropertyType)
+ .Invoke(null, [query, includeInfo.LambdaExpression]);
+
+ _ = result ?? throw new TargetException();
+
+ return (IQueryable)result;
+ }
+
+ var include = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, null), CreateIncludeDelegate).Value;
+
+ return (IQueryable)include(query, includeInfo.LambdaExpression);
+ }
+
+ private static Lazy> CreateIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey)
+ {
+ return new Lazy>(() =>
+ {
+ var concreteInclude = IncludeMethodInfo.MakeGenericMethod(cacheKey.EntityType, cacheKey.PropertyType);
+ var sourceParameter = Expression.Parameter(typeof(IQueryable));
+ var selectorParameter = Expression.Parameter(typeof(LambdaExpression));
+
+ var call = Expression.Call(concreteInclude,
+ Expression.Convert(sourceParameter, typeof(IQueryable<>).MakeGenericType(cacheKey.EntityType)),
+ Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PropertyType))));
+
+ var lambda = Expression.Lambda>(call, sourceParameter, selectorParameter);
+
+ return lambda.Compile();
+ });
+ }
+
+ private IQueryable BuildThenInclude(IQueryable query, IncludeExpressionInfo includeInfo)
+ {
+ ArgumentNullException.ThrowIfNull(includeInfo.PreviousPropertyType);
+
+ if (!_cacheEnabled)
+ {
+ var result = (IsGenericEnumerable(includeInfo.PreviousPropertyType, out var previousPropertyType)
+ ? ThenIncludeAfterEnumerableMethodInfo
+ : ThenIncludeAfterReferenceMethodInfo).MakeGenericMethod(includeInfo.EntityType, previousPropertyType, includeInfo.PropertyType)
+ .Invoke(null, [query, includeInfo.LambdaExpression]);
+
+ _ = result ?? throw new TargetException();
+
+ return (IQueryable)result;
+ }
+
+ var thenInclude = DelegatesCache.GetOrAdd((includeInfo.EntityType, includeInfo.PropertyType, includeInfo.PreviousPropertyType), CreateThenIncludeDelegate).Value;
+
+ return (IQueryable)thenInclude(query, includeInfo.LambdaExpression);
+ }
+
+ private static Lazy> CreateThenIncludeDelegate((Type EntityType, Type PropertyType, Type? PreviousPropertyType) cacheKey)
+ {
+ return new Lazy>(() =>
+ {
+ ArgumentNullException.ThrowIfNull(cacheKey.PreviousPropertyType);
+
+ var thenIncludeInfo = ThenIncludeAfterReferenceMethodInfo;
+ if (IsGenericEnumerable(cacheKey.PreviousPropertyType, out var previousPropertyType))
+ {
+ thenIncludeInfo = ThenIncludeAfterEnumerableMethodInfo;
+ }
+
+ var concreteThenInclude = thenIncludeInfo.MakeGenericMethod(cacheKey.EntityType, previousPropertyType, cacheKey.PropertyType);
+ var sourceParameter = Expression.Parameter(typeof(IQueryable));
+ var selectorParameter = Expression.Parameter(typeof(LambdaExpression));
+
+ var call = Expression.Call(concreteThenInclude,
+ Expression.Convert(sourceParameter, typeof(IIncludableQueryable<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PreviousPropertyType)),
+ Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(previousPropertyType, cacheKey.PropertyType))));
+
+ var lambda = Expression.Lambda>(call, sourceParameter, selectorParameter);
+
+ return lambda.Compile();
+ });
+ }
+
+ private static bool IsGenericEnumerable(Type type, out Type propertyType)
+ {
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
+ {
+ propertyType = type.GenericTypeArguments[0];
+
+ return true;
+ }
+
+ propertyType = type;
+
+ return false;
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs
new file mode 100644
index 0000000..f7a3e87
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs
@@ -0,0 +1,43 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using Geekeey.Request.Persistence.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class OrderEvaluator : IEvaluator
+{
+ private OrderEvaluator() { }
+
+ internal static OrderEvaluator Instance { get; } = new();
+
+ public bool IsCriteriaEvaluator { get; }
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class
+ {
+ if (specification.OrderExpressions.Count(static info => info.OrderType is OrderType.OrderBy or OrderType.OrderByDescending) > 1)
+ {
+ throw new DuplicateOrderChainException();
+ }
+
+ IOrderedQueryable? orderedQuery = null;
+ foreach (var orderExpression in specification.OrderExpressions)
+ {
+ orderedQuery = orderExpression.OrderType switch
+ {
+ OrderType.OrderBy => query.OrderBy(orderExpression.KeySelector),
+ OrderType.OrderByDescending => query.OrderByDescending(orderExpression.KeySelector),
+ OrderType.ThenBy => orderedQuery!.ThenBy(orderExpression.KeySelector),
+ OrderType.ThenByDescending => orderedQuery!.ThenByDescending(orderExpression.KeySelector),
+ _ => orderedQuery
+ };
+ }
+
+ if (orderedQuery is not null)
+ {
+ query = orderedQuery;
+ }
+
+ return query;
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs
new file mode 100644
index 0000000..489bcae
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs
@@ -0,0 +1,28 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class PaginationEvaluator : IEvaluator
+{
+ private PaginationEvaluator() { }
+
+ public static PaginationEvaluator Instance { get; } = new();
+
+ public bool IsCriteriaEvaluator { get; }
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class
+ {
+ if (specification.Skip is not null and not 0)
+ {
+ query = query.Skip(specification.Skip.Value);
+ }
+
+ if (specification.Take is not null)
+ {
+ query = query.Take(specification.Take.Value);
+ }
+
+ return query;
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs
new file mode 100644
index 0000000..ef019c4
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs
@@ -0,0 +1,81 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using System.Linq.Expressions;
+using System.Reflection;
+
+using Microsoft.EntityFrameworkCore;
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class SearchEvaluator : IEvaluator
+{
+ private static readonly MemberExpression Functions
+ = Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions)) ?? throw new TargetException("The EF.Functions not found!"));
+
+ private static readonly MethodInfo LikeMethodInfo
+ = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])
+ ?? throw new TargetException("The EF.Functions.Like not found");
+
+ private SearchEvaluator() { }
+
+ public static SearchEvaluator Instance { get; } = new();
+
+ public bool IsCriteriaEvaluator { get; } = true;
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class
+ {
+ foreach (var searchCriteria in specification.SearchCriteria.GroupBy(static info => info.SearchGroup))
+ {
+ query = Search(query, searchCriteria);
+ }
+
+ return query;
+ }
+
+ private static IQueryable Search(IQueryable source, IEnumerable> criteria)
+ {
+ Expression? expr = null;
+ var parameter = Expression.Parameter(typeof(T), "x");
+
+ foreach (var info in criteria)
+ {
+ if (string.IsNullOrEmpty(info.SearchTerm))
+ {
+ continue;
+ }
+
+ LambdaExpression selector = info.Selector;
+ selector = ParameterReplacerVisitor.Replace(selector, info.Selector.Parameters[0], parameter);
+
+ var expression = ((Expression>)(() => info.SearchTerm)).Body;
+ var likeExpression = Expression.Call(null, LikeMethodInfo, Functions, selector.Body, expression);
+
+ expr = expr is null ? likeExpression : Expression.OrElse(expr, likeExpression);
+ }
+
+ return expr is null ? source : source.Where(Expression.Lambda>(expr, parameter));
+ }
+
+ private sealed class ParameterReplacerVisitor : ExpressionVisitor
+ {
+ private readonly Expression _newExpression;
+ private readonly ParameterExpression _oldParameter;
+
+ private ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression)
+ {
+ _oldParameter = oldParameter;
+ _newExpression = newExpression;
+ }
+
+ internal static T Replace(T expression, ParameterExpression oldParameter, Expression newExpression) where T : Expression
+ {
+ return (T)new ParameterReplacerVisitor(oldParameter, newExpression).Visit(expression);
+ }
+
+ protected override Expression VisitParameter(ParameterExpression expression)
+ {
+ return expression == _oldParameter ? _newExpression : expression;
+ }
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs
new file mode 100644
index 0000000..d876427
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs
@@ -0,0 +1,23 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+internal sealed class WhereEvaluator : IEvaluator
+{
+ private WhereEvaluator() { }
+
+ internal static WhereEvaluator Instance { get; } = new();
+
+ public bool IsCriteriaEvaluator { get; } = true;
+
+ public IQueryable Evaluate(IQueryable query, ISpecification specification) where T : class
+ {
+ foreach (var info in specification.WhereExpressions)
+ {
+ query = query.Where(info.Filter);
+ }
+
+ return query;
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs
new file mode 100644
index 0000000..e18b8dd
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs
@@ -0,0 +1,33 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+///
+/// Represents an error that occurs when a specification defines both Select() and SelectMany() transforms.
+///
+///
+/// Only one selector transform is permitted per specification.
+///
+public sealed class ConcurrentSelectorsException : Exception
+{
+ private const string ExceptionMessage = "Concurrent specification selector transforms defined. Ensure only one of the Select() or SelectMany() transforms is used in the same specification!";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ConcurrentSelectorsException()
+ : base(ExceptionMessage)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a reference
+ /// to the inner exception that is the cause of this exception.
+ ///
+ /// The exception that is the cause of the current exception.
+ public ConcurrentSelectorsException(Exception innerException)
+ : base(ExceptionMessage, innerException)
+ {
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs
new file mode 100644
index 0000000..7167e1c
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs
@@ -0,0 +1,34 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+///
+/// Represents an error that occurs when a specification contains more than one ordering chain.
+///
+///
+/// Only a single OrderBy() or OrderByDescending() clause is permitted per specification.
+/// Subsequent ordering must use ThenBy() or ThenByDescending().
+///
+public sealed class DuplicateOrderChainException : Exception
+{
+ private const string ExceptionMessage = "The specification contains more than one Order chain!";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DuplicateOrderChainException()
+ : base(ExceptionMessage)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a reference
+ /// to the inner exception that is the cause of this exception.
+ ///
+ /// The exception that is the cause of the current exception.
+ public DuplicateOrderChainException(Exception innerException)
+ : base(ExceptionMessage, innerException)
+ {
+ }
+}
diff --git a/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs
new file mode 100644
index 0000000..3394db6
--- /dev/null
+++ b/src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs
@@ -0,0 +1,33 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.EntityFrameworkCore;
+
+///
+/// Represents an error that occurs when a specification has no selector transform defined.
+///
+///
+/// A specification targeting a projected result must define either Select() or SelectMany().
+///
+public sealed class SelectorNotFoundException : Exception
+{
+ private const string ExceptionMessage = "The specification must have a selector transform defined. Ensure either Select() or SelectMany() is used in the specification!";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SelectorNotFoundException()
+ : base(ExceptionMessage)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a reference
+ /// to the inner exception that is the cause of this exception.
+ ///
+ /// The exception that is the cause of the current exception.
+ public SelectorNotFoundException(Exception innerException)
+ : base(ExceptionMessage, innerException)
+ {
+ }
+}
diff --git a/src/request.persistence.tests/.editorconfig b/src/request.persistence.tests/.editorconfig
new file mode 100644
index 0000000..2300467
--- /dev/null
+++ b/src/request.persistence.tests/.editorconfig
@@ -0,0 +1,8 @@
+
+[*.{cs,vb}]
+dotnet_diagnostic.CA1822.severity = none
+dotnet_diagnostic.CA1707.severity = none
+dotnet_diagnostic.IDE0060.severity = none
+dotnet_diagnostic.IDE0005.severity = none
+dotnet_diagnostic.IDE0390.severity = none
+dotnet_diagnostic.IDE0391.severity = none
diff --git a/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj b/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj
new file mode 100644
index 0000000..633378d
--- /dev/null
+++ b/src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/request.persistence.tests/SpecificationBuilderTests.cs b/src/request.persistence.tests/SpecificationBuilderTests.cs
new file mode 100644
index 0000000..1addaff
--- /dev/null
+++ b/src/request.persistence.tests/SpecificationBuilderTests.cs
@@ -0,0 +1,216 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class SpecificationBuilderTests
+{
+ [Test]
+ public async Task Where_adds_filter()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Where(static x => x.Name == "test");
+
+ await Assert.That(builder.WhereExpressions).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Where_with_false_condition_does_not_add_filter()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Where(static x => x.Name == "test", false);
+
+ await Assert.That(builder.WhereExpressions).IsEmpty();
+ }
+
+ [Test]
+ public async Task OrderBy_adds_order_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.OrderBy(static x => x.Name);
+
+ await Assert.That(builder.OrderExpressions).Count().IsEqualTo(1);
+ await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderBy);
+ }
+
+ [Test]
+ public async Task OrderByDescending_adds_order_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.OrderByDescending(static x => x.Name);
+
+ await Assert.That(builder.OrderExpressions).Count().IsEqualTo(1);
+ await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderByDescending);
+ }
+
+ [Test]
+ public async Task ThenBy_after_order_by_chains_correctly()
+ {
+ var builder = new SpecificationBuilder();
+ builder.OrderBy(static x => x.Name).ThenBy(static x => x.Id);
+
+ await Assert.That(builder.OrderExpressions).Count().IsEqualTo(2);
+ await Assert.That(builder.OrderExpressions[0].OrderType).IsEqualTo(OrderType.OrderBy);
+ await Assert.That(builder.OrderExpressions[1].OrderType).IsEqualTo(OrderType.ThenBy);
+ }
+
+ [Test]
+ public async Task ThenByDescending_after_order_by_chains_correctly()
+ {
+ var builder = new SpecificationBuilder();
+ builder.OrderBy(static x => x.Name).ThenByDescending(static x => x.Id);
+
+ await Assert.That(builder.OrderExpressions).Count().IsEqualTo(2);
+ await Assert.That(builder.OrderExpressions[1].OrderType).IsEqualTo(OrderType.ThenByDescending);
+ }
+
+ [Test]
+ public async Task Take_sets_value()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Take(10);
+
+ await Assert.That(builder.Take).IsEqualTo(10);
+ }
+
+ [Test]
+ public async Task DuplicateTake_throws()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Take(10);
+
+ await Assert.That(() => builder.Take(5)).Throws();
+ }
+
+ [Test]
+ public async Task Skip_sets_value()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Skip(5);
+
+ await Assert.That(builder.Skip).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task DuplicateSkip_throws()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Skip(5);
+
+ await Assert.That(() => builder.Skip(10)).Throws();
+ }
+
+ [Test]
+ public async Task Include_adds_include_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static x => x.Name);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Include_on_reference_navigation_adds_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
+ await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
+ await Assert.That(builder.IncludeExpressions[0].EntityType).IsEqualTo(typeof(FakeEntity));
+ await Assert.That(builder.IncludeExpressions[0].PropertyType).IsEqualTo(typeof(FakeChildEntity));
+ }
+
+ [Test]
+ public async Task Include_on_collection_navigation_adds_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Details);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
+ await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
+ await Assert.That(builder.IncludeExpressions[0].EntityType).IsEqualTo(typeof(FakeEntity));
+ await Assert.That(builder.IncludeExpressions[0].PropertyType).IsEqualTo(typeof(List));
+ }
+
+ [Test]
+ public async Task Include_with_false_condition_does_not_add_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child, false);
+
+ await Assert.That(builder.IncludeExpressions).IsEmpty();
+ }
+
+ [Test]
+ public async Task ThenInclude_after_reference_navigation_adds_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child).ThenInclude(static c => c.Value);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(2);
+ await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
+ await Assert.That(builder.IncludeExpressions[1].Type).IsEqualTo(IncludeType.ThenInclude);
+ await Assert.That(builder.IncludeExpressions[1].PreviousPropertyType).IsEqualTo(typeof(FakeChildEntity));
+ }
+
+ [Test]
+ public async Task ThenInclude_with_false_condition_does_not_add_expression()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child).ThenInclude(static c => c.Value, false);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(1);
+ await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
+ }
+
+ [Test]
+ public async Task Include_chains_ThenInclude_multiple_levels()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Include(static e => e.Child).ThenInclude(static c => c.FakeEntity).ThenInclude(static fe => fe.Details);
+
+ await Assert.That(builder.IncludeExpressions).Count().IsEqualTo(3);
+ await Assert.That(builder.IncludeExpressions[0].Type).IsEqualTo(IncludeType.Include);
+ await Assert.That(builder.IncludeExpressions[1].Type).IsEqualTo(IncludeType.ThenInclude);
+ await Assert.That(builder.IncludeExpressions[2].Type).IsEqualTo(IncludeType.ThenInclude);
+ await Assert.That(builder.IncludeExpressions[2].PreviousPropertyType).IsEqualTo(typeof(FakeEntity));
+ }
+
+ [Test]
+ public async Task ThenInclude_on_discarded_chain_does_not_add_expression()
+ {
+ var builder = new SpecificationBuilder();
+ var includable = builder.Include(static e => e.Child, false);
+ includable.ThenInclude(static c => c.Value);
+
+ await Assert.That(builder.IncludeExpressions).IsEmpty();
+ }
+
+ [Test]
+ public async Task Select_sets_selector()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Select(static x => x.Name);
+
+ await Assert.That(builder.Selector).IsNotNull();
+ }
+
+ [Test]
+ public async Task PostProcessing_sets_action()
+ {
+ var builder = new SpecificationBuilder();
+ builder.PostProcessing(static items => items.Where(static x => x.Name.Length > 0));
+
+ await Assert.That(builder.PostProcessingAction).IsNotNull();
+ }
+
+ [Test]
+ public async Task Search_adds_criteria()
+ {
+ var builder = new SpecificationBuilder();
+ builder.Search(static x => x.Name, "test");
+
+ await Assert.That(builder.SearchCriteria).Count().IsEqualTo(1);
+ }
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeChildEntity.cs
new file mode 100644
index 0000000..744d7ee
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeChildEntity.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeEntityId { get; set; }
+ public FakeEntity FakeEntity { get; set; } = null!;
+ public List GrandChildren { get; set; } = [];
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs
new file mode 100644
index 0000000..c34377a
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeDetailChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeDetailEntityId { get; set; }
+ public FakeDetailEntity FakeDetailEntity { get; set; } = null!;
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs b/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs
new file mode 100644
index 0000000..2e76b36
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeDetailEntity.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeDetailEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeEntityId { get; set; }
+ public FakeEntity FakeEntity { get; set; } = null!;
+ public List DetailChildren { get; set; } = [];
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeEntity.cs b/src/request.persistence.tests/_fixtures/FakeEntity.cs
new file mode 100644
index 0000000..7f5f1a7
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeEntity
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public FakeChildEntity Child { get; set; } = null!;
+ public List Details { get; set; } = [];
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs b/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs
new file mode 100644
index 0000000..18ed480
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeGrandChildEntity
+{
+ public Guid Id { get; set; }
+ public string Value { get; set; } = string.Empty;
+ public Guid FakeChildEntityId { get; set; }
+ public FakeChildEntity FakeChildEntity { get; set; } = null!;
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeSpecification.cs b/src/request.persistence.tests/_fixtures/FakeSpecification.cs
new file mode 100644
index 0000000..05d2219
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeSpecification.cs
@@ -0,0 +1,12 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeSpecification : Specification
+{
+ public FakeSpecification()
+ {
+ Query.Where(static x => x.Name.Length > 0);
+ }
+}
diff --git a/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs b/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs
new file mode 100644
index 0000000..3512f5a
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs
@@ -0,0 +1,13 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class FakeSpecificationWithResult : Specification
+{
+ public FakeSpecificationWithResult()
+ {
+ Query.Where(static x => x.Name.Length > 0);
+ Query.Select(static x => x.Name);
+ }
+}
diff --git a/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs b/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs
new file mode 100644
index 0000000..57b6c55
--- /dev/null
+++ b/src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs
@@ -0,0 +1,8 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence.Tests;
+
+internal sealed class SpecificationTestBuilder : Specification
+{
+}
diff --git a/src/request.persistence/Geekeey.Request.Persistence.csproj b/src/request.persistence/Geekeey.Request.Persistence.csproj
new file mode 100644
index 0000000..55ba98c
--- /dev/null
+++ b/src/request.persistence/Geekeey.Request.Persistence.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Library
+ net10.0
+ true
+
+
+
+ true
+
+
+
+
+
+
+
+ package-readme.md
+ Generic repository and specification pattern abstractions for .NET.
+ package-icon.png
+ https://code.geekeey.de/geekeey/request/src/branch/main/src/request.persistence
+ EUPL-1.2
+
+
+
+
+
+
+
+
+
diff --git a/src/request.persistence/IRepository.cs b/src/request.persistence/IRepository.cs
new file mode 100644
index 0000000..391592e
--- /dev/null
+++ b/src/request.persistence/IRepository.cs
@@ -0,0 +1,155 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Request.Persistence;
+
+///
+/// Defines query operations for entities of type .
+///
+/// The type of the entity.
+public partial interface IRepository
+ where TEntity : class
+{
+ ///
+ /// Returns the first element that matches the specification, or .
+ ///
+ /// The specification.
+ /// A cancellation token.
+ /// The entity, or .
+ Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the first projected result that matches the specification, or .
+ ///
+ /// The result type.
+ /// The specification.
+ /// A cancellation token.
+ /// The projected result, or .
+ Task FirstOrDefaultAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns all entities.
+ ///
+ /// A cancellation token.
+ /// A list of entities.
+ Task> ListAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns entities that match the specification.
+ ///
+ /// The specification.
+ /// A cancellation token.
+ /// A list of entities.
+ Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns projected results that match the specification.
+ ///
+ /// The result type.
+ /// The specification.
+ /// A cancellation token.
+ /// A list of projected results.
+ Task> ListAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the number of entities that match the specification.
+ ///
+ /// The specification.
+ /// A cancellation token.
+ /// The count.
+ Task CountAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the total number of entities.
+ ///
+ /// A cancellation token.
+ /// The count.
+ Task CountAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns whether any entity matches the specification.
+ ///
+ /// The specification.
+ /// A cancellation token.
+ /// if any entity matches; otherwise .
+ Task AnyAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns whether any entity exists.
+ ///
+ /// A cancellation token.
+ /// if any entity exists; otherwise .
+ Task AnyAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns an async enumerable for entities that match the specification.
+ ///
+ /// The specification.
+ /// An async enumerable of entities.
+ IAsyncEnumerable AsAsyncEnumerable(ISpecification specification);
+}
+
+///
+/// Defines persistence operations for entities of type .
+///
+/// The type of the entity.
+public partial interface IRepository
+{
+ ///
+ /// Adds an entity.
+ ///
+ /// The entity to add.
+ /// A cancellation token.
+ /// The added entity.
+ Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// Adds multiple entities.
+ ///
+ /// The entities to add.
+ /// A cancellation token.
+ /// The added entities.
+ Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates an entity.
+ ///
+ /// The entity to update.
+ /// A cancellation token.
+ Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates multiple entities.
+ ///
+ /// The entities to update.
+ /// A cancellation token.
+ Task UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes an entity.
+ ///
+ /// The entity to delete.
+ /// A cancellation token.
+ Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes multiple entities.
+ ///
+ /// The entities to delete.
+ /// A cancellation token.
+ Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes entities that match the specification.
+ ///
+ /// The specification.
+ /// A cancellation token.
+ Task DeleteRangeAsync(ISpecification specification, CancellationToken cancellationToken = default);
+
+ ///
+ /// Persists all pending changes.
+ ///
+ /// A cancellation token.
+ /// The number of affected rows.
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/request.persistence/ISpecification.cs b/src/request.persistence/ISpecification.cs
new file mode 100644
index 0000000..2a1038d
--- /dev/null
+++ b/src/request.persistence/ISpecification.cs
@@ -0,0 +1,71 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using System.Linq.Expressions;
+
+namespace Geekeey.Request.Persistence;
+
+///
+/// Encapsulates query logic for .
+///
+/// The type being queried against.
+public interface ISpecification
+{
+ ///
+ /// Gets the collection of filters.
+ ///
+ IReadOnlyList> WhereExpressions { get; }
+
+ ///
+ /// Gets the collection of order expressions.
+ ///
+ IReadOnlyList> OrderExpressions { get; }
+
+ ///
+ /// Gets the collection of include expressions.
+ ///
+ IReadOnlyList IncludeExpressions { get; }
+
+ ///
+ /// Gets the collection of search criteria.
+ ///
+ IReadOnlyList> SearchCriteria { get; }
+
+ ///
+ /// Gets the post-processing action to apply to the result.
+ ///
+ Func, IEnumerable>? PostProcessingAction { get; }
+
+ ///
+ /// Gets the number of elements to take.
+ ///
+ int? Take { get; }
+
+ ///
+ /// Gets the number of elements to skip.
+ ///
+ int? Skip { get; }
+}
+
+///
+/// Encapsulates query logic for and projects the result into .
+///
+/// The type being queried against.
+/// The type of the result.
+public interface ISpecification : ISpecification
+{
+ ///
+ /// Gets the selector expression.
+ ///
+ Expression>? Selector { get; }
+
+ ///
+ /// Gets the selector many expression.
+ ///
+ Expression>>? SelectorMany { get; }
+
+ ///
+ /// Gets the post-processing action to apply to the projected result.
+ ///
+ new Func, IEnumerable