-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
QueryForge is built on one separation: query intent is a data structure, query execution is a plugin. Everything else follows from that.
┌──────────────────────────────────────────────┐
client JSON ───► │ Query │
or C# builder │ Criteria · Paging · SelectColumns │ serializable, provider-free
│ SortColumns · GroupByColumns │
└───────────────────┬──────────────────────────┘
│
┌───────────────────▼──────────────────────────┐
│ Validation engine │ allow/deny lists, page clamps
│ SilentStrip | ThrowException │
└───────────────────┬──────────────────────────┘
│
┌────────────────────────────────┼────────────────────────────────┐
│ │ │
┌───────▼─────────┐ ┌───────────▼──────────┐ ┌──────────▼─────────┐
│ Dapper provider │ │ EF Core provider │ │ In-Memory provider │
│ SqlQueryCompiler│ │ ExpressionCompiler │ │ InMemoryQueryEngine│
│ + ISqlDialect │ │ → IQueryable<T> │ │ → LINQ to objects│
└───────┬─────────┘ └───────────┬──────────┘ └──────────┬─────────┘
│ │ │
└────────────────────────────────┼────────────────────────────────┘
│
┌───────────────────▼──────────────────────────┐
│ Shared semantics (core) │
│ ConditionSemantics · ValueCoercion │ one set of rules,
│ QueryValueComparer · HierarchyBuilder │ used by every provider
│ ProjectionShaper · PropertyAccessor │
└───────────────────┬──────────────────────────┘
│
┌───────────────────▼──────────────────────────┐
│ QueryResult<TModel> │ identical shape everywhere
│ Meta · Models | Groups │
└──────────────────────────────────────────────┘
Three providers producing "roughly the same" answer would be worse than useless — you could not move a query between them, and a bug would have to be found and fixed three times. So the decisions that are not database-specific live in the core and are called by every provider:
| Component | Owns | Used by |
|---|---|---|
ConditionSemantics |
Whether a condition is usable at all; how Logic maps to AND/OR and negation; unwrapping JsonElement into CLR values |
all three |
ValueCoercion |
Converting a caller's loosely-typed value to a column's real type | Dapper |
QueryValueComparer |
Ordering and equality for values whose CLR types do not match | In-Memory, HierarchyBuilder
|
HierarchyBuilder |
Turning flat rows into the key / count / items tree |
all three |
ProjectionShaper |
Applying SelectColumns to already-materialized models |
In-Memory, Dapper's SP path |
PropertyAccessor |
Reading a named column off a POCO, case-insensitively, with caching | In-Memory, HierarchyBuilder
|
InMemoryQueryEngine |
The complete filter/sort/page/group pipeline over objects | In-Memory provider, Dapper's stored-procedure path |
Change a rule in one of these and it changes for every provider at once. That is the point.
The providers are not variations on one implementation — they differ in where the work happens, which is what makes each appropriate for different situations.
| Dapper | EF Core | In-Memory | |
|---|---|---|---|
| Filtering | SQL WHERE, values as parameters |
SQL WHERE, via expression tree |
LINQ Where over objects |
| Sorting | SQL ORDER BY
|
SQL ORDER BY
|
OrderBy/ThenBy with a custom comparer |
| Paging | SQL paging clause |
Skip/Take → SQL |
Skip/Take in memory |
| Projection | narrowed SELECT list |
Select with MemberInit → narrowed SQL |
copy of the object with unselected properties left default |
| Grouping | 3 statements, tree built in app | 3 round trips, tree built in app | one pass, tree built in place |
| Column whitelist | discovered from the live result set | the entity's own properties | the model's own properties |
| Knows about the database | yes — five dialects | no — EF Core does | no database at all |
All three providers page the outermost group keys in the data source, fetch every row belonging to
those keys, and then build the tree with the shared HierarchyBuilder. No provider asks the database
to return a nested structure.
That is deliberate. A node's Count is the number of leaf rows beneath it at any depth, which
cannot be known from a partially fetched set, and no portable SQL returns nesting. Assembling in the
application costs one pass over the fetched rows and buys an identical tree from every engine. The
consequence to be aware of: page size bounds the number of groups, not the number of rows inside
them — see Grouping and Hierarchies.
DapperQuery ──► SqlQueryCompiler ──► CompiledSql { Text, Parameters }
│
├── ISqlDialect quoting, parameter prefix, paging clause,
│ null ordering, LIKE escaping, FROM rendering
│
└── ColumnWhitelist real column names + CLR types,
discovered once per object by SchemaCache
-
SqlQueryCompilerowns everything structural: which conditions apply, how groups combine, how paging maps onto grouping levels, what the projection is. This code is identical for all engines. -
ISqlDialectsupplies only what genuinely differs between databases — about a dozen members. That is why adding an engine is a small class rather than a reimplementation. -
SchemaCachediscovers a target's real columns by runningSELECT * FROM target WHERE 1 = 0and reading the result set's field names and types. It caches perprovider | schema | name | typefor the lifetime of the process.
Reading the shape from the object's own result set — rather than from sys.columns,
information_schema, or all_tab_columns — means discovery is identical on every engine and works
for views and table-valued functions whose columns are computed.
See Dapper: Generated SQL for the exact statements, and Dapper: Dialects for the per-engine table.
Query ──► ExpressionCompiler ──► Expression<Func<TModel,bool>> ─┐
LambdaExpression (selectors) ├─► IQueryable<T> ──► EF Core ──► SQL
Expression<Func<T,T>> (project) ┘
There is no SQL in this package. It builds expression trees and hands them to EF Core, so the generated SQL respects your entity configuration, global query filters, value converters and owned types — and works on every database EF Core has a provider for.
Two details are worth knowing, because both are corrections of EF Core's default behaviour rather than plain translation:
-
Values are injected through a closure, not as
Expression.Constant, so EF Core emits a SQL parameter and can reuse a cached plan. -
Negated groups are compiled by pushing the negation down to individual conditions with explicit
IS NOT NULLguards, rather than wrapping the group inNOT. Only that form reproduces SQL's three-valued logic; EF Core's null compensation would otherwise let null rows back into a negated result.
See EF Core Provider.
A thin set of extension methods over InMemoryQueryEngine, which lives in the core package. It
implements SQL's three-valued logic explicitly — a comparison against null is unknown, and unknown
survives negation — so a negated group behaves the same over objects as it does in a database.
It is also the reference implementation the database providers are asserted against in the cross-provider parity suite.
See In-Memory Provider.
QueryForge/
├── src/
│ ├── PepperX.QueryForge/ core: models, builder, validation, shared semantics
│ │ ├── Models/ Query, QueryCriteria, Condition, enums, results
│ │ ├── Builders/ QueryBuilder, QueryFluent
│ │ ├── Validation/ rules, modes, extensions
│ │ └── Querying/ the shared-semantics layer
│ ├── PepperX.QueryForge.Dapper/
│ │ ├── Compiler/ ISqlDialect, SqlQueryCompiler, ColumnWhitelist, CompiledSql
│ │ ├── Dialects/ five ISqlDialect implementations
│ │ ├── Internals/ registry, executor, schema cache, parameter binding
│ │ ├── Models/ DapperQuery, DapperQueryObject, DapperObjectType
│ │ ├── Services/ IDapperQueryService, IDbConnection extension
│ │ └── Validation/ Dapper-specific object rules
│ ├── PepperX.QueryForge.EFCore/
│ │ ├── Translation/ ExpressionCompiler
│ │ └── QueryForgeQueryableExtensions.cs
│ └── PepperX.QueryForge.InMemory/
├── tests/
│ ├── PepperX.QueryForge.Conformance/ shared suites every provider must satisfy
│ ├── PepperX.QueryForge.Tests/ core, in isolation
│ ├── PepperX.QueryForge.Dapper.Tests/
│ ├── PepperX.QueryForge.EFCore.Tests/
│ └── PepperX.QueryForge.InMemory.Tests/
├── samples/
│ └── PepperX.QueryForge.Sample.WebApi/ 20 endpoints across all three providers
└── .github/workflows/publish-nuget.yml
Knowing what QueryForge does not try to do is as useful as knowing what it does.
- It is a read engine. There is no insert, update, or delete surface.
-
It does not join. A query targets one object. Express a join as a view, a table-valued
function, or an
IQueryableyou compose before handing it over. -
It does not aggregate. There is no
SUM/AVG/MAXin the model. Grouping produces counts and nested rows; anything more is a view or a projection you supply. - It is not an ORM. Dapper and EF Core do the materialization. QueryForge decides what to ask for.
- It does not manage schema. Nothing is deployed, migrated, or created.
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