Skip to content

Results and Metadata

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

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.


QueryResult<TModel>

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);

QueryResultMeta

public record QueryResultMeta(QueryResultMetaTotal Total, QueryResultType Type);
public record QueryResultMetaTotal(int Rows, int Pages);

What Total counts

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.

Examples

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.

Paging past the end

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.


HierarchyNode<TModel>

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.

The contract

  1. A node has either SubGroups or Items, never both. Exactly one is non-null, decided by whether the node sits at the last grouping level.
  2. Count aggregates 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.
  3. Nesting depth equals GroupByColumns.Count, after unknown columns have been dropped.
  4. Key is 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.

Shape

{
  "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.


Serialization notes

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.Type appears as 0 (Flat) or 1 (Grouped) unless you register JsonStringEnumConverter, 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.Rows becomes meta.total.rows.
  • HierarchyNode.Key is object?, 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.
  • null collections never appear — the unused one is an empty array.

Consuming a hierarchy

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!));

Totals for a grouped query, in rows

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 groups

Note 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.

Clone this wiki locally