From 68f615370fdcbab6bb6a44b430f3e11e17aabf2b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 10 Jul 2026 07:44:30 +0200 Subject: [PATCH] feat(query): AST de filtre + compilateur ast_to_sql (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute une représentation canonique du filtre sous forme d'AST booléen (Condition/And/Or/Not) et son compilateur vers SQL DuckDB paramétré (ast_to_sql). Réutilise tokenize_text_filter pour les feuilles texte. Fondation pour la migration /tableau vers dash-ag-grid : cet AST sera alimenté par le filterModel d'AG Grid (tâche suivante) et, plus tard, par un champ de requête booléenne libre (#97). --- src/utils/query_ast.py | 139 ++++++++++++++++++++++++++++++++++++++++ tests/test_query_ast.py | 106 ++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/utils/query_ast.py create mode 100644 tests/test_query_ast.py diff --git a/src/utils/query_ast.py b/src/utils/query_ast.py new file mode 100644 index 0000000..9affe48 --- /dev/null +++ b/src/utils/query_ast.py @@ -0,0 +1,139 @@ +"""Représentation canonique d'un filtre (AST booléen) et compilation en SQL DuckDB. + +Ce module est indépendant de l'UI : plusieurs producteurs (filtres de colonne +AG Grid, futur champ de requête booléenne #97) construisent le même AST, compilé +ici en SQL paramétré. Les identifiants de colonnes sont validés contre le schéma ; +les valeurs passent toujours par le binding `?` (jamais concaténées). +""" + +from dataclasses import dataclass + +import polars as pl + +from src.utils import logger +from src.utils.table_sql import tokenize_text_filter + + +@dataclass +class Condition: + column: str + operator: str + value: object = None + value2: object = None + + +@dataclass +class And: + children: list + + +@dataclass +class Or: + children: list + + +@dataclass +class Not: + child: object + + +Node = object # Condition | And | Or | Not | None + + +def ast_to_sql(node, schema: pl.Schema) -> tuple[str, list]: + """Compile un AST en (where_sql, params). Nœud neutre -> ('TRUE', []).""" + if node is None: + return "TRUE", [] + if isinstance(node, And): + return _join(node.children, "AND", schema) + if isinstance(node, Or): + return _join(node.children, "OR", schema) + if isinstance(node, Not): + sql, params = ast_to_sql(node.child, schema) + if sql == "TRUE": + return "TRUE", [] + return f"NOT ({sql})", params + if isinstance(node, Condition): + return _condition_to_sql(node, schema) + logger.warning(f"Nœud AST inconnu ignoré : {node!r}") + return "TRUE", [] + + +def _join(children, op: str, schema: pl.Schema) -> tuple[str, list]: + fragments: list[str] = [] + params: list = [] + for child in children: + sql, child_params = ast_to_sql(child, schema) + if sql == "TRUE": + continue + fragments.append(f"({sql})") + params.extend(child_params) + if not fragments: + return "TRUE", [] + return f" {op} ".join(fragments), params + + +def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]: + col = cond.column + if col not in schema.names(): + logger.warning(f"Colonne inconnue ignorée : {col!r}") + return "TRUE", [] + + col_type = schema[col] + quoted = f'"{col}"' + + if cond.operator == "blank": + return f"({quoted} IS NULL OR {quoted} = '')", [] + if cond.operator == "notBlank": + return f"({quoted} IS NOT NULL AND {quoted} <> '')", [] + + is_numeric = col_type.is_numeric() + col_is_date = col_type == pl.Date + + if is_numeric: + return _numeric_to_sql(cond, col_type, quoted) + + # texte / date : traité comme texte (parité avec l'existant) + if cond.operator == "contains": + return tokenize_text_filter(col, str(cond.value), col_is_date) + if cond.operator == "notContains": + where, params = tokenize_text_filter(col, str(cond.value), col_is_date) + return f"NOT ({where})", params + + target = f"CAST({quoted} AS VARCHAR)" if col_is_date else quoted + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + return f"{quoted} IS NOT NULL AND {target} {op_map[cond.operator]} ?", [ + str(cond.value) + ] + if cond.operator == "startsWith": + return f"{quoted} ILIKE ?", [f"{cond.value}%"] + if cond.operator == "endsWith": + return f"{quoted} ILIKE ?", [f"%{cond.value}"] + logger.warning(f"Opérateur texte invalide : {cond.operator!r}") + return "TRUE", [] + + +def _coerce_number(value, col_type): + try: + return int(value) if col_type.is_integer() else float(value) + except (TypeError, ValueError): + logger.warning(f"Valeur numérique invalide ignorée : {value!r}") + return None + + +def _numeric_to_sql(cond: Condition, col_type, quoted: str) -> tuple[str, list]: + op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="} + if cond.operator in op_map: + v = _coerce_number(cond.value, col_type) + if v is None: + return "TRUE", [] + return f"{quoted} IS NOT NULL AND {quoted} {op_map[cond.operator]} ?", [v] + if cond.operator == "range": + v1 = _coerce_number(cond.value, col_type) + v2 = _coerce_number(cond.value2, col_type) + if v1 is None or v2 is None: + return "TRUE", [] + return f"{quoted} BETWEEN ? AND ?", [v1, v2] + logger.warning(f"Opérateur numérique invalide : {cond.operator!r}") + return "TRUE", [] diff --git a/tests/test_query_ast.py b/tests/test_query_ast.py new file mode 100644 index 0000000..8d58ffb --- /dev/null +++ b/tests/test_query_ast.py @@ -0,0 +1,106 @@ +import polars as pl + +from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql + +SCHEMA = pl.Schema( + { + "acheteur_nom": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "dureeMois": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def _run(node): + """Compile et retourne (sql, params).""" + return ast_to_sql(node, SCHEMA) + + +def test_none_is_true(): + assert _run(None) == ("TRUE", []) + + +def test_empty_and_is_true(): + assert _run(And([])) == ("TRUE", []) + + +def test_text_contains_uses_ilike_and_params(): + sql, params = _run(Condition("objet", "contains", "voirie")) + assert "ILIKE ?" in sql + assert params == ["%voirie%"] + + +def test_text_contains_multiword_is_and(): + sql, params = _run(Condition("objet", "contains", "metropole rennes")) + assert sql.count("ILIKE ?") == 2 + assert params == ["%metropole%", "%rennes%"] + + +def test_text_contains_wildcard_and_phrase(): + _, params = _run(Condition("objet", "contains", "distri* metropole+rennes")) + assert params == ["distri%", "%metropole rennes%"] + + +def test_text_notcontains_negates(): + sql, params = _run(Condition("objet", "notContains", "construction")) + assert "NOT (" in sql + assert params == ["%construction%"] + + +def test_numeric_gt(): + sql, params = _run(Condition("montant", "gt", 40000)) + assert '"montant" > ?' in sql + assert params == [40000.0] + + +def test_numeric_eq_int_column(): + sql, params = _run(Condition("dureeMois", "eq", "12")) + assert '"dureeMois" = ?' in sql + assert params == [12] + + +def test_numeric_range(): + sql, params = _run(Condition("montant", "range", 100, 200)) + assert params == [100.0, 200.0] + assert "BETWEEN" in sql or ("> ?" in sql and "< ?" in sql) + + +def test_numeric_invalid_value_is_true(): + # valeur non numérique -> condition neutralisée (TRUE), pas d'exception + assert _run(Condition("montant", "gt", "abc")) == ("TRUE", []) + + +def test_date_gt_casts_varchar(): + sql, params = _run(Condition("dateNotification", "gt", "2022")) + assert "VARCHAR" in sql + assert params == ["2022"] + + +def test_blank_and_notblank(): + sql_b, _ = _run(Condition("objet", "blank")) + assert "IS NULL" in sql_b + sql_nb, _ = _run(Condition("objet", "notBlank")) + assert "IS NOT NULL" in sql_nb + + +def test_unknown_column_is_true(): + assert _run(Condition("colonne_inexistante", "contains", "x")) == ("TRUE", []) + + +def test_and_or_not_grouping(): + node = And( + [ + Or( + [ + Condition("objet", "contains", "beton"), + Condition("objet", "contains", "ciment"), + ] + ), + Not(Condition("objet", "contains", "demolition")), + ] + ) + sql, params = _run(node) + assert " OR " in sql and " AND " in sql and "NOT (" in sql + assert params == ["%beton%", "%ciment%", "%demolition%"]