PepperX.QueryForge.InMemory 2.1.0

dotnet add package PepperX.QueryForge.InMemory --version 2.1.0
                    
NuGet\Install-Package PepperX.QueryForge.InMemory -Version 2.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PepperX.QueryForge.InMemory" Version="2.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PepperX.QueryForge.InMemory" Version="2.1.0" />
                    
Directory.Packages.props
<PackageReference Include="PepperX.QueryForge.InMemory" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PepperX.QueryForge.InMemory --version 2.1.0
                    
#r "nuget: PepperX.QueryForge.InMemory, 2.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PepperX.QueryForge.InMemory@2.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PepperX.QueryForge.InMemory&version=2.1.0
                    
Install as a Cake Addin
#tool nuget:?package=PepperX.QueryForge.InMemory&version=2.1.0
                    
Install as a Cake Tool

Part of PepperX Ecosystem

PepperX.QueryForge Logo

PepperX.QueryForge.InMemory

NuGet Version .NET License

Why this exists

Not every list lives in a table. Reference data you cached at startup, results you stitched together from three microservices, rows you just pulled out of a file β€” the moment a client wants to filter, sort, page and group that, you are back to writing the same plumbing by hand.

PepperX.QueryForge.InMemory takes the exact Query your database endpoints already accept and runs it against any IEnumerable<T>. Same input, same QueryResult<T> output, no database and no dependencies. And because it is the simplest possible implementation of QueryForge's semantics, it doubles as the reference the database providers are tested against.

At a glance

  • 🎯 One Query in, one QueryResult<T> out β€” identical to the Dapper and EF Core providers.
  • 🌳 Full hierarchical grouping β€” multi-level key / count / items trees, same as everywhere else.
  • πŸ§ͺ A provider for your tests β€” swap a database out without reshaping the calling code.
  • πŸ”Œ Zero dependencies β€” it needs nothing but the core package.
  • πŸ—ΊοΈ Works on more than POCOs β€” dictionaries, dynamic rows, or models whose property names differ from your API's column names.

Install

dotnet add package PepperX.QueryForge.InMemory

There is nothing to register. No services, no configuration.

using PepperX.QueryForge.InMemory;

The model used in every example below:

