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

shahar1 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 8e0ccacbb2d ElasticsearchSQLHook: add chunked Polars DataFrame support 
via custom SQL reader (#68411)
8e0ccacbb2d is described below

commit 8e0ccacbb2d3f1aa43226d2abc860d06dac43749
Author: SameerMesiah97 <[email protected]>
AuthorDate: Mon Jun 29 13:40:22 2026 +0100

    ElasticsearchSQLHook: add chunked Polars DataFrame support via custom SQL 
reader (#68411)
    
    Implement _get_polars_df_by_chunks using Elasticsearch SQL cursor-based
    pagination and add unit tests covering chunking behavior, pagination
    across cursor pages, and cursor cleanup.
---
 .../providers/elasticsearch/hooks/elasticsearch.py |  32 +++-
 .../airflow/providers/elasticsearch/utils/sql.py   |  88 ++++++++++-
 .../unit/elasticsearch/hooks/test_elasticsearch.py |  25 +++
 .../tests/unit/elasticsearch/utils/test_sql.py     | 176 ++++++++++++++++++++-
 4 files changed, 316 insertions(+), 5 deletions(-)

diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py
 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py
index 7df6ec55916..d66ea9cc1cc 100644
--- 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py
+++ 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/hooks/elasticsearch.py
@@ -29,7 +29,7 @@ from elasticsearch import Elasticsearch
 from airflow.providers.common.compat.sdk import BaseHook
 from airflow.providers.common.sql.hooks.sql import DbApiHook
 from airflow.providers.elasticsearch._compat import apply_compat_with
-from airflow.providers.elasticsearch.utils.sql import read_sql_to_polars
+from airflow.providers.elasticsearch.utils.sql import read_sql_to_polars, 
read_sql_to_polars_by_chunks
 
 if TYPE_CHECKING:
     from elastic_transport import ObjectApiResponse
@@ -283,6 +283,36 @@ class ElasticsearchSQLHook(DbApiHook):
             **kwargs,
         )
 
+    def _get_polars_df_by_chunks(
+        self,
+        sql,
+        parameters=None,
+        *,
+        chunksize: int,
+        **kwargs,
+    ):
+        """
+        Execute an Elasticsearch SQL query and return the results as chunked 
Polars DataFrames.
+
+        This method uses Elasticsearch SQL cursor-based pagination instead of 
DB-API,
+        as Elasticsearch is not fully compatible with polars.read_database.
+
+        :param sql: SQL query string
+        :param parameters: Optional query parameters
+        :param chunksize: Number of rows per yielded DataFrame
+        :param kwargs: Additional arguments passed to the underlying reader
+        :return: Generator of polars.DataFrame objects
+        """
+        client = self.get_conn().es
+
+        yield from read_sql_to_polars_by_chunks(
+            client=client,
+            query=sql,
+            params=parameters,
+            chunksize=chunksize,
+            **kwargs,
+        )
+
 
 class ElasticsearchPythonHook(BaseHook):
     """
diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/utils/sql.py 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/utils/sql.py
index 1cc954949ca..c67e0ce3905 100644
--- a/providers/elasticsearch/src/airflow/providers/elasticsearch/utils/sql.py
+++ b/providers/elasticsearch/src/airflow/providers/elasticsearch/utils/sql.py
@@ -19,7 +19,7 @@
 from __future__ import annotations
 
 import logging
-from collections.abc import Iterable, Mapping
+from collections.abc import Generator, Iterable, Mapping
 from typing import TYPE_CHECKING, Any
 
 from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
@@ -106,3 +106,89 @@ def read_sql_to_polars(
                 log.debug("Failed to clear Elasticsearch SQL cursor", 
exc_info=True)
 
     return pl.DataFrame(rows, schema=columns, orient="row", strict=False)
+
+
+def read_sql_to_polars_by_chunks(
+    client: Elasticsearch,
+    query: str,
+    params: Mapping[str, Any] | Iterable | None = None,
+    fetch_size: int = 1000,
+    chunksize: int = 1000,
+) -> Generator[pl.DataFrame, None, None]:
+    """
+    Execute an Elasticsearch SQL query and return results as chunked Polars 
DataFrames.
+
+    This uses Elasticsearch SQL cursor-based pagination instead of DB-API,
+    as Elasticsearch does not provide a fully compliant DB-API interface.
+
+    :param client: Elasticsearch client
+    :param query: SQL query string
+    :param params: Optional query parameters
+    :param fetch_size: Number of rows fetched per Elasticsearch request
+    :param chunksize: Maximum number of rows per yielded DataFrame
+    :return: Generator of Polars DataFrames
+    """
+    body: dict[str, Any] = {
+        "query": query,
+        "fetch_size": fetch_size,
+    }
+
+    try:
+        import polars as pl
+    except ImportError:
+        raise AirflowOptionalProviderFeatureException(
+            "Polars support requires installing the 'polars' extra: "
+            "pip install apache-airflow-providers-elasticsearch[polars]"
+        ) from None
+
+    if params:
+        body["params"] = params
+
+    response = client.sql.query(**body)
+
+    columns_meta = response.get("columns", [])
+    columns = [col["name"] for col in columns_meta]
+
+    cursor = response.get("cursor")
+    last_cursor = cursor
+
+    rows: list[list[Any]] = list(response.get("rows", []))
+
+    try:
+        while rows:
+            if len(rows) >= chunksize:
+                yield pl.DataFrame(
+                    rows[:chunksize],
+                    schema=columns,
+                    orient="row",
+                    strict=False,
+                )
+                rows = rows[chunksize:]
+                continue
+
+            if not cursor:
+                yield pl.DataFrame(
+                    rows,
+                    schema=columns,
+                    orient="row",
+                    strict=False,
+                )
+                break
+
+            response = client.sql.query(cursor=cursor)
+
+            rows.extend(response.get("rows", []))
+
+            cursor = response.get("cursor")
+            if cursor:
+                last_cursor = cursor
+
+    finally:
+        if last_cursor:
+            try:
+                client.sql.clear_cursor(cursor=last_cursor)
+            except Exception:
+                log.debug(
+                    "Failed to clear Elasticsearch SQL cursor",
+                    exc_info=True,
+                )
diff --git 
a/providers/elasticsearch/tests/unit/elasticsearch/hooks/test_elasticsearch.py 
b/providers/elasticsearch/tests/unit/elasticsearch/hooks/test_elasticsearch.py
index 3c3247aeec6..85e56a358fe 100644
--- 
a/providers/elasticsearch/tests/unit/elasticsearch/hooks/test_elasticsearch.py
+++ 
b/providers/elasticsearch/tests/unit/elasticsearch/hooks/test_elasticsearch.py
@@ -276,6 +276,31 @@ class TestElasticsearchSQLHook:
             max_rows=10,
         )
 
+    
@mock.patch("airflow.providers.elasticsearch.hooks.elasticsearch.read_sql_to_polars_by_chunks")
+    def test_get_df_polars_df_by_chunks(self, mock_reader):
+
+        self.conn.es = MagicMock()
+
+        generator = iter(["df"])
+        mock_reader.return_value = generator
+
+        result = self.db_hook.get_df_by_chunks(
+            sql="SELECT 1",
+            df_type="polars",
+            fetch_size=100,
+            chunksize=100,
+        )
+
+        assert list(result) == ["df"]
+
+        mock_reader.assert_called_once_with(
+            client=self.conn.es,
+            query="SELECT 1",
+            params=None,
+            fetch_size=100,
+            chunksize=100,
+        )
+
     def test_run(self):
         statement = "SELECT * FROM hollywood.actors"
 
diff --git a/providers/elasticsearch/tests/unit/elasticsearch/utils/test_sql.py 
b/providers/elasticsearch/tests/unit/elasticsearch/utils/test_sql.py
index 53a23fedeea..046657fbb17 100644
--- a/providers/elasticsearch/tests/unit/elasticsearch/utils/test_sql.py
+++ b/providers/elasticsearch/tests/unit/elasticsearch/utils/test_sql.py
@@ -22,9 +22,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from airflow.providers.elasticsearch.utils.sql import (
-    read_sql_to_polars,
-)
+from airflow.providers.elasticsearch.utils.sql import read_sql_to_polars, 
read_sql_to_polars_by_chunks
 
 COLUMNS = [
     {"name": "id", "type": "integer"},
@@ -179,3 +177,175 @@ def test_read_sql_to_polars_no_cursor_cleanup():
     read_sql_to_polars(es, "SELECT *")
 
     es.sql.clear_cursor.assert_not_called()
+
+
+def test_read_sql_to_polars_by_chunks_single_chunk():
+    es = _mock_es(
+        [
+            {
+                "columns": COLUMNS,
+                "rows": [[1, "a"], [2, "b"]],
+            }
+        ]
+    )
+
+    chunks = list(
+        read_sql_to_polars_by_chunks(
+            es,
+            "SELECT *",
+            chunksize=10,
+        )
+    )
+
+    assert len(chunks) == 1
+
+    df = chunks[0]
+    assert df.shape == (2, 2)
+    assert df.to_dict(as_series=False) == {
+        "id": [1, 2],
+        "name": ["a", "b"],
+    }
+
+
+def test_read_sql_to_polars_by_chunks_single_page_multiple_chunks():
+    es = _mock_es(
+        [
+            {
+                "columns": COLUMNS,
+                "rows": [
+                    [1, "a"],
+                    [2, "b"],
+                    [3, "c"],
+                ],
+            }
+        ]
+    )
+
+    chunks = list(
+        read_sql_to_polars_by_chunks(
+            es,
+            "SELECT *",
+            chunksize=2,
+        )
+    )
+
+    assert [chunk.shape for chunk in chunks] == [
+        (2, 2),
+        (1, 2),
+    ]
+
+    assert chunks[0].to_dict(as_series=False) == {
+        "id": [1, 2],
+        "name": ["a", "b"],
+    }
+
+    assert chunks[1].to_dict(as_series=False) == {
+        "id": [3],
+        "name": ["c"],
+    }
+
+
+def test_read_sql_to_polars_by_chunks_across_cursor_pages():
+    es = _mock_es(
+        [
+            {
+                "columns": COLUMNS,
+                "rows": [[1, "a"], [2, "b"]],
+                "cursor": "cursor_1",
+            },
+            {
+                "rows": [[3, "c"], [4, "d"]],
+                "cursor": None,
+            },
+        ]
+    )
+
+    chunks = list(
+        read_sql_to_polars_by_chunks(
+            es,
+            "SELECT *",
+            chunksize=3,
+        )
+    )
+
+    assert len(chunks) == 2
+
+    assert chunks[0].to_dict(as_series=False) == {
+        "id": [1, 2, 3],
+        "name": ["a", "b", "c"],
+    }
+
+    assert chunks[1].to_dict(as_series=False) == {
+        "id": [4],
+        "name": ["d"],
+    }
+
+
+def test_read_sql_to_polars_by_chunks_clears_cursor():
+    es = _mock_es(
+        [
+            {
+                "columns": COLUMNS,
+                "rows": [[1, "a"]],
+                "cursor": "cursor_1",
+            },
+            {
+                "rows": [[2, "b"]],
+                "cursor": None,
+            },
+        ]
+    )
+
+    list(
+        read_sql_to_polars_by_chunks(
+            es,
+            "SELECT *",
+            chunksize=1,
+        )
+    )
+
+    es.sql.clear_cursor.assert_called_once()
+
+
+def test_read_sql_to_polars_by_chunks_no_cursor_cleanup():
+    es = _mock_es(
+        [
+            {
+                "columns": COLUMNS,
+                "rows": [[1, "a"]],
+            }
+        ]
+    )
+
+    list(
+        read_sql_to_polars_by_chunks(
+            es,
+            "SELECT *",
+            chunksize=1,
+        )
+    )
+
+    es.sql.clear_cursor.assert_not_called()
+
+
+def test_read_sql_to_polars_clears_cursor_on_pagination_error():
+    es = MagicMock()
+
+    es.sql.query.side_effect = [
+        {
+            "columns": COLUMNS,
+            "rows": [[1, "a"]],
+            "cursor": "cursor_1",
+        },
+        RuntimeError("boom"),
+    ]
+
+    with pytest.raises(RuntimeError, match="boom"):
+        list(
+            read_sql_to_polars_by_chunks(
+                es,
+                "SELECT *",
+            )
+        )
+
+    es.sql.clear_cursor.assert_called_once()

Reply via email to