Vali-Flow — EF Core Evaluator
Table of Contents
- Overview
- Setup
- Specifications
- Read Methods
- negateCondition explained
- EvaluateAsync
- EvaluateAnyAsync
- EvaluateCountAsync
- EvaluateGetFirstAsync
- EvaluateGetLastAsync
- EvaluateGetFirstFailedAsync
- EvaluateGetLastFailedAsync
- EvaluateQueryAsync
- EvaluateQueryFailedAsync
- EvaluateAllAsync
- EvaluateAllFailedAsync
- EvaluateTopAsync
- EvaluateDistinctAsync
- EvaluateDuplicatesAsync
- EvaluatePagedAsync
- EvaluateMinAsync
- EvaluateMaxAsync
- EvaluateAverageAsync
- EvaluateSumAsync
- EvaluateAggregateAsync
- EvaluateGroupedAsync
- EvaluateCountByGroupAsync
- EvaluateSumByGroupAsync
- EvaluateMinByGroupAsync
- EvaluateMaxByGroupAsync
- EvaluateAverageByGroupAsync
- EvaluateDuplicatesByGroupAsync
- EvaluateUniquesByGroupAsync
- EvaluateTopByGroupAsync
- Write Methods
Overview
loading...Vali-Flow is a .NET library that simplifies data access in Entity Framework Core applications through a fluent specification API. It decouples query criteria from the data access layer, producing clean, composable, and testable code.
Key features:
- Fluent specification pattern with filter, ordering, pagination, and EF Core hints
- Full async API built on top of EF Core
- Bulk operations via
EFCore.BulkExtensions - Deferred saves for batching multiple write operations
- Inverted filters with
negateConditionwithout changing the specification
Install
dotnet add package Vali-Flow
Requirements
- .NET 8 or later
- Entity Framework Core 8 or later
Setup
ValiFlowEvaluator<T> is a generic class that accepts a DbContext and implements both IEvaluatorRead<T> and IEvaluatorWrite<T>. It is inheritable, allowing you to extend it with custom logic in repository implementations.
Constructor
public ValiFlowEvaluator<T>(DbContext dbContext)
Dependency Injection (recommended)
Register a typed evaluator per entity in your composition root:
// Program.cs / Startup.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddScoped<ValiFlowEvaluator<Order>>(sp =>
new ValiFlowEvaluator<Order>(sp.GetRequiredService<AppDbContext>()));
Manual instantiation
var evaluator = new ValiFlowEvaluator<Order>(dbContext);
Repository pattern (inheritance)
Since ValiFlowEvaluator<T> is inheritable, you can extend it to create a clean repository:
public class OrderRepository : ValiFlowEvaluator<Order>
{
public OrderRepository(AppDbContext dbContext) : base(dbContext)
{
}
// Add custom methods
public async Task<IEnumerable<Order>> GetRecentOrdersAsync()
{
var spec = new QuerySpecification<Order>(
new ValiFlow<Order>()
.GreaterThanOrEqual(o => o.CreatedAt, DateTime.UtcNow.AddDays(-30)));
return await EvaluateQueryAsync(spec);
}
}
Then register and inject:
// Program.cs
builder.Services.AddScoped<OrderRepository>();
Note: The evaluator holds a reference to the
DbContextprovided. Follow standard EF Core scoping rules — inject a scopedDbContextin web applications.
Examples
1) Query with a spec
var spec = new QuerySpecification<Order>(
new ValiFlow<Order>().GreaterThan(o => o.Total, 100m));
IEnumerable<Order> results = await evaluator.EvaluateQueryAsync(spec);
2) Paged query
var spec = new QuerySpecification<Order>(
new ValiFlow<Order>().EqualTo(o => o.Status, "Open"))
.WithPagination(page: 2, pageSize: 25);
var page = await evaluator.EvaluatePagedAsync(spec);
3) Delete by condition
var filter = new ValiFlow<Order>().EqualTo(o => o.IsArchived, true);
await evaluator.DeleteByConditionAsync(filter);
Advanced Examples
Grouped aggregate (sum by group)
var spec = new QuerySpecification<Order>(
new ValiFlow<Order>().GreaterThan(o => o.Total, 0m));
var grouped = await evaluator.EvaluateSumByGroupAsync(
spec,
groupBy: o => o.CustomerId,
selector: o => o.Total);
Specifications
Specifications encapsulate all query criteria in a single reusable object. There are two specification classes:
| Class | Inherits | Use when |
|---|---|---|
BasicSpecification<T> | — | Filtering, includes, EF hints only |
QuerySpecification<T> | BasicSpecification<T> | Ordering, pagination, top, or ValiSort |
BasicSpecification<T>
BasicSpecification<T> is the base class. Configure it using its fluent methods:
| Method | Description |
|---|---|
WithFilter(ValiFlow<T>) | Sets the filter expression |
WithIncludes(...) | Adds eager-load includes |
AsNoTracking() | Disables EF Core change tracking (default: enabled) |
AsSplitQuery() | Enables split queries for collections |
IgnoreQueryFilters() | Bypasses global query filters |
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.AsNoTracking();
QuerySpecification<T>
QuerySpecification<T> extends BasicSpecification<T> with:
| Method | Description |
|---|---|
WithOrderBy<TProperty>(expr, ascending) | Primary ordering |
AddThenBy<TProperty>(expr, ascending) | Secondary ordering (requires WithOrderBy first) |
AddThenBys<TProperty>(exprs, ascending) | Multiple secondary orderings at once |
WithPagination(page, pageSize) | Sets page and page size together |
WithPage(page) | Sets page number only |
WithPageSize(pageSize) | Sets page size only |
WithTop(count) | Limits result to the first N items |
WithValiSort(ValiSort<T>) | Dynamic ordering via reflection |
Mutual exclusion rules
WithOrderByandWithValiSortcannot be combined.WithTopandWithPagination/WithPage/WithPageSizecannot be combined.AddThenByrequiresWithOrderByto be called first.
Example — combined spec
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>()
.IsTrue(o => o.IsActive)
.GreaterThan(o => o.Total, 500m))
.WithOrderBy(o => o.CreatedAt, ascending: false)
.AddThenBy(o => o.CustomerId)
.WithPagination(page: 2, pageSize: 20)
.AsNoTracking();
Example — constructor with filter
var filter = new ValiFlow<Product>().LessThan(p => p.Stock, 10);
var spec = new QuerySpecification<Product>(filter)
.WithOrderBy(p => p.Name)
.WithTop(5);
Example — ValiSort (dynamic sorting)
var sort = new ValiSort<Order>().By("Total", ascending: false);
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithValiSort(sort);
Read Methods
All read methods are asynchronous and operate on the DbContext set for entity type T. Specifications are passed by interface (IBasicSpecification<T> or IQuerySpecification<T>), so both BasicSpecification<T> and QuerySpecification<T> are accepted where the base interface is expected.
negateCondition explained
Several read methods accept a negateCondition parameter (implicit through "Failed" method variants or explicit through query building). When the underlying query is built with the negated filter, the ValiFlow<T> expression is logically inverted — entities that would normally be excluded become included, and vice versa.
This allows you to reuse the same specification to query both passing and failing entities:
var activeFilter = new ValiFlow<User>().IsTrue(u => u.IsActive);
var spec = new BasicSpecification<User>().WithFilter(activeFilter);
// Returns active users
var active = await evaluator.EvaluateAnyAsync(spec);
// The "Failed" variants automatically negate the filter
var firstInactive = await evaluator.EvaluateGetFirstFailedAsync(spec);
EvaluateAsync
Signature
Task<bool> EvaluateAsync(ValiFlow<T> valiFlow, T entity)
Description
Evaluates whether a single in-memory entity satisfies the given ValiFlow<T> condition. Does not hit the database. Useful for validating entities before saving.
Example
var rule = new ValiFlow<Order>()
.GreaterThan(o => o.Total, 0m)
.IsNotNull(o => o.CustomerId);
var order = new Order { Total = 150m, CustomerId = "C001" };
bool isValid = await evaluator.EvaluateAsync(rule, order);
Note: This is the only read method that does not query the database.
EvaluateAnyAsync
Signature
Task<bool> EvaluateAnyAsync(IBasicSpecification<T> specification, CancellationToken cancellationToken = default)
Description
Returns true if at least one entity in the database satisfies the specification's filter. Translates to a SQL EXISTS or COUNT check.
Example
var spec = new BasicSpecification<Product>()
.WithFilter(new ValiFlow<Product>().LessThan(p => p.Stock, 5));
bool hasLowStock = await evaluator.EvaluateAnyAsync(spec);
EvaluateCountAsync
Signature
Task<int> EvaluateCountAsync(IBasicSpecification<T> specification, CancellationToken cancellationToken = default)
Description Returns the total number of entities matching the specification's filter.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>()
.Equal(o => o.Status, OrderStatus.Pending));
int pendingCount = await evaluator.EvaluateCountAsync(spec);
EvaluateGetFirstAsync
Signature
Task<T?> EvaluateGetFirstAsync(IBasicSpecification<T> specification, CancellationToken cancellationToken = default)
Description
Returns the first entity matching the specification, or null if none match. Equivalent to FirstOrDefaultAsync with the applied filter.
Example
var spec = new BasicSpecification<User>()
.WithFilter(new ValiFlow<User>().Equal(u => u.Email, "admin@example.com"));
User? admin = await evaluator.EvaluateGetFirstAsync(spec);
EvaluateGetLastAsync
Signature
Task<T?> EvaluateGetLastAsync(IQuerySpecification<T> specification, CancellationToken cancellationToken = default)
Description
Returns the last entity matching the specification according to the defined ordering. Returns null if no match is found.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.CreatedAt);
Order? lastOrder = await evaluator.EvaluateGetLastAsync(spec);
Note: A primary ordering (
WithOrderBy) must be defined. SQL databases have no concept of "last" withoutORDER BY.
EvaluateGetFirstFailedAsync
Signature
Task<T?> EvaluateGetFirstFailedAsync(IBasicSpecification<T> specification, CancellationToken cancellationToken = default)
Description
Returns the first entity that does not satisfy the specification's filter, or null if all entities pass. Applies the logical NOT of the filter.
Example
var spec = new BasicSpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsAvailable));
Product? unavailable = await evaluator.EvaluateGetFirstFailedAsync(spec);
EvaluateGetLastFailedAsync
Signature
Task<T?> EvaluateGetLastFailedAsync(IQuerySpecification<T> specification, CancellationToken cancellationToken = default)
Description
Returns the last entity that does not satisfy the specification's filter, ordered according to the specification. Returns null if all entities pass.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsPaid))
.WithOrderBy(o => o.CreatedAt);
Order? lastUnpaid = await evaluator.EvaluateGetLastFailedAsync(spec);
EvaluateQueryAsync
Signature
Task<IQueryable<T>> EvaluateQueryAsync(IQuerySpecification<T> specification)
Description
Returns an IQueryable<T> with the full specification applied — filter, ordering, pagination, includes, and EF hints. The query is not yet executed; you can compose further or materialize with ToListAsync, ToArrayAsync, etc.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.CreatedAt, ascending: false)
.WithPagination(page: 1, pageSize: 10)
.AsNoTracking();
IQueryable<Order> query = await evaluator.EvaluateQueryAsync(spec);
List<Order> orders = await query.ToListAsync();
EvaluateQueryFailedAsync
Signature
Task<IQueryable<T>> EvaluateQueryFailedAsync(IQuerySpecification<T> specification)
Description
Returns an IQueryable<T> for entities that do not satisfy the specification's filter, with ordering and pagination applied.
Example
var spec = new QuerySpecification<User>()
.WithFilter(new ValiFlow<User>().IsTrue(u => u.EmailConfirmed))
.WithOrderBy(u => u.CreatedAt);
IQueryable<User> query = await evaluator.EvaluateQueryFailedAsync(spec);
List<User> unconfirmed = await query.ToListAsync();
EvaluateAllAsync
Signature
Task<IQueryable<T>> EvaluateAllAsync(IQuerySpecification<T> specification)
Description
Alias for EvaluateQueryAsync. Returns all entities matching the specification. Provided for API symmetry with the Vali-Flow.InMemory package.
Example
var spec = new QuerySpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsActive))
.WithOrderBy(p => p.Name);
var query = await evaluator.EvaluateAllAsync(spec);
var products = await query.ToListAsync();
EvaluateAllFailedAsync
Signature
Task<IQueryable<T>> EvaluateAllFailedAsync(IQuerySpecification<T> specification)
Description
Alias for EvaluateQueryFailedAsync. Returns all entities that fail the specification. Provided for API symmetry with the Vali-Flow.InMemory package.
Example
var spec = new QuerySpecification<Product>()
.WithFilter(new ValiFlow<Product>().GreaterThan(p => p.Stock, 0))
.WithOrderBy(p => p.Name);
var query = await evaluator.EvaluateAllFailedAsync(spec);
var outOfStock = await query.ToListAsync();
EvaluateTopAsync
Signature
Task<IQueryable<T>> EvaluateTopAsync(IQuerySpecification<T> specification, int count, CancellationToken cancellationToken = default)
Description
Returns an IQueryable<T> limited to the first count entities that satisfy the specification, with ordering applied.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.Total, ascending: false);
IQueryable<Order> topOrders = await evaluator.EvaluateTopAsync(spec, count: 5);
var result = await topOrders.ToListAsync();
Note:
countmust be greater than zero.
EvaluateDistinctAsync
Signature
Task<IEnumerable<T>> EvaluateDistinctAsync<TKey>(
IQuerySpecification<T> specification,
Expression<Func<T, TKey>> selector
) where TKey : notnull
Description Returns a distinct set of entities grouped by the provided key selector. For each group, only the first entity is returned. Ordering and pagination from the specification are applied after deduplication.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.CreatedAt);
// One order per customer
IEnumerable<Order> distinctOrders = await evaluator.EvaluateDistinctAsync(
spec, o => o.CustomerId);
EvaluateDuplicatesAsync
Signature
Task<IEnumerable<T>> EvaluateDuplicatesAsync<TKey>(
IQuerySpecification<T> specification,
Expression<Func<T, TKey>> selector
) where TKey : notnull
Description Returns all entities belonging to groups where the key appears more than once — i.e., duplicate entries by the given key.
Example
var spec = new QuerySpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsActive));
// Products sharing the same SKU
IEnumerable<Product> duplicates = await evaluator.EvaluateDuplicatesAsync(
spec, p => p.Sku);
EvaluatePagedAsync
Signature
Task<PagedResult<T>> EvaluatePagedAsync(
IQuerySpecification<T> specification,
CancellationToken cancellationToken = default
)
Description
Executes the query and returns a PagedResult<T> containing the items for the requested page along with pagination metadata (total count, total pages, current page, page size).
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.CreatedAt, ascending: false)
.WithPagination(page: 1, pageSize: 20)
.AsNoTracking();
PagedResult<Order> paged = await evaluator.EvaluatePagedAsync(spec);
Console.WriteLine($"Page {paged.Page} of {paged.TotalPages} ({paged.TotalCount} total)");
foreach (var order in paged.Items)
{
Console.WriteLine(order.Id);
}
EvaluateMinAsync
Signature
Task<TResult> EvaluateMinAsync<TResult>(
IBasicSpecification<T> specification,
Expression<Func<T, TResult>> selector,
CancellationToken cancellationToken = default
) where TResult : INumber<TResult>
Description Returns the minimum value of the selected numeric property among entities matching the specification.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive));
decimal minTotal = await evaluator.EvaluateMinAsync(spec, o => o.Total);
EvaluateMaxAsync
Signature
Task<TResult> EvaluateMaxAsync<TResult>(
IBasicSpecification<T> specification,
Expression<Func<T, TResult>> selector,
CancellationToken cancellationToken = default
) where TResult : INumber<TResult>
Description Returns the maximum value of the selected numeric property among entities matching the specification.
Example
var spec = new BasicSpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsAvailable));
decimal maxPrice = await evaluator.EvaluateMaxAsync(spec, p => p.Price);
EvaluateAverageAsync
Signature
Task<decimal> EvaluateAverageAsync<TResult>(
IBasicSpecification<T> specification,
Expression<Func<T, TResult>> selector,
CancellationToken cancellationToken = default
) where TResult : INumber<TResult>
Description
Returns the arithmetic average (as decimal) of the selected numeric property among entities matching the specification.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsPaid));
decimal avgTotal = await evaluator.EvaluateAverageAsync(spec, o => o.Total);
EvaluateSumAsync
Signature
// Overloads for int, long, double, decimal, float
Task<int> EvaluateSumAsync(IBasicSpecification<T>, Expression<Func<T, int>>, CancellationToken = default)
Task<long> EvaluateSumAsync(IBasicSpecification<T>, Expression<Func<T, long>>, CancellationToken = default)
Task<double> EvaluateSumAsync(IBasicSpecification<T>, Expression<Func<T, double>>, CancellationToken = default)
Task<decimal> EvaluateSumAsync(IBasicSpecification<T>, Expression<Func<T, decimal>>, CancellationToken = default)
Task<float> EvaluateSumAsync(IBasicSpecification<T>, Expression<Func<T, float>>, CancellationToken = default)
Description
Returns the sum of the selected numeric property for all entities matching the specification. Five overloads cover int, long, double, decimal, and float.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>()
.Equal(o => o.Status, OrderStatus.Completed));
decimal totalRevenue = await evaluator.EvaluateSumAsync(spec, o => o.Total);
int totalItems = await evaluator.EvaluateSumAsync(spec, o => o.ItemCount);
EvaluateAggregateAsync
Signature
Task<TResult> EvaluateAggregateAsync<TResult>(
IBasicSpecification<T> specification,
Expression<Func<T, TResult>> selector,
Func<TResult, TResult, TResult> aggregator,
CancellationToken cancellationToken = default
) where TResult : INumber<TResult>
Description Applies a custom aggregation function over the selected property values. Useful for aggregations not covered by the built-in Min/Max/Sum/Average methods.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive));
// Product of all totals (custom aggregation)
decimal product = await evaluator.EvaluateAggregateAsync(
spec,
o => o.Total,
(acc, val) => acc * val);
EvaluateGroupedAsync
Signature
Task<Dictionary<TKey, List<T>>> EvaluateGroupedAsync<TKey>(
IBasicSpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
CancellationToken cancellationToken = default
) where TKey : notnull
Description Groups all matching entities by the provided key selector and returns a dictionary mapping each key to the list of entities in that group.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive));
Dictionary<string, List<Order>> byCustomer =
await evaluator.EvaluateGroupedAsync(spec, o => o.CustomerId);
EvaluateCountByGroupAsync
Signature
Task<Dictionary<TKey, int>> EvaluateCountByGroupAsync<TKey>(
IBasicSpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
CancellationToken cancellationToken = default
) where TKey : notnull
Description Returns a dictionary mapping each group key to the count of entities in that group.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive));
Dictionary<OrderStatus, int> countByStatus =
await evaluator.EvaluateCountByGroupAsync(spec, o => o.Status);
EvaluateSumByGroupAsync
Signature
// Overloads for int, long, float, double, decimal
Task<Dictionary<TKey, int>> EvaluateSumByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, int>>, ct)
Task<Dictionary<TKey, long>> EvaluateSumByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, long>>, ct)
Task<Dictionary<TKey, float>> EvaluateSumByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, float>>, ct)
Task<Dictionary<TKey, double>> EvaluateSumByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, double>>, ct)
Task<Dictionary<TKey, decimal>> EvaluateSumByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, decimal>>, ct)
Description Groups entities by the key selector and returns a dictionary mapping each key to the sum of the selected numeric property within that group. Five overloads cover the main numeric types.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsPaid));
Dictionary<string, decimal> revenueByCustomer =
await evaluator.EvaluateSumByGroupAsync(spec, o => o.CustomerId, o => o.Total);
EvaluateMinByGroupAsync
Signature
// Overloads for int, long, float, double, decimal
Task<Dictionary<TKey, int>> EvaluateMinByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, int>>, ct)
Task<Dictionary<TKey, long>> EvaluateMinByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, long>>, ct)
Task<Dictionary<TKey, float>> EvaluateMinByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, float>>, ct)
Task<Dictionary<TKey, double>> EvaluateMinByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, double>>, ct)
Task<Dictionary<TKey, decimal>> EvaluateMinByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, decimal>>, ct)
Description
Groups entities by the key selector and returns the minimum value of the selected numeric property per group. Returns default(TValue) for empty groups.
Example
var spec = new BasicSpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsActive));
Dictionary<string, decimal> minPriceByCategory =
await evaluator.EvaluateMinByGroupAsync(spec, p => p.Category, p => p.Price);
EvaluateMaxByGroupAsync
Signature
// Overloads for int, long, float, double, decimal
Task<Dictionary<TKey, int>> EvaluateMaxByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, int>>, ct)
Task<Dictionary<TKey, long>> EvaluateMaxByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, long>>, ct)
Task<Dictionary<TKey, float>> EvaluateMaxByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, float>>, ct)
Task<Dictionary<TKey, double>> EvaluateMaxByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, double>>, ct)
Task<Dictionary<TKey, decimal>> EvaluateMaxByGroupAsync<TKey>(spec, keySelector, Expression<Func<T, decimal>>, ct)
Description
Groups entities by the key selector and returns the maximum value of the selected numeric property per group. Returns default(TValue) for empty groups.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive));
Dictionary<string, decimal> maxOrderByCustomer =
await evaluator.EvaluateMaxByGroupAsync(spec, o => o.CustomerId, o => o.Total);
EvaluateAverageByGroupAsync
Signature
Task<Dictionary<TKey, decimal>> EvaluateAverageByGroupAsync<TKey, TResult>(
IBasicSpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
Expression<Func<T, TResult>> selector,
CancellationToken cancellationToken = default
) where TResult : INumber<TResult> where TKey : notnull
Description
Groups entities by the key selector and returns the average (as decimal) of the selected numeric property per group.
Example
var spec = new BasicSpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsPaid));
Dictionary<string, decimal> avgByCustomer =
await evaluator.EvaluateAverageByGroupAsync(spec, o => o.CustomerId, o => o.Total);
EvaluateDuplicatesByGroupAsync
Signature
Task<Dictionary<TKey, List<T>>> EvaluateDuplicatesByGroupAsync<TKey>(
IBasicSpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
CancellationToken cancellationToken = default
) where TKey : notnull
Description Returns a dictionary mapping each duplicate key to the list of entities that share it. Only groups with more than one entity are included.
Example
var spec = new BasicSpecification<Product>()
.WithFilter(new ValiFlow<Product>().IsTrue(p => p.IsActive));
Dictionary<string, List<Product>> duplicateBySku =
await evaluator.EvaluateDuplicatesByGroupAsync(spec, p => p.Sku);
EvaluateUniquesByGroupAsync
Signature
Task<Dictionary<TKey, T>> EvaluateUniquesByGroupAsync<TKey>(
IBasicSpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
CancellationToken cancellationToken = default
) where TKey : notnull
Description Returns a dictionary mapping each key to a single unique entity. Only groups with exactly one entity are included — groups with duplicates are excluded.
Example
var spec = new BasicSpecification<User>()
.WithFilter(new ValiFlow<User>().IsTrue(u => u.IsActive));
Dictionary<string, User> uniqueByEmail =
await evaluator.EvaluateUniquesByGroupAsync(spec, u => u.Email);
EvaluateTopByGroupAsync
Signature
Task<Dictionary<TKey, List<T>>> EvaluateTopByGroupAsync<TKey>(
IQuerySpecification<T> specification,
Expression<Func<T, TKey>> keySelector,
CancellationToken cancellationToken = default
) where TKey : notnull
Description
Retrieves the top N entities (as defined by WithTop in the specification) and groups them by the key selector. If Top is not set, defaults to 50 entities. Pagination properties are ignored.
Example
var spec = new QuerySpecification<Order>()
.WithFilter(new ValiFlow<Order>().IsTrue(o => o.IsActive))
.WithOrderBy(o => o.Total, ascending: false)
.WithTop(10);
Dictionary<string, List<Order>> top10ByCustomer =
await evaluator.EvaluateTopByGroupAsync(spec, o => o.CustomerId);
Write Methods
All write methods are asynchronous. Most accept a saveChanges parameter (default true) which controls whether SaveChangesAsync is called immediately. Pass saveChanges: false to batch multiple operations and call SaveChangesAsync() once at the end.
Deferred saves pattern
var order = new Order { CustomerId = "C001", Total = 250m };
var item1 = new OrderItem { ProductId = "P1", Quantity = 2 };
var item2 = new OrderItem { ProductId = "P2", Quantity = 1 };
await orderEvaluator.AddAsync(order, saveChanges: false);
await itemEvaluator.AddAsync(item1, saveChanges: false);
await itemEvaluator.AddAsync(item2, saveChanges: false);
await orderEvaluator.SaveChangesAsync(); // single round-trip
AddAsync
Signature
Task<T> AddAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default)
Description
Adds a single entity to the database. Returns the added entity (with any database-generated values populated if saveChanges: true).
Example
var product = new Product { Name = "Widget", Price = 9.99m, Stock = 100 };
Product saved = await evaluator.AddAsync(product);
Console.WriteLine(saved.Id); // auto-generated ID is available
Note: Throws
ArgumentNullExceptionifentityis null.
AddRangeAsync
Signature
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities, bool saveChanges = true, CancellationToken cancellationToken = default)
Description Adds a collection of entities to the database in a single operation. Returns the added entities.
Example
var products = new List<Product>
{
new Product { Name = "Widget A", Price = 9.99m },
new Product { Name = "Widget B", Price = 14.99m },
};
IEnumerable<Product> saved = await evaluator.AddRangeAsync(products);
Note: Throws
ArgumentExceptionifentitiesis empty.
UpdateAsync
Signature
Task<T> UpdateAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default)
Description Marks a single entity as modified and persists the changes to the database. The entity must be tracked by EF Core or have its primary key set.
Example
var order = await evaluator.EvaluateGetFirstAsync(spec);
order.Status = OrderStatus.Shipped;
Order updated = await evaluator.UpdateAsync(order);
UpdateRangeAsync
Signature
Task<IEnumerable<T>> UpdateRangeAsync(IEnumerable<T> entities, bool saveChanges = true, CancellationToken cancellationToken = default)
Description Marks a collection of entities as modified and persists all changes in one call.
Example
var orders = (await evaluator.EvaluateQueryAsync(spec)).ToList();
orders.ForEach(o => o.Status = OrderStatus.Processing);
await evaluator.UpdateRangeAsync(orders);
DeleteAsync
Signature
Task DeleteAsync(T entity, bool saveChanges = true, CancellationToken cancellationToken = default)
Description Removes a single entity from the database. The entity must be tracked or attached before deletion.
Example
var product = await evaluator.EvaluateGetFirstAsync(spec);
if (product is not null)
await evaluator.DeleteAsync(product);
DeleteRangeAsync
Signature
Task DeleteRangeAsync(IEnumerable<T> entities, bool saveChanges = true, CancellationToken cancellationToken = default)
Description Removes a collection of entities from the database in one call.
Example
var expiredOrders = await (await evaluator.EvaluateQueryAsync(spec)).ToListAsync();
await evaluator.DeleteRangeAsync(expiredOrders);
DeleteByConditionAsync
Signature
Task DeleteByConditionAsync(Expression<Func<T, bool>> condition, CancellationToken cancellationToken = default)
Description
Issues a direct DELETE SQL statement for all entities matching the condition without loading entities into memory. Uses EF Core 7+ ExecuteDeleteAsync. No saveChanges parameter — the delete is applied immediately.
Example
await evaluator.DeleteByConditionAsync(o => o.CreatedAt < DateTime.UtcNow.AddYears(-2));
Note: Does not trigger EF Core change tracking or entity events. Use carefully with soft-delete configurations.
SaveChangesAsync
Signature
Task SaveChangesAsync(CancellationToken cancellationToken = default)
Description
Flushes all pending changes in the DbContext to the database. Use this after batching multiple operations with saveChanges: false.
Example
await evaluator.AddAsync(entity1, saveChanges: false);
await evaluator.UpdateAsync(entity2, saveChanges: false);
await evaluator.SaveChangesAsync();
UpsertAsync
Signature
Task<T> UpsertAsync(
T entity,
Expression<Func<T, bool>> matchCondition,
bool saveChanges = true,
CancellationToken cancellationToken = default)
Description
Inserts the entity if no existing record matches matchCondition; otherwise updates the matched record. Returns the inserted or updated entity.
Example
var product = new Product { Sku = "SKU-001", Name = "Widget", Price = 9.99m };
Product result = await evaluator.UpsertAsync(
product,
matchCondition: p => p.Sku == product.Sku);
UpsertRangeAsync
Signature
Task<IEnumerable<T>> UpsertRangeAsync<TProperty>(
IEnumerable<T> entities,
Expression<Func<T, TProperty>> keySelector,
bool saveChanges = true,
CancellationToken cancellationToken = default
) where TProperty : notnull
Description Inserts or updates a collection of entities using the key selector to match existing records. Returns all inserted or updated entities.
Example
var products = new List<Product>
{
new Product { Sku = "SKU-001", Name = "Widget A", Price = 9.99m },
new Product { Sku = "SKU-002", Name = "Widget B", Price = 14.99m },
};
await evaluator.UpsertRangeAsync(products, keySelector: p => p.Sku);
ExecuteTransactionAsync
Signature
Task ExecuteTransactionAsync(Func<Task> operations, CancellationToken cancellationToken = default)
Description Executes the provided delegate inside a database transaction. If any operation throws, the transaction is rolled back automatically.
Example
await evaluator.ExecuteTransactionAsync(async () =>
{
await orderEvaluator.AddAsync(newOrder, saveChanges: false);
await inventoryEvaluator.UpdateAsync(updatedInventory, saveChanges: false);
await orderEvaluator.SaveChangesAsync();
});
Note: Throws
InvalidOperationExceptionif the transaction or its rollback fails.
ExecuteUpdateAsync
Signature
Task<int> ExecuteUpdateAsync(
Expression<Func<T, bool>> condition,
Expression<Func<SetPropertyCalls<T>, SetPropertyCalls<T>>> setPropertyCalls,
CancellationToken cancellationToken = default)
Description
Issues a direct UPDATE SQL statement for all entities matching condition without loading them into memory. Uses EF Core 7+ ExecuteUpdateAsync. Returns the number of rows affected.
Example
int affected = await evaluator.ExecuteUpdateAsync(
condition: o => o.Status == OrderStatus.Pending && o.CreatedAt < DateTime.UtcNow.AddDays(-30),
setPropertyCalls: s => s
.SetProperty(o => o.Status, OrderStatus.Cancelled)
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow));
Note: Does not trigger EF Core change tracking or entity lifecycle events.
BulkInsertAsync
Signature
Task BulkInsertAsync(IEnumerable<T> entities, BulkConfig? bulkConfig = null, CancellationToken cancellationToken = default)
Description
Inserts a large collection of entities in a single high-performance bulk operation using EFCore.BulkExtensions. Significantly faster than AddRangeAsync + SaveChangesAsync for thousands of records.
BulkConfig options of interest:
BatchSize— controls the number of rows per database round-tripSetOutputIdentity— retrieves auto-generated primary keys after insertIncludeGraph— includes related entities in the insert
Example
var products = Enumerable.Range(1, 10_000)
.Select(i => new Product { Name = $"Product {i}", Price = i * 1.5m })
.ToList();
var config = new BulkConfig { BatchSize = 1000, SetOutputIdentity = true };
await evaluator.BulkInsertAsync(products, config);
BulkUpdateAsync
Signature
Task BulkUpdateAsync(IEnumerable<T> entities, BulkConfig? bulkConfig = null, CancellationToken cancellationToken = default)
Description Updates a large collection of entities in a single bulk operation. Entities must have valid primary keys.
BulkConfig options of interest:
BatchSize— rows per round-tripPropertiesToInclude— limits which columns are updated
Example
var products = await (await evaluator.EvaluateQueryAsync(spec)).ToListAsync();
products.ForEach(p => p.Price *= 1.1m);
var config = new BulkConfig
{
BatchSize = 500,
PropertiesToInclude = new List<string> { nameof(Product.Price) }
};
await evaluator.BulkUpdateAsync(products, config);
BulkDeleteAsync
Signature
Task BulkDeleteAsync(IEnumerable<T> entities, BulkConfig? bulkConfig = null, CancellationToken cancellationToken = default)
Description Deletes a large collection of entities in a single bulk operation. Entities must have valid primary keys.
Example
var obsoleteOrders = await (await evaluator.EvaluateQueryAsync(spec)).ToListAsync();
var config = new BulkConfig { BatchSize = 1000 };
await evaluator.BulkDeleteAsync(obsoleteOrders, config);
BulkInsertOrUpdateAsync
Signature
Task BulkInsertOrUpdateAsync(IEnumerable<T> entities, BulkConfig? bulkConfig = null, CancellationToken cancellationToken = default)
Description
Inserts new entities and updates existing ones in a single bulk operation (bulk upsert). Existence is determined by comparing primary keys or the properties defined in PropertiesToIncludeOnCompare.
BulkConfig options of interest:
BatchSizeSetOutputIdentity— retrieves generated keys for new insertsPropertiesToIncludeOnCompare— defines which columns determine entity existence
Example
var products = new List<Product>
{
new Product { Id = 1, Name = "Existing Product", Price = 15.99m },
new Product { Name = "New Product", Price = 7.99m },
};
var config = new BulkConfig
{
BatchSize = 1000,
SetOutputIdentity = true,
PropertiesToIncludeOnCompare = new List<string> { nameof(Product.Id) }
};
await evaluator.BulkInsertOrUpdateAsync(products, config);