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>? PostProcessingAction { get; } +} diff --git a/src/request.persistence/IncludeType.cs b/src/request.persistence/IncludeType.cs new file mode 100644 index 0000000..259ee1b --- /dev/null +++ b/src/request.persistence/IncludeType.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Specifies the type of include operation. +/// +public enum IncludeType +{ + /// + /// Represents an Include operation. + /// + Include = 1, + + /// + /// Represents a ThenInclude operation. + /// + ThenInclude = 2, +} diff --git a/src/request.persistence/OrderType.cs b/src/request.persistence/OrderType.cs new file mode 100644 index 0000000..50ac213 --- /dev/null +++ b/src/request.persistence/OrderType.cs @@ -0,0 +1,30 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Specifies the ordering direction. +/// +public enum OrderType +{ + /// + /// Orders by ascending. + /// + OrderBy = 1, + + /// + /// Orders by descending. + /// + OrderByDescending = 2, + + /// + /// Then by ascending. + /// + ThenBy = 3, + + /// + /// Then by descending. + /// + ThenByDescending = 4 +} diff --git a/src/request.persistence/Specification.cs b/src/request.persistence/Specification.cs new file mode 100644 index 0000000..6017703 --- /dev/null +++ b/src/request.persistence/Specification.cs @@ -0,0 +1,103 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Represents a specification for filtering, ordering, and including related entities. +/// +/// The entity type. +public abstract class Specification : ISpecification +{ + /// + /// Creates a new specification. + /// + protected Specification() + : this(new SpecificationBuilder()) + { + } + + /// + /// Creates a new specification with the given builder. + /// + /// The specification builder. + protected Specification(SpecificationBuilder builder) + { + Query = builder; + } + + /// + /// Gets the specification builder. + /// + protected SpecificationBuilder Query { get; } + + /// + IReadOnlyList> ISpecification.WhereExpressions + => Query.WhereExpressions; + + /// + IReadOnlyList> ISpecification.OrderExpressions + => Query.OrderExpressions; + + /// + IReadOnlyList ISpecification.IncludeExpressions + => Query.IncludeExpressions; + + /// + IReadOnlyList> ISpecification.SearchCriteria + => Query.SearchCriteria; + + /// + Func, IEnumerable>? ISpecification.PostProcessingAction + => Query.PostProcessingAction; + + /// + int? ISpecification.Take => Query.Take; + + /// + int? ISpecification.Skip => Query.Skip; +} + +/// +/// Represents a specification for filtering, ordering, and including related entities, +/// with a projection to . +/// +/// The entity type. +/// The result type. +public abstract class Specification : Specification, ISpecification +{ + /// + /// Creates a new specification. + /// + protected Specification() : this(new SpecificationBuilder()) + { + } + + /// + /// Creates a new specification with the given builder. + /// + /// The specification builder. + protected Specification(SpecificationBuilder builder) : base(builder) + { + Query = builder; + } + + /// + /// Gets the specification builder. + /// + protected new SpecificationBuilder Query { get; } + + /// + Expression>? ISpecification.Selector + => Query.Selector; + + /// + Expression>>? ISpecification.SelectorMany + => Query.SelectorMany; + + /// + Func, IEnumerable>? ISpecification.PostProcessingAction + => Query.PostProcessingAction; +} diff --git a/src/request.persistence/_builders/IncludableBuilderExtensions.cs b/src/request.persistence/_builders/IncludableBuilderExtensions.cs new file mode 100644 index 0000000..b348fe5 --- /dev/null +++ b/src/request.persistence/_builders/IncludableBuilderExtensions.cs @@ -0,0 +1,101 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class IncludableBuilderExtensions +{ + /// + /// Specifies a ThenInclude operation on a reference navigation property. + /// + /// The includable builder. + /// The expression. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder builder, + Expression> expression) + where TEntity : class + { + return ThenInclude(builder, expression, condition: true); + } + + /// + /// Specifies a ThenInclude operation when is . + /// + /// The includable builder. + /// The expression. + /// Whether to apply the ThenInclude. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder builder, + Expression> expression, bool condition) + where TEntity : class + { + if (condition && !builder.IsChainDiscarded) + { + var info = new IncludeExpressionInfo(expression, typeof(TEntity), typeof(TProperty), typeof(TPreviousProperty)); + + builder.Builder.IncludeExpressions.Add(info); + } + + var includeBuilder = new IncludableSpecificationBuilder(builder.Builder, !condition || builder.IsChainDiscarded); + + return includeBuilder; + } + + /// + /// Specifies a ThenInclude operation on a collection navigation property. + /// + /// The includable builder. + /// The expression. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder> previousBuilder, + Expression> expression) + where TEntity : class + { + return ThenInclude(previousBuilder, expression, condition: true); + } + + /// + /// Specifies a ThenInclude operation on a collection navigation property when is . + /// + /// The includable builder. + /// The expression. + /// Whether to apply the ThenInclude. + /// The entity type. + /// The previous property type. + /// The property type. + /// An includable builder for further chaining. + public static IncludableSpecificationBuilder ThenInclude( + this IncludableSpecificationBuilder> builder, + Expression> expression, + bool condition) + where TEntity : class + { + if (condition && !builder.IsChainDiscarded) + { + var info = new IncludeExpressionInfo(expression, typeof(TEntity), typeof(TProperty), typeof(IEnumerable)); + + builder.Builder.IncludeExpressions.Add(info); + } + + var includeBuilder = new IncludableSpecificationBuilder(builder.Builder, !condition || builder.IsChainDiscarded); + + return includeBuilder; + } +} diff --git a/src/request.persistence/_builders/IncludableSpecificationBuilder.cs b/src/request.persistence/_builders/IncludableSpecificationBuilder.cs new file mode 100644 index 0000000..6a652fa --- /dev/null +++ b/src/request.persistence/_builders/IncludableSpecificationBuilder.cs @@ -0,0 +1,33 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a builder for chaining ThenInclude operations. +/// +/// The entity type. +/// The property type. +public class IncludableSpecificationBuilder where T : class +{ + /// + /// Gets the underlying specification builder. + /// + public SpecificationBuilder Builder { get; } + + /// + /// Gets or sets whether the chain is discarded. + /// + public bool IsChainDiscarded { get; set; } + + /// + /// Creates a new includable specification builder. + /// + /// The specification builder. + /// Whether the chain is discarded. + public IncludableSpecificationBuilder(SpecificationBuilder builder, bool isChainDiscarded = false) + { + Builder = builder; + IsChainDiscarded = isChainDiscarded; + } +} diff --git a/src/request.persistence/_builders/OrderedBuilderExtensions.cs b/src/request.persistence/_builders/OrderedBuilderExtensions.cs new file mode 100644 index 0000000..2f35721 --- /dev/null +++ b/src/request.persistence/_builders/OrderedBuilderExtensions.cs @@ -0,0 +1,80 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class OrderedBuilderExtensions +{ + /// + /// Adds a ThenBy expression for secondary ordering. + /// + /// The ordered builder. + /// The expression. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenBy(this OrderedSpecificationBuilder builder, Expression> expression) + { + return ThenBy(builder, expression, condition: true); + } + + /// + /// Adds a ThenBy expression when is . + /// + /// The ordered builder. + /// The expression. + /// Whether to apply the ThenBy. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenBy(this OrderedSpecificationBuilder builder, Expression> expression, bool condition) + { + if (condition && !builder.IsChainDiscarded) + { + builder.Builder.OrderExpressions.Add(new OrderExpressionInfo(expression, OrderType.ThenBy)); + } + else + { + builder.IsChainDiscarded = true; + } + + return builder; + } + + /// + /// Adds a ThenByDescending expression for secondary ordering. + /// + /// The ordered builder. + /// The expression. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenByDescending(this OrderedSpecificationBuilder builder, Expression> expression) + { + return ThenByDescending(builder, expression, condition: true); + } + + /// + /// Adds a ThenByDescending expression when is . + /// + /// The ordered builder. + /// The expression. + /// Whether to apply the ThenByDescending. + /// The entity type. + /// The ordered builder. + public static OrderedSpecificationBuilder ThenByDescending(this OrderedSpecificationBuilder builder, Expression> expression, bool condition) + { + if (condition && !builder.IsChainDiscarded) + { + builder.Builder.OrderExpressions.Add(new OrderExpressionInfo(expression, OrderType.ThenByDescending)); + } + else + { + builder.IsChainDiscarded = true; + } + + return builder; + } +} diff --git a/src/request.persistence/_builders/OrderedSpecificationBuilder.cs b/src/request.persistence/_builders/OrderedSpecificationBuilder.cs new file mode 100644 index 0000000..f24107c --- /dev/null +++ b/src/request.persistence/_builders/OrderedSpecificationBuilder.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a builder for chaining ThenBy / ThenByDescending operations. +/// +/// The entity type. +public sealed class OrderedSpecificationBuilder +{ + /// + /// Gets the underlying specification builder. + /// + public SpecificationBuilder Builder { get; } + + /// + /// Gets or sets whether the chain is discarded. + /// + public bool IsChainDiscarded { get; set; } + + /// + /// Creates a new ordered specification builder. + /// + /// The specification builder. + /// Whether the chain is discarded. + public OrderedSpecificationBuilder(SpecificationBuilder builder, bool isChainDiscarded = false) + { + Builder = builder; + IsChainDiscarded = isChainDiscarded; + } +} diff --git a/src/request.persistence/_builders/SpecificationBuilder.cs b/src/request.persistence/_builders/SpecificationBuilder.cs new file mode 100644 index 0000000..a1305c7 --- /dev/null +++ b/src/request.persistence/_builders/SpecificationBuilder.cs @@ -0,0 +1,72 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides a mutable container for building a specification for . +/// +/// The entity type. +public class SpecificationBuilder +{ + /// + /// Gets the list of where expressions. + /// + public List> WhereExpressions { get; } = []; + + /// + /// Gets the list of order expressions. + /// + public List> OrderExpressions { get; } = []; + + /// + /// Gets the list of include expressions. + /// + public List IncludeExpressions { get; } = []; + + /// + /// Gets the list of search criteria. + /// + public List> SearchCriteria { get; } = []; + + /// + /// Gets or sets the post-processing action. + /// + public Func, IEnumerable>? PostProcessingAction { get; set; } + + /// + /// Gets or sets the number of elements to take. + /// + public int? Take { get; set; } + + /// + /// Gets or sets the number of elements to skip. + /// + public int? Skip { get; set; } +} + +/// +/// Provides a mutable container for building a specification for +/// with a projection to . +/// +/// The entity type. +/// The result type. +public class SpecificationBuilder : SpecificationBuilder +{ + /// + /// Gets or sets the selector expression. + /// + public Expression>? Selector { get; set; } + + /// + /// Gets or sets the selector many expression. + /// + public Expression>>? SelectorMany { get; set; } + + /// + /// Gets or sets the post-processing action for the projected result. + /// + public new Func, IEnumerable>? PostProcessingAction { get; set; } +} diff --git a/src/request.persistence/_builders/SpecificationBuilderExtensions.cs b/src/request.persistence/_builders/SpecificationBuilderExtensions.cs new file mode 100644 index 0000000..372be89 --- /dev/null +++ b/src/request.persistence/_builders/SpecificationBuilderExtensions.cs @@ -0,0 +1,305 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Provides extension methods for . +/// +public static class SpecificationBuilderExtensions +{ + /// + /// Adds a filter predicate to the specification. + /// + /// The specification builder. + /// The filter expression. + /// The entity type. + /// The builder. + public static SpecificationBuilder Where(this SpecificationBuilder builder, Expression> criteria) + { + return Where(builder, criteria, condition: true); + } + + /// + /// Adds a filter predicate to the specification when is . + /// + /// The specification builder. + /// The filter expression. + /// Whether to apply the filter. + /// The entity type. + /// The builder. + public static SpecificationBuilder Where(this SpecificationBuilder builder, Expression> criteria, bool condition) + { + if (condition) + { + builder.WhereExpressions.Add(new WhereExpressionInfo(criteria)); + } + + return builder; + } + + /// + /// Adds an ascending order expression. + /// + /// The specification builder. + /// The order expression. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderBy(this SpecificationBuilder builder, Expression> orderExpression) + { + return OrderBy(builder, orderExpression, condition: true); + } + + /// + /// Adds an ascending order expression when is . + /// + /// The specification builder. + /// The order expression. + /// Whether to apply the order. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderBy(this SpecificationBuilder builder, Expression> orderExpression, bool condition) + { + if (condition) + { + builder.OrderExpressions.Add(new OrderExpressionInfo(orderExpression, OrderType.OrderBy)); + } + + var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder, !condition); + + return orderedSpecificationBuilder; + } + + /// + /// Adds a descending order expression. + /// + /// The specification builder. + /// The order expression. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderByDescending(this SpecificationBuilder builder, Expression> orderExpression) + { + return OrderByDescending(builder, orderExpression, condition: true); + } + + /// + /// Adds a descending order expression when is . + /// + /// The specification builder. + /// The order expression. + /// Whether to apply the order. + /// The entity type. + /// An ordered specification builder for further chaining. + public static OrderedSpecificationBuilder OrderByDescending(this SpecificationBuilder builder, Expression> orderExpression, bool condition) + { + if (condition) + { + builder.OrderExpressions.Add(new OrderExpressionInfo(orderExpression, OrderType.OrderByDescending)); + } + + var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder, !condition); + + return orderedSpecificationBuilder; + } + + /// + /// Adds an include expression to load a related entity. + /// + /// The specification builder. + /// The include expression. + /// The entity type. + /// The property type. + /// An includable specification builder for further chaining. + public static IncludableSpecificationBuilder Include(this SpecificationBuilder builder, Expression> includeExpression) where T : class + { + return Include(builder, includeExpression, condition: true); + } + + /// + /// Adds an include expression when is . + /// + /// The specification builder. + /// The include expression. + /// Whether to apply the include. + /// The entity type. + /// The property type. + /// An includable specification builder for further chaining. + public static IncludableSpecificationBuilder Include(this SpecificationBuilder builder, Expression> includeExpression, bool condition) where T : class + { + if (condition) + { + var info = new IncludeExpressionInfo(includeExpression, typeof(T), typeof(TProperty)); + + builder.IncludeExpressions.Add(info); + } + + var includeBuilder = new IncludableSpecificationBuilder(builder, !condition); + + return includeBuilder; + } + + /// + /// Specifies the number of elements to return. + /// + /// The specification builder. + /// The number of elements to take. + /// The entity type. + /// The builder. + public static SpecificationBuilder Take(this SpecificationBuilder builder, int take) + { + return Take(builder, take, true); + } + + /// + /// Specifies the number of elements to return when is . + /// + /// The specification builder. + /// The number of elements to take. + /// Whether to apply the take. + /// The entity type. + /// The builder. + public static SpecificationBuilder Take(this SpecificationBuilder builder, int take, bool condition) + { + if (condition) + { + if (builder.Take is not null) + { + throw new DuplicateTakeException(); + } + + builder.Take = take; + } + + return builder; + } + + /// + /// Specifies the number of elements to skip before returning. + /// + /// The specification builder. + /// The number of elements to skip. + /// The entity type. + /// The builder. + public static SpecificationBuilder Skip(this SpecificationBuilder builder, int skip) + { + return Skip(builder, skip, condition: true); + } + + /// + /// Specifies the number of elements to skip when is . + /// + /// The specification builder. + /// The number of elements to skip. + /// Whether to apply the skip. + /// The entity type. + /// The builder. + public static SpecificationBuilder Skip(this SpecificationBuilder builder, int skip, bool condition) + { + if (condition) + { + if (builder.Skip is not null) + { + throw new DuplicateSkipException(); + } + + builder.Skip = skip; + } + + return builder; + } + + /// + /// Specifies a selector to project the result. + /// + /// The specification builder. + /// The selector expression. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder Select(this SpecificationBuilder builder, Expression> selector) + { + builder.Selector = selector; + + return builder; + } + + /// + /// Specifies a selector many to flatten the result. + /// + /// The specification builder. + /// The selector many expression. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder SelectMany(this SpecificationBuilder builder, Expression>> selector) + { + builder.SelectorMany = selector; + + return builder; + } + + /// + /// Specifies a post-processing action to apply to the result. + /// + /// The specification builder. + /// The post-processing action. + /// The entity type. + /// The builder. + public static SpecificationBuilder PostProcessing(this SpecificationBuilder builder, Func, IEnumerable> predicate) + { + builder.PostProcessingAction = predicate; + + return builder; + } + + /// + /// Specifies a post-processing action to apply to the projected result. + /// + /// The specification builder. + /// The post-processing action. + /// The entity type. + /// The result type. + /// The builder. + public static SpecificationBuilder PostProcessing(this SpecificationBuilder builder, Func, IEnumerable> predicate) + { + builder.PostProcessingAction = predicate; + + return builder; + } + + /// + /// Adds a search criteria (SQL LIKE) for the given selector. + /// + /// The specification builder. + /// The selector expression. + /// The search term. + /// The search group. + /// The entity type. + /// The builder. + public static SpecificationBuilder Search(this SpecificationBuilder builder, Expression> selector, string searchTerm, int searchGroup = 1) where T : class + { + return Search(builder, selector, searchTerm, condition: true, searchGroup); + } + + /// + /// Adds a search criteria when is . + /// + /// The specification builder. + /// The selector expression. + /// The search term. + /// Whether to apply the search. + /// The search group. + /// The entity type. + /// The builder. + public static SpecificationBuilder Search(this SpecificationBuilder builder, Expression> selector, string searchTerm, bool condition, int searchGroup = 1) where T : class + { + if (condition) + { + builder.SearchCriteria.Add(new SearchExpressionInfo(selector, searchTerm, searchGroup)); + } + + return builder; + } +} diff --git a/src/request.persistence/_exceptions/DuplicateSkipException.cs b/src/request.persistence/_exceptions/DuplicateSkipException.cs new file mode 100644 index 0000000..6984ca9 --- /dev/null +++ b/src/request.persistence/_exceptions/DuplicateSkipException.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Thrown when Skip is used more than once in the same specification. +/// +public sealed class DuplicateSkipException : Exception +{ + private const string ExceptionMessage = "Duplicate use of Skip(). Ensure you don't use Skip() more than once in the same specification!"; + + /// + /// Creates a new . + /// + public DuplicateSkipException() + : base(ExceptionMessage) + { + } + + /// + /// Creates a new with an inner exception. + /// + /// The inner exception. + public DuplicateSkipException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence/_exceptions/DuplicateTakeException.cs b/src/request.persistence/_exceptions/DuplicateTakeException.cs new file mode 100644 index 0000000..21e8b39 --- /dev/null +++ b/src/request.persistence/_exceptions/DuplicateTakeException.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Persistence; + +/// +/// Thrown when Take is used more than once in the same specification. +/// +public sealed class DuplicateTakeException : Exception +{ + private const string ExceptionMessage = "Duplicate use of Take(). Ensure you don't use Take() more than once in the same specification!"; + + /// + /// Creates a new . + /// + public DuplicateTakeException() + : base(ExceptionMessage) + { + } + + /// + /// Creates a new with an inner exception. + /// + /// The inner exception. + public DuplicateTakeException(Exception innerException) + : base(ExceptionMessage, innerException) + { + } +} diff --git a/src/request.persistence/_expressions/IncludeExpressionInfo.cs b/src/request.persistence/_expressions/IncludeExpressionInfo.cs new file mode 100644 index 0000000..903aded --- /dev/null +++ b/src/request.persistence/_expressions/IncludeExpressionInfo.cs @@ -0,0 +1,74 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about an include expression. +/// +public sealed class IncludeExpressionInfo +{ + /// + /// Gets the lambda expression. + /// + public LambdaExpression LambdaExpression { get; } + + /// + /// Gets the entity type. + /// + public Type EntityType { get; } + + /// + /// Gets the property type. + /// + public Type PropertyType { get; } + + /// + /// Gets the previous property type, or for an Include. + /// + public Type? PreviousPropertyType { get; } + + /// + /// Gets the include type. + /// + public IncludeType Type { get; } + + private IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType, Type? previousPropertyType, IncludeType includeType) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(entityType); + ArgumentNullException.ThrowIfNull(propertyType); + + LambdaExpression = expression; + EntityType = entityType; + PropertyType = propertyType; + PreviousPropertyType = previousPropertyType; + Type = includeType; + } + + /// + /// Creates an Include expression info. + /// + /// The lambda expression. + /// The entity type. + /// The property type. + public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType) + : this(expression, entityType, propertyType, null, IncludeType.Include) + { + } + + /// + /// Creates a ThenInclude expression info. + /// + /// The lambda expression. + /// The entity type. + /// The property type. + /// The previous property type. + public IncludeExpressionInfo(LambdaExpression expression, Type entityType, Type propertyType, Type previousPropertyType) + : this(expression, entityType, propertyType, previousPropertyType, IncludeType.ThenInclude) + { + ArgumentNullException.ThrowIfNull(previousPropertyType); + } +} diff --git a/src/request.persistence/_expressions/OrderExpressionInfo.cs b/src/request.persistence/_expressions/OrderExpressionInfo.cs new file mode 100644 index 0000000..f4ee708 --- /dev/null +++ b/src/request.persistence/_expressions/OrderExpressionInfo.cs @@ -0,0 +1,45 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about an order expression. +/// +/// The entity type. +public sealed class OrderExpressionInfo +{ + private readonly Lazy> _keySelectorFunc; + + /// + /// Creates a new order expression info. + /// + /// The key selector expression. + /// The order type. + public OrderExpressionInfo(Expression> keySelector, OrderType orderType) + { + ArgumentNullException.ThrowIfNull(keySelector); + + KeySelector = keySelector; + OrderType = orderType; + + _keySelectorFunc = new Lazy>(KeySelector.Compile); + } + + /// + /// Gets the key selector expression. + /// + public Expression> KeySelector { get; } + + /// + /// Gets the order type. + /// + public OrderType OrderType { get; } + + /// + /// Gets the compiled key selector function. + /// + public Func KeySelectorFunc => _keySelectorFunc.Value; +} diff --git a/src/request.persistence/_expressions/SearchExpressionInfo.cs b/src/request.persistence/_expressions/SearchExpressionInfo.cs new file mode 100644 index 0000000..bfc0d9f --- /dev/null +++ b/src/request.persistence/_expressions/SearchExpressionInfo.cs @@ -0,0 +1,53 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about a search expression. +/// +/// The entity type. +public sealed class SearchExpressionInfo +{ + private readonly Lazy> _selectorFunc; + + /// + /// Creates a new search expression info. + /// + /// The selector expression. + /// The search term. + /// The search group. + public SearchExpressionInfo(Expression> selector, string searchTerm, int searchGroup = 1) + { + ArgumentNullException.ThrowIfNull(selector); + ArgumentException.ThrowIfNullOrEmpty(searchTerm); + + Selector = selector; + SearchTerm = searchTerm; + SearchGroup = searchGroup; + + _selectorFunc = new Lazy>(Selector.Compile); + } + + /// + /// Gets the selector expression. + /// + public Expression> Selector { get; } + + /// + /// Gets the search term. + /// + public string SearchTerm { get; } + + /// + /// Gets the search group. + /// + public int SearchGroup { get; } + + /// + /// Gets the compiled selector function. + /// + public Func SelectorFunc => _selectorFunc.Value; +} diff --git a/src/request.persistence/_expressions/WhereExpressionInfo.cs b/src/request.persistence/_expressions/WhereExpressionInfo.cs new file mode 100644 index 0000000..6b3fd7a --- /dev/null +++ b/src/request.persistence/_expressions/WhereExpressionInfo.cs @@ -0,0 +1,38 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Persistence; + +/// +/// Stores information about a where expression. +/// +/// The entity type. +public sealed class WhereExpressionInfo +{ + private readonly Lazy> _filterFunc; + + /// + /// Creates a new where expression info. + /// + /// The filter expression. + public WhereExpressionInfo(Expression> filter) + { + ArgumentNullException.ThrowIfNull(filter); + + Filter = filter; + + _filterFunc = new Lazy>(Filter.Compile); + } + + /// + /// Gets the filter expression. + /// + public Expression> Filter { get; } + + /// + /// Gets the compiled filter function. + /// + public Func FilterFunc => _filterFunc.Value; +}