@inwink-mcp/backoffice-extension-helpers 1.0.11 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inwink-mcp/backoffice-extension-helpers",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "author": "inwink",
5
5
  "license": "ISC",
6
6
  "description": "CLI pour bootstrapper, packager et vérifier les extensions backoffice inwink.",
@@ -60,15 +60,19 @@ Les outils MCP donnent les endpoints/champs **exacts** ; la doc statique dans `d
60
60
 
61
61
  | Tâche | Fichier |
62
62
  |-------|---------|
63
+ | URLs de base, shardId, routes d'audience, codes HTTP & règles générales | [docs/inwink-api/README.md](docs/inwink-api/README.md) |
63
64
  | Lire / lister des entités (query, count, pagination, projection, joins) | [docs/inwink-api/query.md](docs/inwink-api/query.md) |
64
65
  | Construire un filtre / tri / projection (langage de requête JSON) | [docs/inwink-api/query-language.md](docs/inwink-api/query-language.md) |
66
+ | Regrouper / agréger (GROUP BY, statistiques, facettes) | [docs/inwink-api/aggregate.md](docs/inwink-api/aggregate.md) |
65
67
  | Créer une entité | [docs/inwink-api/create.md](docs/inwink-api/create.md) |
66
68
  | Modifier (patch / massupdate) | [docs/inwink-api/update.md](docs/inwink-api/update.md) |
67
69
  | Supprimer (delete / massdelete) | [docs/inwink-api/delete.md](docs/inwink-api/delete.md) |
68
70
  | Import en masse (bulk create/update) | [docs/inwink-api/import.md](docs/inwink-api/import.md) |
71
+ | Versions passées et entités supprimées | [docs/inwink-api/history.md](docs/inwink-api/history.md) |
72
+ | Contrats d'entités, endpoints divers | [docs/inwink-api/entities.md](docs/inwink-api/entities.md) |
73
+ | Déclarer un champ custom (template d'entité) | [docs/inwink-api/entity-template.md](docs/inwink-api/entity-template.md) |
69
74
  | Champs de type `file` (upload / download) | [docs/inwink-api/files.md](docs/inwink-api/files.md) |
70
- | Contrats & schémas d'entités, endpoints divers | [docs/inwink-api/entities.md](docs/inwink-api/entities.md) |
71
- | Codes HTTP & règles générales | [docs/inwink-api/README.md](docs/inwink-api/README.md) |
75
+ | Manipuler les `assets` (galerie media) | [docs/inwink-api/assets.md](docs/inwink-api/assets.md) |
72
76
 
73
77
  ## Appeler l'API inwink
74
78
 
@@ -90,7 +94,7 @@ function MonComposant() {
90
94
  }
91
95
  ```
92
96
 
93
- Méthodes disponibles sur chaque hook : `request<T>(path, init?)`, `get<T>`, `post<T>(path, body?)`, `put<T>(path, body?)`, `del<T>`.
97
+ Méthodes disponibles sur chaque hook : `request<T>(path, init?)`, `get<T>`, `post<T>(path, body?)`, `put<T>(path, body?)`, `patch<T>(path, body?)`, `del<T>`.
94
98
 
95
99
  Pour appeler un **hôte externe déclaré** (hors API inwink), utilise le hook générique `useInwinkApi(baseUrl)`.
96
100
 
@@ -130,10 +134,12 @@ import { useInwinkContext } from './hooks/useInwinkContext';
130
134
  function MonComposant() {
131
135
  const { scope, eventId, communityId, tenantId } = useInwinkContext();
132
136
  // scope vaut 'event' | 'community' | 'tenant' | null (priorité event > community > tenant)
133
- // Utilise l'id pertinent comme shardId dans les chemins d'API.
137
+ // En scope 'event' ou 'community', l'id correspondant est le shardId des chemins d'API.
134
138
  }
