33#![ allow( clippy:: useless_conversion) ]
44
55use pyo3:: prelude:: * ;
6- use pyo3:: types:: { PyBytes , PyDict , PyList } ;
6+ use pyo3:: types:: { PyBytes , PyCapsule , PyDict , PyList } ;
77use std:: io:: Cursor ;
88
9+ use arrow:: array:: RecordBatchIterator ;
10+ use arrow:: ffi_stream:: { ArrowArrayStreamReader , FFI_ArrowArrayStream } ;
911use arrow:: ipc:: reader:: StreamReader ;
1012use arrow:: ipc:: writer:: StreamWriter ;
1113
@@ -24,8 +26,19 @@ use ggsql::GgsqlError;
2426// Helper Functions for DataFrame Conversion
2527// ============================================================================
2628
27- /// Convert a ggsql DataFrame to a Python pyarrow Table via Arrow IPC serialization
28- fn df_to_py ( py : Python < ' _ > , df : & DataFrame ) -> PyResult < Py < PyAny > > {
29+ /// Whether to use the legacy Arrow IPC serialization path instead of the
30+ /// zero-copy Arrow C Data Interface path. Enabled by setting GGSQL_ARROW_IPC
31+ /// to any non-empty value other than "0".
32+ fn use_ipc_fallback ( ) -> bool {
33+ match std:: env:: var_os ( "GGSQL_ARROW_IPC" ) {
34+ Some ( v) => !v. is_empty ( ) && v != "0" ,
35+ None => false ,
36+ }
37+ }
38+
39+ /// Convert a ggsql DataFrame to a Python pyarrow Table via Arrow IPC
40+ /// serialization (legacy fallback; see GGSQL_ARROW_IPC)
41+ fn df_to_py_ipc ( py : Python < ' _ > , df : & DataFrame ) -> PyResult < Py < PyAny > > {
2942 let rb = df. inner ( ) ;
3043 let mut buffer = Vec :: new ( ) ;
3144 {
@@ -58,8 +71,99 @@ fn df_to_py(py: Python<'_>, df: &DataFrame) -> PyResult<Py<PyAny>> {
5871 Ok ( table. into ( ) )
5972}
6073
61- /// Convert a Python pyarrow Table to a ggsql DataFrame via Arrow IPC serialization
62- fn py_to_df ( py : Python < ' _ > , obj : & Bound < ' _ , PyAny > ) -> PyResult < DataFrame > {
74+ /// Convert a ggsql DataFrame to a Python pyarrow Table.
75+ ///
76+ /// Uses the zero-copy Arrow C Data Interface by default; set GGSQL_ARROW_IPC=1
77+ /// to fall back to Arrow IPC serialization.
78+ fn df_to_py ( py : Python < ' _ > , df : & DataFrame ) -> PyResult < Py < PyAny > > {
79+ if use_ipc_fallback ( ) {
80+ df_to_py_ipc ( py, df)
81+ } else {
82+ df_to_py_stream ( py, df)
83+ }
84+ }
85+
86+ /// Convert a ggsql DataFrame to a Python pyarrow Table via the Arrow C Data
87+ /// Interface (zero-copy).
88+ fn df_to_py_stream ( py : Python < ' _ > , df : & DataFrame ) -> PyResult < Py < PyAny > > {
89+ let rb = df. inner ( ) . clone ( ) ;
90+ let schema = rb. schema ( ) ;
91+ let reader = RecordBatchIterator :: new ( std:: iter:: once ( Ok ( rb) ) , schema) ;
92+ let stream = FFI_ArrowArrayStream :: new ( Box :: new ( reader) ) ;
93+ let stream_ptr = Box :: into_raw ( Box :: new ( stream) ) ;
94+
95+ // SAFETY: stream_ptr is a valid allocation we own; the capsule takes
96+ // ownership, and c_stream_capsule_destructor reclaims it.
97+ let capsule_ptr = unsafe {
98+ pyo3:: ffi:: PyCapsule_New (
99+ stream_ptr. cast ( ) ,
100+ c"arrow_array_stream" . as_ptr ( ) ,
101+ Some ( c_stream_capsule_destructor) ,
102+ )
103+ } ;
104+ if capsule_ptr. is_null ( ) {
105+ // Capsule creation failed; reclaim the allocation and propagate.
106+ unsafe { drop ( Box :: from_raw ( stream_ptr) ) } ;
107+ return Err ( PyErr :: fetch ( py) ) ;
108+ }
109+ // SAFETY: capsule_ptr is a new reference to a valid PyCapsule object.
110+ let capsule = unsafe { Py :: < PyAny > :: from_owned_ptr ( py, capsule_ptr) } ;
111+
112+ let shim = Py :: new ( py, ArrowStreamShim { capsule } ) ?;
113+ let pa = py. import ( "pyarrow" ) ?;
114+ let table = pa
115+ . getattr ( "RecordBatchReader" ) ?
116+ . call_method1 ( "from_stream" , ( shim, ) ) ?
117+ . call_method0 ( "read_all" ) ?;
118+ Ok ( table. into ( ) )
119+ }
120+
121+ /// Capsule destructor honoring the Arrow C Stream Interface contract: if the
122+ /// stream was never consumed (its release callback is still set), release it;
123+ /// then reclaim the allocation. When pyarrow consumes the stream it moves the
124+ /// contents out and nulls the release callback, so this destructor only frees
125+ /// the (now-empty) allocation in that case.
126+ unsafe extern "C" fn c_stream_capsule_destructor ( capsule : * mut pyo3:: ffi:: PyObject ) {
127+ let ptr = unsafe { pyo3:: ffi:: PyCapsule_GetPointer ( capsule, c"arrow_array_stream" . as_ptr ( ) ) }
128+ . cast :: < FFI_ArrowArrayStream > ( ) ;
129+ if ptr. is_null ( ) {
130+ return ;
131+ }
132+ let mut stream = unsafe { Box :: from_raw ( ptr) } ;
133+ if let Some ( release) = stream. release {
134+ // SAFETY: per the C Data Interface contract, release is called at
135+ // most once and marks the stream as released.
136+ unsafe { release ( & mut * stream) } ;
137+ }
138+ }
139+
140+ /// Python-side holder for an Arrow C Stream capsule, exposing the
141+ /// `__arrow_c_stream__` protocol so pyarrow can consume the stream.
142+ #[ pyclass]
143+ struct ArrowStreamShim {
144+ capsule : Py < PyAny > ,
145+ }
146+
147+ #[ pymethods]
148+ impl ArrowStreamShim {
149+ #[ pyo3( signature = ( requested_schema=None ) ) ]
150+ fn __arrow_c_stream__ (
151+ & self ,
152+ py : Python < ' _ > ,
153+ requested_schema : Option < & Bound < ' _ , PyAny > > ,
154+ ) -> PyResult < Py < PyAny > > {
155+ if requested_schema. is_some_and ( |s| !s. is_none ( ) ) {
156+ return Err ( PyErr :: new :: < pyo3:: exceptions:: PyNotImplementedError , _ > (
157+ "requested_schema is not supported" ,
158+ ) ) ;
159+ }
160+ Ok ( self . capsule . clone_ref ( py) )
161+ }
162+ }
163+
164+ /// Convert a Python pyarrow Table to a ggsql DataFrame via Arrow IPC
165+ /// serialization (legacy fallback; see GGSQL_ARROW_IPC)
166+ fn py_to_df_ipc ( py : Python < ' _ > , obj : & Bound < ' _ , PyAny > ) -> PyResult < DataFrame > {
63167 let pa = py. import ( "pyarrow" ) ?;
64168 let table = if obj. is_instance ( & pa. getattr ( "Table" ) ?) ? {
65169 obj. clone ( )
@@ -112,6 +216,80 @@ fn py_to_df(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<DataFrame> {
112216 Ok ( DataFrame :: from_record_batch ( combined) )
113217}
114218
219+ /// Convert a Python pyarrow Table to a ggsql DataFrame.
220+ ///
221+ /// Uses the zero-copy Arrow C Data Interface by default; set GGSQL_ARROW_IPC=1
222+ /// to fall back to Arrow IPC serialization.
223+ fn py_to_df ( py : Python < ' _ > , obj : & Bound < ' _ , PyAny > ) -> PyResult < DataFrame > {
224+ if use_ipc_fallback ( ) {
225+ py_to_df_ipc ( py, obj)
226+ } else {
227+ py_to_df_stream ( py, obj)
228+ }
229+ }
230+
231+ /// Convert a Python pyarrow Table to a ggsql DataFrame via the Arrow C Data
232+ /// Interface (zero-copy).
233+ fn py_to_df_stream ( py : Python < ' _ > , obj : & Bound < ' _ , PyAny > ) -> PyResult < DataFrame > {
234+ let pa = py. import ( "pyarrow" ) ?;
235+ let table = if obj. is_instance ( & pa. getattr ( "Table" ) ?) ? {
236+ obj. clone ( )
237+ } else {
238+ pa. call_method1 ( "table" , ( obj, ) ) . map_err ( |e| {
239+ let type_name = obj
240+ . get_type ( )
241+ . name ( )
242+ . map ( |n| n. to_string ( ) )
243+ . unwrap_or_else ( |_| "<unknown>" . to_string ( ) ) ;
244+ PyErr :: new :: < pyo3:: exceptions:: PyTypeError , _ > ( format ! (
245+ "Expected a pyarrow.Table or compatible type, got {}: {}" ,
246+ type_name, e
247+ ) )
248+ } ) ?
249+ } ;
250+
251+ let capsule = table. call_method0 ( "__arrow_c_stream__" ) ?;
252+ let capsule = capsule. downcast :: < PyCapsule > ( ) . map_err ( |_| {
253+ PyErr :: new :: < pyo3:: exceptions:: PyValueError , _ > (
254+ "__arrow_c_stream__ did not return a PyCapsule" . to_string ( ) ,
255+ )
256+ } ) ?;
257+ let stream_ptr = capsule. pointer ( ) . cast :: < FFI_ArrowArrayStream > ( ) ;
258+ if stream_ptr. is_null ( ) {
259+ return Err ( PyErr :: new :: < pyo3:: exceptions:: PyValueError , _ > (
260+ "Invalid Arrow C stream capsule" . to_string ( ) ,
261+ ) ) ;
262+ }
263+
264+ // SAFETY: stream_ptr comes from a valid "arrow_array_stream" capsule
265+ // produced by pyarrow. from_raw moves the stream out and sets the
266+ // capsule's copy to a released state, so the capsule destructor will not
267+ // double-release it (it still frees the now-empty allocation).
268+ let reader = unsafe { ArrowArrayStreamReader :: from_raw ( stream_ptr) } . map_err ( |e| {
269+ PyErr :: new :: < pyo3:: exceptions:: PyValueError , _ > ( format ! (
270+ "Failed to import Arrow C stream: {}" ,
271+ e
272+ ) )
273+ } ) ?;
274+
275+ let batches: Vec < _ > = reader. collect :: < Result < Vec < _ > , _ > > ( ) . map_err ( |e| {
276+ PyErr :: new :: < pyo3:: exceptions:: PyValueError , _ > ( format ! (
277+ "Failed to read RecordBatch from Arrow C stream: {}" ,
278+ e
279+ ) )
280+ } ) ?;
281+
282+ if batches. is_empty ( ) {
283+ return Ok ( DataFrame :: empty ( ) ) ;
284+ }
285+
286+ let combined = arrow:: compute:: concat_batches ( & batches[ 0 ] . schema ( ) , & batches) . map_err ( |e| {
287+ PyErr :: new :: < pyo3:: exceptions:: PyValueError , _ > ( format ! ( "Failed to concat batches: {}" , e) )
288+ } ) ?;
289+
290+ Ok ( DataFrame :: from_record_batch ( combined) )
291+ }
292+
115293/// Convert a Python object to a ggsql DataFrame - for use inside Python::attach
116294/// This variant is used by PyReaderBridge where we already hold the GIL.
117295fn py_to_df_inner ( obj : & Bound < ' _ , PyAny > ) -> PyResult < DataFrame > {
@@ -1213,6 +1391,7 @@ fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> {
12131391 m. add_class :: < PyVegaLiteWriter > ( ) ?;
12141392 m. add_class :: < PyValidated > ( ) ?;
12151393 m. add_class :: < PySpec > ( ) ?;
1394+ m. add_class :: < ArrowStreamShim > ( ) ?;
12161395
12171396 // Functions
12181397 m. add_function ( wrap_pyfunction ! ( validate, m) ?) ?;
0 commit comments