-
Notifications
You must be signed in to change notification settings - Fork 0
EF Core Provider
PepperX.QueryForge.EFCore translates a Query into expression trees and hands them to EF Core, so
EF Core generates the SQL. There is no SQL in this package at all.
That means the result respects your entity configuration, global query filters, value converters and owned types — and works on every database EF Core has a provider for, not just the five the Dapper provider ships dialects for.
dotnet add package PepperX.QueryForge.EFCoreReferences Microsoft.EntityFrameworkCore 10.0.0. There is no registration step — these are
extension methods on IQueryable<T>.
using PepperX.QueryForge;
using PepperX.QueryForge.EFCore;namespace PepperX.QueryForge.EFCore;
public static class QueryForgeQueryableExtensions
{
// Execute and return the standard result — flat or grouped.
public static Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IQueryable<TModel> source, Query query, CancellationToken cancellationToken = default);
// Compose without executing.
public static IQueryable<TModel> ApplyQuery<TModel>(this IQueryable<TModel> source, Query query);
// The individual stages.
public static IQueryable<TModel> ApplyFilter<TModel>(this IQueryable<TModel> source, Query query);
public static IQueryable<TModel> ApplySort<TModel>(this IQueryable<TModel> source, Query query);
public static IQueryable<TModel> ApplyPaging<TModel>(this IQueryable<TModel> source, Query query);
public static IQueryable<TModel> ApplyProjection<TModel>(
this IQueryable<TModel> source, Query query, IReadOnlyList<string>? alsoKeep = null);
}ApplyQuery is ApplyFilter → ApplySort → ApplyPaging → ApplyProjection. It does not apply
grouping, because a hierarchy is a shape rather than a queryable — use ToQueryResultAsync for
grouped queries.
app.MapPost("/api/users/query", async (Query query, AppDbContext db) =>
await db.Users.AsNoTracking().ToQueryResultAsync<User>(query));The extensions take an IQueryable<T>, so anything you apply first survives. This is the pattern for
any server-side restriction a client must not be able to remove:
await db.Users
.Where(u => u.TenantId == currentTenant) // the client cannot query this away
.Where(u => !u.IsDeleted)
.Include(u => u.Profile)
.AsNoTracking()
.ToQueryResultAsync<User>(query);Because EF Core composes it all into one IQueryable, the restriction becomes part of the same
WHERE clause rather than a second pass. Global query filters configured on the model apply too, with
no code here at all.
The same composition covers Join, Include/ThenInclude and AsSplitQuery — with three
interactions worth knowing before you rely on them. See
EF Core: Joins and Includes.
var sql = db.Users.ApplyQuery(query).ToQueryString();Useful for confirming values became parameters rather than inlined literals. The sample application exposes this as an endpoint — see Sample Application.
ExpressionCompiler (in PepperX.QueryForge.EFCore.Translation) is public, so you can use it
directly if you need the raw expressions.
public static class ExpressionCompiler
{
public static Expression<Func<TModel, bool>>? BuildPredicate<TModel>(QueryCriteria criteria);
public static LambdaExpression? BuildSelector<TModel>(string columnName);
public static Expression<Func<TModel, TModel>>? BuildProjection<TModel>(
IReadOnlyList<string> selectColumns, IReadOnlyList<string>? alsoKeep = null);
public static LambdaExpression? BuildNullRank<TModel>(PropertyInfo property, bool nullsFirst);
public static Expression<Func<TKey, int>>? BuildKeyNullRank<TKey>(bool nullsFirst);
public static PropertyInfo? ResolveProperty<TModel>(string? columnName);
}Each returns null rather than throwing when nothing usable remains, which is what lets the caller
skip a stage instead of failing a whole query.
ResolveProperty looks up readable instance properties, case-insensitively, cached per type. A
condition, sort, or grouping naming something that is not a property of the entity is dropped.
That gives the same protection against schema probing the Dapper provider gets from inspecting a result set — without a round trip. See Security.
A value injected as Expression.Constant would be inlined into the SQL as a literal, defeating plan
caching. So each value is wrapped in a tiny generic box and read through a property access:
private sealed class ValueBox<T>(T value) { public T Value { get; } = value; }EF Core sees a captured variable and emits a SQL parameter. Confirm it with ToQueryString().
This is the subtlest part of the provider, and it exists to preserve SQL's three-valued logic.
Compiling AndNot / OrNot as Expression.Not(group) would be wrong. EF Core applies null
compensation: it rewrites col != value into col <> value OR col IS NULL so that C# semantics are
preserved. Under a NOT, that lets rows with a null column back into the result — which SQL would
have excluded, because a comparison against NULL is unknown and unknown survives negation.
So a negated group is compiled by:
- inverting each condition's operator (
Equals↔NotEquals,LessThan↔GreaterThanOrEqualTo,Contains↔NotContains, and so on); - flipping the group's connector (AND becomes OR, OR becomes AND — De Morgan);
- adding an explicit
col IS NOT NULLguard to each fragment on a nullable property.
Between, StartsWith and EndsWith have no inverse in the operator set, so those are negated
directly with Expression.Not and then guarded.
The result matches what the Dapper provider's NOT (…) produces on a real database, which the
cross-provider parity suite asserts.
EF Core hands ORDER BY straight to the database, so it inherited the engines' disagreement about
null placement. ApplySort fixes it by ordering on an explicit rank first, for nullable properties
only:
// conceptually, for an ascending sort on a nullable column
.OrderBy(e => e.Column == null ? 0 : 1)
.ThenBy(e => e.Column)Non-nullable properties get no extra term, so nothing is paid for columns that cannot be null.
The standard is the same everywhere: nulls first ascending, nulls last descending. See Query Semantics.
ApplyProjection builds a Select with a MemberInit, so unselected columns are never fetched:
.Select(e => new User { UserId = e.UserId, FirstName = e.FirstName })Consequences worth knowing:
- Results are untracked. EF Core does not track instances constructed inside a projection. This is the behaviour you want — a partially populated entity must never be written back by the change tracker.
- A model without a parameterless constructor is left unprojected rather than failing the query.
- A selection naming nothing real is ignored, so everything comes back rather than every row being blank.
- Grouping columns always survive, because the hierarchy is rebuilt from them.
Three round trips, following the shared algorithm in Grouping and Hierarchies:
// 1. count the distinct outermost keys
var totalGroups = await filtered.Select(keySelector).Distinct().CountAsync();
// 2. page them, with null placement pinned
var pagedKeys = await filtered.Select(keySelector).Distinct()
.OrderBy(nullRank).ThenBy(k => k)
.Skip((number - 1) * size).Take(size)
.ToListAsync();
// 3. every row under those keys — translated to SQL IN
var rows = await filtered
.Where(e => pagedKeys.Contains(e.Key))
.ApplySort(query)
.ApplyProjection(query, groupingColumns)
.ToListAsync();
// 4. build the tree with the shared HierarchyBuilderThe key type is only known at run time, so the typed implementation is reached through one reflective
call. That is a single MakeGenericMethod per grouped query, not per row.
pagedKeys.Contains(...) translates to a SQL IN list bounded by page size.
| Behaviour | |
|---|---|
| Text operators | Apply to string properties only. On any other type the condition is dropped, because the alternative — a client-side ToString() — is not translatable. |
| Case sensitivity | Decided by the database's collation, not by QueryForge. Contains becomes LIKE '%x%' and follows the column's collation. |
| Unconvertible values | A value that cannot be converted to the property's CLR type causes the condition to be dropped — an expression tree cannot express "compare an int to 'abc'". The Dapper provider binds it unchanged instead, so it matches nothing. Same visible outcome, different mechanism. |
| Tracking | Unprojected queries follow the context's default. Use AsNoTracking() for read endpoints; projected queries are untracked regardless. |
| Owned types and value converters | Applied by EF Core as normal — QueryForge only names top-level properties. |
| Navigation properties | Filterable, but never sortable or groupable — see EF Core: Joins and Includes. Dotted paths such as "Profile.City" do not resolve at all. Project to a flat DTO to query joined data. |
Include with SelectColumns |
The projection is skipped, because EF Core would otherwise drop the Include and return empty navigations. See EF Core: Joins and Includes. |
Meta.Total |
A separate CountAsync against the filtered query, before paging. |
app.MapPost("/api/users/query", async (Query query, AppDbContext db, ClaimsPrincipal user) =>
{
query.Validate(rules =>
{
rules.Select(c => c.Allow("UserId", "FirstName", "LastName", "Country", "Score"));
rules.Where(c => c.Allow("Country", "Department", "Score", "IsActive"));
rules.Sort(c => c.Allow("LastName", "Score"));
rules.GroupBy(c => c.Allow("Country", "Department"));
rules.PageSize(p => p.Max(100));
}, QueryValidationMode.SilentStrip);
return await db.Users
.Where(u => u.TenantId == user.TenantId())
.AsNoTracking()
.ToQueryResultAsync<User>(query);
})
.Accepts<Query>("application/json")
.Produces<QueryResult<User>>();| Prefer EF Core when | Prefer Dapper when |
|---|---|
You already have a DbContext and a model |
You have no model, or query views and functions directly |
| You need global query filters, value converters or owned types | You want no ORM in the request path |
| Your database has no QueryForge dialect but does have an EF Core provider | You want to see and control the exact SQL |
You want to compose with Include, joins, or an existing IQueryable
|
You need to query a stored procedure or table-valued function |
| The target is an entity | The target is chosen at run time from several tables |
Both are supported first-class and can be used in the same application. The sample does exactly that.
QueryForge · part of the PepperX Ecosystem · MIT licensed · packages 2.0.0, .NET 10
Foundations
- Getting Started
- Architecture
- Query Model
- Query Semantics
- Results and Metadata
- JSON Contract
- Fluent Builders
Behaviour
Providers
- Dapper Provider
- Dapper: Generated SQL
- Dapper: Dialects
- EF Core Provider
- EF Core: Joins & Includes
- In-Memory Provider
Practice
Reference