@@ -352,6 +352,9 @@ impl PyDuckDBReader {
352352 /// ----------
353353 /// query : str
354354 /// The ggsql query (SQL + VISUALISE clause).
355+ /// data : dict[str, polars.DataFrame] | None
356+ /// Optional dictionary mapping table names to DataFrames. Tables are
357+ /// registered before execution and unregistered afterward (even on error).
355358 ///
356359 /// Returns
357360 /// -------
@@ -369,11 +372,48 @@ impl PyDuckDBReader {
369372 /// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point")
370373 /// >>> writer = VegaLiteWriter()
371374 /// >>> json_output = writer.render(spec)
372- fn execute ( & self , query : & str ) -> PyResult < PySpec > {
373- self . inner
375+ #[ pyo3( signature = ( query, * , data=None ) ) ]
376+ fn execute ( & self , py : Python < ' _ > , query : & str , data : Option < & Bound < ' _ , PyDict > > ) -> PyResult < PySpec > {
377+ // Register DataFrames from data dict
378+ let registered_names = if let Some ( data_dict) = data {
379+ self . register_data_dict ( py, data_dict) ?
380+ } else {
381+ vec ! [ ]
382+ } ;
383+
384+ // Execute query (capture result, don't return early)
385+ let result = self . inner
374386 . execute ( query)
375387 . map ( |s| PySpec { inner : s } )
376- . map_err ( ggsql_err_to_py)
388+ . map_err ( ggsql_err_to_py) ;
389+
390+ // Cleanup: unregister temporary tables (even on error)
391+ for name in & registered_names {
392+ let _ = self . inner . unregister ( name) ;
393+ }
394+
395+ result
396+ }
397+ }
398+
399+ impl PyDuckDBReader {
400+ /// Register DataFrames from a Python dict. Returns list of registered names for cleanup.
401+ /// This is a private Rust helper, not exposed to Python.
402+ fn register_data_dict (
403+ & self ,
404+ py : Python < ' _ > ,
405+ data : & Bound < ' _ , PyDict > ,
406+ ) -> PyResult < Vec < String > > {
407+ let mut names = Vec :: new ( ) ;
408+ for ( key, value) in data. iter ( ) {
409+ let name: String = key. extract ( ) ?;
410+ let df = py_to_polars ( py, & value) ?;
411+ self . inner
412+ . register ( & name, df, true )
413+ . map_err ( ggsql_err_to_py) ?;
414+ names. push ( name) ;
415+ }
416+ Ok ( names)
377417 }
378418}
379419
@@ -741,6 +781,9 @@ fn validate(query: &str) -> PyResult<PyValidated> {
741781/// The database reader to execute SQL against. Can be a native Reader
742782/// for optimal performance, or any Python object with an
743783/// `execute_sql(sql: str) -> polars.DataFrame` method.
784+ /// data : dict[str, polars.DataFrame] | None
785+ /// Optional dictionary mapping table names to DataFrames. Tables are
786+ /// registered before execution and unregistered afterward (even on error).
744787///
745788/// Returns
746789/// -------
@@ -767,19 +810,80 @@ fn validate(query: &str) -> PyResult<PyValidated> {
767810/// >>> reader = MyReader()
768811/// >>> spec = execute("SELECT * FROM data VISUALISE x, y DRAW point", reader)
769812#[ pyfunction]
770- fn execute ( query : & str , reader : & Bound < ' _ , PyAny > ) -> PyResult < PySpec > {
771- // Fast path: try all known native reader types
772- // Add new native readers to this list as they're implemented
773- try_native_readers ! ( query, reader, PyDuckDBReader ) ;
813+ #[ pyo3( signature = ( query, reader, * , data=None ) ) ]
814+ fn execute ( py : Python < ' _ > , query : & str , reader : & Bound < ' _ , PyAny > , data : Option < & Bound < ' _ , PyDict > > ) -> PyResult < PySpec > {
815+ // Native reader fast path: DuckDBReader
816+ // Note: we can't use the try_native_readers! macro here because it uses `return`
817+ // which would skip cleanup of registered tables.
818+ if let Ok ( native) = reader. downcast :: < PyDuckDBReader > ( ) {
819+ // Register DataFrames if provided
820+ let registered_names = if let Some ( data_dict) = data {
821+ native. borrow ( ) . register_data_dict ( py, data_dict) ?
822+ } else {
823+ vec ! [ ]
824+ } ;
825+
826+ // Execute (capture result for cleanup)
827+ let result = native. borrow ( ) . inner . execute ( query)
828+ . map ( |s| PySpec { inner : s } )
829+ . map_err ( ggsql_err_to_py) ;
830+
831+ // Cleanup: unregister temporary tables (even on error)
832+ for name in & registered_names {
833+ let _ = native. borrow ( ) . inner . unregister ( name) ;
834+ }
835+
836+ return result;
837+ }
774838
775839 // Bridge path: wrap Python object as Reader
840+ // Register DataFrames if provided
841+ let registered_names = if let Some ( data_dict) = data {
842+ register_data_on_reader ( py, reader, data_dict) ?
843+ } else {
844+ vec ! [ ]
845+ } ;
846+
776847 let bridge = PyReaderBridge {
777848 obj : reader. clone ( ) . unbind ( ) ,
778849 } ;
779- bridge
850+ let result = bridge
780851 . execute ( query)
781852 . map ( |s| PySpec { inner : s } )
782- . map_err ( ggsql_err_to_py)
853+ . map_err ( ggsql_err_to_py) ;
854+
855+ // Cleanup for bridge path
856+ for name in & registered_names {
857+ let _ = call_unregister ( py, reader, name) ;
858+ }
859+
860+ result
861+ }
862+
863+ /// Register DataFrames from a Python dict onto a Python reader object.
864+ /// Returns list of registered names for cleanup.
865+ fn register_data_on_reader (
866+ py : Python < ' _ > ,
867+ reader : & Bound < ' _ , PyAny > ,
868+ data : & Bound < ' _ , PyDict > ,
869+ ) -> PyResult < Vec < String > > {
870+ let mut names = Vec :: new ( ) ;
871+ for ( key, value) in data. iter ( ) {
872+ let name: String = key. extract ( ) ?;
873+ let df = py_to_polars ( py, & value) ?;
874+ let py_df = polars_to_py ( py, & df) ?;
875+ reader. call_method ( "register" , ( & name, py_df, true ) , None ) ?;
876+ names. push ( name) ;
877+ }
878+ Ok ( names)
879+ }
880+
881+ /// Call unregister on a reader if the method exists.
882+ fn call_unregister ( _py : Python < ' _ > , reader : & Bound < ' _ , PyAny > , name : & str ) -> PyResult < ( ) > {
883+ if reader. hasattr ( "unregister" ) ? {
884+ reader. call_method1 ( "unregister" , ( name, ) ) ?;
885+ }
886+ Ok ( ( ) )
783887}
784888
785889// ============================================================================
0 commit comments