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

pierrejeambrun 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 6036de15b5b API: Fix prefix pattern search returning rows without the 
prefix (#73520)
6036de15b5b is described below

commit 6036de15b5b075a932f83421e478ad3cad9e9563
Author: Y-C <[email protected]>
AuthorDate: Tue Sep 22 22:02:12 2026 +0800

    API: Fix prefix pattern search returning rows without the prefix (#73520)
    
    The *_prefix_pattern filters turn a prefix into an index-friendly range
    scan. Whenever bumping the last character would leave the alphanumeric
    range ('9', 'z', 'Z'), the upper bound is recomputed from a shorter
    prefix, so the scanned range is far wider than the prefix the caller
    asked for: searching "dag_9" also returns "dag_abc", searching "a9"
    returns everything starting with "a", and searching "z" gets no upper
    bound at all. This affects every *_prefix_pattern query parameter, so a
    UI search box and a REST client both get results that do not match what
    was typed.
    
    The documented trade-off of stripping trailing non-alphanumeric
    characters is deliberate and stays; only the undocumented widening is
    addressed here, and the bounds themselves are left alone so the B-tree
    index still drives the scan.
    
    Co-authored-by: Eason09053360 
<[email protected]>
---
 .../api_fastapi/common/parameters/search.py        | 44 +++++++-----
 .../unit/api_fastapi/common/test_parameters.py     | 80 +++++++++++++++++++++-
 2 files changed, 104 insertions(+), 20 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/search.py 
b/airflow-core/src/airflow/api_fastapi/common/parameters/search.py
index 642416d22a5..d9b9685bdba 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/search.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/search.py
@@ -26,7 +26,7 @@ from typing import (
 )
 
 from fastapi import HTTPException, Query, status
-from sqlalchemy import and_, or_, true as sql_true
+from sqlalchemy import and_, func, or_, true as sql_true
 
 from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.common.parameters.base import BaseParam
@@ -36,6 +36,7 @@ from airflow.typing_compat import Self
 from airflow.utils.sqlalchemy import apply_regex_query_timeout
 
 if TYPE_CHECKING:
+    from sqlalchemy.orm import InstrumentedAttribute
     from sqlalchemy.sql import ColumnElement, Select
 
 
@@ -86,6 +87,26 @@ class _PrefixPatternParam(BaseParam[str], ABC):
             term = term[:-1]
         return term
 
+    @staticmethod
+    def _build_prefix_range_clauses(
+        column: ColumnElement | InstrumentedAttribute, lower: str, upper: str 
| None
+    ) -> list[ColumnElement[bool]]:
+        """
+        Return the predicates matching values that start with ``lower`` on 
``column``.
+
+        The bounds keep a B-tree index usable but are wider than the prefix: 
bumping the last
+        character out of the alphanumeric range derives the upper bound from a 
shorter prefix
+        (``"a9"`` yields ``< "b"``, ``"z"`` yields none at all). The substring 
equality narrows
+        that back. It is an equality and not ``LIKE 'lower%'`` so it resolves 
under the same
+        collation as the bounds — ``LIKE`` is case-insensitive on SQLite and 
MySQL ``_ci``
+        columns, and would keep leaking case variants.
+        """
+        clauses: list[ColumnElement[bool]] = [column >= lower]
+        if upper is not None:
+            clauses.append(column < upper)
+        clauses.append(func.substr(column, 1, len(lower)) == lower)
+        return clauses
+
     @abstractmethod
     def _prefix_clause(self, term: str):
         """Return the SQL boolean for one prefix term (including empty string 
after ``~`` alias)."""
@@ -195,9 +216,7 @@ class _PrefixSearchParam(_PrefixPatternParam):
         if not lower:
             return self.attribute.is_not(None)
         upper = self._prefix_range_upper(term)
-        if upper is None:
-            return self.attribute >= lower
-        return and_(self.attribute >= lower, self.attribute < upper)
+        return and_(*self._build_prefix_range_clauses(self.attribute, lower, 
upper))
 
     @classmethod
     def depends(cls, *args: Any, **kwargs: Any) -> Self:
@@ -218,27 +237,14 @@ class 
_TaskDisplayNamePrefixPatternParam(_PrefixPatternParam):
         if not lower:
             return sql_true()
         upper = self._prefix_range_upper(term)
-        if upper is None:
-            return or_(
-                and_(
-                    TaskInstance._task_display_property_value.is_(None),
-                    TaskInstance.task_id >= lower,
-                ),
-                and_(
-                    TaskInstance._task_display_property_value.is_not(None),
-                    TaskInstance._task_display_property_value >= lower,
-                ),
-            )
         return or_(
             and_(
                 TaskInstance._task_display_property_value.is_(None),
-                TaskInstance.task_id >= lower,
-                TaskInstance.task_id < upper,
+                *self._build_prefix_range_clauses(TaskInstance.task_id, lower, 
upper),
             ),
             and_(
                 TaskInstance._task_display_property_value.is_not(None),
-                TaskInstance._task_display_property_value >= lower,
-                TaskInstance._task_display_property_value < upper,
+                
*self._build_prefix_range_clauses(TaskInstance._task_display_property_value, 
lower, upper),
             ),
         )
 
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_parameters.py 
b/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
index 9cf0412b440..d6cb3c40a6a 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_parameters.py
@@ -25,7 +25,7 @@ from unittest import mock
 
 import pytest
 from fastapi import Depends, FastAPI, HTTPException
-from sqlalchemy import select
+from sqlalchemy import Column, MetaData, String, Table, create_engine, select
 
 from airflow.api_fastapi.common.parameters import (
     FilterParam,
@@ -453,6 +453,17 @@ class TestPrefixSearchParam:
         # Range scan uses the full composite-key value as the lower bound.
         assert "2026-01-01|us" in sql
 
+    def test_to_orm_narrows_the_range_with_a_leading_substring(self):
+        """The index-friendly bounds are wider than the prefix, so an equality 
pins the prefix down."""
+        param = _PrefixSearchParam(DagModel.dag_id).set_value("dag_9")
+        statement = param.to_orm(select(DagModel))
+
+        sql = _compile(statement)
+        # '9' bumps to ':' (non-alphanumeric), so the upper bound comes from 
the shorter 'dag'.
+        assert "dag_id >= 'dag_9'" in sql
+        assert "dag_id < 'dah'" in sql
+        assert "substr(dag.dag_id, 1, 5) = 'dag_9'" in sql
+
     def test_to_orm_pipe_as_or_false_tilde_alias_still_works(self):
         """``pipe_as_or=False`` must not interfere with the ``~`` → empty 
alias."""
         param = _PrefixSearchParam(DagModel.dag_id, pipe_as_or=False)
@@ -464,6 +475,64 @@ class TestPrefixSearchParam:
         assert "is not null" in sql
 
 
+class TestPrefixSearchParamOnARealEngine:
+    """
+    Run the predicate against SQLite so the rows it lets through are checked, 
not just the SQL.
+
+    Every term below computes an upper bound wider than the term itself, so 
the bounds alone
+    would also return the rows that are missing from each ``expected`` list.
+    """
+
+    NAMES = [
+        "dag_9",
+        "dag_99",
+        "dag_abc",
+        "dagz9",
+        "dagZ1",
+        "a9",
+        "aa",
+        "abz",
+        "abz1",
+        "ab~",
+        "z1",
+        "zz",
+        "{a",
+    ]
+
+    @pytest.fixture
+    def select_names(self):
+        table = Table("prefix_probe", MetaData(), Column("name", String(64), 
primary_key=True))
+        engine = create_engine("sqlite://")
+        table.metadata.create_all(engine)
+        with engine.connect() as conn:
+            conn.execute(table.insert(), [{"name": name} for name in 
self.NAMES])
+
+            def run(term: str) -> list[str]:
+                param = _PrefixSearchParam(table.c.name).set_value(term)
+                return sorted(row[0] for row in 
conn.execute(param.to_orm(select(table.c.name))))
+
+            yield run
+
+    @pytest.mark.parametrize(
+        ("term", "expected"),
+        [
+            # 'dagz9' pins down that the user's '_' is literal, not a 
single-character wildcard.
+            ("dag_9", ["dag_9", "dag_99"]),
+            # Differs from 'dagz9' only in case, which the bounds distinguish 
and so must the
+            # predicate narrowing them.
+            ("dagZ", ["dagZ1"]),
+            ("a9", ["a9"]),
+            ("abz", ["abz", "abz1"]),
+            # 'z' cascades down to no upper bound at all.
+            ("z", ["z1", "zz"]),
+            # Documented trade-off, unchanged: a trailing '_' is stripped, so 
'dag_' means 'dag'.
+            ("dag_", ["dagZ1", "dag_9", "dag_99", "dag_abc", "dagz9"]),
+        ],
+    )
+    def test_only_values_with_the_prefix_come_back(self, select_names, term, 
expected):
+        assert select_names(term) == expected
+
+
 class TestTaskDisplayNamePrefixPatternParam:
     """Prefix filter splits on NULL override so ``task_id`` can use indexes."""
 
@@ -478,6 +547,15 @@ class TestTaskDisplayNamePrefixPatternParam:
         assert "task_id <" in sql
         assert "task_display_name is not null" in sql
 
+    def test_to_orm_narrows_both_branches_with_a_leading_substring(self):
+        """Both sides of the ``IS NULL`` split need the prefix predicate, not 
only the bounds."""
+        param = _TaskDisplayNamePrefixPatternParam().set_value("task_9")
+        statement = param.to_orm(select(TaskInstance))
+
+        sql = _compile(statement)
+        assert "substr(task_instance.task_id, 1, 6) = 'task_9'" in sql
+        assert "substr(task_instance.task_display_name, 1, 6) = 'task_9'" in 
sql
+
     def test_to_orm_empty_matches_all(self):
         param = _TaskDisplayNamePrefixPatternParam().set_value("")
         statement = select(TaskInstance)

Reply via email to