@@ -333,6 +333,9 @@ impl PyDuckDBReader {
333333 /// ----------
334334 /// query : str
335335 /// The ggsql query (SQL + VISUALISE clause).
336+ /// data : dict[str, polars.DataFrame] | None
337+ /// Optional dictionary mapping table names to DataFrames. Tables are
338+ /// registered before execution and unregistered afterward (even on error).
336339 ///
337340 /// Returns
338341 /// -------
@@ -350,11 +353,48 @@ impl PyDuckDBReader {
350353 /// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point")
351354 /// >>> writer = VegaLiteWriter()
352355 /// >>> json_output = writer.render(spec)
353- fn execute ( & self , query : & str ) -> PyResult < PySpec > {
354- self . inner
356+ #[ pyo3( signature = ( query, * , data=None ) ) ]
357+ fn execute ( & self , py : Python < ' _ > , query : & str , data : Option < & Bound < ' _ , PyDict > > ) -> PyResult < PySpec > {
358+ // Register DataFrames from data dict
359+ let registered_names = if let Some ( data_dict) = data {
360+ self . register_data_dict ( py, data_dict) ?
361+ } else {
362+ vec ! [ ]
363+ } ;
364+
365+ // Execute query (capture result, don't return early)
366+ let result = self . inner
355367 . execute ( query)
356368 . map ( |s| PySpec { inner : s } )
357- . map_err ( ggsql_err_to_py)
369+ . map_err ( ggsql_err_to_py) ;
370+
371+ // Cleanup: unregister temporary tables (even on error)
372+ for name in & registered_names {
373+ let _ = self . inner . unregister ( name) ;
374+ }
375+
376+ result
377+ }
378+ }
379+
380+ impl PyDuckDBReader {
381+ /// Register DataFrames from a Python dict. Returns list of registered names for cleanup.
382+ /// This is a private Rust helper, not exposed to Python.
383+ fn register_data_dict (
384+ & self ,
385+ py : Python < ' _ > ,
386+ data : & Bound < ' _ , PyDict > ,
387+ ) -> PyResult < Vec < String > > {
388+ let mut names = Vec :: new ( ) ;
389+ for ( key, value) in data. iter ( ) {
390+ let name: String = key. extract ( ) ?;
391+ let df = py_to_polars ( py, & value) ?;
392+ self . inner
393+ . register ( & name, df, true )
394+ . map_err ( ggsql_err_to_py) ?;
395+ names. push ( name) ;
396+ }
397+ Ok ( names)
358398 }
359399}
360400
@@ -725,6 +765,9 @@ fn validate(query: &str) -> PyResult<PyValidated> {
725765/// The database reader to execute SQL against. Can be a native Reader
726766/// for optimal performance, or any Python object with an
727767/// `execute_sql(sql: str) -> polars.DataFrame` method.
768+ /// data : dict[str, polars.DataFrame] | None
769+ /// Optional dictionary mapping table names to DataFrames. Tables are
770+ /// registered before execution and unregistered afterward (even on error).
728771///
729772/// Returns
730773/// -------
@@ -751,19 +794,80 @@ fn validate(query: &str) -> PyResult<PyValidated> {
751794/// >>> reader = MyReader()
752795/// >>> spec = execute("SELECT * FROM data VISUALISE x, y DRAW point", reader)
753796#[ pyfunction]
754- fn execute ( query : & str , reader : & Bound < ' _ , PyAny > ) -> PyResult < PySpec > {
755- // Fast path: try all known native reader types
756- // Add new native readers to this list as they're implemented
757- try_native_readers ! ( query, reader, PyDuckDBReader ) ;
797+ #[ pyo3( signature = ( query, reader, * , data=None ) ) ]
798+ fn execute ( py : Python < ' _ > , query : & str , reader : & Bound < ' _ , PyAny > , data : Option < & Bound < ' _ , PyDict > > ) -> PyResult < PySpec > {
799+ // Native reader fast path: DuckDBReader
800+ // Note: we can't use the try_native_readers! macro here because it uses `return`
801+ // which would skip cleanup of registered tables.
802+ if let Ok ( native) = reader. downcast :: < PyDuckDBReader > ( ) {
803+ // Register DataFrames if provided
804+ let registered_names = if let Some ( data_dict) = data {
805+ native. borrow ( ) . register_data_dict ( py, data_dict) ?
806+ } else {
807+ vec ! [ ]
808+ } ;
809+
810+ // Execute (capture result for cleanup)
811+ let result = native. borrow ( ) . inner . execute ( query)
812+ . map ( |s| PySpec { inner : s } )
813+ . map_err ( ggsql_err_to_py) ;
814+
815+ // Cleanup: unregister temporary tables (even on error)
816+ for name in & registered_names {
817+ let _ = native. borrow ( ) . inner . unregister ( name) ;
818+ }
819+
820+ return result;
821+ }
758822
759823 // Bridge path: wrap Python object as Reader
824+ // Register DataFrames if provided
825+ let registered_names = if let Some ( data_dict) = data {
826+ register_data_on_reader ( py, reader, data_dict) ?
827+ } else {
828+ vec ! [ ]
829+ } ;
830+
760831 let bridge = PyReaderBridge {
761832 obj : reader. clone ( ) . unbind ( ) ,
762833 } ;
763- bridge
834+ let result = bridge
764835 . execute ( query)
765836 . map ( |s| PySpec { inner : s } )
766- . map_err ( ggsql_err_to_py)
837+ . map_err ( ggsql_err_to_py) ;
838+
839+ // Cleanup for bridge path
840+ for name in & registered_names {
841+ let _ = call_unregister ( py, reader, name) ;
842+ }
843+
844+ result
845+ }
846+
847+ /// Register DataFrames from a Python dict onto a Python reader object.
848+ /// Returns list of registered names for cleanup.
849+ fn register_data_on_reader (
850+ py : Python < ' _ > ,
851+ reader : & Bound < ' _ , PyAny > ,
852+ data : & Bound < ' _ , PyDict > ,
853+ ) -> PyResult < Vec < String > > {
854+ let mut names = Vec :: new ( ) ;
855+ for ( key, value) in data. iter ( ) {
856+ let name: String = key. extract ( ) ?;
857+ let df = py_to_polars ( py, & value) ?;
858+ let py_df = polars_to_py ( py, & df) ?;
859+ reader. call_method ( "register" , ( & name, py_df, true ) , None ) ?;
860+ names. push ( name) ;
861+ }
862+ Ok ( names)
863+ }
864+
865+ /// Call unregister on a reader if the method exists.
866+ fn call_unregister ( _py : Python < ' _ > , reader : & Bound < ' _ , PyAny > , name : & str ) -> PyResult < ( ) > {
867+ if reader. hasattr ( "unregister" ) ? {
868+ reader. call_method1 ( "unregister" , ( name, ) ) ?;
869+ }
870+ Ok ( ( ) )
767871}
768872
769873// ============================================================================
0 commit comments