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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3bc53b4d feat(python): support streaming SQL results (#1103)
3bc53b4d is described below

commit 3bc53b4d5f2fcd54d4f4bcbc2dec2aea1c4357a6
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Oct 12 13:49:38 2026 +0800

    feat(python): support streaming SQL results (#1103)
---
 bindings/python/README.md                          |  26 ++++
 .../python/python/pypaimon_rust/datafusion.pyi     |  12 ++
 bindings/python/src/context.rs                     | 106 ++++++++++++---
 bindings/python/src/read.rs                        |  81 +++++++----
 bindings/python/tests/test_datafusion.py           |  15 ++-
 bindings/python/tests/test_sql_stream.py           | 149 +++++++++++++++++++++
 6 files changed, 341 insertions(+), 48 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index 76ea23d8..9a55cf8b 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -56,6 +56,32 @@ ctx.sql("DROP TEMPORARY TABLE paimon.default.my_temp")
 
 For the full SQL reference, see the [SQL Integration 
docs](https://paimon.apache.org/docs/master/sql/).
 
+### Streaming SQL results
+
+`sql_stream()` executes one SQL query and returns a `RecordBatchReader`, 
yielding
+PyArrow batches without collecting the entire result in memory. Close the 
reader
+when stopping early to release the execution stream:
+
+```python
+from contextlib import closing
+
+with closing(ctx.sql_stream("SELECT id, name FROM paimon.my_db.users")) as 
reader:
+    for batch in reader:
+        print(batch.to_pydict())
+```
+
+`read_next_batch()` returns `None` when the reader is exhausted or closed.
+`reader.schema` is known before the first batch, so
+`pa.Table.from_batches(reader, schema=reader.schema)` also handles an empty 
result.
+Statements that write, such as `INSERT`, finish before `sql_stream()` returns.
+Errors during execution can surface while reading a batch and end the stream.
+Operators such as sorting and aggregation may still buffer data during 
execution.
+
+`sql_stream_with_options(sql, options)` applies `SQLOptions` as 
`sql_with_options()`
+does: they restrict statements delegated to DataFusion, but not statements 
Paimon
+handles itself, such as `DELETE`, `UPDATE`, `MERGE INTO` or `DROP TABLE` on a
+Paimon table.
+
 ### Native Vector Search
 
 A table with an existing vector column can search through the Rust core without
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index b14bbe5b..abc262e2 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -94,6 +94,10 @@ class StreamTableScan:
 
 
 class RecordBatchReader:
+    @property
+    def schema(self) -> Optional[pyarrow.Schema]:
+        """Result schema of a SQL stream; None for a table read."""
+        ...
     def __iter__(self) -> "RecordBatchReader": ...
     def __next__(self) -> pyarrow.RecordBatch: ...
     def read_next_batch(self) -> Optional[pyarrow.RecordBatch]: ...
@@ -589,6 +593,14 @@ class SQLContext:
     def sql_with_options(
         self, sql: str, options: SQLOptions
     ) -> List[pyarrow.RecordBatch]: ...
+    def sql_stream(self, sql: str) -> RecordBatchReader:
+        """Stream PyArrow batches; close the reader when stopping early."""
+        ...
+    def sql_stream_with_options(
+        self, sql: str, options: SQLOptions
+    ) -> RecordBatchReader:
+        """Stream results with SQLOptions restrictions."""
+        ...
 
 
 class VectorSearchBuilder:
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 117dc05e..1b9c5397 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -20,16 +20,19 @@ use std::path::PathBuf;
 use std::sync::Arc;
 
 use arrow::compute::cast;
-use arrow::datatypes::{DataType as ArrowDataType, Field as ArrowField};
+use arrow::datatypes::{DataType as ArrowDataType, Field as ArrowField, 
SchemaRef};
 use arrow::pyarrow::{FromPyArrow, ToPyArrow};
 use arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use datafusion::catalog::CatalogProvider;
 use datafusion::execution::context::SQLOptions;
 use datafusion::execution::memory_pool::{FairSpillPool, GreedyMemoryPool, 
MemoryPool};
 use datafusion::execution::runtime_env::RuntimeEnvBuilder;
-use datafusion::logical_expr::{Signature, TypeSignature, Volatility};
+use datafusion::logical_expr::{LogicalPlan, Signature, TypeSignature, 
Volatility};
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::SendableRecordBatchStream;
 use datafusion_ffi::catalog_provider::FFI_CatalogProvider;
 use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
+use futures::StreamExt;
 use paimon::catalog::Identifier;
 use paimon::{Catalog, CatalogFactory, Options};
 use paimon_datafusion::{PaimonCatalogProvider, SQLContext};
@@ -39,14 +42,15 @@ use pyo3::types::PyCapsule;
 
 use crate::blob::PyBlobReaderRegistry;
 use crate::error::{df_to_py_err, to_py_err};
+use crate::read::PyRecordBatchReader;
 use crate::table::PyTable;
 use crate::udf::{build_python_scalar_udf, udf, PyPythonScalarUDFObject};
 use paimon_datafusion::runtime::runtime;
 
-fn pyarrow_compatible_batch(batch: &RecordBatch) -> 
arrow::error::Result<RecordBatch> {
+/// Returns `None` when the schema is already PyArrow compatible.
+fn pyarrow_compatible_schema(schema: &arrow::datatypes::Schema) -> 
Option<SchemaRef> {
     let mut changed = false;
-    let fields = batch
-        .schema()
+    let fields = schema
         .fields()
         .iter()
         .map(|field| {
@@ -60,13 +64,18 @@ fn pyarrow_compatible_batch(batch: &RecordBatch) -> 
arrow::error::Result<RecordB
         .collect::<Vec<_>>();
 
     if !changed {
-        return Ok(batch.clone());
+        return None;
     }
-
-    let schema = Arc::new(arrow::datatypes::Schema::new_with_metadata(
+    Some(Arc::new(arrow::datatypes::Schema::new_with_metadata(
         fields,
-        batch.schema().metadata().clone(),
-    ));
+        schema.metadata().clone(),
+    )))
+}
+
+fn pyarrow_compatible_batch(batch: &RecordBatch) -> 
arrow::error::Result<RecordBatch> {
+    let Some(schema) = pyarrow_compatible_schema(batch.schema_ref()) else {
+        return Ok(batch.clone());
+    };
     let columns = batch
         .columns()
         .iter()
@@ -84,17 +93,28 @@ fn pyarrow_compatible_batch(batch: &RecordBatch) -> 
arrow::error::Result<RecordB
     RecordBatch::try_new_with_options(schema, columns, &options)
 }
 
+/// DataFusion runs these plans only while their result stream is polled.
+/// `EXPLAIN ANALYZE` executes its input, so a wrapped write counts too.
+fn is_write_plan(plan: &LogicalPlan) -> bool {
+    match plan {
+        LogicalPlan::Dml(_) | LogicalPlan::Copy(_) => true,
+        LogicalPlan::Analyze(analyze) => is_write_plan(&analyze.input),
+        _ => false,
+    }
+}
+
+fn query_result_batch(batch: &RecordBatch) -> PyResult<RecordBatch> {
+    pyarrow_compatible_batch(batch).map_err(|err| {
+        PyValueError::new_err(format!(
+            "Failed to convert query result for PyArrow compatibility: {err}"
+        ))
+    })
+}
+
 fn batches_to_pyarrow(py: Python<'_>, batches: &[RecordBatch]) -> 
PyResult<Vec<Py<PyAny>>> {
     batches
         .iter()
-        .map(|batch| {
-            let batch = pyarrow_compatible_batch(batch).map_err(|err| {
-                PyValueError::new_err(format!(
-                    "Failed to convert query result for PyArrow compatibility: 
{err}"
-                ))
-            })?;
-            Ok(batch.to_pyarrow(py)?.unbind())
-        })
+        .map(|batch| Ok(query_result_batch(batch)?.to_pyarrow(py)?.unbind()))
         .collect()
 }
 
@@ -394,6 +414,41 @@ pub struct PySQLContext {
 }
 
 impl PySQLContext {
+    fn execute_sql_stream(
+        &self,
+        py: Python<'_>,
+        sql: &str,
+        options: SQLOptions,
+    ) -> PyResult<PyRecordBatchReader> {
+        let stream = py.detach(|| {
+            runtime().block_on(async {
+                let df = self
+                    .inner
+                    .sql_with_options(sql, options)
+                    .await
+                    .map_err(df_to_py_err)?;
+                if !is_write_plan(df.logical_plan()) {
+                    return df.execute_stream().await.map_err(df_to_py_err);
+                }
+                // Finish the write before returning, so it does not depend on
+                // the caller reading or closing the result.
+                let schema = Arc::clone(df.schema().inner());
+                let batches = df.collect().await.map_err(df_to_py_err)?;
+                let stream = 
futures::stream::iter(batches.into_iter().map(Ok));
+                let stream: SendableRecordBatchStream =
+                    Box::pin(RecordBatchStreamAdapter::new(schema, stream));
+                Ok(stream)
+            })
+        })?;
+        let schema = stream.schema();
+        let schema = pyarrow_compatible_schema(&schema).unwrap_or(schema);
+        let stream = stream.map(|batch| 
query_result_batch(&batch.map_err(df_to_py_err)?));
+        Ok(PyRecordBatchReader::from_py_stream(
+            Some(schema),
+            Box::pin(stream),
+        ))
+    }
+
     fn build_sql_context(
         memory_pool_type: Option<String>,
         memory_pool_bytes: Option<usize>,
@@ -720,6 +775,21 @@ impl PySQLContext {
         })?;
         batches_to_pyarrow(py, &batches)
     }
+
+    /// Stream SQL results. Close the reader when stopping early.
+    fn sql_stream(&self, py: Python<'_>, sql: String) -> 
PyResult<PyRecordBatchReader> {
+        self.execute_sql_stream(py, &sql, SQLOptions::new())
+    }
+
+    /// Stream SQL results using the supplied options.
+    fn sql_stream_with_options(
+        &self,
+        py: Python<'_>,
+        sql: String,
+        options: PyRef<'_, PySQLOptions>,
+    ) -> PyResult<PyRecordBatchReader> {
+        self.execute_sql_stream(py, &sql, options.inner)
+    }
 }
 
 pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index e101e93b..0cec6518 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -19,8 +19,10 @@ use std::collections::HashMap;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
 
+use arrow::datatypes::SchemaRef;
 use arrow::pyarrow::ToPyArrow;
 use arrow::record_batch::RecordBatch;
+use futures::stream::BoxStream;
 use futures::TryStreamExt;
 use paimon::spec::{is_variant_extraction_row, DataField, DataType, Predicate, 
RowType};
 use paimon::table::{
@@ -746,11 +748,9 @@ impl PyTableRead {
         py: Python<'_>,
         splits: &Bound<'_, PyAny>,
     ) -> PyResult<PyRecordBatchReader> {
-        Ok(PyRecordBatchReader {
-            stream: Mutex::new(Some(self.read_stream(py, splits)?)),
-            closed: AtomicBool::new(false),
-            close_notify: Notify::new(),
-        })
+        Ok(PyRecordBatchReader::from_stream(
+            self.read_stream(py, splits)?,
+        ))
     }
 
     /// Read the given splits into a list of PyArrow RecordBatches.
@@ -770,14 +770,23 @@ impl PyTableRead {
 
 #[pyclass(name = "RecordBatchReader", module = "pypaimon_rust.datafusion")]
 pub struct PyRecordBatchReader {
-    stream: Mutex<Option<ArrowRecordBatchStream>>,
+    schema: Option<SchemaRef>,
+    stream: Mutex<Option<BoxStream<'static, PyResult<RecordBatch>>>>,
     closed: AtomicBool,
     close_notify: Notify,
 }
 
 impl PyRecordBatchReader {
     pub(crate) fn from_stream(stream: ArrowRecordBatchStream) -> Self {
+        Self::from_py_stream(None, Box::pin(stream.map_err(to_py_err)))
+    }
+
+    pub(crate) fn from_py_stream(
+        schema: Option<SchemaRef>,
+        stream: BoxStream<'static, PyResult<RecordBatch>>,
+    ) -> Self {
         Self {
+            schema,
             stream: Mutex::new(Some(stream)),
             closed: AtomicBool::new(false),
             close_notify: Notify::new(),
@@ -794,18 +803,16 @@ impl PyRecordBatchReader {
             return Ok(None);
         }
         let result = match stream.as_mut() {
-            Some(stream) => runtime()
-                .block_on(async {
-                    tokio::select! {
-                        biased;
-                        _ = self.close_notify.notified() => Ok(None),
-                        batch = stream.try_next() => batch,
-                    }
-                })
-                .map_err(to_py_err),
+            Some(stream) => runtime().block_on(async {
+                tokio::select! {
+                    biased;
+                    _ = self.close_notify.notified() => Ok(None),
+                    batch = stream.try_next() => batch,
+                }
+            }),
             None => Ok(None),
         };
-        if self.closed.load(Ordering::Acquire) {
+        if self.closed.load(Ordering::Acquire) || !matches!(result, 
Ok(Some(_))) {
             stream.take();
         }
         result
@@ -820,9 +827,7 @@ impl PyRecordBatchReader {
 
     fn close_reader(&self) {
         self.closed.store(true, Ordering::Release);
-        // notify_one stores a permit when next_record_batch has not started
-        // polling yet, avoiding a lost wake-up between its closed check and
-        // the select.
+        // notify_one retains a permit if the read has not started polling.
         self.close_notify.notify_one();
         if let Ok(mut stream) = self.stream.try_lock() {
             stream.take();
@@ -832,6 +837,15 @@ impl PyRecordBatchReader {
 
 #[pymethods]
 impl PyRecordBatchReader {
+    /// Result schema, known before the first batch for SQL streams.
+    #[getter]
+    fn schema(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
+        self.schema
+            .as_ref()
+            .map(|schema| Ok(schema.to_pyarrow(py)?.unbind()))
+            .transpose()
+    }
+
     fn read_next_batch(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
         self.next_batch(py)
     }
@@ -967,6 +981,29 @@ mod tests {
 
     use super::*;
 
+    #[test]
+    fn record_batch_reader_releases_stream_on_completion_or_error() {
+        for fail in [false, true] {
+            let resource = Arc::new(());
+            let weak = Arc::downgrade(&resource);
+            let stream: ArrowRecordBatchStream = 
Box::pin(futures::stream::poll_fn(move |_cx| {
+                let _resource = &resource;
+                Poll::Ready(fail.then(|| {
+                    Err(paimon::Error::Unsupported {
+                        message: "read failed".to_string(),
+                    })
+                }))
+            }));
+            let reader = PyRecordBatchReader::from_stream(stream);
+            assert!(weak.upgrade().is_some());
+
+            let result = reader.next_record_batch();
+            assert_eq!(result.is_err(), fail);
+            assert!(weak.upgrade().is_none());
+            assert!(matches!(reader.next_record_batch(), Ok(None)));
+        }
+    }
+
     #[test]
     fn record_batch_reader_close_interrupts_pending_next() {
         let (polled_tx, polled_rx) = mpsc::channel();
@@ -978,11 +1015,7 @@ mod tests {
             }
             Poll::<Option<paimon::Result<RecordBatch>>>::Pending
         }));
-        let reader = Arc::new(PyRecordBatchReader {
-            stream: Mutex::new(Some(stream)),
-            closed: AtomicBool::new(false),
-            close_notify: Notify::new(),
-        });
+        let reader = Arc::new(PyRecordBatchReader::from_stream(stream));
         let worker_reader = Arc::clone(&reader);
         let (result_tx, result_rx) = mpsc::channel();
         let worker = std::thread::spawn(move || {
diff --git a/bindings/python/tests/test_datafusion.py 
b/bindings/python/tests/test_datafusion.py
index f9d09372..4fc8cfc8 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -132,30 +132,33 @@ def 
test_sql_context_rejects_invalid_runtime_resource_configuration(kwargs, mess
         SQLContext(**kwargs)
 
 
-def test_sql_with_options_disables_statements():
[email protected]("method", ["sql_with_options", 
"sql_stream_with_options"])
+def test_sql_with_options_disables_statements(method):
     ctx = SQLContext()
     options = SQLOptions().with_allow_statements(False)
 
     with pytest.raises(ValueError, match="Statement not supported"):
-        ctx.sql_with_options("SET datafusion.execution.batch_size = 1024", 
options)
+        getattr(ctx, method)("SET datafusion.execution.batch_size = 1024", 
options)
 
 
-def test_sql_with_options_disables_ddl():
[email protected]("method", ["sql_with_options", 
"sql_stream_with_options"])
+def test_sql_with_options_disables_ddl(method):
     ctx = SQLContext()
     options = SQLOptions().with_allow_ddl(False)
 
     with pytest.raises(ValueError, match="DDL not supported"):
-        ctx.sql_with_options("CREATE VIEW blocked_view AS SELECT 1", options)
+        getattr(ctx, method)("CREATE VIEW blocked_view AS SELECT 1", options)
 
 
-def test_sql_with_options_disables_dml(tmp_path):
[email protected]("method", ["sql_with_options", 
"sql_stream_with_options"])
+def test_sql_with_options_disables_dml(tmp_path, method):
     ctx = SQLContext()
     ctx.register_catalog("paimon", {"warehouse": str(tmp_path / "warehouse")})
     ctx.sql("CREATE TABLE paimon.default.test_table (id INT)")
     options = SQLOptions().with_allow_dml(False)
 
     with pytest.raises(ValueError, match="DML not supported"):
-        ctx.sql_with_options(
+        getattr(ctx, method)(
             "INSERT INTO paimon.default.test_table VALUES (1)", options
         )
 
diff --git a/bindings/python/tests/test_sql_stream.py 
b/bindings/python/tests/test_sql_stream.py
new file mode 100644
index 00000000..cfa92d95
--- /dev/null
+++ b/bindings/python/tests/test_sql_stream.py
@@ -0,0 +1,149 @@
+# 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.
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import RecordBatchReader, SQLContext, SQLOptions
+
+
[email protected]
+def ctx(tmp_path):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": str(tmp_path / "warehouse")})
+    ctx.sql("SET datafusion.execution.batch_size = 2")
+    ctx.sql("SET datafusion.execution.target_partitions = 1")
+    return ctx
+
+
[email protected](params=["sql_stream", "sql_stream_with_options"])
+def stream_sql(ctx, request):
+    if request.param == "sql_stream_with_options":
+        return lambda sql: ctx.sql_stream_with_options(sql, SQLOptions())
+    return ctx.sql_stream
+
+
+def test_sql_stream_returns_multiple_batches(ctx, stream_sql):
+    sql = "SELECT value FROM generate_series(1, 9) AS t(value)"
+    reader = stream_sql(sql)
+
+    assert isinstance(reader, RecordBatchReader)
+    assert iter(reader) is reader
+    first = reader.read_next_batch()
+    assert isinstance(first, pa.RecordBatch)
+    assert first.num_rows == 2
+    batches = [first, *reader]
+    assert len(batches) > 1
+    assert 
pa.Table.from_batches(batches).equals(pa.Table.from_batches(ctx.sql(sql)))
+    assert reader.read_next_batch() is None
+    assert list(reader) == []
+    reader.close()
+
+
+def test_sql_stream_reads_paimon_table(ctx, stream_sql):
+    ctx.sql("CREATE TABLE paimon.default.users (id INT, name STRING)")
+    ctx.sql(
+        "INSERT INTO paimon.default.users VALUES "
+        "(1, 'alice'), (2, NULL), (3, 'charlie')"
+    )
+    sql = "SELECT id, name FROM paimon.default.users WHERE id >= 2 ORDER BY id"
+
+    result = pa.Table.from_batches(stream_sql(sql))
+
+    assert result.equals(pa.Table.from_batches(ctx.sql(sql)))
+    assert result.to_pydict() == {"id": [2, 3], "name": [None, "charlie"]}
+
+
+def test_sql_stream_preserves_pyarrow_string_compatibility(ctx, stream_sql):
+    ctx.register_batch(
+        "labels",
+        pa.record_batch(
+            [pa.array(["alice", None, "charlie"], type=pa.string_view())],
+            names=["name"],
+        ),
+    )
+    sql = "SELECT name FROM labels"
+
+    result = pa.Table.from_batches(stream_sql(sql))
+
+    assert result.schema.field("name").type == pa.string()
+    assert result.equals(pa.Table.from_batches(ctx.sql(sql)))
+
+
+def test_sql_stream_empty_result(stream_sql):
+    reader = stream_sql("SELECT 1 AS value WHERE FALSE")
+
+    assert sum(batch.num_rows for batch in reader) == 0
+    assert reader.read_next_batch() is None
+    assert list(reader) == []
+    reader.close()
+
+
+def test_sql_stream_schema_describes_an_empty_result(ctx, stream_sql):
+    ctx.sql("CREATE TABLE paimon.default.users (id INT, name STRING)")
+    reader = stream_sql("SELECT id, name FROM paimon.default.users WHERE id < 
0")
+
+    result = pa.Table.from_batches(reader, schema=reader.schema)
+
+    assert result.num_rows == 0
+    assert result.schema.names == ["id", "name"]
+    assert result.schema.types == [pa.int32(), pa.string()]
+
+
[email protected]("prefix", ["", "EXPLAIN ANALYZE "])
+def test_sql_stream_finishes_insert_before_returning(ctx, stream_sql, prefix):
+    ctx.sql("CREATE TABLE paimon.default.events (id INT)")
+
+    sql = f"{prefix}INSERT INTO paimon.default.events VALUES (1), (2)"
+    stream_sql(sql).close()
+
+    result = pa.Table.from_batches(
+        ctx.sql("SELECT id FROM paimon.default.events ORDER BY id")
+    )
+    assert result.to_pydict() == {"id": [1, 2]}
+
+
+def test_sql_stream_reports_planning_errors(stream_sql):
+    with pytest.raises(ValueError, match="missing_table"):
+        stream_sql("SELECT * FROM missing_table")
+
+
+def test_sql_stream_yields_batches_before_execution_error(stream_sql):
+    reader = stream_sql(
+        "SELECT 10 / (3 - value) AS value FROM generate_series(1, 4) AS 
t(value)"
+    )
+
+    assert next(reader).to_pydict() == {"value": [5, 10]}
+    with pytest.raises(ValueError, match="(?i)(divide|division) by zero"):
+        reader.read_next_batch()
+    assert reader.read_next_batch() is None
+    reader.close()
+
+
[email protected]("read_first", [False, True])
+def test_sql_stream_close_stops_iteration(stream_sql, read_first):
+    reader = stream_sql(
+        "SELECT value FROM generate_series(1, 1000000) AS t(value)"
+    )
+    if read_first:
+        assert next(reader).num_rows == 2
+
+    reader.close()
+
+    assert reader.read_next_batch() is None
+    assert list(reader) == []
+    reader.close()

Reply via email to