Skip to content

Architecture

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

Architecture

QueryForge is built on one separation: query intent is a data structure, query execution is a plugin. Everything else follows from that.


The layers

                     ┌──────────────────────────────────────────────┐
   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                  │
                     └──────────────────────────────────────────────┘

Why the shared-semantics layer exists

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.

How each provider differs

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

Why grouping is assembled in the application

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.

The Dapper provider in detail

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
  • SqlQueryCompiler owns 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.
  • ISqlDialect supplies only what genuinely differs between databases — about a dozen members. That is why adding an engine is a small class rather than a reimplementation.
  • SchemaCache discovers a target's real columns by running SELECT * FROM target WHERE 1 = 0 and reading the result set's field names and types. It caches per provider | schema | name | type for 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.

The EF Core provider in detail

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 NULL guards, rather than wrapping the group in NOT. 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.

The In-Memory provider in detail

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.

Repository layout

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

Deliberate non-goals

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 IQueryable you compose before handing it over.
  • It does not aggregate. There is no SUM/AVG/MAX in 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.

Clone this wiki locally