docs: spec parité API decp.info / tabular-api data.gouv.fr (#78)
Opérateurs manquants : differs + agrégation (groupby/count/sum/avg/min/max), renommage du param réservé count -> count_results. or hors périmètre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
# Parité de l'API decp.info avec tabular-api (data.gouv.fr) — opérateurs manquants
|
||||||
|
|
||||||
|
**Date :** 2026-06-22
|
||||||
|
**Périmètre :** `count_results` + `differs` + suite d'agrégation. **Hors périmètre :** le paramètre réservé `or` (itération dédiée ultérieure).
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'API `/api/v1/data` de decp.info reproduit le schéma de requête de
|
||||||
|
`tabular-api` (`datagouv/api-tabular`), qui sert la même donnée DECP sur
|
||||||
|
data.gouv.fr. L'objectif est d'atteindre la parité sur les **opérateurs**
|
||||||
|
de filtrage/agrégation, pour qu'une requête écrite pour data.gouv.fr
|
||||||
|
fonctionne à l'identique sur decp.info.
|
||||||
|
|
||||||
|
Source faisant autorité du comportement cible : `api_tabular/core/query.py`
|
||||||
|
du dépôt `datagouv/api-tabular`. Tous les comportements ci-dessous ont été
|
||||||
|
vérifiés en direct contre la ressource DECP
|
||||||
|
`22847056-61df-452d-837d-8b8ceadbfc52`.
|
||||||
|
|
||||||
|
### Écart constaté
|
||||||
|
|
||||||
|
Opérateurs présents chez data.gouv.fr et absents de decp.info :
|
||||||
|
|
||||||
|
| Mot-clé | Nature |
|
||||||
|
| ----------------------------------- | ----------------------------------- |
|
||||||
|
| `differs` | opérateur de filtre |
|
||||||
|
| `groupby` | drapeau d'agrégation (sans valeur) |
|
||||||
|
| `count`, `sum`, `avg`, `min`, `max` | drapeaux d'agrégation (sans valeur) |
|
||||||
|
|
||||||
|
De plus, le paramètre réservé `count=true|false` de decp.info entre en
|
||||||
|
collision avec l'opérateur d'agrégation `count` de data.gouv.fr.
|
||||||
|
|
||||||
|
État courant pertinent :
|
||||||
|
|
||||||
|
- `src/api/filters.py` : `OPERATORS`, `RESERVED_PARAMS`, `build_where()`.
|
||||||
|
- `src/api/routes.py` : route `data()`, doc swagger des paramètres.
|
||||||
|
- `src/db.py` : `query_marches()`, `count_marches()`.
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
1. Renommer le paramètre réservé `count` → `count_results` (valeurs
|
||||||
|
`true|false`, défaut `true`), libérant `count` comme opérateur.
|
||||||
|
2. Ajouter l'opérateur de filtre `differs`.
|
||||||
|
3. Ajouter les opérateurs d'agrégation `groupby`, `count`, `sum`, `avg`,
|
||||||
|
`min`, `max`, avec la même forme de réponse que data.gouv.fr.
|
||||||
|
|
||||||
|
Non-objectifs : le paramètre `or` (grammaire récursive imbriquée), les
|
||||||
|
opérateurs `groupby`/agrégats appliqués via `or`, toute évolution du
|
||||||
|
benchmark (sera traitée après).
|
||||||
|
|
||||||
|
## Conception
|
||||||
|
|
||||||
|
### 1. Renommage `count` → `count_results`
|
||||||
|
|
||||||
|
- `RESERVED_PARAMS` : `{"page", "page_size", "columns", "count_results"}`.
|
||||||
|
- `routes.data()` : lire `request.args.get("count_results", "true")`.
|
||||||
|
- Doc swagger : remplacer le paramètre `count` par `count_results`, même
|
||||||
|
description (« inclure le total `COUNT(*)` ; `false` pour accélérer »).
|
||||||
|
- Le mot `count` n'est donc plus réservé ; il est interprété comme
|
||||||
|
opérateur d'agrégation (section 3).
|
||||||
|
|
||||||
|
**Rupture de contrat assumée :** un client qui passait `count=false`
|
||||||
|
verra ce paramètre ré-interprété. Acceptable : l'API est privée et
|
||||||
|
l'objectif est explicitement la parité avec data.gouv.fr. À documenter
|
||||||
|
dans le message de commit.
|
||||||
|
|
||||||
|
### 2. Opérateur `differs`
|
||||||
|
|
||||||
|
Sémantique data.gouv.fr : `col__differs=val` → PostgREST `isdistinct`, soit
|
||||||
|
`IS DISTINCT FROM` (≠ null-safe : `NULL differs 44` est vrai).
|
||||||
|
|
||||||
|
- Ajouter `"differs"` à `OPERATORS`.
|
||||||
|
- Dans `build_where()`, après coercition de la valeur :
|
||||||
|
`where_parts.append('"col" IS DISTINCT FROM ?')` ; `params.append(v)`.
|
||||||
|
- DuckDB supporte nativement `IS DISTINCT FROM`.
|
||||||
|
|
||||||
|
### 3. Opérateurs d'agrégation
|
||||||
|
|
||||||
|
#### Forme des requêtes (vérifiée)
|
||||||
|
|
||||||
|
Drapeaux **sans valeur** dans la query string :
|
||||||
|
`?acheteur_departement_code__groupby&uid__count&montant__sum&montant__avg&montant__min&montant__max`
|
||||||
|
|
||||||
|
Une valeur (`__groupby=1`) est un cas d'erreur côté data.gouv.fr ; on
|
||||||
|
n'impose pas cette stricte interdiction mais on accepte la forme sans
|
||||||
|
valeur (Werkzeug fournit alors la valeur `""`).
|
||||||
|
|
||||||
|
Opérateurs : `groupby`, `count`, `sum`, `avg`, `min`, `max`.
|
||||||
|
|
||||||
|
#### Forme de la réponse (vérifiée)
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT <cols groupby>, FN("<col>") AS "<col>__<op>", ...
|
||||||
|
FROM decp
|
||||||
|
WHERE <filtres>
|
||||||
|
GROUP BY <cols groupby>
|
||||||
|
LIMIT <page_size> OFFSET <offset>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Colonnes de sortie : la colonne `groupby` garde son nom ; chaque agrégat
|
||||||
|
est nommé `"<colonne>__<opérateur>"` (ex. `uid__count`, `montant__sum`).
|
||||||
|
- `meta` : `{"page", "page_size"}` **sans `total`** (data.gouv.fr n'en
|
||||||
|
renvoie pas en mode agrégation). `count_results` est ignoré dans ce mode.
|
||||||
|
- Pas de tri par défaut.
|
||||||
|
- Les filtres `WHERE` (y compris `differs`) restent appliqués.
|
||||||
|
|
||||||
|
#### Contraintes répliquées
|
||||||
|
|
||||||
|
- `columns` + agrégation → erreur 400 (`columns ne peut pas être combiné avec des agrégateurs`). Vérifié identique chez data.gouv.fr.
|
||||||
|
- Un agrégat (`count`/`sum`/…) sans `groupby` est autorisé (agrégat global,
|
||||||
|
une ligne).
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
Nouvelle fonction de parsing dans `src/api/filters.py` :
|
||||||
|
|
||||||
|
```
|
||||||
|
parse_aggregators(args, schema) -> AggregationSpec | None
|
||||||
|
```
|
||||||
|
|
||||||
|
- Retourne `None` si aucun opérateur d'agrégation présent → la route suit
|
||||||
|
le chemin existant.
|
||||||
|
- Sinon retourne les colonnes `groupby` et la liste des agrégats
|
||||||
|
`(fonction_sql, colonne, alias)`.
|
||||||
|
- Valide que chaque colonne existe dans le schéma ; opérateur inconnu →
|
||||||
|
`FilterError`.
|
||||||
|
|
||||||
|
`build_where()` est inchangé pour WHERE/ORDER ; il continue d'ignorer les
|
||||||
|
clés réservées et **doit ignorer les drapeaux d'agrégation** (ne pas les
|
||||||
|
traiter comme des filtres). Comme les drapeaux d'agrégation arrivent comme
|
||||||
|
`(col__op, "")`, et que `op` ∈ agrégateurs, `build_where` les saute.
|
||||||
|
|
||||||
|
Nouvelle fonction dans `src/db.py` :
|
||||||
|
|
||||||
|
```
|
||||||
|
aggregate_marches(select_sql, where_sql, params, group_by, limit, offset) -> pl.DataFrame
|
||||||
|
```
|
||||||
|
|
||||||
|
- `select_sql` et `group_by` sont des fragments SQL construits depuis des
|
||||||
|
noms de colonnes validés contre le schéma (jamais de valeur utilisateur
|
||||||
|
libre) ; les valeurs de filtre passent par le binding `?`.
|
||||||
|
|
||||||
|
Orchestration dans `routes.data()` :
|
||||||
|
|
||||||
|
```
|
||||||
|
agg = parse_aggregators(args, schema)
|
||||||
|
where_sql, params, order_sql = build_where(args, schema) # filtres seuls
|
||||||
|
if agg:
|
||||||
|
if columns: -> abort(400)
|
||||||
|
df = aggregate_marches(agg.select_sql, where_sql, params, agg.group_by, page_size, offset)
|
||||||
|
meta = {"page", "page_size"} # pas de total
|
||||||
|
else:
|
||||||
|
<chemin existant>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sécurité SQL
|
||||||
|
|
||||||
|
Les noms de colonnes proviennent du schéma DuckDB validé (`col in schema`),
|
||||||
|
jamais interpolés depuis une valeur arbitraire ; les fonctions d'agrégation
|
||||||
|
sont une liste blanche fixe (`COUNT/SUM/AVG/MIN/MAX`). Les valeurs de
|
||||||
|
filtre restent liées par paramètres `?`. Aucun chemin n'interpole de valeur
|
||||||
|
utilisateur dans le SQL.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Tests existants à adapter (renommage `count` → `count_results`).
|
||||||
|
|
||||||
|
Nouveaux tests (`tests/` API) :
|
||||||
|
|
||||||
|
- `differs` : exclut les lignes égales, inclut les NULL.
|
||||||
|
- agrégation `groupby` + `count` : nombre de groupes, noms de colonnes
|
||||||
|
`col__count`.
|
||||||
|
- agrégation multiple `groupby`+`count`+`sum`+`avg`+`min`+`max` : alias et
|
||||||
|
types corrects, `meta` sans `total`.
|
||||||
|
- agrégat sans `groupby` : une ligne.
|
||||||
|
- `groupby` + filtre `WHERE` : le filtre s'applique avant l'agrégation.
|
||||||
|
- `columns` + agrégation : 400.
|
||||||
|
- `count_results=false` : réponse sans `total` (chemin non-agrégé).
|
||||||
|
- non-régression : opérateurs existants inchangés.
|
||||||
|
|
||||||
|
Comparaison de référence : pour quelques requêtes, les valeurs agrégées
|
||||||
|
doivent correspondre à celles renvoyées par data.gouv.fr sur la même
|
||||||
|
ressource (aux différences de fraîcheur de données près).
|
||||||
|
|
||||||
|
## Gestion des erreurs
|
||||||
|
|
||||||
|
- Opérateur inconnu, colonne inconnue → `FilterError` → 400 (existant).
|
||||||
|
- `columns` + agrégation → 400 avec message explicite.
|
||||||
|
- Valeur non coercible pour `differs` → `FilterError` (existant via
|
||||||
|
`_coerce`).
|
||||||
Reference in New Issue
Block a user