Skip to content

In Memory Provider

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

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.


Install

dotnet add package PepperX.QueryForge.InMemory
using PepperX.QueryForge;
using PepperX.QueryForge.InMemory;

No registration. These are extension methods.

The API

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.


Semantics

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, with bool? where null means 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. QueryValueComparer reconciles values whose CLR types differ, so 1, 1L and "1" compare equal, and "30" > 9 is numeric rather than lexical.
  • Case-insensitive text. Equals on strings and all four text operators use OrdinalIgnoreCase, 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.


Sources that are not plain POCOs

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) => value

Two ready-made ones ship in InMemoryAccessors:

Dictionary rows

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?>.

Renaming columns

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.

Your own

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 null for 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.


Composable stages

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 rows

Uses

Cached reference data

public 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.

Composed API results

var merged = (await Task.WhenAll(
    serviceA.GetOrdersAsync(),
    serviceB.GetOrdersAsync()))
    .SelectMany(x => x);

return merged.ToQueryResult(query);

A test double for a database provider

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.

Post-processing a stored-procedure result

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

Performance

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.Total requires a count. Do not point this at an unbounded or expensive IEnumerable — materialize it first.
  • Property reads are reflective, not compiled expressions. For very large collections in a hot path, a hand-written valueAccessor using a switch avoids reflection entirely and is significantly faster.
  • Projection uses reflection to copy properties. If you do not need it, leave SelectColumns empty 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.

Relationship to the core package

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.

Clone this wiki locally