Download apps/api/mbf/retrieval/sql_safety.py from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 2.9 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/retrieval/sql_safety.py
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/api/mbf/retrieval/sql_safety.py
-
curl -L -o sql_safety.py https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/retrieval/sql_safety.py
2.9 kB
| """SQL safety: AST allow-list (SELECT-only, no DDL/DML, no risky functions). | |
| Uses sqlglot to parse and validate any LLM-generated SQL before execution. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Iterable | |
| import sqlglot | |
| from sqlglot import exp | |
| ALLOWED_TABLES_PREFIXES: tuple[str, ...] = ("raw.", "dim.", "sem.") | |
| DENY_FUNCTIONS = {"load_file", "sleep", "benchmark", "user", "version"} | |
| MAX_LIMIT = 5000 | |
| class SqlValidation: | |
| ok: bool | |
| reason: str = "" | |
| rewritten: str = "" | |
| def validate_sql(sql: str, dialect: str = "postgres") -> SqlValidation: | |
| try: | |
| parsed = sqlglot.parse_one(sql, read=dialect) | |
| except Exception as e: # noqa: BLE001 | |
| return SqlValidation(False, f"parse_error: {e}") | |
| if not isinstance(parsed, (exp.Select, exp.Subqueryable, exp.With, exp.Union)): | |
| return SqlValidation(False, f"not_a_select: {type(parsed).__name__}") | |
| # Reject any DML/DDL nodes anywhere in tree. | |
| forbidden = ( | |
| exp.Insert, | |
| exp.Update, | |
| exp.Delete, | |
| exp.Drop, | |
| exp.Create, | |
| exp.AlterTable, | |
| exp.TruncateTable, | |
| exp.Merge, | |
| ) | |
| for node in parsed.walk(): | |
| n = node[0] if isinstance(node, tuple) else node | |
| if isinstance(n, forbidden): | |
| return SqlValidation(False, f"forbidden_statement:{type(n).__name__}") | |
| if isinstance(n, exp.Anonymous) and (n.name or "").lower() in DENY_FUNCTIONS: | |
| return SqlValidation(False, f"forbidden_function:{n.name}") | |
| # Table allow-list | |
| for tbl in parsed.find_all(exp.Table): | |
| fq = ".".join(p for p in [tbl.db, tbl.name] if p).lower() | |
| if not any(fq.startswith(p) or fq == p.rstrip(".") for p in ALLOWED_TABLES_PREFIXES): | |
| # Also allow bare names that match dim/raw common tables (no schema qualifier) | |
| if tbl.name.lower() in { | |
| "extended_bva_v5", "bva", "item_mb", "open_po", "expense_mb", | |
| "opening_budget_fy_25_26", "opening_underspents", "training_blocked_budget", | |
| "funders", "projects", "categories", "vendors", "employees", | |
| }: | |
| continue | |
| return SqlValidation(False, f"forbidden_table:{fq or tbl.name}") | |
| # Force a LIMIT cap | |
| if isinstance(parsed, exp.Select): | |
| lim = parsed.args.get("limit") | |
| if lim is None: | |
| parsed.set("limit", exp.Limit(expression=exp.Literal.number(MAX_LIMIT))) | |
| else: | |
| try: | |
| n = int(lim.expression.name) | |
| if n > MAX_LIMIT: | |
| parsed.set("limit", exp.Limit(expression=exp.Literal.number(MAX_LIMIT))) | |
| except Exception: | |
| pass | |
| return SqlValidation(True, rewritten=parsed.sql(dialect=dialect)) | |