feat(query): AST de filtre + compilateur ast_to_sql (#41)
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).
This commit is contained in:
@@ -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", []
|
||||||
@@ -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%"]
|
||||||
Reference in New Issue
Block a user