PepperX.QueryForge.Dapper
2.1.0
dotnet add package PepperX.QueryForge.Dapper --version 2.1.0
NuGet\Install-Package PepperX.QueryForge.Dapper -Version 2.1.0
<PackageReference Include="PepperX.QueryForge.Dapper" Version="2.1.0" />
<PackageVersion Include="PepperX.QueryForge.Dapper" Version="2.1.0" />
<PackageReference Include="PepperX.QueryForge.Dapper" />
paket add PepperX.QueryForge.Dapper --version 2.1.0
#r "nuget: PepperX.QueryForge.Dapper, 2.1.0"
#:package PepperX.QueryForge.Dapper@2.1.0
#addin nuget:?package=PepperX.QueryForge.Dapper&version=2.1.0
#tool nuget:?package=PepperX.QueryForge.Dapper&version=2.1.0
![]()
PepperX.QueryForge.Dapper
Why this exists
Tired of writing the same "list" endpoint for the hundredth time? A raw SELECT * dumped straight to the client, with no real filtering, no pagination, no security — just an unstructured blob, and every bit of that missing logic pushed onto the frontend to deal with?
PepperX.QueryForge.Dapper turns that into one call. Declare what data you want — filters, sorting, paging, grouping — and, optionally, the rules that keep it safe. Point it at a table, a view, a stored procedure, or a function, custom parameters and all. You always get the data back through the same standardized, predictable contract — fast, and secure by default.
It compiles your query into parameterized SQL at call time and runs it with Dapper. Nothing is deployed to your database, so it needs no schema permissions — and the same code works on SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and SQLite.
At a glance
- 🎯 One
Queryin, oneQueryResult<T>out — for tables, views, stored procedures, and table-valued functions alike. - 🌳 Native hierarchical grouping — multi-level
key / count / itemstrees, generated by the query engine itself. - 🛡️ Built-in validation — silently strip or hard-block denied columns, tables, schemas, and oversized page sizes before they ever touch SQL.
- 🔌 Two ways to build a query — accept it as JSON from a client, or build it entirely in C# with a fluent API. Same models either way.
- 🗄️ Five database engines — SQL Server, PostgreSQL, MySQL/MariaDB, Oracle and SQLite, from one codebase. The engine is inferred from the connection you pass.
- ⚡ Nothing to deploy — no stored procedures, no DDL permissions, no startup migration. SQL is built per call and every value is a parameter.
Install
dotnet add package PepperX.QueryForge.Dapper
Register it
builder.Services.AddQueryForgeDapper(options =>
{
options.ConnectionFactory = sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return new SqlConnection(config.GetConnectionString("DefaultConnection"));
};
});
ConnectionFactory is only needed for the overloads that manage their own connection — if every call
hands in its own IDbConnection, AddQueryForgeDapper() with no arguments is enough.
All five dialects are registered for you. The engine is chosen from the connection type at call time, so an application talking to more than one database needs no extra configuration:
await svc.QueryAsync<User>(new NpgsqlConnection(pgConnectionString), query); // PostgreSQL syntax
await svc.QueryAsync<User>(new SqlConnection(mssqlConnectionString), query); // SQL Server syntax
Two ways to execute
Inject IDapperQueryService (shown throughout this README) — or, if you'd rather not add a service dependency, call the IDbConnection extension method directly:
app.MapPost("/api/users/query-raw", async (Query clientQuery, IDbConnection connection) =>
{
var dapperQuery = DapperQueryBuilder
.FromBase(clientQuery)
.ForObject("TestUsers", "dbo", DapperObjectType.Table)
.Build();
return await connection.QueryForgeAsync<TestUser>(dapperQuery); // same QueryResult<T>, no service needed
});
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 DapperQuery has 6 things you can control: Criteria, Paging, SelectColumns, SortColumns, GroupByColumns, and Object. This example touches every one of them except grouping (that's Example 2).
A. Client-driven, with validation
The frontend sends a plain Query — notice it has no object property, so it's physically impossible for a client to spoof which table gets hit.
POST /api/users/query
{
"criteria": {
"logic": 0, // Logic.And -> combine the groups below with AND (only one group here)
"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 } // operator 0 = Equals
]
}
]
},
"paging": { "size": 5, "number": 1 },
"selectColumns": ["UserId", "FirstName", "LastName", "Email", "Country", "Department", "Score"],
"sortColumns": [
{ "columnName": "Score", "sortOrder": 1 } // sortOrder 1 = Descending
]
}
Logic:0=And,1=Or,2=AndNot,3=OrNot |ConditionOperator0=Equals |SortOrder:0=Ascending,1=Descending
app.MapPost("/api/users/query", async (Query clientQuery, IDapperQueryService svc) =>
{
var dapperQuery = DapperQueryBuilder
.FromBase(clientQuery) // Criteria + Paging + SelectColumns + SortColumns from the client
.ForObject("TestUsers", "dbo", DapperObjectType.Table) // Object: locked server-side, never client-controlled
.Build();
dapperQuery.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 await svc.QueryAsync<TestUser>(dapperQuery);
});
B. Fully backend-built, no client input at all
Same DapperQuery, built end-to-end in C# with the fluent builder — useful for internal jobs, reports, or endpoints where the frontend shouldn't control the shape of the query at all.
app.MapGet("/api/users/top-active", async (IDapperQueryService svc) =>
{
var dapperQuery = DapperQueryBuilder
.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)
.ForObject("TestUsers", "dbo", DapperObjectType.Table)
.Build();
return await svc.QueryAsync<TestUser>(dapperQuery);
});
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 (used above) |
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 the moment one is found. |
Internal APIs / strict environments where an invalid request should fail loudly. |
try
{
dapperQuery.Validate(rules => rules.Select(c => c.Deny("Email")), QueryValidationMode.ThrowException);
return Results.Ok(await svc.QueryAsync<TestUser>(dapperQuery));
}
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 table turns into a nested tree — with row counts at every level — computed by the engine, not in application code.
A. Client-driven, with validation
POST /api/users/grouped-query
{
"criteria": {
"groups": [
{ "conditions": [ { "columnName": "IsActive", "operator": 0, "value": true } ] } // operator 0 = Equals
]
},
"paging": { "size": 5, "number": 1 },
"selectColumns": ["UserId", "FirstName", "LastName", "Score"],
"sortColumns": [ { "columnName": "Score", "sortOrder": 1 } ], // sortOrder 1 = Descending
"groupByColumns": [
{ "columnName": "Country", "sortOrder": 0 }, // sortOrder 0 = Ascending
{ "columnName": "Department", "sortOrder": 0 } // sortOrder 0 = Ascending
]
}
app.MapPost("/api/users/grouped-query", async (Query clientQuery, IDapperQueryService svc) =>
{
var dapperQuery = DapperQueryBuilder
.FromBase(clientQuery)
.ForObject("TestUsers", "dbo", DapperObjectType.Table)
.Build();
dapperQuery.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 await svc.QueryAsync<TestUser>(dapperQuery);
});
B. Fully backend-built, no client input at all
app.MapGet("/api/users/by-country", async (IDapperQueryService svc) =>
{
var dapperQuery = DapperQueryBuilder
.Select("UserId", "FirstName", "LastName", "Score")
.Sort(new SortDescriptor("Score", SortOrder.Descending))
.GroupBy(
new GroupByDescriptor("Country", SortOrder.Ascending),
new GroupByDescriptor("Department", SortOrder.Ascending))
.Page(5, 1)
.ForObject("TestUsers", "dbo", DapperObjectType.Table)
.Build();
return await svc.QueryAsync<TestUser>(dapperQuery);
});
The result — a real hierarchy, straight from the engine
{
"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 } ] },
{ "key": "Sales", "count": 3, "items": [ { "userId": 6, "firstName": "First6", "lastName": "Last6", "score": 56.00 } ] },
{ "key": "HR", "count": 2, "items": [ { "userId": 1, "firstName": "First1", "lastName": "Last1", "score": 51.00 } ] }
]
}
]
}
Built for enterprise data grids
QueryForge is architecturally purpose-built to be the backend counterpart for advanced UI data grids like DevExtreme (dxDataGrid), AG Grid, and Kendo UI.
These grids send rich loadOptions — nested filter groups, multi-level grouping, sorting, paging — that just need a thin frontend mapper to become a QueryForge Query. From there, QueryForge does the heavy lifting the grid actually demands:
| Grid feature | QueryForge capability |
|---|---|
| Complex filtering | Deeply nested AND / OR / AND NOT / OR NOT groups (Criteria) |
| Multi-level grouping | Native key / count / items hierarchy trees, any number of levels |
| Server-side paging | Accurate row/page totals, on flat results or on top-level groups |
| Dynamic multi-column sorting | SortColumns, independent direction per column |
Database objects — not just tables
Point ForObject at a table, a view, a table-valued function, or a stored procedure — filters, sorting, and paging still apply on top, and each can take its own parameters:
// Table-Valued Function
var tvfQuery = DapperQueryBuilder.New()
.ForObject("tvf_GetUsersByTenant", "dbo", DapperObjectType.TVF,
new Dictionary<string, object?> { { "TenantId", 1 } })
.Build();
// Stored Procedure
var spQuery = DapperQueryBuilder.New()
.ForObject("usp_GetUserReport", "dbo", DapperObjectType.SP,
new Dictionary<string, object?> { { "IncludeDeleted", false } })
.Page(20, 1)
.Build();
Where things stand
Execution providers
| Provider | Package | Status |
|---|---|---|
| Dapper | PepperX.QueryForge.Dapper |
✅ Released |
| Entity Framework Core | PepperX.QueryForge.EFCore |
✅ Released |
| In-Memory | included in PepperX.QueryForge |
✅ Released |
Database engine support (via the Dapper provider)
| Database engine | Tables & views | Table-valued functions | Stored procedures |
|---|---|---|---|
| Microsoft SQL Server | ✅ | ✅ | ✅ |
| PostgreSQL | ✅ | ✅ | ✅ |
| MySQL / MariaDB | ✅ | — (no TVFs in MySQL) | ✅ |
| Oracle | ✅ | ✅ (pipelined functions) | — (use a pipelined function) |
| SQLite | ✅ | — | — |
A dash means the engine itself has no such concept; QueryForge throws a NotSupportedException
explaining the alternative rather than generating SQL that cannot work.
Need an engine that isn't listed? Implement ISqlDialect — it is a small interface covering
identifier quoting, parameter prefix, paging syntax and LIKE escaping — and register it with
services.AddQueryForgeDialect(new MyDialect()).
Portability guarantees
Two things differ between engines by default. QueryForge pins both, so moving a query from one database to another does not silently change the answer:
- Null ordering. PostgreSQL and Oracle sort nulls last ascending; SQL Server, MySQL and SQLite
sort them first. QueryForge standardises on nulls first ascending and nulls last descending, by
emitting an explicit
NULLS FIRST/NULLS LASTwhere the engine's default differs. - Loosely-typed filter values. A value arriving from JSON is often text even when the column is
numeric or a date. Permissive engines coerce it; PostgreSQL rejects
integer > textoutright. QueryForge discovers each column's real type from the result set and coerces the value before binding it — so"30"works everywhere, and the comparison uses the column's type and its indexes.
Both were found by running the test suites against live PostgreSQL and MySQL servers rather than only asserting generated SQL.
Oracle specifics
Oracle needs a little more care than the others, and QueryForge handles most of it for you:
- Named parameter binding is forced on. ODP.NET binds parameters positionally by default, which
would silently pair
:p0and:p1by insertion order rather than by name. QueryForge setsBindByNameon the command, so you do not have to. - Derived tables are aliased without
AS. Oracle rejectsFROM (...) AS x, so the compiler emits the bare form that every engine accepts. - Object names are case-sensitive as written. Oracle folds unquoted identifiers to upper case and QueryForge quotes what it is given, so write object names the way Oracle stored them — usually upper case. Column names are discovered from the result set and are always correct.
- Stored procedures are not supported; Oracle returns result sets through REF CURSOR output
parameters, which cannot be expressed as portable command text. Wrap the logic in a pipelined
function and query it with
DapperObjectType.TVFinstead. - Empty strings are NULL in Oracle. This is the database's own behaviour, not something QueryForge
can paper over: a filter for
Equals ""behaves asIS NULLthere and as an empty-string match everywhere else. - Paging needs Oracle 12c or later, where
OFFSET … FETCH NEXTbecame available.
Upgrading from 1.x
2.0 replaces the T-SQL stored-procedure engine with a SQL compiler written in C#. That is what makes the other four databases possible, and it changes a few things:
| 1.x | 2.0 |
|---|---|
usp_QueryForge* procedures deployed into your database at startup |
Nothing is deployed; SQL is compiled per call |
DapperExecutionApproach (RawQuery / UseReadySp / DevelopAndUseSp) |
Removed — there is one execution path |
| DDL permissions needed for the default mode | No schema permissions needed at all |
DapperQueryObject.Schema defaulted to "dbo" |
Defaults to empty, meaning "use the dialect's default" (dbo on SQL Server, public on PostgreSQL) |
To upgrade, delete the Approach line from your AddQueryForgeDapper call. Everything else —
IDapperQueryService, DapperQuery, DapperQueryBuilder, QueryForgeAsync, validation — is
unchanged. You can drop the old usp_QueryForge, usp_QueryForge_BuildWhere and
usp_QueryForge_ExecGrouped procedures from your database; nothing calls them any more.
Two behaviour improvements worth knowing about, because they are also behaviour changes:
- Comparisons now use the column's real type. The old engine inlined every value as text, so
Age > 9compared strings and ranked9above30. Values are now parameters, and numbers, dates and booleans compare correctly. - Stored procedure results are filtered in the application. A procedure's result set cannot be
composed into a larger
SELECTportably, so its rows are materialized and then filtered, sorted, paged and grouped in memory. The result is identical; for a very large procedure result set the data transfer is not. Tables, views and functions are unaffected — those push everything down to the database.
🤝 Contributing & License
This project is part of the PepperX Ecosystem. Licensed under the MIT License — see the LICENSE file for details.
| Product | Versions 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. |
-
net10.0
- Dapper (>= 2.1.79)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- PepperX.QueryForge (>= 2.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.