feat: ajouter traducteurs filter_query→SQL et sort_by→SQL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from src.utils import logger
|
||||||
|
from src.utils.table import split_filter_part
|
||||||
|
|
||||||
|
|
||||||
|
def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list]:
|
||||||
|
"""Traduit le DSL de filtres de dash_table.DataTable en fragment SQL DuckDB.
|
||||||
|
|
||||||
|
Retourne (where_clause, params) où where_clause est un fragment à injecter
|
||||||
|
après WHERE et params est la liste des valeurs à passer à
|
||||||
|
cursor.execute(sql, params). Les identifiants de colonnes sont validés
|
||||||
|
contre le schéma fourni ; jamais concaténés avec des valeurs utilisateur.
|
||||||
|
"""
|
||||||
|
if not filter_query:
|
||||||
|
return "TRUE", []
|
||||||
|
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
for part in filter_query.split(" && "):
|
||||||
|
col_name, operator, raw_value = split_filter_part(part)
|
||||||
|
if not isinstance(col_name, str) or not isinstance(raw_value, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if col_name not in schema.names():
|
||||||
|
logger.warning(f"Colonne inconnue ignorée : {col_name!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
col_type = str(schema[col_name])
|
||||||
|
is_numeric = col_type.startswith("Int") or col_type.startswith("Float")
|
||||||
|
quoted_col = f'"{col_name}"'
|
||||||
|
|
||||||
|
if is_numeric:
|
||||||
|
try:
|
||||||
|
value = (
|
||||||
|
int(raw_value) if col_type.startswith("Int") else float(raw_value)
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if operator == "contains":
|
||||||
|
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} = ?")
|
||||||
|
elif operator == ">":
|
||||||
|
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} > ?")
|
||||||
|
elif operator == "<":
|
||||||
|
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} < ?")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Opérateur invalide pour numérique : {operator!r}")
|
||||||
|
continue
|
||||||
|
params.append(value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# String / Date : toujours traité comme texte (parité avec Polars)
|
||||||
|
value = raw_value.strip('"')
|
||||||
|
col_is_date = col_type == "Date"
|
||||||
|
|
||||||
|
if operator == "contains":
|
||||||
|
if value.endswith("*") and not value.startswith("*"):
|
||||||
|
like = value[:-1] + "%"
|
||||||
|
elif value.startswith("*") and not value.endswith("*"):
|
||||||
|
like = "%" + value[1:]
|
||||||
|
else:
|
||||||
|
like = "%" + value + "%"
|
||||||
|
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
||||||
|
clauses.append(
|
||||||
|
f"{quoted_col} IS NOT NULL AND {target} <> '' AND {target} ILIKE ?"
|
||||||
|
)
|
||||||
|
params.append(like)
|
||||||
|
elif operator in (">", "<"):
|
||||||
|
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
||||||
|
clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?")
|
||||||
|
params.append(value)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Opérateur invalide pour chaîne : {operator!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not clauses:
|
||||||
|
return "TRUE", []
|
||||||
|
return " AND ".join(clauses), params
|
||||||
|
|
||||||
|
|
||||||
|
def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str:
|
||||||
|
"""Traduit sort_by (format Dash) en clause ORDER BY DuckDB.
|
||||||
|
|
||||||
|
Retourne '' si pas de tri (aucun ORDER BY à ajouter).
|
||||||
|
"""
|
||||||
|
if not sort_by:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
fragments: list[str] = []
|
||||||
|
for entry in sort_by:
|
||||||
|
col = entry.get("column_id")
|
||||||
|
direction = entry.get("direction")
|
||||||
|
if col not in schema.names():
|
||||||
|
logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}")
|
||||||
|
continue
|
||||||
|
if direction not in ("asc", "desc"):
|
||||||
|
continue
|
||||||
|
fragments.append(f'"{col}" {direction.upper()} NULLS LAST')
|
||||||
|
|
||||||
|
return ", ".join(fragments)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import polars as pl
|
||||||
|
|
||||||
|
SCHEMA = pl.Schema(
|
||||||
|
{
|
||||||
|
"uid": pl.String,
|
||||||
|
"objet": pl.String,
|
||||||
|
"acheteur_id": pl.String,
|
||||||
|
"montant": pl.Float64,
|
||||||
|
"dureeMois": pl.Int64,
|
||||||
|
"dateNotification": pl.Date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_filter_returns_true():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("", SCHEMA)
|
||||||
|
assert where == "TRUE"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_icontains_string_is_case_insensitive_like():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{objet} icontains travaux", SCHEMA)
|
||||||
|
assert where == '"objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?'
|
||||||
|
assert params == ["%travaux%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_icontains_with_trailing_wildcard_is_starts_with():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql(
|
||||||
|
"{acheteur_id} icontains 24350013900189*", SCHEMA
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
where
|
||||||
|
== '"acheteur_id" IS NOT NULL AND "acheteur_id" <> \'\' AND "acheteur_id" ILIKE ?'
|
||||||
|
)
|
||||||
|
assert params == ["24350013900189%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_icontains_with_leading_wildcard_is_ends_with():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{uid} icontains *2024", SCHEMA)
|
||||||
|
assert where == '"uid" IS NOT NULL AND "uid" <> \'\' AND "uid" ILIKE ?'
|
||||||
|
assert params == ["%2024"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_greater_than():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{montant} i> 40000", SCHEMA)
|
||||||
|
assert where == '"montant" IS NOT NULL AND "montant" > ?'
|
||||||
|
assert params == [40000.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_less_than():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{montant} i< 1000", SCHEMA)
|
||||||
|
assert where == '"montant" IS NOT NULL AND "montant" < ?'
|
||||||
|
assert params == [1000.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_equality_via_icontains():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{dureeMois} icontains 12", SCHEMA)
|
||||||
|
assert where == '"dureeMois" IS NOT NULL AND "dureeMois" = ?'
|
||||||
|
assert params == [12]
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_column_treated_as_string_ilike():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{dateNotification} icontains 2024*", SCHEMA)
|
||||||
|
assert "ILIKE" in where
|
||||||
|
assert params == ["2024%"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_filters_joined_by_and():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
filter_query = "{objet} icontains voirie && {montant} i> 40000"
|
||||||
|
where, params = filter_query_to_sql(filter_query, SCHEMA)
|
||||||
|
assert " AND " in where
|
||||||
|
assert params == ["%voirie%", 40000.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_numeric_value_is_skipped():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{montant} i> notanumber", SCHEMA)
|
||||||
|
assert where == "TRUE"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_column_is_skipped():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql("{inexistant} icontains foo", SCHEMA)
|
||||||
|
assert where == "TRUE"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_identifier_with_quotes_not_concatenation():
|
||||||
|
from src.utils.table_sql import filter_query_to_sql
|
||||||
|
|
||||||
|
where, params = filter_query_to_sql(
|
||||||
|
"{objet} icontains '; DROP TABLE decp; --", SCHEMA
|
||||||
|
)
|
||||||
|
assert "DROP TABLE" not in where
|
||||||
|
assert any("DROP TABLE" in str(p) for p in params)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_empty():
|
||||||
|
from src.utils.table_sql import sort_by_to_sql
|
||||||
|
|
||||||
|
assert sort_by_to_sql([], SCHEMA) == ""
|
||||||
|
assert sort_by_to_sql(None, SCHEMA) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_single_column_desc():
|
||||||
|
from src.utils.table_sql import sort_by_to_sql
|
||||||
|
|
||||||
|
result = sort_by_to_sql([{"column_id": "montant", "direction": "desc"}], SCHEMA)
|
||||||
|
assert result == '"montant" DESC NULLS LAST'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_multiple_columns_preserves_order():
|
||||||
|
from src.utils.table_sql import sort_by_to_sql
|
||||||
|
|
||||||
|
result = sort_by_to_sql(
|
||||||
|
[
|
||||||
|
{"column_id": "dateNotification", "direction": "desc"},
|
||||||
|
{"column_id": "montant", "direction": "asc"},
|
||||||
|
],
|
||||||
|
SCHEMA,
|
||||||
|
)
|
||||||
|
assert result == '"dateNotification" DESC NULLS LAST, "montant" ASC NULLS LAST'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_ignores_unknown_column():
|
||||||
|
from src.utils.table_sql import sort_by_to_sql
|
||||||
|
|
||||||
|
result = sort_by_to_sql([{"column_id": "fake", "direction": "asc"}], SCHEMA)
|
||||||
|
assert result == ""
|
||||||
Reference in New Issue
Block a user