135
139
  ```
136
140
 
141
+ En scope `tenant`, le `tenantId` **n'est pas un shardId** : les entités d'audience (`authtenant*`) vivent sous `{customerId}/audience/{tenantId}/{entity}/...` sur l'API Customer, où le shardId reste le `customerId` (cf. [docs/inwink-api/README.md](docs/inwink-api/README.md#audience-scoped-entities-customerapi)).
142
+
137
143
  Pour laisser l'utilisateur **choisir** un contexte, monte un picker (`EventPicker`, `CommunityPicker`, `AudiencePicker`) — typiquement en **surchargeant le `Header`** (le `Header` par défaut est minimal et prévu pour ça). Les pickers ne sont pas montés par défaut : décide selon le besoin de l'extension.
138
144
 
139
145
  ## Recettes
@@ -232,5 +238,6 @@ npx @inwink-mcp/backoffice-extension-helpers@latest verify # vérification de
232
238
 
233
239
  > Ces consignes s'appliquent à **tout** assistant IA / outil agentique qui manipule cette extension, quel qu'il soit.
234
240
 
241
+ - **Langue de l'extension.** Ce guide est rédigé en français et la référence API (`docs/inwink-api/`) en anglais : cela ne dit rien de la langue de l'extension. Échange avec l'utilisateur dans sa langue. Pour le code, les commentaires et les textes d'interface, utilise la langue qu'il demande ou, à défaut, celle dans laquelle il t'écrit — pas celle de cette documentation ni des commentaires du squelette.
235
242
  - **Ne jamais publier sans validation explicite de l'utilisateur.** Le build, le packaging et la vérification **en local** sont autorisés sans demander. En revanche, toute étape qui **expose l'extension côté inwink** (upload d'une version, `publish`, promotion en `latest`) doit être **confirmée explicitement par l'utilisateur à chaque fois**. En cas de doute, demande avant d'agir.
236
243
  - **Tester dans un vrai navigateur, pas dans un aperçu intégré.** Ne lance pas l'extension dans un panneau d'aperçu ou une webview embarquée de l'outil agentique : le flux OIDC y échoue. L'authentification exige que l'origine soit le serveur de dev local — le `redirect_uri` vaut `${window.location.origin}${window.location.pathname}` et n'est enregistré côté IdP que pour `http://localhost:<port>`, alors qu'un aperçu intégré sert l'application depuis une autre origine. **Recommandé** : lancer `npm run dev` et ouvrir un **vrai onglet de navigateur** (Chrome de préférence) sur l'URL locale affichée (ex. `http://localhost:5173`).
@@ -1,43 +1,82 @@
1
- # inwink API Reference
1
+ # inwink API reference
2
2
 
3
- This document explains how to consume inwink APIs. It is intended as a reference for building API calls programmatically.
3
+ Reference for consuming the inwink APIs from the extension. Calls always go through the `useInwink*Api` hooks, which set the Bearer token and the base URL: the paths below are relative to that base.
4
4
 
5
- > **Découverte dynamique d'abord.** Cette doc décrit les *patterns* d'appel (verbes, forme des payloads, langage de requête). Les **endpoints exacts, champs et entités** se découvrent via les outils MCP (`inwink_mcp_find_api_endpoint`, `inwink_mcp_get_entity_description`) — ne devine jamais une route ou un schéma.
5
+ > **Dynamic discovery first.** This documentation describes call *patterns* (verbs, payload shapes, query language). The **exact endpoints, fields and entities** are discovered with the MCP tools (`inwink_mcp_find_api_endpoint`, `inwink_mcp_get_entity_description`) — never guess a route or a schema.
6
6
 
7
- ## Sommaire
7
+ ## Contents
8
8
 
9
- | Tâche | Fichier |
9
+ | Task | File |
10
10
  |---|---|
11
- | Lire / lister des entités (query, count, pagination, projection, joins) | [query.md](./query.md) |
12
- | Construire un filtre / tri / projection (langage de requête JSON) | [query-language.md](./query-language.md) |
13
- | Créer une entité | [create.md](./create.md) |
14
- | Modifier une entité (patch / massupdate) | [update.md](./update.md) |
15
- | Supprimer une entité (delete / massdelete) | [delete.md](./delete.md) |
16
- | Import en masse (bulk create/update) | [import.md](./import.md) |
17
- | Contrats & schémas d'entités, endpoints divers | [entities.md](./entities.md) |
18
- | Champs de type `file` (upload / download) | [files.md](./files.md) |
19
- | Manipuler les `assets` (galerie media) | [assets.md](./assets.md) |
11
+ | Read / list entities (query, count, pagination, projection, joins) | [query.md](./query.md) |
12
+ | Build a filter / sort / projection (JSON query language) | [query-language.md](./query-language.md) |
13
+ | Group / aggregate (GROUP BY, statistics, facets) | [aggregate.md](./aggregate.md) |
14
+ | Create an entity | [create.md](./create.md) |
15
+ | Edit an entity (patch / massupdate) | [update.md](./update.md) |
16
+ | Delete an entity (delete / massdelete) | [delete.md](./delete.md) |
17
+ | Bulk import (bulk create/update) | [import.md](./import.md) |
18
+ | Past versions and deleted entities | [history.md](./history.md) |
19
+ | Entity contracts, other endpoints | [entities.md](./entities.md) |
20
+ | Declare a custom field (entity template) | [entity-template.md](./entity-template.md) |
21
+ | `file` fields (upload / download) | [files.md](./files.md) |
22
+ | Handle `assets` (media library) | [assets.md](./assets.md) |
20
23
 
