feat(query): sort_model_to_sql + sérialisation AST (#41)

Ajoute sort_model_to_sql (adapte le sortModel AG Grid vers sort_by_to_sql
existant) et ast_to_dict/ast_from_dict pour le round-trip JSON de l'AST,
nécessaire aux vues sauvegardées.
This commit is contained in:
Colin Maudry
2026-07-10 07:57:41 +02:00
parent 33ea1205cf
commit a7662a8561
2 changed files with 88 additions and 2 deletions
+50 -1
View File
@@ -11,7 +11,7 @@ from dataclasses import dataclass
import polars as pl
from src.utils import logger
from src.utils.table_sql import tokenize_text_filter
from src.utils.table_sql import sort_by_to_sql, tokenize_text_filter
@dataclass
@@ -212,3 +212,52 @@ def filtermodel_to_ast(filter_model, schema):
continue
children.append(node)
return And(children) if children else None
def sort_model_to_sql(sort_model: list | None, schema: pl.Schema) -> str:
"""Traduit un sortModel AG Grid en clause ORDER BY DuckDB (adapte à sort_by_to_sql)."""
if not sort_model:
return ""
sort_by = [
{"column_id": s.get("colId"), "direction": s.get("sort")} for s in sort_model
]
return sort_by_to_sql(sort_by, schema)
def ast_to_dict(node: Node) -> dict | None:
"""Sérialise un AST en dict JSON-compatible (pour persistance en base)."""
if node is None:
return None
if isinstance(node, Condition):
return {
"t": "cond",
"column": node.column,
"operator": node.operator,
"value": node.value,
"value2": node.value2,
}
if isinstance(node, And):
return {"t": "and", "children": [ast_to_dict(c) for c in node.children]}
if isinstance(node, Or):
return {"t": "or", "children": [ast_to_dict(c) for c in node.children]}
if isinstance(node, Not):
return {"t": "not", "child": ast_to_dict(node.child)}
return None
def ast_from_dict(data: dict | None) -> Node:
"""Reconstruit un AST à partir d'un dict produit par ast_to_dict."""
if data is None:
return None
t = data.get("t")
if t == "cond":
return Condition(
data["column"], data["operator"], data.get("value"), data.get("value2")
)
if t == "and":
return And([ast_from_dict(c) for c in data["children"]])
if t == "or":
return Or([ast_from_dict(c) for c in data["children"]])
if t == "not":
return Not(ast_from_dict(data["child"]))
return None
+38 -1
View File
@@ -1,6 +1,16 @@
import polars as pl
from src.utils.query_ast import And, Condition, Not, Or, ast_to_sql, filtermodel_to_ast
from src.utils.query_ast import (
And,
Condition,
Not,
Or,
ast_from_dict,
ast_to_dict,
ast_to_sql,
filtermodel_to_ast,
sort_model_to_sql,
)
SCHEMA = pl.Schema(
{
@@ -198,3 +208,30 @@ def test_filtermodel_multiple_columns_are_anded():
}
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