Skip to content

EF Core Provider

AmirHosseinMp02 edited this page Aug 2, 2026 · 2 revisions

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.


Install

dotnet add package PepperX.QueryForge.EFCore

References Microsoft.EntityFrameworkCore 10.0.0. There is no registration step — these are extension methods on IQueryable<T>.

using PepperX.QueryForge;
using PepperX.QueryForge.EFCore;

The API

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 ApplyFilterApplySortApplyPagingApplyProjection. It does not apply grouping, because a hierarchy is a shape rather than a queryable — use ToQueryResultAsync for grouped queries.

Basic use

app.MapPost("/api/users/query", async (Query query, AppDbContext db) =>
    await db.Users.AsNoTracking().ToQueryResultAsync<User>(query));

Composing with what you already have

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.

Seeing the SQL

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.


How translation works

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.

The whitelist is the entity's own properties

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.

Values become parameters, not constants

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().

Negation is pushed down, not wrapped

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:

  1. inverting each condition's operator (EqualsNotEquals, LessThanGreaterThanOrEqualTo, ContainsNotContains, and so on);
  2. flipping the group's connector (AND becomes OR, OR becomes AND — De Morgan);
  3. adding an explicit col IS NOT NULL guard 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.

Null ordering is pinned with a rank key

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.

Projection narrows the SELECT list

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.

Grouped queries

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 HierarchyBuilder

The 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 notes

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.

Full example

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>>();

When to prefer this over the Dapper provider

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.

Clone this wiki locally