Files
colibre/tests/test_query_ast.py
T
Colin Maudry 117ce9a4ab fix(tableau): filtres à conditions multiples ignorés + tri non réinitialisé (#41)
Deux bugs découverts en usage réel sur /tableau :

1. filtermodel_to_ast lisait condition1/condition2 pour un filtre à
   deux conditions (ET/OU) sur une colonne — mais AG Grid >=29.2 (la
   version 35.2.0 utilisée ici) encode ça via une liste `conditions`,
   confirmé par la doc Dash AG Grid "Filter Model & Dash Callbacks" >
   "Filter Model Multiple Conditions". Le filtre était donc
   silencieusement ignoré (colonne exclue du AST, comme si aucun
   filtre n'était posé), d'où des résultats sans rapport avec les
   valeurs saisies. Corrigé : filtermodel_to_ast lit désormais
   `conditions` (N éléments) en priorité, avec repli sur
   condition1/condition2 (forme dépréciée mais "still accepted" selon
   AG Grid). L'inverse (ast_to_filtermodel, utilisé au rappel d'une
   vue sauvegardée) produit aussi la forme `conditions`.

2. Le bouton "Réinitialiser" ("Supprime tous les filtres et les tris")
   ne réinitialisait que filterModel, jamais le tri. Corrigé via
   resetColumnState (remet les colonnes à l'état de columnDefs, qui
   reflète déjà la visibilité choisie via le sélecteur de colonnes —
   seul le tri est donc affecté).

Reproduit et corrigé en TDD (tests/test_query_ast.py,
tests/test_grid.py), vérifié manuellement en navigateur par l'auteur
du rapport de bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:21:53 +02:00

380 lines
11 KiB
Python

import polars as pl
from src.utils.query_ast import (
And,
Condition,
Not,
Or,
ast_from_dict,
ast_to_dict,
ast_to_filtermodel,
ast_to_sql,
filtermodel_to_ast,
sort_model_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_date_range_uses_between():
sql, params = _run(
Condition("dateNotification", "range", "2022-01-01", "2022-12-31")
)
assert "BETWEEN" in sql
assert params == ["2022-01-01", "2022-12-31"]
def test_text_range_uses_between():
sql, params = _run(Condition("acheteur_nom", "range", "a", "m"))
assert "BETWEEN" in sql
assert params == ["a", "m"]
def test_blank_on_numeric_column_no_empty_string():
sql, params = _run(Condition("montant", "blank"))
assert sql == '"montant" IS NULL'
assert params == []
def test_notblank_on_date_column_no_empty_string():
sql, params = _run(Condition("dateNotification", "notBlank"))
assert sql == '"dateNotification" IS NOT NULL'
assert params == []
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%"]
def test_filtermodel_empty_is_none():
assert filtermodel_to_ast(None, SCHEMA) is None
assert filtermodel_to_ast({}, SCHEMA) is None
def test_filtermodel_text_contains():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == ["%voirie%"]
def test_filtermodel_number_greaterthan():
fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert '"montant"' in sql and params == [40000.0]
def test_filtermodel_number_inrange():
fm = {
"montant": {
"filterType": "number",
"type": "inRange",
"filter": 100,
"filterTo": 200,
}
}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == [100.0, 200.0]
def test_filtermodel_date_uses_datefrom():
fm = {
"dateNotification": {
"filterType": "date",
"type": "greaterThan",
"dateFrom": "2022-01-01",
}
}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == ["2022-01-01"]
def test_filtermodel_two_conditions_or():
fm = {
"objet": {
"filterType": "text",
"operator": "OR",
"condition1": {"filterType": "text", "type": "contains", "filter": "beton"},
"condition2": {
"filterType": "text",
"type": "contains",
"filter": "ciment",
},
}
}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert " OR " in sql and params == ["%beton%", "%ciment%"]
def test_filtermodel_two_conditions_or_real_ag_grid_shape():
"""AG Grid >=29.2 (pinné à 35.2.0 ici) envoie 'conditions' (liste), pas
condition1/condition2 (déprécié). Voir la doc Dash AG Grid "Filter Model
& Dash Callbacks" > "Filter Model Multiple Conditions". Bug de régression :
filtermodel_to_ast ignorait silencieusement ces filtres (#41)."""
fm = {
"acheteur_nom": {
"filterType": "text",
"operator": "OR",
"conditions": [
{"filterType": "text", "type": "contains", "filter": "91"},
{"filterType": "text", "type": "contains", "filter": "78"},
],
}
}
ast = filtermodel_to_ast(fm, SCHEMA)
assert ast is not None, "le filtre ne doit pas être silencieusement ignoré"
sql, params = ast_to_sql(ast, SCHEMA)
assert " OR " in sql
assert params == ["%91%", "%78%"]
def test_filtermodel_conditions_and_three_values():
"""La liste 'conditions' n'est pas limitée à 2 éléments (maxNumConditions)."""
fm = {
"objet": {
"filterType": "text",
"operator": "AND",
"conditions": [
{"filterType": "text", "type": "contains", "filter": "voirie"},
{"filterType": "text", "type": "notContains", "filter": "reparation"},
{"filterType": "text", "type": "contains", "filter": "route"},
],
}
}
ast = filtermodel_to_ast(fm, SCHEMA)
sql, params = ast_to_sql(ast, SCHEMA)
assert sql.count(" AND ") >= 2
assert params == ["%voirie%", "%reparation%", "%route%"]
def test_filtermodel_multiple_columns_are_anded():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},
"montant": {"filterType": "number", "type": "greaterThan", "filter": 1000},
}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert " AND " in sql and set(params) == {"%voirie%", 1000.0}
def test_sort_model_to_sql():
sm = [{"colId": "montant", "sort": "desc"}, {"colId": "dureeMois", "sort": "asc"}]
out = sort_model_to_sql(sm, SCHEMA)
assert out == '"montant" DESC NULLS LAST, "dureeMois" ASC NULLS LAST'
def test_sort_model_empty():
assert sort_model_to_sql(None, SCHEMA) == ""
assert sort_model_to_sql([], SCHEMA) == ""
def test_ast_dict_roundtrip():
node = And(
[
Or([Condition("objet", "contains", "beton")]),
Not(Condition("objet", "contains", "x")),
]
)
restored = ast_from_dict(ast_to_dict(node))
assert ast_to_sql(restored, SCHEMA) == ast_to_sql(node, SCHEMA)
def test_ast_dict_none():
assert ast_to_dict(None) is None
assert ast_from_dict(None) is None
def _roundtrip_sql(fm):
"""Compile fm -> ast -> filterModel -> ast à nouveau, renvoie (sql, params)
de la première et de la seconde compilation, pour vérifier l'équivalence
sémantique du round-trip (pas l'égalité dict-à-dict)."""
ast1 = filtermodel_to_ast(fm, SCHEMA)
rebuilt_fm = ast_to_filtermodel(ast1, SCHEMA)
ast2 = filtermodel_to_ast(rebuilt_fm, SCHEMA)
return ast_to_sql(ast1, SCHEMA), ast_to_sql(ast2, SCHEMA)
def test_ast_to_filtermodel_roundtrip_text_contains():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_greaterthan():
fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_inrange():
fm = {
"montant": {
"filterType": "number",
"type": "inRange",
"filter": 100,
"filterTo": 200,
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_date_greaterthan():
fm = {
"dateNotification": {
"filterType": "date",
"type": "greaterThan",
"dateFrom": "2022-01-01",
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_two_conditions_or():
fm = {
"objet": {
"filterType": "text",
"operator": "OR",
"condition1": {"filterType": "text", "type": "contains", "filter": "beton"},
"condition2": {
"filterType": "text",
"type": "contains",
"filter": "ciment",
},
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_multiple_columns():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},
"montant": {"filterType": "number", "type": "greaterThan", "filter": 1000},
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_none_and_empty_and():
assert ast_to_filtermodel(None, SCHEMA) == {}
assert ast_to_filtermodel(And([]), SCHEMA) == {}
def test_ast_to_filtermodel_skips_not_with_warning():
node = And([Not(Condition("objet", "contains", "x"))])
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_skips_mismatched_columns_with_warning():
node = And(
[Or([Condition("objet", "contains", "a"), Condition("montant", "gt", 1)])]
)
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_bare_single_condition():
"""filtermodel_to_ast enveloppe toujours dans And, mais on tolère un nœud
non enveloppé (Condition seule) en entrée, défensivement."""
node = Condition("objet", "contains", "voirie")
fm = ast_to_filtermodel(node, SCHEMA)
assert fm == {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}
}