Skip to content

Commit e6bf1ef

Browse files
authored
Add CachingReader binding (stacked on #9) (#10)
* test: failing tests for CachingReader binding * feat: add CachingReader binding (duckdb-string and bridged primaries) Wraps upstream ggsql::reader::CachingReader: an internal in-memory DuckDB cache backend, primary given as a duckdb:// connection string (built fresh internally) or any Python object with the reader bridge protocol. Config from GGSQL_CACHE_* env vars with keyword overrides. Package-level export included here rather than as a later task because the constructor tests reference ggsql.CachingReader directly. * chore: cargo fmt for CachingReader binding * test: CachingReader memoization, TTL, and disable paths * test: CachingReader layer-source and cache-routing scenarios * test: export check for CachingReader; hoist _CountingReader to module scope * docs: clarify enabled=False only disables memoization Registered tables still live in the private cache, reads referencing them still route there, and derived SQL still runs on the cache backend — the flag only stops the memo being consulted or written. Addresses Copilot review on #10. * Add CHANGELOG entry for CachingReader (#10)
1 parent cfe47e7 commit e6bf1ef

4 files changed

Lines changed: 345 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- New native writers `SVGWriter`, `PDFWriter`, and `HepWriter`, rendered directly by ggsql (via hephaestus) with no JavaScript round-trip. Each exposes `render(spec)` returning `str` (SVG) or `bytes` (PDF/HEP); render warnings are surfaced via `warnings.warn()`.
88
- New `ggsql.save(spec, file, ...)` helper that writes a spec to disk, picking the writer from the file extension (`.svg`, `.pdf`, `.hep`, or `.json` for Vega-Lite).
99
- Enabled the crate's `builtin-data` feature, making builtin datasets such as `ggsql:world` available.
10+
- New `ggsql.CachingReader` that caches query results to avoid redundant source reads. Wraps a `duckdb://` connection string or any custom Python reader; reads are memoized with a configurable TTL and size budget, and derived `__ggsql_*` tables run on a private in-memory DuckDB cache.
1011

1112
### Changed
1213

‎python/ggsql/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from narwhals.typing import IntoFrame
1414

1515
from ggsql._ggsql import (
16+
CachingReader,
1617
DuckDBReader,
1718
HepWriter as _RustHepWriter,
1819
PDFWriter as _RustPDFWriter,
@@ -27,6 +28,7 @@
2728
# PyO3 classes default to __module__ = "builtins"; point them at their real
2829
# home so docs tooling (great-docs/griffe) can locate them.
2930
for _cls in (
31+
CachingReader,
3032
DuckDBReader,
3133
_RustHepWriter,
3234
_RustPDFWriter,
@@ -40,6 +42,7 @@
4042

4143
__all__ = [
4244
# Classes
45+
"CachingReader",
4346
"DuckDBReader",
4447
"SVGWriter",
4548
"PDFWriter",

‎src/lib.rs‎

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ use arrow::ipc::reader::StreamReader;
1010
use arrow::ipc::writer::StreamWriter;
1111

1212
use ggsql::dataframe::DataFrame;
13+
use ggsql::reader::cache::{CacheConfig, CacheConfigOverride};
1314
use ggsql::reader::Spec;
14-
use ggsql::reader::{DuckDBReader as RustDuckDBReader, Reader};
15+
use ggsql::reader::{CachingReader as RustCachingReader, DuckDBReader as RustDuckDBReader, Reader};
1516
use ggsql::validate::{validate as rust_validate, ValidationWarning};
1617
use ggsql::writer::{
1718
HepWriter as RustHepWriter, PdfWriter as RustPdfWriter, SvgWriter as RustSvgWriter,
@@ -382,6 +383,215 @@ impl PyDuckDBReader {
382383
}
383384
}
384385

386+
// ============================================================================
387+
// PyCachingReader
388+
// ============================================================================
389+
390+
/// Caching reader: wraps a primary reader with an in-memory DuckDB cache.
391+
///
392+
/// Source reads go to the primary and are memoized (TTL + LRU byte budget);
393+
/// all ggsql-generated derived SQL runs on the private in-memory cache.
394+
/// Configuration is read from the environment (GGSQL_CACHE_DISABLED,
395+
/// GGSQL_CACHE_TTL, GGSQL_CACHE_MAX_BYTES) with keyword arguments taking
396+
/// precedence.
397+
///
398+
/// Examples
399+
/// --------
400+
/// >>> reader = CachingReader("duckdb://memory", ttl_secs=60)
401+
/// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point")
402+
///
403+
/// >>> # Wrapping a custom Python reader (bridge protocol):
404+
/// >>> class MyReader:
405+
/// ... def execute_sql(self, sql): ...
406+
/// ... def register(self, name, table, replace): ...
407+
/// ... def unregister(self, name): ...
408+
/// >>> reader = CachingReader(MyReader())
409+
#[pyclass(name = "CachingReader", unsendable)]
410+
struct PyCachingReader {
411+
inner: RustCachingReader,
412+
}
413+
414+
#[pymethods]
415+
impl PyCachingReader {
416+
/// Create a new caching reader.
417+
///
418+
/// Parameters
419+
/// ----------
420+
/// primary : str or object
421+
/// Either a "duckdb://..." connection string (a fresh native
422+
/// DuckDBReader is constructed internally) or a Python object
423+
/// implementing the reader protocol: execute_sql(sql) ->
424+
/// pyarrow.Table, register(name, table, replace), unregister(name).
425+
/// ttl_secs : int, optional
426+
/// Seconds before a memoized read is treated as stale.
427+
/// Default 300 (or GGSQL_CACHE_TTL).
428+
/// max_bytes : int, optional
429+
/// Cumulative byte budget across memoized results before LRU
430+
/// eviction. Default 512 MiB (or GGSQL_CACHE_MAX_BYTES).
431+
/// enabled : bool, optional
432+
/// When False, source reads always hit the primary: the memo is
433+
/// neither consulted nor written. Note this only disables
434+
/// memoization — registered tables still live in the private
435+
/// in-memory cache, reads referencing them still route there,
436+
/// and derived SQL still runs on the cache backend.
437+
/// Default True (unless GGSQL_CACHE_DISABLED is set).
438+
///
439+
/// Raises
440+
/// ------
441+
/// ValueError
442+
/// If a connection string has an unsupported scheme, or a
443+
/// DuckDBReader instance is passed (pass its connection
444+
/// string instead).
445+
#[new]
446+
#[pyo3(signature = (primary, *, ttl_secs=None, max_bytes=None, enabled=None))]
447+
fn new(
448+
primary: &Bound<'_, PyAny>,
449+
ttl_secs: Option<u64>,
450+
max_bytes: Option<u64>,
451+
enabled: Option<bool>,
452+
) -> PyResult<Self> {
453+
// Private in-memory DuckDB is the cache backend: derived tables,
454+
// registered data, and memoized reads all live there.
455+
let cache = RustDuckDBReader::from_connection_string("duckdb://memory")
456+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
457+
458+
let (primary_reader, primary_uri): (Box<dyn Reader + Send>, String) = if let Ok(uri) =
459+
primary.extract::<&str>()
460+
{
461+
if !uri.starts_with("duckdb://") {
462+
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
463+
"Unsupported connection string {uri:?}: only duckdb:// primaries are currently supported"
464+
)));
465+
}
466+
let reader = RustDuckDBReader::from_connection_string(uri)
467+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
468+
(Box::new(reader), uri.to_string())
469+
} else {
470+
// Moving the inner reader out of a live pyclass isn't
471+
// possible; guide the user to the connection-string form.
472+
if primary.downcast::<PyDuckDBReader>().is_ok() {
473+
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
474+
"CachingReader cannot wrap an existing DuckDBReader; pass its connection string instead (e.g. CachingReader(\"duckdb://memory\"))",
475+
));
476+
}
477+
// The URI only feeds cache-key derivation, which just needs a
478+
// stable-per-instance string; the type name suffices.
479+
let type_name = primary.get_type().name()?.to_str()?.to_string();
480+
(
481+
Box::new(PyReaderBridge {
482+
obj: primary.clone().unbind(),
483+
}),
484+
type_name,
485+
)
486+
};
487+
488+
let config = CacheConfig::from_env().merge(CacheConfigOverride {
489+
enabled,
490+
ttl_secs,
491+
max_bytes,
492+
});
493+
494+
Ok(Self {
495+
inner: RustCachingReader::with_config(
496+
primary_reader,
497+
Box::new(cache),
498+
primary_uri,
499+
"duckdb",
500+
config,
501+
),
502+
})
503+
}
504+
505+
/// Register a pyarrow Table as a queryable table.
506+
///
507+
/// The table lands in the in-memory cache; reads referencing its name
508+
/// are routed to the cache rather than the primary.
509+
///
510+
/// Parameters
511+
/// ----------
512+
/// name : str
513+
/// The table name to register under.
514+
/// table : pyarrow.Table
515+
/// The Arrow table to register.
516+
/// replace : bool
517+
/// Whether to replace an existing table of the same name.
518+
#[pyo3(signature = (name, table, replace=false))]
519+
fn register(
520+
&self,
521+
py: Python<'_>,
522+
name: &str,
523+
table: &Bound<'_, PyAny>,
524+
replace: bool,
525+
) -> PyResult<()> {
526+
let df = py_to_df(py, table)?;
527+
self.inner
528+
.register(name, df, replace)
529+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
530+
}
531+
532+
/// Unregister a previously registered table.
533+
///
534+
/// Parameters
535+
/// ----------
536+
/// name : str
537+
/// The table name to unregister.
538+
fn unregister(&self, name: &str) -> PyResult<()> {
539+
self.inner
540+
.unregister(name)
541+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
542+
}
543+
544+
/// Execute a SQL query against the source surface.
545+
///
546+
/// Row-returning reads are memoized: an identical query within the TTL
547+
/// is served from the cache without hitting the primary.
548+
///
549+
/// Parameters
550+
/// ----------
551+
/// sql : str
552+
/// The SQL query to execute.
553+
///
554+
/// Returns
555+
/// -------
556+
/// pyarrow.Table
557+
/// The query result as a pyarrow Table.
558+
fn execute_sql(&self, py: Python<'_>, sql: &str) -> PyResult<Py<PyAny>> {
559+
let df = self
560+
.inner
561+
.execute_sql(sql)
562+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
563+
df_to_py(py, &df)
564+
}
565+
566+
/// Execute a ggsql query and return the visualization specification.
567+
///
568+
/// Base reads hit the (memoized) primary; derived tables are
569+
/// materialized into the cache. Unlike a plain bridged reader, queries
570+
/// with layer-level sources (VISUALISE ... FROM <table>) work, because
571+
/// every layer source is staged through the cache.
572+
///
573+
/// Parameters
574+
/// ----------
575+
/// query : str
576+
/// The ggsql query (SQL + VISUALISE clause).
577+
///
578+
/// Returns
579+
/// -------
580+
/// Spec
581+
/// The resolved visualization specification ready for rendering.
582+
///
583+
/// Raises
584+
/// ------
585+
/// ValueError
586+
/// If the query syntax is invalid or SQL execution fails.
587+
fn execute(&self, query: &str) -> PyResult<PySpec> {
588+
self.inner
589+
.execute(query)
590+
.map(|s| PySpec { inner: s })
591+
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
592+
}
593+
}
594+
385595
// ============================================================================
386596
// PyVegaLiteWriter
387597
// ============================================================================
@@ -976,7 +1186,7 @@ fn validate(query: &str) -> PyResult<PyValidated> {
9761186
fn execute(query: &str, reader: &Bound<'_, PyAny>) -> PyResult<PySpec> {
9771187
// Fast path: try all known native reader types
9781188
// Add new native readers to this list as they're implemented
979-
try_native_readers!(query, reader, PyDuckDBReader);
1189+
try_native_readers!(query, reader, PyDuckDBReader, PyCachingReader);
9801190

9811191
// Bridge path: wrap Python object as Reader
9821192
let bridge = PyReaderBridge {
@@ -996,6 +1206,7 @@ fn execute(query: &str, reader: &Bound<'_, PyAny>) -> PyResult<PySpec> {
9961206
fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> {
9971207
// Classes
9981208
m.add_class::<PyDuckDBReader>()?;
1209+
m.add_class::<PyCachingReader>()?;
9991210
m.add_class::<PySVGWriter>()?;
10001211
m.add_class::<PyPDFWriter>()?;
10011212
m.add_class::<PyHepWriter>()?;

0 commit comments

Comments
 (0)