-
Notifications
You must be signed in to change notification settings - Fork 0
Results and Metadata
Every provider returns the same type. That is the whole point of the contract: a caller can switch from Dapper to EF Core to in-memory without touching a line of consuming code.
public record QueryResult<TModel>
{
public QueryResultMeta Meta { get; init; } = new(new(0, 0), QueryResultType.Flat);
public IReadOnlyList<TModel> Models { get; init; } = Array.Empty<TModel>();
public IReadOnlyList<HierarchyNode<TModel>> Groups { get; init; } = Array.Empty<HierarchyNode<TModel>>();
}| Property | Populated when | Notes |
|---|---|---|
Meta |
always | totals and result shape |
Models |
Meta.Type == Flat |
the requested page of rows |
Groups |
Meta.Type == Grouped |
the nested hierarchy |
Both collections are always non-null. The unused one is an empty array, never null — so a consumer can enumerate either without a guard.
Read Meta.Type to decide which to look at. Do not infer it from emptiness: a grouped query that
matched nothing has an empty Groups and an empty Models.
if (result.Meta.Type == QueryResultType.Flat)
Render(result.Models);
else
RenderTree(result.Groups);public record QueryResultMeta(QueryResultMetaTotal Total, QueryResultType Type);
public record QueryResultMetaTotal(int Rows, int Pages);This is the one place the naming can mislead, so it is worth stating exactly:
| Result type |
Total.Rows is |
Total.Pages is |
|---|---|---|
Flat |
the number of rows matching Criteria, before paging
|
ceil(Rows / size) |
Grouped |
the number of distinct outermost group keys matching Criteria
|
ceil(Rows / size) |
In a grouped result, Total.Rows therefore counts groups, not rows — because paging applies to
groups. The field name is shared with the flat case for contract stability; the value follows what is
being paged.
size is the normalized page size: Paging.Size > 0 ? Paging.Size : 12.
A flat query, 137 matching rows, page size 20:
{ "meta": { "total": { "rows": 137, "pages": 7 }, "type": "Flat" }, "models": [ /* 20 rows */ ] }A grouped query over the same data, grouped by Country, 9 distinct countries, page size 5:
{ "meta": { "total": { "rows": 9, "pages": 2 }, "type": "Grouped" }, "groups": [ /* 5 countries */ ] }The second page returns the remaining 4 countries, and the same totals.
Not an error. Requesting page 99 of a 7-page result returns:
{ "meta": { "total": { "rows": 137, "pages": 7 }, "type": "Flat" }, "models": [] }The totals stay accurate, which is what a data grid needs in order to correct its own paging state.
public record HierarchyNode<TModel>(
object? Key,
int Count,
IReadOnlyList<HierarchyNode<TModel>>? SubGroups,
IReadOnlyList<TModel>? Items);| Property | Meaning |
|---|---|
Key |
The grouping value for this node. May be null — null is a valid key and forms its own group. |
Count |
The number of leaf rows beneath this node at any depth — not the number of direct children. |
SubGroups |
Child nodes, when this is not the innermost level. null at the leaf level. |
Items |
The rows, when this is the innermost level. null at every other level. |
-
A node has either
SubGroupsorItems, never both. Exactly one is non-null, decided by whether the node sits at the last grouping level. -
Countaggregates all the way down. A country node's count is every row in that country, not the number of departments in it. Summing the direct children's counts gives the parent's count. -
Nesting depth equals
GroupByColumns.Count, after unknown columns have been dropped. -
Keyis the raw column value, not a string. It serializes as whatever the column's type is — a number stays a number, a date stays a date.
{
"meta": { "total": { "rows": 2, "pages": 1 }, "type": "Grouped" },
"groups": [
{
"key": "Canada",
"count": 9,
"subGroups": [
{ "key": "HR", "count": 5, "items": [ { "userId": 9, "score": 59.0 } ] },
{ "key": "IT", "count": 4, "items": [ { "userId": 4, "score": 54.0 } ] }
]
},
{
"key": "Germany",
"count": 12,
"subGroups": [
{ "key": null, "count": 2, "items": [ /* rows with no department */ ] },
{ "key": "IT", "count": 4, "items": [ /* … */ ] },
{ "key": "HR", "count": 6, "items": [ /* … */ ] }
]
}
]
}Note the "key": null group — rows with no department are a real group, sorted first because this
level is ascending.
Note also that 9 = 5 + 4 and 12 = 2 + 4 + 6: counts aggregate.
QueryResult<T> and its parts are ordinary records with public getters, so
System.Text.Json handles them with no configuration.
-
Enums serialize as numbers by default.
Meta.Typeappears as0(Flat) or1(Grouped) unless you registerJsonStringEnumConverter, in which case it appears as"Flat"/"Grouped". The examples in this wiki show the string form for readability. Pick one and be consistent — it is part of your API's contract with its clients. -
Property names are camel-cased by ASP.NET Core's default options:
Meta.Total.Rowsbecomesmeta.total.rows. -
HierarchyNode.Keyisobject?, so it serializes as whatever the underlying value is. A strongly-typed client should treat it as a union of string, number, boolean, date-string and null. -
nullcollections never appear — the unused one is an empty array.
Walking the tree is the same regardless of depth:
static void Walk<T>(IReadOnlyList<HierarchyNode<T>> nodes, int depth = 0)
{
foreach (var node in nodes)
{
Console.WriteLine($"{new string(' ', depth * 2)}{node.Key ?? "(none)"} — {node.Count}");
if (node.SubGroups is not null)
Walk(node.SubGroups, depth + 1);
else if (node.Items is not null)
foreach (var item in node.Items)
Console.WriteLine($"{new string(' ', depth * 2 + 2)}· {item}");
}
}Flattening back to rows:
static IEnumerable<T> Leaves<T>(IReadOnlyList<HierarchyNode<T>> nodes)
=> nodes.SelectMany(n => n.Items ?? Leaves(n.SubGroups!));Meta.Total.Rows counts groups when the result is grouped. If you also need the total number of
matching rows, sum the top-level counts of the current page — or run the same Query without
GroupByColumns and read the flat total.
var rowsOnThisPage = result.Groups.Sum(g => g.Count); // rows within the paged groupsNote this is the row count for the groups on this page, not for every matching row — the rows belonging to groups on other pages were never fetched.
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