feat(api): parsing des opérateurs d'agrégation (groupby/count/sum/avg/min/max) (#78)
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
@@ -20,6 +21,55 @@ OPERATORS = {
|
|||||||
|
|
||||||
RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"}
|
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):
|
class FilterError(ValueError):
|
||||||
def __init__(self, message: str, field: str | None = None):
|
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)
|
raise FilterError(f"Paramètre non reconnu : {key}", field=key)
|
||||||
col, op = parsed
|
col, op = parsed
|
||||||
|
|
||||||
|
if op in AGGREGATORS:
|
||||||
|
continue
|
||||||
|
|
||||||
if op not in OPERATORS:
|
if op not in OPERATORS:
|
||||||
raise FilterError(f"Opérateur inconnu : __{op}", field=key)
|
raise FilterError(f"Opérateur inconnu : __{op}", field=key)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import polars as pl
|
import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.api.filters import FilterError, build_where
|
from src.api.filters import AggregationSpec, FilterError, build_where, parse_aggregators
|
||||||
|
|
||||||
SCHEMA = pl.Schema(
|
SCHEMA = pl.Schema(
|
||||||
{
|
{
|
||||||
@@ -151,3 +151,54 @@ def test_differs_filter_on_int():
|
|||||||
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
|
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
|
||||||
assert where == '"annee" IS DISTINCT FROM ?'
|
assert where == '"annee" IS DISTINCT FROM ?'
|
||||||
assert params == [2020]
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user