From 2206fe027ab37bb3e128b5abb5f6efc2f83709a5 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Wed, 1 Jul 2026 20:44:24 +0200 Subject: [PATCH 01/11] feat: spec + repo --- Directory.Packages.props | 3 + request.slnx | 4 + .../.editorconfig | 8 + .../EvaluationTests.cs | 507 ++++++++++++++++++ ...rsistence.EntityFrameworkCore.Tests.csproj | 22 + .../IncludeEvaluatorTests.cs | 403 ++++++++++++++ .../RepositoryTests.cs | 119 ++++ .../_fixtures/CombinedWhereOrderTakeSpec.cs | 15 + .../_fixtures/ConcurrentSelectorSpec.cs | 13 + .../_fixtures/DuplicateOrderSpec.cs | 13 + .../_fixtures/FakeChildEntity.cs | 13 + .../_fixtures/FakeDbContext.cs | 60 +++ .../_fixtures/FakeDetailChildEntity.cs | 12 + .../_fixtures/FakeDetailEntity.cs | 13 + .../_fixtures/FakeEntity.cs | 12 + .../_fixtures/FakeGrandChildEntity.cs | 12 + .../_fixtures/FirstOrderedSpec.cs | 12 + .../_fixtures/IncludeChildSpec.cs | 12 + .../IncludeChildThenGrandChildrenSpec.cs | 12 + .../_fixtures/IncludeDetailsSpec.cs | 12 + .../_fixtures/IncludeOnlySpec.cs | 11 + .../_fixtures/MultiSearchSpec.cs | 13 + .../_fixtures/MultipleWhereSpec.cs | 13 + .../_fixtures/NameEqualSpec.cs | 12 + .../_fixtures/NameProjectionSpec.cs | 12 + .../_fixtures/NoSelectorSpec.cs | 8 + .../_fixtures/OrderByNameDescSpec.cs | 12 + .../_fixtures/OrderByNameSpec.cs | 12 + .../_fixtures/OrderByNameThenByIdDescSpec.cs | 12 + .../_fixtures/OrderByNameThenByIdSpec.cs | 12 + .../_fixtures/OrderedSkipSpec.cs | 13 + .../_fixtures/OrderedSkipTakeSpec.cs | 13 + .../_fixtures/OrderedTakeSpec.cs | 13 + .../_fixtures/PostProcessingSpec.cs | 13 + .../_fixtures/SearchSpec.cs | 12 + .../_fixtures/SkipOnlySpec.cs | 12 + .../_fixtures/WhereSpec.cs | 12 + ...est.Persistence.EntityFrameworkCore.csproj | 40 ++ .../Repository.cs | 174 ++++++ .../_Specification/SpecificationEvaluator.cs | 60 +++ .../_Specification/_evaluators/IEvaluator.cs | 25 + .../_evaluators/IncludeEvaluator.cs | 173 ++++++ .../_evaluators/OrderEvaluator.cs | 43 ++ .../_evaluators/PaginationEvaluator.cs | 28 + .../_evaluators/SearchEvaluator.cs | 81 +++ .../_evaluators/WhereEvaluator.cs | 23 + .../ConcurrentSelectorsException.cs | 33 ++ .../DuplicateOrderChainException.cs | 34 ++ .../_exceptions/SelectorNotFoundException.cs | 33 ++ src/request.persistence.tests/.editorconfig | 8 + .../Geekeey.Request.Persistence.Tests.csproj | 21 + .../SpecificationBuilderTests.cs | 216 ++++++++ .../_fixtures/FakeChildEntity.cs | 13 + .../_fixtures/FakeDetailChildEntity.cs | 12 + .../_fixtures/FakeDetailEntity.cs | 13 + .../_fixtures/FakeEntity.cs | 12 + .../_fixtures/FakeGrandChildEntity.cs | 12 + .../_fixtures/FakeSpecification.cs | 12 + .../_fixtures/FakeSpecificationWithResult.cs | 13 + .../_fixtures/SpecificationTestBuilder.cs | 8 + .../Geekeey.Request.Persistence.csproj | 31 ++ src/request.persistence/IRepository.cs | 155 ++++++ src/request.persistence/ISpecification.cs | 71 +++ src/request.persistence/IncludeType.cs | 20 + src/request.persistence/OrderType.cs | 30 ++ src/request.persistence/Specification.cs | 103 ++++ .../_builders/IncludableBuilderExtensions.cs | 101 ++++ .../IncludableSpecificationBuilder.cs | 33 ++ .../_builders/OrderedBuilderExtensions.cs | 80 +++ .../_builders/OrderedSpecificationBuilder.cs | 32 ++ .../_builders/SpecificationBuilder.cs | 72 +++ .../SpecificationBuilderExtensions.cs | 305 +++++++++++ .../_exceptions/DuplicateSkipException.cs | 29 + .../_exceptions/DuplicateTakeException.cs | 29 + .../_expressions/IncludeExpressionInfo.cs | 74 +++ .../_expressions/OrderExpressionInfo.cs | 45 ++ .../_expressions/SearchExpressionInfo.cs | 53 ++ .../_expressions/WhereExpressionInfo.cs | 38 ++ 78 files changed, 3870 insertions(+) create mode 100644 src/request.persistence.entityframeworkcore.tests/.editorconfig create mode 100644 src/request.persistence.entityframeworkcore.tests/EvaluationTests.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj create mode 100644 src/request.persistence.entityframeworkcore.tests/IncludeEvaluatorTests.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/RepositoryTests.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/CombinedWhereOrderTakeSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/ConcurrentSelectorSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/DuplicateOrderSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeChildEntity.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDbContext.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailChildEntity.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeDetailEntity.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeEntity.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FakeGrandChildEntity.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/FirstOrderedSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeChildThenGrandChildrenSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeDetailsSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/IncludeOnlySpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/MultiSearchSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/MultipleWhereSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/NameEqualSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/NameProjectionSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/NoSelectorSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameDescSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdDescSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderByNameThenByIdSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedSkipTakeSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/OrderedTakeSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/PostProcessingSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/SearchSpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/SkipOnlySpec.cs create mode 100644 src/request.persistence.entityframeworkcore.tests/_fixtures/WhereSpec.cs create mode 100644 src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj create mode 100644 src/request.persistence.entityframeworkcore/Repository.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/SpecificationEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/IEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/IncludeEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/OrderEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/PaginationEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/SearchEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_evaluators/WhereEvaluator.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_exceptions/ConcurrentSelectorsException.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_exceptions/DuplicateOrderChainException.cs create mode 100644 src/request.persistence.entityframeworkcore/_Specification/_exceptions/SelectorNotFoundException.cs create mode 100644 src/request.persistence.tests/.editorconfig create mode 100644 src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj create mode 100644 src/request.persistence.tests/SpecificationBuilderTests.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeChildEntity.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeDetailChildEntity.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeDetailEntity.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeEntity.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeGrandChildEntity.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeSpecification.cs create mode 100644 src/request.persistence.tests/_fixtures/FakeSpecificationWithResult.cs create mode 100644 src/request.persistence.tests/_fixtures/SpecificationTestBuilder.cs create mode 100644 src/request.persistence/Geekeey.Request.Persistence.csproj create mode 100644 src/request.persistence/IRepository.cs create mode 100644 src/request.persistence/ISpecification.cs create mode 100644 src/request.persistence/IncludeType.cs create mode 100644 src/request.persistence/OrderType.cs create mode 100644 src/request.persistence/Specification.cs create mode 100644 src/request.persistence/_builders/IncludableBuilderExtensions.cs create mode 100644 src/request.persistence/_builders/IncludableSpecificationBuilder.cs create mode 100644 src/request.persistence/_builders/OrderedBuilderExtensions.cs create mode 100644 src/request.persistence/_builders/OrderedSpecificationBuilder.cs create mode 100644 src/request.persistence/_builders/SpecificationBuilder.cs create mode 100644 src/request.persistence/_builders/SpecificationBuilderExtensions.cs create mode 100644 src/request.persistence/_exceptions/DuplicateSkipException.cs create mode 100644 src/request.persistence/_exceptions/DuplicateTakeException.cs create mode 100644 src/request.persistence/_expressions/IncludeExpressionInfo.cs create mode 100644 src/request.persistence/_expressions/OrderExpressionInfo.cs create mode 100644 src/request.persistence/_expressions/SearchExpressionInfo.cs create mode 100644 src/request.persistence/_expressions/WhereExpressionInfo.cs 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; +} From 06a40184ee5f6fe0706ee6313eebfa3c78ba96da Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:28:33 +0200 Subject: [PATCH 02/11] fix(result): correct wrong namespace in Prelude doc comment The doc comment referenced `Geekeey.Extensions.Result.Prelude` but the actual namespace is `Geekeey.Request.Result`, so the suggested using directive would not compile. --- CHANGELOG.md | 4 ++++ src/request.result/Prelude.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 240c251..6b08db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,10 @@ To have a consistent experience across all packages, some public interfaces have ### Changed +### Fixed + +- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`) + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 diff --git a/src/request.result/Prelude.cs b/src/request.result/Prelude.cs index de48354..e59349d 100644 --- a/src/request.result/Prelude.cs +++ b/src/request.result/Prelude.cs @@ -9,7 +9,7 @@ namespace Geekeey.Request.Result; /// A class containing various utility methods, a 'prelude' to the rest of the library. /// /// -/// This class is meant to be imported statically, e.g. using static Geekeey.Extensions.Result.Prelude;. +/// This class is meant to be imported statically, e.g. using static Geekeey.Request.Result.Prelude;. /// Recommended to be imported globally via a global using statement. /// public static class Prelude From fbbe94bfa8caa65fe7e8fb568e4e82d1d8051194 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:32:16 +0200 Subject: [PATCH 03/11] fix(result): Result hash collision A success result wrapping a value whose GetHashCode is 0 (e.g. Result with 0) collides with a failure result, both hashing to 0. --- CHANGELOG.md | 1 + .../ResultEqualityTests.cs | 70 ++++++++++++++++++- src/request.result/Result.Equality.cs | 7 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b08db3..a7f9bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ To have a consistent experience across all packages, some public interfaces have ### Fixed - **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`) +- **request.result:** Fold `IsSuccess` into `Result.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0` ### Removed diff --git a/src/request.result.tests/ResultEqualityTests.cs b/src/request.result.tests/ResultEqualityTests.cs index 67233dd..ad54683 100644 --- a/src/request.result.tests/ResultEqualityTests.cs +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -166,11 +166,28 @@ internal sealed class ResultEqualityTests } [Test] - public async Task I_can_get_hashcode_and_get_zero_for_failure() + public async Task I_can_get_hashcode_and_get_non_zero_for_failure() { var result = Prelude.Failure("error"); - await Assert.That(result.GetHashCode()).IsZero(); + await Assert.That(result.GetHashCode()).IsNotEqualTo(0); + } + + [Test] + public async Task I_can_get_hashcode_and_not_collide_non_generic_success_and_failure() + { + await Assert.That(Prelude.Success().GetHashCode()) + .IsNotEqualTo(Prelude.Failure("error").GetHashCode()); + } + + [Test] + public async Task I_can_get_hashcode_and_not_collide_success_value_zero_with_failure() + { + var success = Prelude.Success(0); + var failure = Prelude.Failure("error"); + + await Assert.That(success.Equals(failure)).IsFalse(); + await Assert.That(success.GetHashCode()).IsNotEqualTo(failure.GetHashCode()); } [Test] @@ -199,4 +216,53 @@ internal sealed class ResultEqualityTests await Assert.That(a.Equals(b)).IsFalse(); } + + [Test] + public async Task I_can_use_equality_operator_and_get_false_for_success_and_failure() + { + var success = Prelude.Success(2); + var failure = Prelude.Failure("error"); + + await Assert.That(success == failure).IsFalse(); + await Assert.That(success != failure).IsTrue(); + } + + [Test] + public async Task I_can_use_equality_operator_and_get_true_for_successes_with_equal_value() + { + await Assert.That(Prelude.Success(2) == Prelude.Success(2)).IsTrue(); + await Assert.That(Prelude.Success(2) != Prelude.Success(3)).IsTrue(); + } + + [Test] + public async Task I_can_use_equality_operator_and_compare_result_to_value() + { + await Assert.That(Prelude.Success(2) == 2).IsTrue(); + await Assert.That(Prelude.Success(2) != 3).IsTrue(); + await Assert.That(Prelude.Failure("x") == 2).IsFalse(); + } + + [Test] + public async Task I_can_equal_object_and_get_false_for_null() + { + object result = Prelude.Success(2); + + await Assert.That(result.Equals(null)).IsFalse(); + } + + [Test] + public async Task I_can_equal_object_and_get_false_for_wrong_type() + { + object result = Prelude.Success(2); + + await Assert.That(result.Equals("not a result")).IsFalse(); + } + + [Test] + public async Task I_can_equal_object_and_get_true_for_boxed_equal_result() + { + object result = Prelude.Success(2); + + await Assert.That(result.Equals(Prelude.Success(2))).IsTrue(); + } } diff --git a/src/request.result/Result.Equality.cs b/src/request.result/Result.Equality.cs index 0253b88..2a8cbbb 100644 --- a/src/request.result/Result.Equality.cs +++ b/src/request.result/Result.Equality.cs @@ -181,7 +181,12 @@ public partial class Result : IEquatable>, IEquatable return comparer.GetHashCode(result.Value); } - return 0; + if (result is { IsSuccess: true, Value: null }) + { + return 0; + } + + return result.Error?.GetHashCode() ?? 0; } } From 5b368602fe47d6edf18f116244c2c039ace2f2ff Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:35:50 +0200 Subject: [PATCH 04/11] docs(result): document that failure equality ignores error content Result/Result equality treats any two failures as equal regardless of their Error, which was undocumented and surprising for failure-specific branching. --- CHANGELOG.md | 2 ++ .../ResultEqualityTests.cs | 20 +++++++++++++++++++ src/request.result/Result.Equality.cs | 20 ++++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f9bdb..006fd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ To have a consistent experience across all packages, some public interfaces have ### Changed +- **request.result:** Document that `Result`/`Result` equality treats any two failures as equal, ignoring error content; branch on the `Error` directly for failure-specific logic + ### Fixed - **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`) diff --git a/src/request.result.tests/ResultEqualityTests.cs b/src/request.result.tests/ResultEqualityTests.cs index ad54683..99d7094 100644 --- a/src/request.result.tests/ResultEqualityTests.cs +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -190,6 +190,26 @@ internal sealed class ResultEqualityTests await Assert.That(success.GetHashCode()).IsNotEqualTo(failure.GetHashCode()); } + [Test] + public async Task I_can_equal_failure_and_failure_ignoring_error_content() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a.Equals(b)).IsTrue(); + await Assert.That(a.Error).IsNotEqualTo(b.Error); + } + + [Test] + public async Task I_can_equal_non_generic_failure_and_failure_ignoring_error_content() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a.Equals(b)).IsTrue(); + await Assert.That(a.Error).IsNotEqualTo(b.Error); + } + [Test] public async Task I_can_equal_non_generic_result_and_get_true_for_success_and_success() { diff --git a/src/request.result/Result.Equality.cs b/src/request.result/Result.Equality.cs index 2a8cbbb..da9372d 100644 --- a/src/request.result/Result.Equality.cs +++ b/src/request.result/Result.Equality.cs @@ -9,7 +9,15 @@ namespace Geekeey.Request.Result; public partial class Result : IEquatable { - /// + /// + /// Checks whether two results are equal. Results are equal if both are success or both are failure. + /// + /// + /// Two failures are considered equal regardless of their content. Equality therefore + /// ignores the error; if you need to branch on a specific failure, compare the values + /// directly instead of relying on equality. + /// + /// The result to check for equality with the current result. [Pure] public bool Equals(Result? other) { @@ -49,7 +57,8 @@ public partial class Result : IEquatable public partial class Result : IEqualityOperators { /// - /// Checks whether two results are equal. Results are equal if they are both success or both failure. + /// Checks whether two results are equal. Results are equal if they are both success or both failure. Two + /// failures are equal regardless of their error content. /// /// The first result to compare. /// The second result to compare. @@ -79,6 +88,11 @@ public partial class Result : IEquatable>, IEquatable /// Checks whether the result is equal to another result. Results are equal if both results are success values and /// the success values are equal, or if both results are failures. /// + /// + /// Two failures are considered equal regardless of their content. Equality therefore + /// ignores the error; if you need to branch on a specific failure, compare the values + /// directly instead of relying on equality. + /// /// The result to check for equality with the current result. [Pure] public bool Equals(Result? other) @@ -194,7 +208,7 @@ public partial class Result : IEqualityOperators, Result, bool>, { /// /// Checks whether two results are equal. Results are equal if both results are success values and the success - /// values are equal, or if both results are failures. + /// values are equal, or if both results are failures. Two failures are equal regardless of their error content. /// /// The first result to compare. /// The second result to compare. From fd6ed647557a314558ae9aa3612db6ec792fdd21 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:42:07 +0200 Subject: [PATCH 05/11] fix(validation): guard null comparison bounds in comparison builders Passing a null bound to GreaterThan/LessThan/Between etc. called value.CompareTo(null), throwing NullReferenceException for non-null reference-type values. Short-circuit when the bound is null so the rule is satisfied instead of crashing. --- CHANGELOG.md | 1 + .../RuleBuilderExtensionsTests.cs | 65 +++++++++++++++++++ .../_fixtures/ComparableModel.cs | 19 ++++++ .../RuleBuilderExtensions.cs | 12 ++-- 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 src/request.validation.tests/_fixtures/ComparableModel.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 006fd7b..a7ba39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`) - **request.result:** Fold `IsSuccess` into `Result.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0` +- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`) ### Removed diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs index 5b5271a..c0d2cae 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -314,6 +314,71 @@ internal sealed class RuleBuilderExtensionsTests .Throws(); } + [Test] + public async Task I_can_validate_greater_than_with_null_bound_without_throwing() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.GreaterThan(null)); + + var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } }); + var ignoredNull = validator.Validate(new ComparableModel()); + + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.GreaterThanOrEqualTo(null)); + + var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } }); + var ignoredNull = validator.Validate(new ComparableModel()); + + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_less_than_with_null_bound_without_throwing() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.LessThan(null)); + + var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } }); + var ignoredNull = validator.Validate(new ComparableModel()); + + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.LessThanOrEqualTo(null)); + + var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } }); + var ignoredNull = validator.Validate(new ComparableModel()); + + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_between_with_null_bounds_without_throwing() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Between(null, null)); + + var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } }); + var ignoredNull = validator.Validate(new ComparableModel()); + + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + [Test] public async Task I_can_see_it_throw_for_invalid_between_configuration() { diff --git a/src/request.validation.tests/_fixtures/ComparableModel.cs b/src/request.validation.tests/_fixtures/ComparableModel.cs new file mode 100644 index 0000000..9178228 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ComparableModel.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ComparableModel +{ + public ComparableValue? Value { get; init; } +} + +internal sealed class ComparableValue : IComparable +{ + public int Number { get; init; } + + public int CompareTo(ComparableValue? other) + { + return Number.CompareTo(other!.Number); + } +} diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs index 528b387..7e1a5db 100644 --- a/src/request.validation/RuleBuilderExtensions.cs +++ b/src/request.validation/RuleBuilderExtensions.cs @@ -170,7 +170,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) > 0, $"Value must be greater than {comparisonValue}."); } @@ -189,7 +189,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) >= 0, $"Value must be greater than or equal to {comparisonValue}."); } @@ -208,7 +208,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) < 0, $"Value must be less than {comparisonValue}."); } @@ -227,7 +227,7 @@ public static class RuleBuilderExtensions { ArgumentNullException.ThrowIfNull(rule); - return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0, + return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0, $"Value must be less than or equal to {comparisonValue}."); } @@ -254,7 +254,9 @@ public static class RuleBuilderExtensions "Maximum value must be greater than or equal to minimum value."); } - return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0), + return rule.Must(value => IsNull(value) + || ((minValue is null || value.CompareTo(minValue) >= 0) + && (maxValue is null || value.CompareTo(maxValue) <= 0)), $"Value must be between {minValue} and {maxValue}."); } From 581b0a1cedcdb474c6369a6fc395ac68ece63921 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:43:37 +0200 Subject: [PATCH 06/11] fix(validation): return no problems for null reference instance in Rule.Validate Rule.Validate cast a null instance to T and validated it, so a reference T whose accessor dereferences the instance threw NullReferenceException on a direct validator.Validate(context) with a null instance. Guard the null-instance path and return no problems instead. --- CHANGELOG.md | 1 + src/request.validation.tests/ValidatorTests.cs | 11 +++++++++++ src/request.validation/Rule.cs | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ba39b..c2c6fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result` → `Geekeey.Request.Result`) - **request.result:** Fold `IsSuccess` into `Result.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0` - **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`) +- **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`) ### Removed diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index 14239f7..6613720 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -45,6 +45,17 @@ internal sealed class ValidatorTests } } + [Test] + public async Task I_can_validate_with_a_null_instance_without_throwing() + { + var validator = new PropertyValidator(person + => person!.Name, rule => rule.Must(value => value is not null, "Name is required.")); + + var result = validator.Validate((Person?)null); + + await Assert.That(result.IsValid).IsTrue(); + } + [Test] public async Task I_can_compose_nested_validators_and_aggregate_property_paths() { diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs index 0c65cc0..a6a397c 100644 --- a/src/request.validation/Rule.cs +++ b/src/request.validation/Rule.cs @@ -46,7 +46,7 @@ internal abstract record Rule : Rule if (context.Instance is null && default(T) is null) { - return Validate((T)context.Instance!, context); + return []; } var actualType = context.Instance?.GetType().FullName ?? "null"; From 15bf9a8e56359345430470853d32aea173a0b0f8 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 18:50:06 +0200 Subject: [PATCH 07/11] fix(validation): make Severity meaningful in Validation.IsValid Validation.IsValid previously counted every problem regardless of severity, leaving Severity inert. Now IsValid only fails on Error problems, so Warning and Info no longer invalidate the result, and IsValidFor(Severity) lets callers set the failing-severity threshold. --- CHANGELOG.md | 1 + .../ValidationSeverityTests.cs | 49 +++++++++++++++++++ .../ValidatorTests.cs | 6 ++- src/request.validation/Validation.cs | 22 ++++++++- 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/request.validation.tests/ValidationSeverityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c6fb0..c41600a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.result:** Fold `IsSuccess` into `Result.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0` - **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`) - **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`) +- **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold ### Removed diff --git a/src/request.validation.tests/ValidationSeverityTests.cs b/src/request.validation.tests/ValidationSeverityTests.cs new file mode 100644 index 0000000..6f24c8d --- /dev/null +++ b/src/request.validation.tests/ValidationSeverityTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ValidationSeverityTests +{ + [Test] + public async Task I_can_have_warning_problems_not_invalidate_by_default() + { + var validator = new PropertyValidator(person + => person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.") + .WithSeverity(Severity.Warning)); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse(); + await Assert.That(result.IsValidFor(Severity.Info)).IsFalse(); + } + + [Test] + public async Task I_can_have_info_problems_only_invalidate_at_info_threshold() + { + var validator = new PropertyValidator(person + => person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.") + .WithSeverity(Severity.Info)); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.IsValidFor(Severity.Warning)).IsTrue(); + await Assert.That(result.IsValidFor(Severity.Info)).IsFalse(); + } + + [Test] + public async Task I_can_have_error_problems_invalidate_by_default() + { + var validator = new PropertyValidator(person + => person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.IsValid).IsFalse(); + await Assert.That(result.IsValidFor(Severity.Error)).IsFalse(); + } +} diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index 6613720..820edc4 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -14,7 +14,8 @@ internal sealed class ValidatorTests var result = validator.Validate(new Person { Name = "" }); - await Assert.That(result.IsValid).IsFalse(); + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse(); await Assert.That(result.Problems).Count().IsEqualTo(1); var problem = result.Problems.Single(); @@ -39,7 +40,8 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(invalid.IsValid).IsFalse(); + await Assert.That(invalid.IsValid).IsTrue(); + await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse(); await Assert.That(valid.IsValid).IsTrue(); await Assert.That(valid.Problems).IsEmpty(); } diff --git a/src/request.validation/Validation.cs b/src/request.validation/Validation.cs index fadadff..8e9c7c7 100644 --- a/src/request.validation/Validation.cs +++ b/src/request.validation/Validation.cs @@ -23,7 +23,27 @@ public sealed class Validation /// /// Whether the validation was successful. /// - public bool IsValid => Problems.Count is 0; + /// + /// A validation is considered successful unless it contains at least one problem with + /// . Problems of lower severity ( or + /// ) do not invalidate the result. Use + /// to control which severities are treated as failures. + /// + public bool IsValid => IsValidFor(Severity.Error); + + /// + /// Whether the validation was successful, treating problems at or above the given severity as failures. + /// + /// The minimum severity that should be considered a failure. + /// + /// A problem invalidates the validation when its is at least as severe as + /// . For example, IsValidFor(Severity.Warning) fails on errors and + /// warnings but not on informational problems. + /// + public bool IsValidFor(Severity minimum) + { + return !Problems.Any(problem => problem.Severity <= minimum); + } /// /// The problems that were found during validation. From a7d7e9fbaade2f5abaf8c901a8221681df1e45d2 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 19:18:21 +0200 Subject: [PATCH 08/11] fix(dispatcher): throw on ambiguous handlers instead of silent .First() Multiple handlers for the same request were previously resolved via .First(), with ordering depending on Type.GetInterfaces() order (non-deterministic). Now ScalarRequestInvoker/StreamRequestInvoker throw InvalidOperationException listing the candidate handlers when more than one matches. --- CHANGELOG.md | 1 + .../ScalarDispatcherTests.cs | 33 ++++++++++++++++--- .../StreamDispatcherTests.cs | 31 +++++++++++++++-- .../_fixtures/DuplicateScalarHandler.cs | 24 ++++++++++++++ .../_fixtures/DuplicateStreamHandler.cs | 24 ++++++++++++++ .../ScalarRequestInvoker.cs | 15 ++++++++- .../StreamRequestInvoker.cs | 15 ++++++++- 7 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs create mode 100644 src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c41600a..b05e20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`) - **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`) - **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold +- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered ### Removed diff --git a/src/request.dispatcher.tests/ScalarDispatcherTests.cs b/src/request.dispatcher.tests/ScalarDispatcherTests.cs index 5c10998..8c5ffb1 100644 --- a/src/request.dispatcher.tests/ScalarDispatcherTests.cs +++ b/src/request.dispatcher.tests/ScalarDispatcherTests.cs @@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests } [Test] - public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler() + public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers() { var sc = new ServiceCollection(); sc.AddRequestDispatcher(builder => builder @@ -126,13 +126,18 @@ internal sealed class ScalarDispatcherTests var dispatcher = provider.GetRequiredService(); var request = new InterfaceInheritedScalarRequest { Name = "Constrained" }; - var result = await dispatcher.DispatchAsync(request); // Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match. // InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest. // InterfaceConstrainedScalarHandler is an open generic match. - // Currently Dispatcher.SendAsync checks concrete handlers first. - await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled"); + // Ambiguity is no longer resolved silently with .First(); it throws instead. + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler)); + await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler"); + } } [Test] @@ -234,6 +239,26 @@ internal sealed class ScalarDispatcherTests } } + [Test] + public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers() + { + var sc = new ServiceCollection(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(DuplicateScalarHandlerA)) + .Add(typeof(DuplicateScalarHandlerB))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + var request = new DuplicateScalarRequest(); + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerA)); + await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerB)); + } + } + [Test] public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build() { diff --git a/src/request.dispatcher.tests/StreamDispatcherTests.cs b/src/request.dispatcher.tests/StreamDispatcherTests.cs index c06cb36..5fcc7b2 100644 --- a/src/request.dispatcher.tests/StreamDispatcherTests.cs +++ b/src/request.dispatcher.tests/StreamDispatcherTests.cs @@ -7,6 +7,26 @@ namespace Geekeey.Request.Dispatcher.Tests; public class StreamDispatcherTests { + [Test] + public async Task I_can_see_it_throw_on_ambiguous_stream_handlers() + { + var sc = new ServiceCollection(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(DuplicateStreamHandlerA)) + .Add(typeof(DuplicateStreamHandlerB))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + var request = new DuplicateStreamRequest(); + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerA)); + await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerB)); + } + } + [Test] public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler() { @@ -121,13 +141,18 @@ public class StreamDispatcherTests var dispatcher = provider.GetRequiredService(); var request = new InterfaceInheritedStreamRequest { Name = "Constrained" }; - var results = await dispatcher.DispatchAsync(request).ToListAsync(); // Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match. // InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest. // InterfaceConstrainedStreamHandler is an open generic match. - // Currently Dispatcher checks concrete handlers first. - await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]); + // Ambiguity is no longer resolved silently with .First(); it throws instead. + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws(); + + using (Assert.Multiple()) + { + await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler)); + await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler"); + } } [Test] diff --git a/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs new file mode 100644 index 0000000..6a0504e --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DuplicateScalarHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public sealed class DuplicateScalarRequest : IScalarRequest +{ +} + +public sealed class DuplicateScalarHandlerA : IScalarRequestHandler +{ + public Task HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult("A"); + } +} + +public sealed class DuplicateScalarHandlerB : IScalarRequestHandler +{ + public Task HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult("B"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs new file mode 100644 index 0000000..d3f0414 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DuplicateStreamHandler.cs @@ -0,0 +1,24 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public sealed class DuplicateStreamRequest : IStreamRequest +{ +} + +public sealed class DuplicateStreamHandlerA : IStreamRequestHandler +{ + public async IAsyncEnumerable HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return "A"; + } +} + +public sealed class DuplicateStreamHandlerB : IStreamRequestHandler +{ + public async IAsyncEnumerable HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return "B"; + } +} diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs index 63de03e..2c6d129 100644 --- a/src/request.dispatcher/ScalarRequestInvoker.cs +++ b/src/request.dispatcher/ScalarRequestInvoker.cs @@ -43,7 +43,20 @@ internal sealed class ScalarRequestInvoker : ScalarRequestI Task Head(IScalarRequest r, CancellationToken ct) { - return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, ct); + var handlers = options.GetRequestHandlers>(serviceProvider).ToArray(); + + if (handlers.Length is 0) + { + throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'."); + } + + if (handlers.Length > 1) + { + var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName)); + throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}"); + } + + return handlers[0].HandleAsync((TRequest)r, ct); } } } diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs index 882d538..817b5b2 100644 --- a/src/request.dispatcher/StreamRequestInvoker.cs +++ b/src/request.dispatcher/StreamRequestInvoker.cs @@ -47,7 +47,20 @@ internal sealed class StreamRequestInvoker : StreamRequestI IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) { - return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, ct); + var handlers = options.GetRequestHandlers>(serviceProvider).ToArray(); + + if (handlers.Length is 0) + { + throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'."); + } + + if (handlers.Length > 1) + { + var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName)); + throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}"); + } + + return handlers[0].HandleAsync((TRequest)r, ct); } } } From 8099898f3d29c00a47a11761fc7ce113fd1fccee Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 19:22:50 +0200 Subject: [PATCH 09/11] fix(dispatcher): resolve handlers in registration order across closed and open generics Handler/behavior resolution previously forced closed generics before open generics regardless of registration order, which was surprising and fragile. TypeIndex now tracks each registered type's index and orders matching candidates by registration order, so closed/open ordering is deterministic and follows registration. --- CHANGELOG.md | 1 + .../ScalarDispatcherTests.cs | 23 ++++++++++++++ .../RequestDispatcherOptions.cs | 31 +++++++++++++------ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05e20b..0541f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`) - **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold - **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered +- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order ### Removed diff --git a/src/request.dispatcher.tests/ScalarDispatcherTests.cs b/src/request.dispatcher.tests/ScalarDispatcherTests.cs index 8c5ffb1..f274da9 100644 --- a/src/request.dispatcher.tests/ScalarDispatcherTests.cs +++ b/src/request.dispatcher.tests/ScalarDispatcherTests.cs @@ -259,6 +259,29 @@ internal sealed class ScalarDispatcherTests } } + [Test] + public async Task I_can_see_candidates_ordered_by_registration_not_closed_first() + { + var sc = new ServiceCollection(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(InterfaceConstrainedScalarHandler<>)) + .Add(typeof(InterfaceInheritedScalarHandler))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + var request = new InterfaceInheritedScalarRequest { Name = "Constrained" }; + + // The open generic handler is registered before the closed handler, so candidates must be + // listed in registration order (open first), not the old fixed closed-before-open order. + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); + + var message = ex?.Message ?? string.Empty; + var openIndex = message.IndexOf("InterfaceConstrainedScalarHandler", StringComparison.Ordinal); + var closedIndex = message.IndexOf("InterfaceInheritedScalarHandler", StringComparison.Ordinal); + + await Assert.That(openIndex >= 0 && closedIndex >= 0 && openIndex < closedIndex).IsTrue(); + } + [Test] public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build() { diff --git a/src/request.dispatcher/RequestDispatcherOptions.cs b/src/request.dispatcher/RequestDispatcherOptions.cs index d18def9..6560f46 100644 --- a/src/request.dispatcher/RequestDispatcherOptions.cs +++ b/src/request.dispatcher/RequestDispatcherOptions.cs @@ -46,11 +46,16 @@ internal sealed class RequestDispatcherOptions protected readonly Dictionary> _closedTypeInfo = []; protected readonly List _openTypeInfo = []; + protected readonly Dictionary _order = []; protected TypeIndex(IEnumerable collection, Func predicate) { + var index = 0; + foreach (var type in collection) { + _order[type] = index++; + if (type.IsGenericTypeDefinition) { if (type.GetInterfaces().Any(predicate)) @@ -102,11 +107,14 @@ internal sealed class RequestDispatcherOptions { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List(); + var result = new List<(int Order, Type Type)>(); if (_closedTypeInfo.TryGetValue(@interface, out var list)) { - result.AddRange(list); + foreach (var type in list) + { + result.Add((_order[type], type)); + } } var requestType = @interface.GetGenericArguments()[0]; @@ -121,7 +129,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((_order[type], impl)); } } catch (ArgumentException) @@ -137,7 +145,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((_order[type], impl)); } } } @@ -146,7 +154,7 @@ internal sealed class RequestDispatcherOptions } } - return result; + return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); } } @@ -162,11 +170,14 @@ internal sealed class RequestDispatcherOptions { protected override IReadOnlyList IsAssignableTo(Type @interface) { - var result = new List(); + var result = new List<(int Order, Type Type)>(); if (_closedTypeInfo.TryGetValue(@interface, out var list)) { - result.AddRange(list); + foreach (var type in list) + { + result.Add((_order[type], type)); + } } var requestType = @interface.GetGenericArguments()[0]; @@ -180,7 +191,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType, responseType); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((_order[behaviour], impl)); } } catch (ArgumentException) @@ -193,7 +204,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add(impl); + result.Add((_order[behaviour], impl)); } } catch (ArgumentException) @@ -201,7 +212,7 @@ internal sealed class RequestDispatcherOptions } } - return result; + return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); } } } From f9de3a99de51c4de520721a176462638a41507f6 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 19:30:59 +0200 Subject: [PATCH 10/11] feat(dispatcher): add registration-time pipeline behavior ordering via AddBehavior(order:) Pipeline behavior ordering previously depended on discovery/registration order. Add an AddBehavior(order:) API that captures the order at registration time; the BehaviorTypeIndex now orders matching behaviors by that explicit order (lower runs first), falling back to registration order for ties. Removed the runtime Order property approach. --- CHANGELOG.md | 1 + .../ScalarBehaviourTests.cs | 20 ++++++++ .../StreamBehaviourTests.cs | 20 ++++++++ .../_fixtures/OrderedScalarBehavior.cs | 22 +++++++++ .../_fixtures/OrderedStreamBehavior.cs | 28 +++++++++++ .../RequestDispatcherBuilderExtensions.cs | 21 ++++++++ .../RequestDispatcherOptions.cs | 48 ++++++++++++------- 7 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs create mode 100644 src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0541f06..a7052a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold - **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered - **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order +- **request.dispatcher:** Add `AddBehavior(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order ### Removed diff --git a/src/request.dispatcher.tests/ScalarBehaviourTests.cs b/src/request.dispatcher.tests/ScalarBehaviourTests.cs index 9c10e4b..d2a6dd6 100644 --- a/src/request.dispatcher.tests/ScalarBehaviourTests.cs +++ b/src/request.dispatcher.tests/ScalarBehaviourTests.cs @@ -87,6 +87,26 @@ internal sealed class ScalarBehaviourTests await Assert.That(tracker.Executed).IsTrue(); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .AddBehavior(2) + .AddBehavior(1)); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new ScalarTestRequest { Value = "Hello" }; + await dispatcher.DispatchAsync(request); + + // Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order. + await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]); + } + [Test] public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours() { diff --git a/src/request.dispatcher.tests/StreamBehaviourTests.cs b/src/request.dispatcher.tests/StreamBehaviourTests.cs index c96b530..7e9377f 100644 --- a/src/request.dispatcher.tests/StreamBehaviourTests.cs +++ b/src/request.dispatcher.tests/StreamBehaviourTests.cs @@ -66,6 +66,26 @@ internal sealed class StreamBehaviourTests await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2"); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .AddBehavior(2) + .AddBehavior(1)); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new StreamTestRequest { Value = "Hello" }; + await dispatcher.DispatchAsync(request).ToListAsync(); + + // Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order. + await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]); + } + [Test] public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour() { diff --git a/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs new file mode 100644 index 0000000..1eab048 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedA"); + return await next(request, cancellationToken); + } +} + +public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedB"); + return await next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs new file mode 100644 index 0000000..0439f21 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs @@ -0,0 +1,28 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedA"); + await foreach (var item in next(request, cancellationToken)) + { + yield return item; + } + } +} + +public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedB"); + await foreach (var item in next(request, cancellationToken)) + { + yield return item; + } + } +} diff --git a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs index 8d53e3b..c2b6976 100644 --- a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs +++ b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs @@ -70,6 +70,27 @@ public static class RequestDispatcherBuilderExtensions return builder; } + /// + /// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit + /// pipeline used to determine its position in the pipeline. Lower values run + /// first (outermost); behaviors with equal order run in registration order. + /// + /// The behavior type to add. Must implement a request behavior interface. + /// The to configure. + /// The pipeline order for this behavior. Defaults to 0. + /// The instance for further configuration. + public static IRequestDispatcherBuilder AddBehavior(this IRequestDispatcherBuilder builder, int order = 0) + where TBehavior : class + { + ArgumentNullException.ThrowIfNull(builder); + ValidateNoNestedRequestHandlers([typeof(TBehavior)]); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([typeof(TBehavior)], order)); + + return builder; + } + /// /// Adds the specified type to the request dispatcher configuration for inspection. /// diff --git a/src/request.dispatcher/RequestDispatcherOptions.cs b/src/request.dispatcher/RequestDispatcherOptions.cs index 6560f46..f7f8c68 100644 --- a/src/request.dispatcher/RequestDispatcherOptions.cs +++ b/src/request.dispatcher/RequestDispatcherOptions.cs @@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher; internal sealed class RequestDispatcherOptions { - private readonly List _search = []; + private readonly List<(Type Type, int? Order)> _search = []; private readonly Lazy _behaviorsTypeIndex; private readonly Lazy _handlersTypeIndex; public RequestDispatcherOptions() { - _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.Distinct())); - _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.Distinct())); + _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type))); + _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type))); } - public void Inspect(IEnumerable assembly) + public void Inspect(IEnumerable assembly, int? order = null) { if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated) { throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); } - _search.AddRange(assembly); + foreach (var type in assembly) + { + _search.Add((type, order)); + } } public IEnumerable GetRequestBehaviors(IServiceProvider services) @@ -47,15 +50,21 @@ internal sealed class RequestDispatcherOptions protected readonly Dictionary> _closedTypeInfo = []; protected readonly List _openTypeInfo = []; protected readonly Dictionary _order = []; + protected readonly Dictionary _explicitOrder = []; - protected TypeIndex(IEnumerable collection, Func predicate) + protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func predicate) { var index = 0; - foreach (var type in collection) + foreach (var (type, order) in collection) { _order[type] = index++; + if (order is { } explicitOrder) + { + _explicitOrder[type] = explicitOrder; + } + if (type.IsGenericTypeDefinition) { if (type.GetInterfaces().Any(predicate)) @@ -73,6 +82,11 @@ internal sealed class RequestDispatcherOptions } } + protected int EffectiveOrder(Type type) + { + return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type]; + } + public IEnumerable Resolve(IServiceProvider services) { return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); @@ -102,7 +116,7 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>)); } - private sealed class HandlerTypeIndex(IEnumerable collection) + private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestHandlerType) { protected override IReadOnlyList IsAssignableTo(Type @interface) @@ -113,7 +127,7 @@ internal sealed class RequestDispatcherOptions { foreach (var type in list) { - result.Add((_order[type], type)); + result.Add((EffectiveOrder(type), type)); } } @@ -129,7 +143,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[type], impl)); + result.Add((EffectiveOrder(type), impl)); } } catch (ArgumentException) @@ -145,7 +159,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[type], impl)); + result.Add((EffectiveOrder(type), impl)); } } } @@ -154,7 +168,7 @@ internal sealed class RequestDispatcherOptions } } - return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } @@ -165,7 +179,7 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>)); } - private sealed class BehaviorTypeIndex(IEnumerable collection) + private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestBehaviorType) { protected override IReadOnlyList IsAssignableTo(Type @interface) @@ -176,7 +190,7 @@ internal sealed class RequestDispatcherOptions { foreach (var type in list) { - result.Add((_order[type], type)); + result.Add((EffectiveOrder(type), type)); } } @@ -191,7 +205,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType, responseType); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[behaviour], impl)); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -204,7 +218,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[behaviour], impl)); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -212,7 +226,7 @@ internal sealed class RequestDispatcherOptions } } - return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } } From 49eafc21813530e1bb54d24b342661d7d83a6139 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sun, 12 Jul 2026 22:02:05 +0200 Subject: [PATCH 11/11] chore(ci): add manual workflow dispatch --- .forgejo/workflows/default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml index 51c054c..2796b83 100644 --- a/.forgejo/workflows/default.yml +++ b/.forgejo/workflows/default.yml @@ -1,6 +1,7 @@ name: default on: + workflow_dispatch: push: branches: [ "main", "develop" ] paths-ignore: