diff --git a/src/utils/table_sql.py b/src/utils/table_sql.py new file mode 100644 index 0000000..bf00af0 --- /dev/null +++ b/src/utils/table_sql.py @@ -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) diff --git a/tests/test_table_sql.py b/tests/test_table_sql.py new file mode 100644 index 0000000..8675e19 --- /dev/null +++ b/tests/test_table_sql.py @@ -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 == ""