From 9324a734d41aceacfdef7bb45c5e3cc1379a2a91 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 22 Jun 2026 13:59:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20parsing=20des=20op=C3=A9rateurs=20?= =?UTF-8?q?d'agr=C3=A9gation=20(groupby/count/sum/avg/min/max)=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/filters.py | 53 +++++++++++++++++++++++++++++++++++++++ tests/api/test_filters.py | 53 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/api/filters.py b/src/api/filters.py index 2106fdc..f30a7c7 100644 --- a/src/api/filters.py +++ b/src/api/filters.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import date, datetime import polars as pl @@ -20,6 +21,55 @@ OPERATORS = { RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"} +AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"} +AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"} + + +@dataclass +class AggregationSpec: + select_sql: str + group_by_sql: str | None + + +def parse_aggregators( + args: list[tuple[str, str]], schema: pl.Schema +) -> "AggregationSpec | None": + """Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...). + + Retourne None si aucun agrégateur. Sinon, construit les fragments SQL + `select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma). + """ + group_cols: list[str] = [] + aggregates: list[tuple[str, str]] = [] # (operator, column) + has_agg = False + + for key, _ in args: + parsed = _split_key(key) + if not parsed: + continue + col, op = parsed + if op not in AGGREGATORS: + continue + has_agg = True + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + if op == "groupby": + group_cols.append(col) + else: + aggregates.append((op, col)) + + if not has_agg: + return None + + select_parts = [f'"{c}"' for c in group_cols] + for op, col in aggregates: + select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"') + + group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None + return AggregationSpec( + select_sql=", ".join(select_parts), group_by_sql=group_by_sql + ) + class FilterError(ValueError): def __init__(self, message: str, field: str | None = None): @@ -85,6 +135,9 @@ def build_where( raise FilterError(f"Paramètre non reconnu : {key}", field=key) col, op = parsed + if op in AGGREGATORS: + continue + if op not in OPERATORS: raise FilterError(f"Opérateur inconnu : __{op}", field=key) diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py index 239a317..b3ecd39 100644 --- a/tests/api/test_filters.py +++ b/tests/api/test_filters.py @@ -1,7 +1,7 @@ import polars as pl import pytest -from src.api.filters import FilterError, build_where +from src.api.filters import AggregationSpec, FilterError, build_where, parse_aggregators SCHEMA = pl.Schema( { @@ -151,3 +151,54 @@ def test_differs_filter_on_int(): where, params, _ = build_where([("annee__differs", "2020")], SCHEMA) assert where == '"annee" IS DISTINCT FROM ?' assert params == [2020] + + +def test_parse_aggregators_none_when_absent(): + assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None + + +def test_parse_aggregators_groupby_and_count(): + spec = parse_aggregators([("annee__groupby", ""), ("uid__count", "")], SCHEMA) + assert isinstance(spec, AggregationSpec) + assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"' + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_multiple_aggregates(): + spec = parse_aggregators( + [ + ("annee__groupby", ""), + ("montant__sum", ""), + ("montant__avg", ""), + ("montant__min", ""), + ("montant__max", ""), + ], + SCHEMA, + ) + assert spec.select_sql == ( + '"annee", SUM("montant") AS "montant__sum", ' + 'AVG("montant") AS "montant__avg", ' + 'MIN("montant") AS "montant__min", ' + 'MAX("montant") AS "montant__max"' + ) + assert spec.group_by_sql == '"annee"' + + +def test_parse_aggregators_global_without_groupby(): + spec = parse_aggregators([("uid__count", "")], SCHEMA) + assert spec.select_sql == 'COUNT("uid") AS "uid__count"' + assert spec.group_by_sql is None + + +def test_parse_aggregators_unknown_column_raises(): + with pytest.raises(FilterError): + parse_aggregators([("nope__count", "")], SCHEMA) + + +def test_build_where_ignores_aggregator_flags(): + where, params, _ = build_where( + [("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")], + SCHEMA, + ) + assert where == '"montant" >= ?' + assert params == [100.0]