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

potiuk 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 664e2a8b579 Fix asset event extra filter matching wrong events for 
dotted keys on SQLite (#69675)
664e2a8b579 is described below

commit 664e2a8b579884e24e35f7400d788ad9983d7a02
Author: Steve Ahn <[email protected]>
AuthorDate: Fri Jul 31 12:35:08 2026 -0700

    Fix asset event extra filter matching wrong events for dotted keys on 
SQLite (#69675)
---
 airflow-core/newsfragments/69675.bugfix.rst        |  1 +
 airflow-core/src/airflow/utils/sqlalchemy.py       |  4 +-
 .../core_api/routes/public/test_assets.py          | 65 ++++++++++++++++++++++
 3 files changed, 69 insertions(+), 1 deletion(-)

diff --git a/airflow-core/newsfragments/69675.bugfix.rst 
b/airflow-core/newsfragments/69675.bugfix.rst
new file mode 100644
index 00000000000..38ca8fd6068
--- /dev/null
+++ b/airflow-core/newsfragments/69675.bugfix.rst
@@ -0,0 +1 @@
+Fix asset event ``extra`` filtering silently matching the wrong events on 
SQLite when a filter key contains JSON-path metacharacters such as dots
diff --git a/airflow-core/src/airflow/utils/sqlalchemy.py 
b/airflow-core/src/airflow/utils/sqlalchemy.py
index 0064c0ec3c8..61ac586bda6 100644
--- a/airflow-core/src/airflow/utils/sqlalchemy.py
+++ b/airflow-core/src/airflow/utils/sqlalchemy.py
@@ -229,7 +229,9 @@ def _default_json_contains(element, compiler, **kw):
 
     clauses = []
     for k, v in element.kv_dict.items():
-        path = f"$.{k}"
+        # Quote the key (json.dumps also covers embedded quotes/backslashes) 
so metacharacters
+        # like "." match literally, as PostgreSQL ``@>`` and MySQL 
``JSON_CONTAINS`` do.
+        path = f"$.{json.dumps(k, ensure_ascii=False)}"
         clauses.append(func.json_extract(element.column, literal(path)) == 
literal(v))
     if len(clauses) == 1:
         return compiler.process(clauses[0], **kw)
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index c8e6b65ccaf..f0e60462bf3 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -1324,6 +1324,71 @@ class TestGetAssetEventsExtraFilter(TestAssets):
         assert response.json()["total_entries"] == expected_count
 
 
+class TestGetAssetEventsExtraFilterSpecialKeys(TestAssets):
+    """
+    Keys containing JSON-path metacharacters must be matched literally on 
every backend.
+
+    PostgreSQL (``@>``) and MySQL (``JSON_CONTAINS``) compare keys literally 
by containment.
+    The SQLite fallback builds a ``json_extract`` path from the key, where an 
unquoted ``.``
+    or ``[`` is interpreted as path navigation instead — silently missing 
literal dotted keys
+    and wrongly matching nested objects.
+    """
+
+    @pytest.fixture
+    def _setup(self, session):
+        self.create_assets(num=1, session=session)
+        events = [
+            AssetEvent(
+                asset_id=1,
+                extra={"spark.executor.memory": "4g"},
+                source_task_id="t1",
+                source_dag_id="d1",
+                source_run_id="r1",
+                timestamp=DEFAULT_DATE,
+            ),
+            AssetEvent(
+                asset_id=1,
+                extra={"spark": {"executor": {"memory": "4g"}}},
+                source_task_id="t1",
+                source_dag_id="d1",
+                source_run_id="r2",
+                timestamp=DEFAULT_DATE,
+            ),
+            AssetEvent(
+                asset_id=1,
+                extra={"partitions[0]": "2024-01-01"},
+                source_task_id="t1",
+                source_dag_id="d1",
+                source_run_id="r3",
+                timestamp=DEFAULT_DATE,
+            ),
+        ]
+        session.add_all(events)
+        session.commit()
+
+    @pytest.mark.usefixtures("_setup")
+    @pytest.mark.parametrize(
+        ("params", "expected_count"),
+        [
+            # Matches only the event whose extra has the literal dotted key,
+            # not the one nesting the same path as objects.
+            ({"extra": "spark.executor.memory=4g"}, 1),
+            ({"extra": "partitions[0]=2024-01-01"}, 1),
+            ({"extra": "spark.executor.memory=8g"}, 0),
+        ],
+    )
+    def test_extra_filter_metacharacter_keys_match_literally(self, 
test_client, params, expected_count):
+        response = test_client.get("/assets/events", params=params)
+        assert response.status_code == 200
+        assert response.json()["total_entries"] == expected_count
+
+    @pytest.mark.usefixtures("_setup")
+    def test_extra_filter_dotted_key_matches_the_literal_key_event(self, 
test_client):
+        response = test_client.get("/assets/events", params={"extra": 
"spark.executor.memory=4g"})
+        assert response.status_code == 200
+        assert [e["source_run_id"] for e in response.json()["asset_events"]] 
== ["r1"]
+
+
 class TestGetAssetEndpoint(TestAssets):
     @provide_session
     def test_should_respond_200(self, test_client, *, session):

Reply via email to