Repository navigation
Grouping and Hierarchies
Add GroupByColumns and a flat result becomes a nested key / count / items tree, with row counts at
every level, computed by the engine rather than by your application code.
This page specifies exactly how that works — the algorithm, the guarantees, the performance profile, and the parts that surprise people.
public record HierarchyNode<TModel>(
object? Key,
int Count,
IReadOnlyList<HierarchyNode<TModel>>? SubGroups,
IReadOnlyList<TModel>? Items);{
"meta": { "total": { "rows": 2, "pages": 5 }, "type": "Grouped" },
"groups": [
{ "key": "Canada", "count": 9, "subGroups": [
{ "key": "HR", "count": 5, "items": [ /* 5 rows */ ] },
{ "key": "IT", "count": 4, "items": [ /* 4 rows */ ] } ] },
{ "key": "Germany", "count": 12, "subGroups": [
{ "key": "IT", "count": 7, "items": [ /* 7 rows */ ] },
{ "key": "HR", "count": 5, "items": [ /* 5 rows */ ] } ] }
]
}-
A node holds either
SubGroupsorItems, never both. Which one is decided purely by depth: the innermost grouping level carriesItems, every other level carriesSubGroups. -
Countis the number of leaf rows beneath the node at any depth — not the number of direct children. Children's counts sum to their parent's. -
Depth equals
GroupByColumns.Count, after unknown columns have been dropped. -
nullis a valid key and forms its own group. It is not merged with empty strings, and it is not skipped. -
Keys are ordered by that level's
SortOrder, with nulls first ascending and last descending. -
Leaf items keep the order
SortColumnsestablished. Grouping does not reshuffle rows within a group. -
Keyis the raw column value, not a string — a number stays a number, a date stays a date.
This is the single most important thing to understand, and the most common surprise.
When a query is grouped,
Pagingslices the outermost group keys, not the rows.Meta.Total.Rowsbecomes the number of distinct outermost keys, andMeta.Total.Pagesisceil(that / size).
So "paging": { "size": 5 } on a query grouped by Country returns five countries — and every
row belonging to those five countries.
Because Count is defined as the number of leaf rows beneath a node, and that cannot be known from a
partially fetched set. If the provider fetched only 5 rows per country, it could not truthfully say a
country has 40. Truthful counts require the rows.
The practical consequence: page size bounds how many groups come back, not how much data. Grouping by a low-cardinality column over a large table returns a large payload. Two ways to keep it bounded:
-
Filter first.
Criteriais applied before grouping, so a date range or status filter shrinks the row set the groups are built from. - Group by something with more keys, so each group is smaller.
If what you actually want is "the top 5 rows in each country", that is a windowed query, which QueryForge does not model. Express it as a view or a table-valued function and query that instead.
| Field | Value |
|---|---|
Meta.Type |
Grouped |
Meta.Total.Rows |
distinct outermost keys matching the filter |
Meta.Total.Pages |
ceil(keys / size) |
Models |
empty |
Groups |
the page of keys, each fully populated |
All three providers follow the same three steps. Only step 1 and 2 differ in where they run.
1. Count the distinct outermost keys → Meta.Total
2. Page the distinct outermost keys → the keys on this page
3. Fetch every row whose outermost key is on that page
4. Hand those rows to HierarchyBuilder → the tree
HierarchyBuilder is in the core package and is shared, so the tree is byte-identical regardless of
how the rows were fetched. It:
- buckets rows by the level's key, in first-seen order;
- sorts the buckets explicitly by
SortOrderusingQueryValueComparer, so the result does not depend on dictionary enumeration order; - recurses into each bucket for the next level;
- sets
Countto the bucket's row count — which, because a bucket holds every row beneath it, is automatically the leaf count at any depth.
Null keys are handled by a small null-safe key wrapper, so null can be a dictionary key rather than
being dropped.
| Dapper | EF Core | In-Memory | |
|---|---|---|---|
| Count keys | SELECT COUNT(*) FROM (SELECT DISTINCT key …) qf_groups |
.Select(key).Distinct().CountAsync() |
Distinct() over the filtered rows |
| Page keys | SELECT DISTINCT key … ORDER BY key … <paging> |
.Distinct().OrderBy(nullRank).ThenBy(k).Skip().Take() |
OrderBy + Skip/Take
|
| Fetch rows | … WHERE (filter) AND key IN (@p0, @p1, …) |
.Where(pagedKeys.Contains(key)) |
filter by a key set |
| Round trips | 3 | 3 | 0 — one pass in memory |
The Dapper key predicate handles nulls explicitly, because IN never matches NULL:
| Keys on the page | Predicate |
|---|---|
| all non-null | key IN (@p0, @p1, …) |
| includes null | (key IN (@p0, …) OR key IS NULL) |
| only null | key IS NULL |
| none | 1 = 0 |
Levels nest outermost-first, in list order.
var query = QueryBuilder.New()
.GroupBy(
new GroupByDescriptor("Country", SortOrder.Ascending),
new GroupByDescriptor("Department", SortOrder.Ascending),
new GroupByDescriptor("Role", SortOrder.Descending))
.Sort(new SortDescriptor("Score", SortOrder.Descending))
.Page(size: 5, number: 1)
.Build();Produces Country → Department → Role → rows, four levels deep, where:
-
CountryandDepartmentkeys sort ascending,Rolekeys sort descending; - paging slices
Countryonly — 5 countries per page; - within each
Rolenode, rows are ordered byScoredescending; -
Counton aCountrynode is every row in that country, across all its departments and roles.
There is no hard limit on depth. Practical depth is bounded by payload size, since every row under the paged outermost keys is returned.
Two independent orderings are at work, and it is worth keeping them apart:
| What | Controlled by | Applies to |
|---|---|---|
| The order of group keys at each level | that level's GroupByDescriptor.SortOrder
|
SubGroups / the top-level Groups list |
| The order of rows inside a leaf node | Query.SortColumns |
Items |
SortColumns does not affect group key order, and GroupByColumns.SortOrder does not affect row
order.
Null keys follow the standard: first ascending, last descending, at every level, on every
provider. That is enforced by QueryValueComparer in memory, by the dialect's NULLS FIRST /
NULLS LAST in Dapper, and by an explicit null-rank ordering key in EF Core.
Grouping columns always survive SelectColumns, on every provider — the hierarchy is rebuilt from
them after the rows come back, so dropping them would leave nothing to group by.
var query = QueryBuilder.New()
.Select("UserId", "FirstName", "Score") // Country is NOT selected
.GroupBy(new GroupByDescriptor("Country"))
.Build();The Dapper provider adds Country to its SELECT list; EF Core adds it to the MemberInit; the
in-memory shaper keeps it. Whether the returned models therefore carry a populated Country property
depends on the provider: Dapper and EF Core materialize the column and so it is populated. This is a
deliberate, documented consequence of asking for a grouped projection.
| Case | Behaviour |
|---|---|
GroupByColumns is empty |
flat result — this is the normal path |
| Every grouping column is unknown | falls back to a flat result rather than failing |
| Some grouping columns are unknown | the unknown ones are dropped; the rest still nest |
| The filter matches nothing |
Groups is empty, Meta.Total.Rows is 0
|
| Paging past the last group |
Groups is empty, totals still accurate |
| Every row has a null key | one group, key: null, holding everything |
The Dapper compiler throws InvalidOperationException — "A grouped query needs at least one
GroupByColumn naming a real column of the target object" — only if it is called for a grouped
statement with no valid grouping column. The executor checks first and routes to the flat path, so
you will not see this in normal use; it appears only when calling the compiler directly.
QueryForge's hierarchy is shaped for the server-side grouping protocols the major grids use.
| Grid concept | QueryForge |
|---|---|
DevExtreme group in loadOptions
|
GroupByColumns, in order |
DevExtreme group key / count / items
|
HierarchyNode.Key / .Count / .Items
|
AG Grid row-group groupKeys
|
one GroupByDescriptor per level |
Kendo group descriptors |
GroupByColumns, field → ColumnName, dir → SortOrder
|
| Group-level paging |
Paging on a grouped query |
A thin frontend mapper is all that is needed; see Recipes.
- Grouped queries cost three round trips on Dapper and EF Core, against one for a flat query.
- The row-fetch statement is the expensive one. It returns every row under the paged keys, so filter before grouping.
-
The
INlist is bounded by page size — 5 groups means 5 parameters — so it never approaches a parameter-count limit under normal use. - The tree build is one pass over the fetched rows per level, with dictionary bucketing.
- In-memory grouping is a single pass, no round trips, and is the fastest path by a wide margin for data you already hold.
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