21
24
  ---
22
25
 
23
- ## General Rules
26
+ ## API Base URLs
27
+
28
+ | API | Base URL | Hook | Scope |
29
+ |---|---|---|---|
30
+ | EventAPI | `https://api-event.inwink.com` | `useInwinkEventApi` | Entities tied to an event (persons, sessions, exhibitors…) |
31
+ | CommunityAPI | `https://api-community.inwink.com` | `useInwinkCommunityApi` | Entities tied to a community (members, forums, forumsubjects…) |
32
+ | CustomerAPI | `https://api-customer.inwink.com` | `useInwinkCustomerApi` | Entities tied to the customer (events, contacts…) |
33
+
34
+ ## URL pattern
35
+
36
+ - EventAPI: `{eventAPI}/{eventId}/{entity}/...`
37
+ - CommunityAPI: `{communityAPI}/{communityId}/{entity}/...`
38
+ - CustomerAPI: `{customerAPI}/{customerId}/{entity}/...`
39
+
40
+ The `{eventId}`, `{communityId}` or `{customerId}` is called the **shardId** — it scopes each request to one specific event, community or customer.
41
+
42
+ ### Audience-scoped entities (CustomerAPI)
43
+
44
+ A customer holds one or more **audiences** (also called *tenants*). Entities that belong to an audience rather than to the whole customer have a name starting with `authtenant` (`authtenantcommunity`, `authtenantcontact`, `authtenantcompany`, `authtenantmailtemplate`…) and live on a two-segment route:
45
+
46
+ ```
47
+ {customerAPI}/{customerId}/audience/{tenantId}/{entity}/...
48
+ ```
49
+
50
+ The `{customerId}` remains the shardId; the `{tenantId}` identifies the audience. On these entities, `{shardId}` reads `{customerId}/audience/{tenantId}` in every route of this documentation. An `authtenant*` entity called without the audience segment returns 404.
51
+
52
+ ## Common headers
53
+
54
+ Every request requires (set by the hooks):
55
+
56
+ ```
57
+ Authorization: Bearer {access_token}
58
+ Content-Type: application/json
59
+ ```
60
+
61
+ ---
62
+
63
+ ## General rules
24
64
 
25
65
  - Property names are **case-insensitive**.
26
66
  - Enum values are **case-insensitive**.
27
67
  - All IDs are UUIDs.
28
68
  - Dates are ISO 8601 / UTC.
29
- - All write operations (create, edit, delete, import) use **POST** method.
30
-
31
- ---
69
+ - Write operations on entities (create, edit, delete, import) use the **POST** method. Only the partial edit of an entity's template uses `PATCH` (see [entity-template.md](./entity-template.md)).
32
70
 
33
- ## HTTP Status Codes
71
+ ## HTTP codes
34
72
 
35
73
  | Code | Meaning |
36
74
  |---|---|
37
- | 200 | Success — parse body as JSON |
75
+ | 200 | Success — parse the body as JSON (some writes, such as entity template writes, return 200 with no body) |
38
76
  | 204 | Success — no content |
39
- | 400 | Bad request — body contains error details |
40
- | 401 | Unauthorized — token expired or invalid |
41
- | 403 | Forbidden — missing rights, contact CSM |
42
- | 500 | Server error — may be transient, contact support if persistent |
77
+ | 400 | Bad request — the body holds the error details |
78
+ | 401 | Unauthorized — token expired or invalid (handled by the hooks) |
79
+ | 403 | Forbidden — missing rights, contact the CSM |
80
+ | 404 | Entity not found, empty body: entity name does not exist, entity exists but under another name on another API, or `authtenant*` entity called without the audience segment. Do not guess another name: run `inwink_mcp_find_api_endpoint` again with a natural-language description of the need |
81
+ | 500 | Server error — possibly transient, contact support if it persists. Also returned for a `shardId` that does not exist: check the identifier before concluding to an outage |
43
82
  | 503 | Transient infrastructure error — retry later |
