-
Notifications
You must be signed in to change notification settings - Fork 0
Home
A provider-agnostic dynamic query engine for .NET.
Describe a query once — filters, sorting, paging, grouping — and execute it against SQL Server,
PostgreSQL, MySQL/MariaDB, Oracle, SQLite, anything EF Core can reach, or a plain in-memory list.
QueryForge separates what you want from how it is fetched.
A Query is a plain, serializable description of intent: which rows, in which order, which page,
grouped how, projecting which columns. It contains no SQL, no LINQ, and no reference to a database.
An execution provider takes that intent and runs it — compiling parameterized SQL, building an
EF Core expression tree, or evaluating it over an IEnumerable<T>.
The value of the split is that the same request works everywhere and answers identically. A filter payload posted by a data grid, a report built in C#, and a unit test over a hard-coded list all use one model and one result contract.
// The same Query object, three execution providers, one result shape.
QueryResult<User> a = await dapperService.QueryAsync<User>(dapperQuery); // parameterized SQL
QueryResult<User> b = await db.Users.ToQueryResultAsync<User>(query); // EF Core
QueryResult<User> c = cachedUsers.ToQueryResult(query); // in-memory| Package | Role | Depends on |
|---|---|---|
| PepperX.QueryForge | The query model, fluent builder, validation engine, and the shared execution semantics every provider obeys. No dependencies, runs no SQL. | — |
| PepperX.QueryForge.Dapper | Compiles a query into parameterized SQL and executes it with Dapper. Five engines from one codebase. Nothing is deployed to your database. | Dapper |
| PepperX.QueryForge.EFCore | Translates a query into expression trees so EF Core generates the SQL, honouring your model, global filters and value converters. | EF Core 10 |
| PepperX.QueryForge.InMemory | Runs a query over any IEnumerable<T>. Cached data, composed API results, test doubles. |
— |
All four target .NET 10, are versioned 2.0.0, and are MIT licensed.
| If you want to… | Read |
|---|---|
| Install a package and run your first query | Getting Started |
| Understand how the pieces fit together | Architecture |
| Know exactly what every field of a query means | Query Model |
| Know exactly how a query is evaluated — the specification | Query Semantics |
| Accept a query from a browser or mobile client | JSON Contract |
| Stop a client reaching a column it should not | Validation and Security |
Build nested key / count / items trees |
Grouping and Hierarchies |
| See the SQL that actually runs | Dapper: Generated SQL |
| Join tables, eager-load, or use split queries | EF Core: Joins and Includes |
| Add a database engine QueryForge does not ship | Extending QueryForge |
| Look up a type or method signature | API Reference |
| Understand an exception you just hit | Error Reference |
| Upgrade from QueryForge 1.x | Migration: 1.x to 2.0 |
Foundations Getting Started · Architecture · Query Model · Query Semantics · Results and Metadata · JSON Contract · Fluent Builders
Behaviour Grouping and Hierarchies · Validation · Security · Cross-Provider Parity
Providers Dapper Provider · Dapper: Generated SQL · Dapper: Dialects · EF Core Provider · EF Core: Joins and Includes · In-Memory Provider
Practice Recipes · Sample Application · Testing · Extending QueryForge
Reference API Reference · Error Reference · Migration: 1.x to 2.0 · Release Process · FAQ
// 1. Describe the intent. This object is serializable and provider-free.
var query = QueryBuilder.New()
.Where(new QueryCriteria(
logic: Logic.And,
groups:
[
new ConditionGroup(
logic: Logic.Or,
conditions:
[
new Condition("Country", ConditionOperator.Equals, "Germany"),
new Condition("Country", ConditionOperator.Equals, "Canada")
]),
new ConditionGroup([ new Condition("Score", ConditionOperator.GreaterThan, 50) ])
]))
.Select("UserId", "FirstName", "Country", "Score")
.Sort(new SortDescriptor("Score", SortOrder.Descending))
.Page(size: 20, number: 1)
.Build();
// 2. Constrain what a caller is allowed to ask for.
query.Validate(rules =>
{
rules.Select(c => c.Deny("PasswordHash"));
rules.PageSize(p => p.Max(100));
}, QueryValidationMode.SilentStrip);
// 3. Execute it. Pick one.
var viaEfCore = await db.Users.AsNoTracking().ToQueryResultAsync<User>(query);
var viaMemory = cachedUsers.ToQueryResult(query);
var viaDapper = await svc.QueryAsync<User>(
DapperQueryBuilder.FromBase(query).ForObject("Users", "dbo").Build());
// 4. Same contract from all three.
Console.WriteLine(viaDapper.Meta.Total.Rows); // matching rows, before paging
Console.WriteLine(viaDapper.Models.Count); // rows on this pageThese are the promises the test suites are written to enforce. They are described in full under Cross-Provider Parity.
-
One
Queryin, oneQueryResult<T>out — the same request returns the same answer from every provider, including group counts, null placement, and page boundaries. -
Values are parameters, never inlined text. Comparisons use the column's real type, so
Age > 9is numeric rather than lexical, and databases can cache a plan. - Column names are checked against what the target actually exposes. Anything unrecognised is dropped rather than escaped and emitted, so a filter payload cannot be used to probe your schema.
- Nothing is deployed. No stored procedures, no DDL permissions, no startup migration step.
- An unusable filter is dropped, not turned into a match-nothing clause. A filter the caller never filled in must not silently empty the result.
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