feat: spec + repo

This commit is contained in:
Louis Seubert 2026-07-01 20:44:24 +02:00
commit 2206fe027a
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
78 changed files with 3870 additions and 0 deletions

View file

@ -12,5 +12,8 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" /> <PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
<PackageVersion Include="TUnit" Version="1.11.51" /> <PackageVersion Include="TUnit" Version="1.11.51" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -5,4 +5,8 @@
<Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" /> <Project Path="src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj" />
<Project Path="src/request.result/Geekeey.Request.Result.csproj" /> <Project Path="src/request.result/Geekeey.Request.Result.csproj" />
<Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" /> <Project Path="src/request.result.tests/Geekeey.Request.Result.Tests.csproj" />
<Project Path="src/request.persistence/Geekeey.Request.Persistence.csproj" />
<Project Path="src/request.persistence.tests/Geekeey.Request.Persistence.Tests.csproj" />
<Project Path="src/request.persistence.entityframeworkcore/Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
<Project Path="src/request.persistence.entityframeworkcore.tests/Geekeey.Request.Persistence.EntityFrameworkCore.Tests.csproj" />
</Solution> </Solution>

View file

@ -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

View file

@ -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<FakeDbContext> CreateDbContext()
{
var options = new DbContextOptionsBuilder<FakeDbContext>()
.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
.EnableServiceProviderCaching(false)
.Options;
var context = new FakeDbContext(options);
await context.Database.EnsureCreatedAsync();
return context;
}
private static async Task<Repository<FakeEntity>> CreateRepo()
{
var context = await CreateDbContext();
return new Repository<FakeEntity>(context);
}
private static async Task SeedData(Repository<FakeEntity> 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<DuplicateOrderChainException>();
}
[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<SelectorNotFoundException>();
}
[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<ConcurrentSelectorsException>();
}
[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);
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.persistence.entityframeworkcore\Geekeey.Request.Persistence.EntityFrameworkCore.csproj" />
</ItemGroup>
</Project>

View file

@ -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<FakeDbContext> CreateDbContext()
{
var context = new FakeDbContext(new DbContextOptionsBuilder<FakeDbContext>()
.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<FakeEntity>();
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<FakeEntity>(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<FakeEntity>();
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
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<FakeEntity>();
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<FakeEntity>();
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<FakeEntity>();
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<FakeEntity>();
Expression<Func<FakeDetailEntity, List<FakeDetailChildEntity>>> expr = static d => d.DetailChildren;
builder.IncludeExpressions.Add(new IncludeExpressionInfo(
expr, typeof(FakeEntity), typeof(List<FakeDetailChildEntity>), typeof(IEnumerable<FakeDetailEntity>)));
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<FakeEntity>();
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<FakeEntity>();
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<FakeEntity>();
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<FakeEntity>();
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<Repository<FakeEntity>> CreateRepo()
{
var context = await CreateDbContext();
return new Repository<FakeEntity>(context);
}
}

View file

@ -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<FakeDbContext> CreateDbContext()
{
var options = new DbContextOptionsBuilder<FakeDbContext>()
.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<FakeEntity>(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<FakeEntity>(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<FakeEntity>(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<FakeEntity>(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<FakeEntity>(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<FakeEntity>(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<FakeEntity>(context);
var any = await repo.AnyAsync();
await Assert.That(any).IsFalse();
}
}

View file

@ -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<FakeEntity>
{
public CombinedWhereOrderTakeSpec()
{
Query.Where(e => e.Name != "David")
.OrderBy(e => e.Name)
.Builder
.Take(2);
}
}

View file

@ -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<FakeEntity, string>
{
public ConcurrentSelectorSpec()
{
Query.Select(e => e.Name);
Query.SelectMany(e => e.Name.Split(' '));
}
}

View file

@ -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<FakeEntity>
{
public DuplicateOrderSpec()
{
Query.OrderBy(e => e.Name);
Query.OrderBy(e => e.Id);
}
}

View file

@ -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<FakeGrandChildEntity> GrandChildren { get; set; } = [];
}

View file

@ -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<FakeDbContext> options) : DbContext(options)
{
public DbSet<FakeEntity> FakeEntities => Set<FakeEntity>();
public DbSet<FakeChildEntity> FakeChildEntities => Set<FakeChildEntity>();
public DbSet<FakeDetailEntity> FakeDetailEntities => Set<FakeDetailEntity>();
public DbSet<FakeGrandChildEntity> FakeGrandChildEntities => Set<FakeGrandChildEntity>();
public DbSet<FakeDetailChildEntity> FakeDetailChildEntities => Set<FakeDetailChildEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<FakeEntity>(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<FakeChildEntity>(static c => c.FakeEntityId);
entity.HasMany(static e => e.Details)
.WithOne(static d => d.FakeEntity)
.HasForeignKey(static d => d.FakeEntityId);
});
modelBuilder.Entity<FakeChildEntity>(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<FakeGrandChildEntity>(grandChild =>
{
grandChild.HasKey(static g => g.Id);
grandChild.Property(static g => g.Value).IsRequired();
});
modelBuilder.Entity<FakeDetailEntity>(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<FakeDetailChildEntity>(detailChild =>
{
detailChild.HasKey(static d => d.Id);
detailChild.Property(static d => d.Value).IsRequired();
});
}
}

View file

@ -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!;
}

View file

@ -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<FakeDetailChildEntity> DetailChildren { get; set; } = [];
}

View file

@ -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<FakeDetailEntity> Details { get; set; } = [];
}

View file

@ -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!;
}

View file

@ -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<FakeEntity>
{
public FirstOrderedSpec()
{
Query.OrderBy(e => e.Name);
}
}

View file

@ -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<FakeEntity>
{
public IncludeChildSpec()
{
Query.Include(static e => e.Child);
}
}

View file

@ -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<FakeEntity>
{
public IncludeChildThenGrandChildrenSpec()
{
Query.Include(static e => e.Child).ThenInclude(static c => c.GrandChildren);
}
}

View file

@ -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<FakeEntity>
{
public IncludeDetailsSpec()
{
Query.Include(static e => e.Details);
}
}

Some files were not shown because too many files have changed in this diff Show more