public class TestUser
{
    public int UserId { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
    public string Department { get; set; } = string.Empty;
    public decimal Score { get; set; }
    public bool IsActive { get; set; }
}

Example 1 β€” Flat results

A Query has 5 things you can control: Criteria, Paging, SelectColumns, SortColumns and GroupByColumns. This example touches every one except grouping (that's Example 2).

A. Client-driven, with validation

The frontend sends a plain Query. Because the source is a collection you chose in code, there is no table name for a client to spoof in the first place.

POST /api/users/query
{
  "criteria": {
    "logic": 0,                    // Logic.And -> combine the groups below with AND
    "groups": [
      {
        "logic": 0,                // Logic.And -> Country = 'Germany' AND IsActive = true
        "conditions": [
          { "columnName": "Country", "operator": 0, "value": "Germany" },  // operator 0 = Equals
          { "columnName": "IsActive", "operator": 0, "value": true }
        ]
      }
    ]
  },
  "paging": { "size": 5, "number": 1 },
  "selectColumns": ["UserId", "FirstName", "LastName", "Country", "Department", "Score"],
  "sortColumns": [
    { "columnName": "Score", "sortOrder": 1 }   // sortOrder 1 = Descending
  ]
}

Logic: 0=And, 1=Or, 2=AndNot, 3=OrNot  |  ConditionOperator 0=Equals  |  SortOrder: 0=Ascending, 1=Descending

app.MapPost("/api/users/query", (Query clientQuery, IUserCache cache) =>
{
    clientQuery.Validate(rules =>
    {
        rules.Select(c => c.Deny("Email"));   // never leak this column, even if asked for
        rules.PageSize(p => p.Max(50));       // no data-dump attacks
    }, QueryValidationMode.SilentStrip);

    return cache.Users.ToQueryResult(clientQuery);
});

B. Fully backend-built, no client input at all

app.MapGet("/api/users/top-active", (IUserCache cache) =>
{
    var query = QueryBuilder
        .Where(new QueryCriteria(
            logic: Logic.And,
            groups: [ new ConditionGroup([ new Condition("IsActive", ConditionOperator.Equals, true) ]) ]))
        .Select("UserId", "FirstName", "LastName", "Country", "Score")
        .Sort(new SortDescriptor("Score", SortOrder.Descending))
        .Page(size: 10, number: 1)
        .Build();

    return cache.Users.ToQueryResult(query);
});

The result β€” always the same shape

{
  "meta": { "total": { "rows": 4, "pages": 1 }, "type": "Flat" },
  "models": [
    { "userId": 16, "firstName": "First16", "lastName": "Last16", "country": "Germany", "department": "IT", "score": 66.00 },
    { "userId": 11, "firstName": "First11", "lastName": "Last11", "country": "Germany", "department": "Marketing", "score": 61.00 },
    { "userId": 6,  "firstName": "First6",  "lastName": "Last6",  "country": "Germany", "department": "Sales", "score": 56.00 },
    { "userId": 1,  "firstName": "First1",  "lastName": "Last1",  "country": "Germany", "department": "HR", "score": 51.00 }
  ]
}

Validation: two modes, your choice

Validate() always takes a QueryValidationMode:

Mode Behavior Best for
SilentStrip Quietly removes denied/disallowed columns and clamps paging to your limits. The request still succeeds. Public APIs β€” never break the client over a permissions mismatch.
ThrowException Throws a QueryValidationException listing every violated rule. Internal APIs where an invalid request should fail loudly.
try
{
    clientQuery.Validate(rules => rules.Select(c => c.Deny("Email")), QueryValidationMode.ThrowException);
    return Results.Ok(cache.Users.ToQueryResult(clientQuery));
}
catch (QueryValidationException ex)
{
    // ex.InvalidProperties -> ["Email"]
    return Results.ValidationProblem(ex.InvalidProperties.ToDictionary(x => x, x => new[] { "Denied by security policy" }));
}

Example 2 β€” Grouped results

Add GroupByColumns and the flat list turns into a nested tree, with row counts at every level.

POST /api/users/grouped-query
{
  "criteria": {
    "groups": [
      { "conditions": [ { "columnName": "IsActive", "operator": 0, "value": true } ] }
    ]
  },
  "paging": { "size": 5, "number": 1 },
  "selectColumns": ["UserId", "FirstName", "LastName", "Score"],
  "sortColumns": [ { "columnName": "Score", "sortOrder": 1 } ],
  "groupByColumns": [
    { "columnName": "Country", "sortOrder": 0 },
    { "columnName": "Department", "sortOrder": 0 }
  ]
}
app.MapPost("/api/users/grouped-query", (Query clientQuery, IUserCache cache) =>
{
    clientQuery.Validate(rules =>
    {
        rules.GroupBy(c => c.Allow("Country", "Department"));  // only these two levels are groupable
        rules.PageSize(p => p.Max(20));                         // caps top-level groups per page
    }, QueryValidationMode.SilentStrip);

    return cache.Users.ToQueryResult(clientQuery);
});

The result β€” a real hierarchy

{
  "meta": { "total": { "rows": 4, "pages": 1 }, "type": "Grouped" },
  "groups": [
    {
      "key": "Canada",
      "count": 9,
      "subGroups": [
        { "key": "HR", "count": 5, "items": [ { "userId": 9, "firstName": "First9", "lastName": "Last9", "score": 59.00 } ] },
        { "key": "IT", "count": 4, "items": [ { "userId": 4, "firstName": "First4", "lastName": "Last4", "score": 54.00 } ] }
      ]
    },
    {
      "key": "Germany",
      "count": 12,
      "subGroups": [
        { "key": "IT",        "count": 4, "items": [ { "userId": 16, "firstName": "First16", "lastName": "Last16", "score": 66.00 } ] },
        { "key": "Marketing", "count": 3, "items": [ { "userId": 11, "firstName": "First11", "lastName": "Last11", "score": 61.00 } ] }
      ]
    }
  ]
}

Paging applies to the outermost group, not to rows. size: 5 means five countries, each carrying all of its rows β€” which is what makes every count a true total rather than a count of what fit on the page.


Composable pieces

ToQueryResult is the whole thing. When you want the parts, each stage is its own extension and the sequence stays lazy:

var page = users
    .ApplyFilter(query)        // Criteria
    .ApplySort(query)          // SortColumns
    .ApplyPaging(query)        // Paging
    .ApplyProjection(query);   // SelectColumns

// or all four at once, still lazy
var composed = users.ApplyQuery(query);

Grouping is deliberately not one of these β€” a hierarchy is a shape, not a sequence. Use ToQueryResult for grouped queries.

There is also an async wrapper, so an in-memory source can stand in for a database provider without reshaping the calling code:

var result = await users.ToQueryResultAsync(query);

The work is synchronous; the method exists purely so the call site does not have to change.

Sources that aren't POCOs

By default, columns are read as properties by name, case-insensitively. Pass your own accessor for anything else.

Dictionary rows β€” rows from a CSV, a document store, or a dynamic API:

List<Dictionary<string, object?>> rows = LoadCsv();

var result = rows.ToQueryResult(query, InMemoryAccessors.ForDictionary<Dictionary<string, object?>>());

Renamed columns β€” when the names your API exposes are a contract, and shouldn't have to track how the model happens to be written:

var accessor = InMemoryAccessors.WithColumnMap<TestUser>(new Dictionary<string, string>
{
    ["name"] = nameof(TestUser.FirstName),
    ["dept"] = nameof(TestUser.Department)
});

var result = users.ToQueryResult(query, accessor);

Anything else β€” the accessor is just a function:

var result = rows.ToQueryResult(query, (row, column) => column switch
{
    "Total" => row.Price * row.Quantity,   // a computed column
    _       => row[column]
});

With the default accessor, the model's properties act as a whitelist: a condition naming a column that does not exist is dropped, exactly as the SQL providers drop names missing from the table. When you supply your own accessor you own that decision, so every column name is taken at face value β€” validate explicitly if the names come from a client.

Using it as a test double

This is the provider's other job. Because it satisfies the same contract as the database providers, a test can swap the storage out and keep everything else:

public interface IUserQueries
{
    Task<QueryResult<TestUser>> QueryAsync(Query query);
}

// production
public sealed class SqlUserQueries(IDapperQueryService svc) : IUserQueries
{
    public Task<QueryResult<TestUser>> QueryAsync(Query query) =>
        svc.QueryAsync<TestUser>(DapperQueryBuilder.FromBase(query).ForObject("TestUsers").Build());
}

// tests
public sealed class FakeUserQueries(IEnumerable<TestUser> users) : IUserQueries
{
    public Task<QueryResult<TestUser>> QueryAsync(Query query) => users.ToQueryResultAsync(query);
}

Your assertions about filtering, paging and grouping then hold for the real thing too β€” a shared conformance suite in this repository runs the same 90 tests against all three providers on every build.

Behaviour notes

Unfilled filters are ignored, not treated as "match nothing". A GreaterThan with no value is a filter the user did not fill in. Equals and NotEquals are the exceptions: a null value there is a deliberate IS NULL / IS NOT NULL test.

Values are coerced before comparison. A "30" arriving as JSON against an int column compares as the number 30, not as text β€” so 9 does not sort above 30.

Null follows SQL's three-valued logic. A comparison against null is unknown, not false, and stays unknown under negation. So AndNot on Country = 'Germany' excludes rows whose country is null, matching what the database providers return.

Text matching is case-insensitive. There is no collation to follow here, so ordinal-ignore-case is used. On the database providers this follows the store's collation instead β€” case-insensitive on SQL Server by default, case-sensitive on PostgreSQL.

Everything is materialized. Filtering and sorting walk the sequence; this is the right tool for thousands of rows, not millions. For a large table, use the Dapper or EF Core provider and let the database do the work.

Where things stand

Execution providers

Provider Package Status
Dapper PepperX.QueryForge.Dapper βœ… Released
Entity Framework Core PepperX.QueryForge.EFCore βœ… Released
In-Memory PepperX.QueryForge.InMemory βœ… Released

The same Query produces the same QueryResult<T> on all three.

🀝 Contributing & License

This project is part of the PepperX Ecosystem.

Licensed under the MIT License β€” see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.0 98 8/3/2026
2.0.0 102 8/1/2026