pavansai1997 commented on code in PR #76230:
URL: https://github.com/apache/airflow/pull/76230#discussion_r4438890593


##########
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:

Review Comment:
   Good catch, my mistake. Moved the class above the decorator so the approval 
suite keeps its 3.1 skip, and `test_reviewer_modified_sql_is_checked` now has 
its own skip, in 80e91e559b.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -192,10 +216,27 @@ def execute_complete(
         """Resume after human review, re-validating if the reviewer modified 
the SQL."""
         output = self._complete_review(context, generated_output, event, 
decision)
         if output != generated_output:
-            _validate_sql(output, allowed_types=self.allowed_sql_types, 
dialect=self._resolved_dialect)
+            self._check_sql(output)
         self._clear_usage_budget(context)
         return output
 
+    def _check_sql(self, sql: str) -> None:
+        """Validate the statement types of ``sql`` and, when set, the tables 
it may reference."""
+        statements = _validate_sql(sql, allowed_types=self.allowed_sql_types, 
dialect=self._resolved_dialect)
+        if self.allowed_tables is None:
+            return
+        violations = find_allowed_tables_violations(statements, 
canonical_allowed_tables(self.allowed_tables))

Review Comment:
   Added `allowed_functions` with the same semantics as `SQLToolset` 
(case-insensitive, only consulted when `allowed_tables` is set), so the 
existing message is now accurate. 
`test_allowed_functions_permits_unrecognized_functions` covers your 
`json_build_object` and `age` examples, and `age` without it is pinned as 
refused.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -172,7 +196,7 @@ def execute(self, context: Context) -> str:
         sql = self._strip_llm_output(result.output, 
dialect=self._resolved_dialect)
 
         if self.validate_sql:
-            _validate_sql(sql, allowed_types=self.allowed_sql_types, 
dialect=self._resolved_dialect)
+            self._check_sql(sql)

Review Comment:
   Done both. With `allowed_tables` set, the prompt lists the tables and asks 
for unquoted table names and no comments, and the check runs as an output 
validator that raises `ModelRetry`, so `output_retries` covers it. The post-run 
check stays as a backstop and the reviewer path still raises `SQLSafetyError`. 
`test_refused_sql_is_sent_back_to_the_model` drives a real `Agent` with a 
`FunctionModel` through a refusal and a successful retry.



##########
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`.



##########
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.



-- 
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]

Reply via email to