pavansai1997 commented on code in PR #76230:
URL: https://github.com/apache/airflow/pull/76230#discussion_r4438891094
##########
providers/common/ai/docs/operators/llm_sql.rst:
##########
@@ -178,6 +178,38 @@ By default, the operator validates generated SQL using an
allowlist approach:
You can disable validation with ``validate_sql=False`` or customize the allowed
statement types with ``allowed_sql_types``.
+Restricting the tables a query may reference
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``table_names`` only selects the schemas shown to the LLM; it does not stop the
+generated SQL from reading other tables. Set ``allowed_tables`` to reject any
query
+that references a table outside the list:
+
+.. code-block:: python
+
+ LLMSQLQueryOperator(
+ task_id="generate_sql",
+ prompt="Total order value per customer last month",
+ llm_conn_id="pydanticai_default",
+ db_conn_id="postgres_default",
+ table_names=["orders"],
+ allowed_tables=["orders"],
+ )
+
+Every table the query reaches is checked, including tables in subqueries and
joins,
+while a CTE defined in the query is not mistaken for a table. Constructs that
cannot
+be checked against the list, such as table-valued functions like ``dblink``,
are
+rejected too. The rules match ``SQLToolset(allowed_tables=...)``: entries are
Review Comment:
Agreed, reworded. The docs now list comments, quoted identifiers and
unrecognized functions (pointing at `allowed_functions`) as the refusals users
will hit, and say the matching is the same as `SQLToolset` except that
`SQLToolset` resolves unqualified entries through `schema`.
---
Drafted-by: Code Puppy (no human review before posting)
##########
providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py:
##########
@@ -759,6 +759,103 @@ def _make_context(ti_id=None):
@pytest.mark.skipif(
not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with
Airflow >= 3.1.0"
)
+class TestLLMSQLQueryOperatorAllowedTables:
+ @staticmethod
+ def _execute(mock_hook_cls, make_mock_run_result, sql, allowed_tables,
**kwargs):
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
_make_mock_agent(
+ sql, make_mock_run_result
+ )
+ op = LLMSQLQueryOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ schema_context="Table: orders",
+ allowed_tables=allowed_tables,
+ **kwargs,
+ )
+ return op.execute(context=MagicMock())
+
+ @pytest.mark.parametrize(
+ ("sql", "allowed_tables"),
+ [
+ pytest.param("SELECT customer_id, SUM(amount) FROM orders GROUP BY
1", ["orders"], id="allowed"),
+ pytest.param("SELECT * FROM ORDERS", ["orders"],
id="case-insensitive"),
+ pytest.param("SELECT * FROM sales.orders", ["sales.orders"],
id="schema-qualified"),
+ pytest.param(
+ "WITH salaries AS (SELECT * FROM orders) SELECT * FROM
salaries",
+ ["orders"],
+ id="cte-named-like-another-table",
+ ),
+ ],
+ )
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_sql_within_allowed_tables_is_returned(
+ self, mock_hook_cls, make_mock_run_result, sql, allowed_tables
+ ):
+ assert self._execute(mock_hook_cls, make_mock_run_result, sql,
allowed_tables) == sql
+
+ @pytest.mark.parametrize(
+ ("sql", "disallowed"),
+ [
+ pytest.param("SELECT name, salary FROM salaries", "salaries",
id="other-table"),
+ pytest.param(
+ "SELECT * FROM orders WHERE customer_id IN (SELECT id FROM
customers)",
+ "customers",
+ id="subquery",
+ ),
+ pytest.param(
+ "SELECT * FROM orders o JOIN salaries s ON o.customer_id =
s.id", "salaries", id="join"
+ ),
+ pytest.param("SELECT * FROM otherdb.public.orders",
"otherdb.public.orders", id="catalog"),
Review Comment:
Added `SELECT * FROM sales.orders` against `["orders"]` to the rejected
cases.
---
Drafted-by: Code Puppy (no human review before posting)
##########
providers/common/ai/docs/operators/llm_sql.rst:
##########
@@ -178,6 +178,38 @@ By default, the operator validates generated SQL using an
allowlist approach:
You can disable validation with ``validate_sql=False`` or customize the allowed
statement types with ``allowed_sql_types``.
+Restricting the tables a query may reference
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``table_names`` only selects the schemas shown to the LLM; it does not stop the
+generated SQL from reading other tables. Set ``allowed_tables`` to reject any
query
+that references a table outside the list:
+
+.. code-block:: python
Review Comment:
Moved it to `example_llm_sql.py` as `example_llm_sql_allowed_tables`,
included through `exampleinclude`. It also shows `allowed_functions`.
---
Drafted-by: Code Puppy (no human review before posting)
##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -124,6 +135,7 @@ def __init__(
schema_context: str | None = None,
validate_sql: bool = True,
allowed_sql_types: tuple[type[exp.Expr], ...] = DEFAULT_ALLOWED_TYPES,
+ allowed_tables: list[str] | None = None,
Review Comment:
Static is intended. Since it is a safety control, I would rather it be fixed
when the Dag is parsed: the empty-list and `validate_sql=False` checks can fail
at construction, and the list stays out of reach of run-time values such as
`params` or `dag_run.conf`, which whoever triggers the run can set. A Dag can
still keep both in step by building them from the same Python list. I noted
this in the docstring and docs, but happy to make it a template field if you
would prefer parity with `table_names`.
---
Drafted-by: Code Puppy (no human review before posting)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]