-
Notifications
You must be signed in to change notification settings - Fork 0
In Memory Provider
PepperX.QueryForge.InMemory runs the same Query against any IEnumerable<T> — no database, no
registration, no dependencies beyond the core package.
It is useful in its own right for cached reference data and results composed from several services, and it is the reference implementation the database providers are asserted against in the cross-provider parity suite.
dotnet add package PepperX.QueryForge.InMemoryusing PepperX.QueryForge;
using PepperX.QueryForge.InMemory;No registration. These are extension methods.
namespace PepperX.QueryForge.InMemory;
public static class InMemoryQueryExtensions
{
public static QueryResult<TModel> ToQueryResult<TModel>(
this IEnumerable<TModel> source, Query query,
Func<TModel, string, object?>? valueAccessor = null);
public static Task<QueryResult<TModel>> ToQueryResultAsync<TModel>(
this IEnumerable<TModel> source, Query query,
Func<TModel, string, object?>? valueAccessor = null,
CancellationToken cancellationToken = default);
// Lazy stages, for composing.
public static IEnumerable<TModel> ApplyFilter<TModel>(this IEnumerable<TModel> source, Query query,
Func<TModel, string, object?>? valueAccessor = null);
public static IEnumerable<TModel> ApplySort<TModel>(this IEnumerable<TModel> source, Query query,
Func<TModel, string, object?>? valueAccessor = null);
public static IEnumerable<TModel> ApplyPaging<TModel>(this IEnumerable<TModel> source, Query query);
public static IEnumerable<TModel> ApplyProjection<TModel>(this IEnumerable<TModel> source, Query query);
public static IEnumerable<TModel> ApplyQuery<TModel>(this IEnumerable<TModel> source, Query query,
Func<TModel, string, object?>? valueAccessor = null);
}var result = cachedUsers.ToQueryResult(query);ToQueryResultAsync does the same work synchronously and wraps it in a completed task. It exists so
an in-memory source can stand in for a database provider without reshaping the calling code — which
is what makes it useful as a test double. It checks the cancellation token before starting.
ApplyQuery is filter → sort → page → project, left lazy. Grouping is not applied, because a
hierarchy is a shape rather than a sequence; use ToQueryResult for grouped queries.
The engine lives in the core package (PepperX.QueryForge.Querying.InMemoryQueryEngine) and
implements Query Semantics exactly — including the parts that are usually only
visible in a database:
-
Three-valued logic. A comparison against null is unknown, and unknown survives negation. A row
is returned only when the result is definitely
true. This is implemented literally, withbool?wherenullmeans unknown, so a negated group behaves the same over objects as it does in SQL. - Null ordering. Nulls sort first ascending, last descending.
-
Loose type comparison.
QueryValueComparerreconciles values whose CLR types differ, so1,1Land"1"compare equal, and"30" > 9is numeric rather than lexical. -
Case-insensitive text.
Equalson strings and all four text operators useOrdinalIgnoreCase, matching the default SQL Server collation. - Unknown columns are dropped, not treated as null — a condition on a property the model does not have is ignored rather than matching nothing.
The last two are the ones to keep in mind when comparing against a database, because a database follows its column's collation rather than QueryForge's default. See Cross-Provider Parity.
By default, a column name is read as a property name, case-insensitively, with the reflection
metadata cached per type. Supply a valueAccessor for anything else.
Func<TRow, string, object?> accessor; // (row, columnName) => valueTwo ready-made ones ship in InMemoryAccessors:
var rows = new List<Dictionary<string, object?>>
{
new() { ["Id"] = 1, ["Name"] = "Ada", ["Country"] = "UK" },
new() { ["Id"] = 2, ["Name"] = "Grace", ["Country"] = "US" }
};
var result = rows.ToQueryResult(query, InMemoryAccessors.ForDictionary<Dictionary<string, object?>>());Matches keys case-insensitively and normalizes DBNull to null. Works with any
IReadOnlyDictionary<string, object?>.
When the names your clients send are part of your API contract and should not track how the model happens to be written:
var accessor = InMemoryAccessors.WithColumnMap<User>(new Dictionary<string, string>
{
["name"] = nameof(User.FirstName),
["country"] = nameof(User.Country)
});
var result = users.ToQueryResult(query, accessor);Names absent from the map are read as-is.
var result = rows.ToQueryResult(query, (row, column) => column switch
{
"Name" => row.Profile.DisplayName,
"Country" => row.Address?.CountryCode,
_ => null
});Supplying an accessor switches off existence checking. QueryForge cannot know which names your accessor can resolve, so every column name is taken at face value and no condition is dropped for being unknown. A condition on a name your accessor returns
nullfor behaves as "the column holds null", which is not the same as "there is no such column". If your source is client-facing, add an allow-list with validation.
Each stage is available separately and stays lazy, so you can interleave your own logic:
var page = users
.ApplyFilter(query)
.Where(u => u.IsVisibleTo(currentUser)) // your own rule, after QueryForge's
.ApplySort(query)
.ApplyPaging(query)
.ApplyProjection(query)
.ToList();Note that inserting a filter after ApplyFilter but before counting means you no longer get an
accurate Meta.Total from ToQueryResult. Apply your own restriction first if the totals matter:
var result = users
.Where(u => u.IsVisibleTo(currentUser))
.ToQueryResult(query); // Meta.Total now counts only visible rowspublic sealed class CountryService(IMemoryCache cache)
{
public QueryResult<Country> Query(Query query)
=> cache.Get<IReadOnlyList<Country>>("countries")!.ToQueryResult(query);
}The same filtering, sorting, paging and grouping your database endpoints offer, with no round trip.
var merged = (await Task.WhenAll(
serviceA.GetOrdersAsync(),
serviceB.GetOrdersAsync()))
.SelectMany(x => x);
return merged.ToQueryResult(query);public interface IUserQueries
{
Task<QueryResult<User>> QueryAsync(Query query);
}
public sealed class FakeUserQueries(IEnumerable<User> users) : IUserQueries
{
public Task<QueryResult<User>> QueryAsync(Query query) => users.ToQueryResultAsync(query);
}Because the semantics match, a test written against the fake exercises the same filtering rules the database will apply — including null handling and negation, which is where hand-rolled fakes usually diverge.
This is exactly what the Dapper provider does internally for DapperObjectType.SP: materialize the
procedure's rows, then apply the query in memory. If you are calling a procedure with Dapper yourself,
the same trick works:
var rows = await connection.QueryAsync<User>("usp_GetUserReport", new { IncludeDeleted = false },
commandType: CommandType.StoredProcedure);
return rows.ToQueryResult(query);| Operation | Cost |
|---|---|
| Filtering | one pass, O(n) per condition |
| Sorting |
O(n log n), materializes the sequence |
| Paging |
Skip/Take over the sorted list |
| Projection | one object allocation per returned row, via reflection |
| Grouping | one dictionary pass per level |
| Property access | reflection, with the property map cached per type |
Notes:
-
The sequence is enumerated fully before paging, because
Meta.Totalrequires a count. Do not point this at an unbounded or expensiveIEnumerable— materialize it first. -
Property reads are reflective, not compiled expressions. For very large collections in a hot
path, a hand-written
valueAccessorusing aswitchavoids reflection entirely and is significantly faster. -
Projection uses reflection to copy properties. If you do not need it, leave
SelectColumnsempty and the whole stage is skipped. - Grouping is a single pass with no round trips, and is by far the fastest grouped path of the three providers for data you already hold.
The engine — InMemoryQueryEngine — lives in PepperX.QueryForge, not in this package, because the
Dapper provider needs it for the stored-procedure path. This package is a thin, discoverable extension
surface over it, plus the accessors.
If you have only the core package you can still call it directly:
using PepperX.QueryForge.Querying;
var result = InMemoryQueryEngine.Apply(users, query);ToQueryResult(query) is the same call with a nicer name.
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