This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 91b18259e93 Require explicit limits for single-row lookups in core 
(#72856)
91b18259e93 is described below

commit 91b18259e938bf0a00a4a6aa4e3dddf719add301
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Sat Sep 12 20:27:01 2026 +0800

    Require explicit limits for single-row lookups in core (#72856)
    
    * Require explicit limits for single-row lookups in core
    
    Guard core first() lookups against unbounded SQLAlchemy results with a 
core-specific prek hook. Follow local query assignments, require a 
statement-level limit, and bound the remaining operator-link XCom lookup.
    
    * Name first-limit diagnostic fields
---
 airflow-core/.pre-commit-config.yaml               |   6 +
 .../api_fastapi/core_api/routes/public/xcom.py     |   3 +-
 .../serialization/definitions/operatorlink.py      |   4 +-
 .../tests/unit/serialization/test_operatorlink.py  |  43 +++++
 scripts/ci/prek/check_first_limit.py               | 215 +++++++++++++++++++++
 scripts/tests/ci/prek/test_check_first_limit.py    | 141 ++++++++++++++
 6 files changed, 409 insertions(+), 3 deletions(-)

diff --git a/airflow-core/.pre-commit-config.yaml 
b/airflow-core/.pre-commit-config.yaml
index c59444e234d..d7b96c960d7 100644
--- a/airflow-core/.pre-commit-config.yaml
+++ b/airflow-core/.pre-commit-config.yaml
@@ -24,6 +24,12 @@ default_language_version:
 repos:
   - repo: local
     hooks:
+      - id: check-first-limit
+        name: Check single-row lookups have an explicit limit
+        entry: ../scripts/ci/prek/check_first_limit.py
+        language: python
+        files: ^src/airflow/.*\.py$
+        pass_filenames: true
       - id: check-taskinstance-tis-attrs
         name: Check that TI and TIS have the same attributes
         entry: ../scripts/ci/prek/check_ti_vs_tis_attributes.py
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
index e2c644ea16a..48ed3d11bf1 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/xcom.py
@@ -94,7 +94,6 @@ def get_xcom_entry(
         task_ids=task_id,
         dag_ids=dag_id,
         map_indexes=map_index,
-        limit=1,
     ).options(
         joinedload(XComModel.task),
         joinedload(XComModel.dag_run).joinedload(DR.dag_model),
@@ -104,7 +103,7 @@ def get_xcom_entry(
     # We use `BaseXCom.get_many` to fetch XComs directly from the database, 
bypassing the XCom Backend.
     # This avoids deserialization via the backend (e.g., from a remote storage 
like S3) and instead
     # retrieves the raw serialized value from the database.
-    raw_result: tuple[XComModel] | None = session.scalars(xcom_query).first()
+    raw_result: tuple[XComModel] | None = 
session.scalars(xcom_query.limit(1)).first()
 
     if raw_result is None:
         raise HTTPException(status.HTTP_404_NOT_FOUND, f"XCom entry with key: 
`{xcom_key}` not found")
diff --git a/airflow-core/src/airflow/serialization/definitions/operatorlink.py 
b/airflow-core/src/airflow/serialization/definitions/operatorlink.py
index b7a6eef11b8..86e245b4c63 100644
--- a/airflow-core/src/airflow/serialization/definitions/operatorlink.py
+++ b/airflow-core/src/airflow/serialization/definitions/operatorlink.py
@@ -62,7 +62,9 @@ class XComOperatorLink(LoggingMixin):
                     dag_ids=ti_key.dag_id,
                     task_ids=ti_key.task_id,
                     map_indexes=ti_key.map_index,
-                ).with_only_columns(XComModel.value)
+                )
+                .with_only_columns(XComModel.value)
+                .limit(1)
             ).first()
         if not result:
             self.log.debug(
diff --git a/airflow-core/tests/unit/serialization/test_operatorlink.py 
b/airflow-core/tests/unit/serialization/test_operatorlink.py
new file mode 100644
index 00000000000..ef3a399279c
--- /dev/null
+++ b/airflow-core/tests/unit/serialization/test_operatorlink.py
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest import mock
+
+from sqlalchemy.engine import Result
+from sqlalchemy.orm import Session
+
+from airflow.models.taskinstancekey import TaskInstanceKey
+from airflow.serialization.definitions.operatorlink import XComOperatorLink
+
+
[email protected]("airflow.serialization.definitions.operatorlink.create_session", 
autospec=True)
+def test_operator_link_lookup_is_bounded(create_session):
+    session = mock.create_autospec(Session, instance=True)
+    create_session.return_value.__enter__.return_value = session
+    result = mock.create_autospec(Result, instance=True)
+    result.first.return_value = 
SimpleNamespace(value='"https://example.com/task";')
+    session.execute.return_value = result
+    link = XComOperatorLink(name="Example", xcom_key="_link_example")
+    ti_key = TaskInstanceKey(dag_id="example", task_id="task", run_id="run", 
try_number=1, map_index=-1)
+
+    assert link.get_link(None, ti_key=ti_key) == "https://example.com/task";
+
+    session.execute.assert_called_once()
+    statement = session.execute.call_args.args[0]
+    assert "LIMIT 1" in str(statement.compile(compile_kwargs={"literal_binds": 
True}))
diff --git a/scripts/ci/prek/check_first_limit.py 
b/scripts/ci/prek/check_first_limit.py
new file mode 100755
index 00000000000..08b78180473
--- /dev/null
+++ b/scripts/ci/prek/check_first_limit.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["rich>=13.6.0"]
+# ///
+"""Require an explicit statement-level limit for core's single-row lookups.
+
+Track straight-line local assignments, without inferring limits across helper
+calls or control-flow joins. Put ``.limit(1)`` at the execution site when a
+query's bound cannot be established locally.
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+from enum import Enum, auto
+from pathlib import Path
+from typing import NamedTuple
+
+from common_prek_utils import console
+
+
+class QueryState(Enum):
+    UNKNOWN = auto()
+    LIMITED = auto()
+    RESULT = auto()
+    LIMITED_RESULT = auto()
+
+
+class ErrorLocation(NamedTuple):
+    line: int
+    column: int
+
+
+class FirstLimitVisitor(ast.NodeVisitor):
+    def __init__(self) -> None:
+        self.bindings: dict[str, QueryState] = {}
+        self.errors: list[ErrorLocation] = []
+
+    def get_state(self, node: ast.AST | None) -> QueryState:
+        if isinstance(node, ast.Name):
+            return self.bindings.get(node.id, QueryState.UNKNOWN)
+        if isinstance(node, (ast.Await, ast.NamedExpr)):
+            return self.get_state(node.value)
+        if not isinstance(node, ast.Call) or not isinstance(node.func, 
ast.Attribute):
+            return QueryState.UNKNOWN
+        method = node.func.attr
+        receiver = self.get_state(node.func.value)
+        if receiver in {QueryState.RESULT, QueryState.LIMITED_RESULT}:
+            if method in {"scalars", "unique", "mappings", "tuples", 
"columns", "yield_per"}:
+                return receiver
+            return QueryState.RESULT
+        if method in {"execute", "scalars", "stream", "stream_scalars"} and (
+            node.args or any(keyword.arg == "statement" for keyword in 
node.keywords)
+        ):
+            statement = (
+                node.args[0]
+                if node.args
+                else next(keyword.value for keyword in node.keywords if 
keyword.arg == "statement")
+            )
+            return (
+                QueryState.LIMITED_RESULT
+                if self.get_state(statement) == QueryState.LIMITED
+                else QueryState.RESULT
+            )
+        if method == "limit":
+            if (
+                len(node.args) == 1
+                and not node.keywords
+                and isinstance(node.args[0], ast.Constant)
+                and type(node.args[0].value) is int
+                and node.args[0].value == 1
+            ):
+                return QueryState.LIMITED
+            return QueryState.UNKNOWN
+        if method in {
+            "where",
+            "filter",
+            "filter_by",
+            "order_by",
+            "options",
+            "with_only_columns",
+            "offset",
+            "join",
+            "outerjoin",
+            "select_from",
+            "with_for_update",
+            "execution_options",
+            "distinct",
+        }:
+            return receiver
+        return QueryState.UNKNOWN
+
+    def visit_Call(self, node: ast.Call) -> None:
+        self.generic_visit(node)
+        if (
+            isinstance(node.func, ast.Attribute)
+            and node.func.attr == "first"
+            and self.get_state(node.func.value) not in {QueryState.LIMITED, 
QueryState.LIMITED_RESULT}
+        ):
+            self.errors.append(ErrorLocation(node.lineno, node.col_offset + 1))
+
+    def visit_Assign(self, node: ast.Assign) -> None:
+        self.visit(node.value)
+        state = self.get_state(node.value)
+        for target in node.targets:
+            self.set_binding(target, state)
+
+    def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
+        if node.value is not None:
+            self.visit(node.value)
+            self.set_binding(node.target, self.get_state(node.value))
+
+    def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
+        self.visit(node.value)
+        self.set_binding(node.target, self.get_state(node.value))
+
+    def set_binding(self, target: ast.AST, state: QueryState) -> None:
+        if isinstance(target, ast.Name):
+            self.bindings[target.id] = state
+        else:
+            self.invalidate_bindings(target)
+
+    def invalidate_bindings(self, node: ast.AST) -> None:
+        for child in ast.walk(node):
+            if isinstance(child, ast.Name) and isinstance(child.ctx, 
(ast.Store, ast.Del)):
+                self.bindings.pop(child.id, None)
+
+    def visit_Name(self, node: ast.Name) -> None:
+        if isinstance(node.ctx, (ast.Store, ast.Del)):
+            self.bindings.pop(node.id, None)
+
+    def generic_visit(self, node: ast.AST) -> None:
+        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, 
ast.ClassDef, ast.Lambda)):
+            previous = self.bindings
+            self.bindings = {}
+            super().generic_visit(node)
+            self.bindings = previous
+        elif isinstance(
+            node,
+            (
+                ast.If,
+                ast.For,
+                ast.AsyncFor,
+                ast.While,
+                ast.Try,
+                ast.Match,
+                ast.ListComp,
+                ast.SetComp,
+                ast.DictComp,
+                ast.GeneratorExp,
+            ),
+        ):
+            self.invalidate_bindings(node)
+            previous = self.bindings.copy()
+            for field, value in ast.iter_fields(node):
+                self.bindings = previous.copy()
+                if isinstance(value, list):
+                    for child in value:
+                        if field in {"handlers", "cases"}:
+                            self.bindings = previous.copy()
+                        self.visit(child)
+                elif isinstance(value, ast.AST):
+                    self.visit(value)
+            self.bindings = previous
+        else:
+            if isinstance(node, (ast.AugAssign, ast.Delete)):
+                self.invalidate_bindings(node)
+            super().generic_visit(node)
+
+
+def check_source(source: str, filename: str = "<unknown>") -> 
list[ErrorLocation]:
+    visitor = FirstLimitVisitor()
+    visitor.visit(ast.parse(source, filename=filename))
+    return sorted(visitor.errors)
+
+
+def main(filenames: list[str]) -> int:
+    failed = False
+    for filename in filenames:
+        try:
+            errors = check_source(Path(filename).read_text(encoding="utf-8"), 
filename)
+        except SyntaxError as error:
+            console.print(f"{filename}:{error.lineno}: {error.msg}", 
markup=False, highlight=False)
+            failed = True
+            continue
+        for location in errors:
+            console.print(
+                f"{filename}:{location.line}:{location.column}: .first() 
requires .limit(1) on the statement before execution.",
+                markup=False,
+                highlight=False,
+            )
+            failed = True
+    return int(failed)
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/scripts/tests/ci/prek/test_check_first_limit.py 
b/scripts/tests/ci/prek/test_check_first_limit.py
new file mode 100644
index 00000000000..4d09fdc5fb0
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_first_limit.py
@@ -0,0 +1,141 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from textwrap import dedent
+
+import pytest
+from check_first_limit import check_source, main
+
+
[email protected](
+    "source",
+    [
+        "session.execute(select(Model).limit(1)).first()",
+        "session.scalars(query.limit(1)).first()",
+        "session.execute(statement=query.limit(1)).first()",
+        "session.execute(query.limit(1)).scalars().unique().first()",
+        "session.execute(query.limit(1)).scalars(0).first()",
+        "session.execute(query.limit(1)).mappings().first()",
+        "session.query(Model).limit(1).first()",
+        "query = select(Model).limit(1)\nsession.scalars(query).first()",
+        "query = select(Model)\nquery = 
query.limit(1)\nsession.execute(query).first()",
+        "query: Select = 
select(Model).limit(1)\nsession.execute(query).first()",
+        "query = select(Model).limit(1)\nother = query\nquery = 
select(Other)\nsession.execute(other).first()",
+        "result = session.execute(query.limit(1))\nresult.first()",
+        "(result := session.execute(query.limit(1))).first()",
+        "session.execute(query.limit(1).where(Model.id == 
1).order_by(Model.id)).first()",
+        
"session.execute(query.limit(1).options(joinedload(Model.other))).first()",
+        "(await session.execute(query.limit(1))).first()",
+        "(await session.stream_scalars(query.limit(1))).first()",
+        "if condition:\n    query = select(Model).limit(1)\n    
session.execute(query).first()",
+        "query = select(Model).limit(1)\nif condition:\n    
pass\nsession.execute(query).first()",
+        "# session.execute(query).first()\ntext = 'result.first()'",
+        "obj.first",
+        "query: Select\nquery = 
select(Model).limit(1)\nsession.execute(query).first()",
+    ],
+)
+def test_accepts_bounded_first(source: str):
+    assert check_source(source) == []
+
+
[email protected](
+    "source",
+    [
+        "session.execute(select(Model)).first()",
+        "session.scalars(query).first()",
+        "session.execute(statement=query).first()",
+        "session.execute(query).scalars().unique().first()",
+        "session.execute(query.limit(2)).first()",
+        "session.execute(query.limit(True)).first()",
+        "session.execute(query.limit(1.0)).first()",
+        "session.execute(query.limit(count)).first()",
+        "session.execute(query.limit(1).limit(None)).first()",
+        "session.execute(query.limit(1).fetch(10)).first()",
+        
"session.execute(select(Model).where(Model.id.in_(select(Other.id).limit(1)))).first()",
+        "session.execute(select(query.limit(1).subquery())).first()",
+        "session.execute(query).limit(1).first()",
+        "session.execute(query).limit(1).limit(1).first()",
+        "query = select(Model).limit(1)\nquery = 
select(Other)\nsession.execute(query).first()",
+        "query = 
select(Model)\nquery.limit(1)\nsession.execute(query).first()",
+        "result = session.execute(query)\nquery = 
query.limit(1)\nresult.first()",
+        "result = session.execute(query.limit(1))\nresult = 
session.execute(query)\nresult.first()",
+        "session.execute(query).first()\nquery = query.limit(1)",
+        "query = select(Model).limit(1)\ndel 
query\nsession.execute(query).first()",
+        "query = select(Model).limit(1)\nquery += 
other\nsession.execute(query).first()",
+        "query = select(Model).limit(1)\nquery, other = 
get_queries()\nsession.execute(query).first()",
+        "if condition:\n    query = 
select(Model).limit(1)\nsession.execute(query).first()",
+        "if condition:\n    query = select(Model).limit(1)\nelse:\n    
session.execute(query).first()",
+        "query = select(Model).limit(1)\nif condition:\n    query = 
select(Other)\nsession.execute(query).first()",
+        "query = select(Model).limit(1)\nfor query in queries:\n    
session.execute(query).first()",
+        "query = select(Model).limit(1)\nwith context() as query:\n    
session.execute(query).first()",
+        "query = select(Model).limit(1)\ndef lookup(query):\n    return 
session.execute(query).first()",
+        "def a():\n    query = select(Model).limit(1)\ndef b():\n    return 
session.execute(query).first()",
+        "query = select(Model).limit(1)\nlookup = lambda query: 
session.execute(query).first()",
+        "try:\n    query = select(Model).limit(1)\nexcept Error:\n    
session.execute(query).first()",
+        "(await session.execute(query)).first()",
+        "session.execute(build_query(limit=1)).first()",
+    ],
+)
+def test_rejects_unbounded_first(source: str):
+    assert len(check_source(source)) == 1
+
+
+def test_reports_all_locations():
+    assert check_source("session.execute(query).first()\nresult.first()") == [
+        (1, 1),
+        (2, 1),
+    ]
+
+
[email protected]("invalid", [False, True])
+def test_main(tmp_path, capsys, invalid):
+    bounded = tmp_path / "bounded.py"
+    bounded.write_text("session.execute(query.limit(1)).first()", 
encoding="utf-8")
+    other = tmp_path / "other.py"
+    other.write_text("session.execute(query).first()" if invalid else "pass", 
encoding="utf-8")
+
+    assert main([str(bounded), str(other)]) == int(invalid)
+    output = capsys.readouterr().out
+    if invalid:
+        assert f"{other}:1:1:" in output
+        assert ".limit(1) on the statement before execution" in output
+        assert str(bounded) not in output
+    else:
+        assert output == ""
+
+
+def test_syntax_error_does_not_hide_other_failures(tmp_path, capsys):
+    broken = tmp_path / "broken.py"
+    broken.write_text("def broken(", encoding="utf-8")
+    unbounded = tmp_path / "unbounded.py"
+    unbounded.write_text("result.first()", encoding="utf-8")
+    assert main([str(broken), str(unbounded)]) == 1
+    output = capsys.readouterr().out
+    assert f"{broken}:1:" in output
+    assert f"{unbounded}:1:1:" in output
+
+
+def test_multiline_first():
+    source = dedent("""\
+        def lookup():
+            return session.execute(
+                select(Model)
+                .where(Model.id == 1)
+            ).first()
+    """)
+    assert check_source(source) == [(2, 12)]

Reply via email to