@@ -0,0 +1,238 @@
1
+ ◂ Back to [README](./README.md)
2
+
3
+ # Endpoint `aggregate` — native GROUP BY
4
+
5
+ Generic endpoint to **group and aggregate** inwink entities, pushed down to SQL (`GROUP BY`).
6
+
7
+ ## Principle
8
+
9
+ - **Route**: `POST <entity-route>/aggregate` (same base as `query`/`count`, e.g. `…/person/aggregate`,
10
+ `…/order/aggregate`). Available on every entity that exposes `query`.
11
+ - **Body**: a JSON object (`ExpressionEntityAggregateModel`). Response: `{ data, count, totalGroups }`.
12
+ - The computation is **pushed down to SQL** (`GROUP BY`); global filters and rights/scopes are applied automatically.
13
+ - `filters` use the same language as `query`. However, **`groupBy` keys and aggregate values are more
14
+ restrictive** than the fields accepted elsewhere: see "Referencing a field" below before grouping on
15
+ anything other than a field of the entity itself.
16
+
17
+ ## Payload structure
18
+
19
+ ```jsonc
20
+ {
21
+ "filters": { /* MongoDB-like predicate, optional, applied BEFORE grouping (WHERE) */ },
22
+ "groupBy": [ /* 0..N grouping keys (empty or absent = global aggregate, a single row) */ ],
23
+ "aggregates":[ /* 1..N aggregated values per group (required) */ ],
24
+ "having": [ /* 0..N filters AFTER grouping (HAVING), optional */ ],
25
+ "orders": [ /* 0..N sorts, optional */ ],
26
+ "limit": 100 /* required, positive integer, capped (default 1000) */
27
+ }
28
+ ```
29
+
30
+ ### `filters`
31
+ Same syntax as `query`: `{ "field": { "$eq": value } }`. Applied before grouping.
32
+ The operator list in [query-language.md](./query-language.md#filters) is authoritative: refer to it rather than guessing a name; an
33
+ operator outside that list returns 400.
34
+
35
+ ### `groupBy[]` — grouping keys
36
+ ```jsonc
37
+ {
38
+ "name": "month", // output name of the column (result key)
39
+ "value": { "registrationDate": {} }, // field reference (see "Referencing a field")
40
+ "dateTrunc": "month", // optional: truncates a DATE (year|quarter|month|week|day|hour)
41
+ "bucketSize": 100 // optional: buckets a NUMERIC (exclusive with dateTrunc)
42
+ }
43
+ ```
44
+ - `dateTrunc`: `year`, `quarter`, `month`, `week` (Monday), `day`, `hour`. The returned key is the truncated date (start of the bucket).
45
+ - `bucketSize`: for a numeric field, key = lower bound `floor(value / size) * size`.
46
+ - `dateTrunc` and `bucketSize` are **mutually exclusive**.
47
+ - A key can be a **multi-valued** field (multiselectlist/multiselecttree): the array is **denormalized**
48
+ (an entity `["a","b"]` counts in group `a` AND group `b`). **At most 3** multi-valued keys (Cartesian product).
49
+
50
+ ### `aggregates[]` — aggregation functions
51
+ ```jsonc
52
+ { "name": "total", "op": "count" } // count(*): no "value"
53
+ { "name": "distinct", "op": "countDistinct", "value": { "company": {} } }
54
+ { "name": "sumHt", "op": "sum", "value": { "totalHt": {} } }
55
+ { "name": "avgHt", "op": "avg", "value": { "totalHt": {} } }
56
+ { "name": "minHt", "op": "min", "value": { "totalHt": {} } }
57
+ { "name": "maxDate", "op": "max", "value": { "registrationDate": {} } }
58
+ { "name": "stdHt", "op": "stdDevSamp", "value": { "totalHt": {} } }
59
+ ```
60
+ Operations (`op`):
61
+
62
+ | op | description | field type | output type |
63
+ |---|---|---|---|
64
+ | `count` | number of rows in the group (no `value`) | — | integer |
65
+ | `countDistinct` | number of distinct values | scalar | integer |
66
+ | `sum` | sum | numeric | decimal (nullable) |
67
+ | `avg` | average | numeric | decimal (nullable) |
68
+ | `min` / `max` | min / max | scalar (num/date/text) | field type |
69
+ | `stdDevPop` / `stdDevSamp` | population / sample standard deviation | numeric | double (nullable) |
70
+ | `varPop` / `varSamp` | population / sample variance | numeric | double (nullable) |
71
+
72
+ > `*Samp` (sample) returns `null` if the group has fewer than 2 values.
73
+
74
+ ### `having[]` — post-grouping filters (SQL HAVING)
75
+ ```jsonc
76
+ { "name": "total", "op": "gte", "value": 5 } // keeps only groups where total >= 5
77
+ ```
78
+ - `op`: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`.
79
+ - `name` references an output name (`groupBy` key OR aggregate). Multiple entries = **AND**.
80
+ - Prefer `filters` (before grouping) when filtering on a field value; reserve `having` for **aggregates**.
81
+
82
+ ### `orders[]`
83
+ ```jsonc
84
+ { "name": "total", "desc": true } // name = output name of a key or an aggregate
85
+ ```
86
+ A deterministic secondary sort on the keys is added automatically (reproducible paging).
87
+
88
+ ### `limit`
89
+ Required, integer > 0, capped by `AggregateMaxGroups` (default 1000). Exceeding it → 400.
90
+
91
+ ## Referencing a field (`value`)
92
+ Syntax: `{ "<fieldName>": {} }`. A navigation is written by nesting: `{ "member": { "lastname": {} } }`.
93
+ - Works for **static** fields (entity properties) **and dynamic** fields (stored as JSON); case-insensitive.
94
+ - For a `selectlist`/`selecttree` field (single value), the returned key is the stored **option key** (e.g. `v1`), not the label.
95
+ - For a `multiselectlist`/`multiselecttree` field, see the denormalization described in `groupBy`.
96
+ - A **localizable** field (`title`, `description` — object `{ "fr": …, "en": … }`) used as a key returns the
97
+ label resolved in the default language, **provided it belongs to the root entity**.
98
+
99
+ ### What can be traversed, and what cannot
100
+
101
+ `aggregate` does not accept every path that `filters` accepts. The table applies to `groupBy[].value`
102
+ and to `aggregates[].value`:
103
+
104
+ | Key | Example | Result |
105
+ |---|---|---|
106
+ | Entity field, scalar | `{ "pinned": {} }` | ✅ |
107
+ | Entity field, localizable | `{ "title": {} }` | ✅ resolved label |
108
+ | **to-one** relation + scalar field | `{ "forum": { "status": {} } }` | ✅ |
109
+ | **to-one** relation + **localizable** field | `{ "forum": { "title": {} } }` | ❌ 400 |
110
+ | **to-many** relation (scalar or localizable) | `{ "categorySubjects": { "id": {} } }` | ❌ 400 |
111
+
112
+ Both failing cases return the same message, which **names the last segment of the path** (`property
113
+ resolution error for title`) even though the field does exist on the related entity: it is the **traversal**
114
+ that is rejected, not the field. Do not go looking for another field name, and do not retry the same path in dot
115
+ notation (`"forum.title"`) — the two forms are strictly equivalent on the server side.
116
+
117
+ **Working around a to-many relation:** there is no native `groupBy`. Two options, both server-side:
118
+ - one `count` per value, with the `$any` quantifier in `filters` (see [query-language.md](./query-language.md#collection-operators)):
119
+ `{ "categorySubjects": { "$any": { "title": { "$eq": "Archived" } } } }`;
120
+ - or `query` requesting the relation in `selects`, then grouping client-side.
121
+
122
+ **Working around a to-one localizable field:** group on the foreign key (`{ "forumId": {} }`) then resolve
123
+ the labels with an `query` on the related entity.
124
+
125
+ ## Response
126
+ ```jsonc
127
+ {
128
+ "data": [ { "<key1>": …, "<key2>": …, "<agg1>": …, "<agg2>": … }, … ],
129
+ "count": 2, // number of rows returned (after limit)
130
+ "totalGroups": 2 // total number of distinct groups (independent of limit)
131
+ }
132
+ ```
133
+ - **Global aggregate** (no `groupBy`) → a single row. On an empty set → one row `{ count: 0, … : null }`.
134
+
135
+ ## Constraints / errors (400)
136
+ - `sum`/`avg`/`var*`/`stdDev*` require a **numeric** field.
137
+ - A multi-valued field is allowed as a **key** (denormalized) but **not** as an aggregate value.
138
+ - At most **3** multi-valued keys.
139
+ - `limit` required and ≤ cap.
140
+ - Field not found, inconsistent op×type, `dateTrunc` on a non-date, `bucketSize` on a non-numeric → 400 with message.
141
+ - **Not supported**: a key traversing a **to-many** relation, or a **localizable** field reached through
142
+ a relation (see "What can be traversed") → 400 `property resolution error for <last segment>`.
143
+ - **Not supported**: median / percentiles (limitation of the SQL `GROUP BY` translation).
144
+
145
+ ## Examples
146
+
147
+ ### 1. Registrations per month (date bucket) + average, filtered
148
+ ```json
149
+ {
150
+ "filters": { "registrationStatus": { "$eq": "registered" } },
151
+ "groupBy": [ { "name": "month", "value": { "registrationDate": {} }, "dateTrunc": "month" } ],
152
+ "aggregates": [
153
+ { "name": "total", "op": "count" },
154
+ { "name": "avgScore", "op": "avg", "value": { "score": {} } }
155
+ ],
156
+ "orders": [ { "name": "month", "desc": false } ],
157
+ "limit": 100
158
+ }
159
+ ```
160
+
161
+ ### 2. Facets: values of a multi-select field + count
162
+ ```json
163
+ {
164
+ "filters": { "country": { "$eq": "FR" } },
165
+ "groupBy": [ { "name": "choice", "value": { "testselect": {} } } ],
166
+ "aggregates": [ { "name": "total", "op": "count" } ],
167
+ "orders": [ { "name": "total", "desc": true } ],
168
+ "limit": 100
169
+ }
170
+ ```
171
+
172
+ ### 3. Cross-tab: country × month
173
+ ```json
174
+ {
175
+ "groupBy": [
176
+ { "name": "country", "value": { "country": {} } },
177
+ { "name": "month", "value": { "registrationDate": {} }, "dateTrunc": "month" }
178
+ ],
179
+ "aggregates": [ { "name": "total", "op": "count" } ],
180
+ "limit": 500
181
+ }
182
+ ```
183
+
184
+ ### 4. Amount ranges (numeric bucket) with HAVING
185
+ ```json
186
+ {
187
+ "groupBy": [ { "name": "bucket", "value": { "totalHt": {} }, "bucketSize": 100 } ],
188
+ "aggregates": [
189
+ { "name": "nbOrders", "op": "count" },
190
+ { "name": "sumHt", "op": "sum", "value": { "totalHt": {} } }
191
+ ],
192
+ "having": [ { "name": "nbOrders", "op": "gte", "value": 5 } ],
193
+ "orders": [ { "name": "bucket", "desc": false } ],
194
+ "limit": 100
195
+ }
196
+ ```
197
+
198
+ ### 5. Global statistics (no grouping)
199
+ ```json
200
+ {
201
+ "aggregates": [
202
+ { "name": "count", "op": "count" },
203
+ { "name": "avgHt", "op": "avg", "value": { "totalHt": {} } },
204
+ { "name": "minHt", "op": "min", "value": { "totalHt": {} } },
205
+ { "name": "maxHt", "op": "max", "value": { "totalHt": {} } },
206
+ { "name": "stdHt", "op": "stdDevSamp", "value": { "totalHt": {} } }
207
+ ],
208
+ "limit": 1
209
+ }
210
+ ```
211
+
212
+ ### 6. Distinct + sort by aggregate
213
+ ```json
214
+ {
215
+ "groupBy": [ { "name": "country", "value": { "country": {} } } ],
216
+ "aggregates": [
217
+ { "name": "members", "op": "count" },
218
+ { "name": "companies", "op": "countDistinct", "value": { "company": {} } }
219
+ ],
220
+ "orders": [ { "name": "members", "desc": true } ],
221
+ "limit": 50
222
+ }
223
+ ```
224
+
225
+ ### 7. Grouping on a to-one relation
226
+ ```json
227
+ {
228
+ "groupBy": [ { "name": "author", "value": { "member": { "lastname": {} } } } ],
229
+ "aggregates": [ { "name": "n", "op": "count" } ],
230
+ "orders": [ { "name": "n", "desc": true } ],
231
+ "limit": 50
232
+ }
233
+ ```
234
+ The `null` group gathers the entities whose relation is empty.
235
+
236
+ ---
237
+
238
+ *See also: [query-language.md](./query-language.md) (WHERE predicates, sorting), [entities.md](./entities.md).*