use super::CachingReader;
use crate::reader::test_support::{ReadOnlyReader, SpyReader};
use crate::reader::{CacheBackend, DuckDBReader, Reader, SqliteReader};
use crate::DataFrame;
struct Case {
query: &'static str,
builtin_only: bool,
}
const CORPUS: &[Case] = &[
Case {
query: "VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW boxplot",
builtin_only: true,
},
Case {
query: "SELECT Temp FROM ggsql:airquality VISUALISE Temp AS x DRAW histogram",
builtin_only: true,
},
Case {
query: "VISUALISE bill_len AS x, species AS colour FROM ggsql:penguins DRAW density",
builtin_only: true,
},
Case {
query: "SELECT species, bill_len, island FROM ggsql:penguins \
VISUALISE species AS x, bill_len AS y \
DRAW bar SETTING aggregate => 'mean' FACET island",
builtin_only: true,
},
Case {
query: "WITH hot AS (SELECT Date, Temp FROM ggsql:airquality WHERE Temp > 70) \
SELECT Date, Temp FROM hot \
VISUALISE Date AS x, Temp AS y \
DRAW line DRAW point FILTER Temp > 80 SCALE x VIA date",
builtin_only: true,
},
Case {
query: "VISUALISE species AS x, bill_len AS y \
DRAW boxplot MAPPING species AS x, bill_len AS y FROM cache_eq_tbl",
builtin_only: false,
},
];
fn seed(reader: &dyn Reader) {
let df = crate::df! {
"species" => vec!["A", "A", "B", "B", "B", "C"],
"bill_len" => vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0],
}
.unwrap();
reader.register("cache_eq_tbl", df, true).unwrap();
}
fn row_multiset(df: &DataFrame, names: &[String]) -> Vec<String> {
(0..df.height())
.map(|i| {
names
.iter()
.map(|n| crate::array_util::value_to_string(df.column(n).unwrap(), i))
.collect::<Vec<_>>()
.join("\u{1f}")
})
.collect()
}
fn assert_data_equivalent(a: &DataFrame, b: &DataFrame, ctx: &str) {
let mut na: Vec<String> = a
.schema()
.fields()
.iter()
.map(|f| f.name().to_string())
.collect();
let mut nb: Vec<String> = b
.schema()
.fields()
.iter()
.map(|f| f.name().to_string())
.collect();
na.sort();
nb.sort();
assert_eq!(na, nb, "{ctx}: column-name set");
for name in &na {
assert_eq!(
a.column(name).unwrap().data_type(),
b.column(name).unwrap().data_type(),
"{ctx}: column '{name}' type",
);
}
assert_eq!(a.height(), b.height(), "{ctx}: row count");
let (mut ra, mut rb) = (row_multiset(a, &na), row_multiset(b, &na));
ra.sort();
rb.sort();
assert_eq!(ra, rb, "{ctx}: row multiset mismatch");
}
fn assert_equivalent(plain: &dyn Reader, cached: &dyn Reader, query: &str) {
let a = plain.execute(query);
let b = cached.execute(query);
assert_eq!(
a.is_ok(),
b.is_ok(),
"ok-mismatch for `{query}`: plain={:?} cached={:?}",
a.as_ref().err(),
b.as_ref().err(),
);
let (Ok(sa), Ok(sb)) = (a, b) else { return };
assert_eq!(
sa.layer_count(),
sb.layer_count(),
"layer count for `{query}`"
);
for i in 0..sa.layer_count() {
match (sa.layer_data(i), sb.layer_data(i)) {
(Some(da), Some(db)) => assert_data_equivalent(da, db, &format!("`{query}` layer {i}")),
(None, None) => {}
_ => panic!("layer {i} data-presence mismatch for `{query}`"),
}
}
}
#[test]
fn mode1_builtin_equivalence_matches_plain_duckdb() {
if !cfg!(feature = "builtin-data") {
return; }
for case in CORPUS.iter().filter(|c| c.builtin_only) {
let plain = DuckDBReader::new_in_memory().unwrap();
let primary = ReadOnlyReader::new(Box::new(SqliteReader::new().unwrap()));
let cache = DuckDBReader::new_in_memory().unwrap();
let cached = CachingReader::new(
Box::new(primary),
Box::new(cache),
"test://primary",
"duckdb",
);
assert_equivalent(&plain, &cached, case.query);
}
}
#[test]
fn mode1_read_only_primary_is_sufficient() {
for case in CORPUS {
if case.builtin_only && !cfg!(feature = "builtin-data") {
continue;
}
let sqlite = SqliteReader::new().unwrap();
seed(&sqlite);
let primary = ReadOnlyReader::new(Box::new(sqlite));
let cache = DuckDBReader::new_in_memory().unwrap();
let cached = CachingReader::new(
Box::new(primary),
Box::new(cache),
"test://primary",
"duckdb",
);
let r = cached.execute(case.query);
assert!(
r.is_ok(),
"read-only primary should suffice with caching for `{}`: {:?}",
case.query,
r.err(),
);
}
}
#[test]
fn cross_call_memoization_avoids_second_primary_read() {
let sqlite = SqliteReader::new().unwrap();
seed(&sqlite);
let (primary, log) = SpyReader::wrap(Box::new(sqlite));
let cache = DuckDBReader::new_in_memory().unwrap();
let cached = CachingReader::new(primary, Box::new(cache), "test://primary", "duckdb");
let query = "SELECT bill_len FROM cache_eq_tbl VISUALISE bill_len AS x DRAW histogram";
cached.execute(query).unwrap();
let after_first = log.lock().unwrap().len();
cached.execute(query).unwrap();
let after_second = log.lock().unwrap().len();
assert!(after_first >= 1, "first execute should read the primary");
assert_eq!(
after_first,
after_second,
"second execute must not re-hit the primary; log: {:?}",
*log.lock().unwrap(),
);
}
#[test]
fn factory_builds_caching_readers() {
use crate::reader::connection::reader_from_uri;
for uri in ["duckdb+sqlite://memory", "sqlite+duckdb://memory"] {
let r = reader_from_uri(uri).unwrap_or_else(|e| panic!("build `{uri}`: {e}"));
if cfg!(feature = "builtin-data") {
let spec =
r.execute("VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW boxplot");
assert!(
spec.is_ok(),
"factory reader failed for `{uri}`: {:?}",
spec.err()
);
}
}
}
#[cfg(all(feature = "spatial", feature = "builtin-data"))]
#[test]
fn map_projection_runs_on_cache_not_primary() {
let (primary, log) = SpyReader::wrap(Box::new(DuckDBReader::new_in_memory().unwrap()));
let cache = Box::new(DuckDBReader::new_in_memory().unwrap());
let reader = CachingReader::new(primary, cache, "test://primary", "duckdb");
let spec = reader.execute("VISUALISE FROM ggsql:world DRAW spatial PROJECT TO orthographic");
assert!(
spec.is_ok(),
"map projection via cache failed: {:?}",
spec.err()
);
let log = log.lock().unwrap();
for stmt in log.iter() {
let upper = stmt.to_uppercase();
assert!(
!upper.contains("ST_") && !upper.contains("TEMP TABLE") && !stmt.contains("__ggsql_"),
"derived spatial SQL leaked to the primary: {stmt}"
);
}
}
#[cfg(feature = "adbc")]
mod adbc_mode {
use super::*;
use crate::reader::sqlite::SqliteDialect;
use crate::reader::test_support::assert_dataframes_equal;
use crate::reader::{AdbcReader, Spec, SqlDialect};
use crate::{DataFrame, Result};
use adbc_core::options::{AdbcVersion, OptionDatabase, OptionValue};
use adbc_core::LOAD_FLAG_DEFAULT;
use adbc_driver_manager::ManagedDriver;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tempfile::NamedTempFile;
fn make_adbc_reader(db_path: &str) -> AdbcReader<ManagedDriver> {
let driver = ManagedDriver::load_from_name(
"sqlite",
None,
AdbcVersion::V110,
LOAD_FLAG_DEFAULT,
None,
)
.expect("`dbc install sqlite` first; see adbc.rs::equivalence_tests docs");
let dialect: Box<dyn SqlDialect + Send> = Box::new(SqliteDialect);
AdbcReader::new_with_database_opts(
driver,
dialect,
std::iter::once((
OptionDatabase::Uri,
OptionValue::String(format!("file:{}", db_path)),
)),
)
.expect("construct AdbcReader<sqlite>")
}
fn seed_adbc(path: &str) {
let bare = make_adbc_reader(path);
let df = crate::df! {
"x" => vec![1_i64, 2, 3, 4, 5],
"y" => vec![10_i64, 20, 30, 40, 50],
}
.unwrap();
bare.register("t", df, false).unwrap();
}
struct CountingAdbcReader {
inner: AdbcReader<ManagedDriver>,
calls: Arc<AtomicUsize>,
}
impl Reader for CountingAdbcReader {
fn execute_sql(&self, sql: &str) -> Result<DataFrame> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.inner.execute_sql(sql)
}
fn register(&self, name: &str, df: DataFrame, replace: bool) -> Result<()> {
self.inner.register(name, df, replace)
}
fn unregister(&self, name: &str) -> Result<()> {
self.inner.unregister(name)
}
fn execute(&self, query: &str) -> Result<Spec> {
crate::reader::execute_with_reader(self, query)
}
fn dialect(&self) -> &dyn SqlDialect {
self.inner.dialect()
}
}
#[test]
#[ignore = "requires `dbc install sqlite`"]
fn mode2_adbc_primary_duckdb_cache_equiv_and_memo() {
let db = NamedTempFile::new().unwrap();
let path = db.path().to_str().unwrap();
seed_adbc(path);
let sql = "SELECT x, y, x*y AS xy FROM t WHERE y > 15 ORDER BY x";
let golden = make_adbc_reader(path).execute_sql(sql).unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let primary = CountingAdbcReader {
inner: make_adbc_reader(path),
calls: calls.clone(),
};
let cache = DuckDBReader::new_in_memory().unwrap();
let cached = CachingReader::new(
Box::new(primary),
Box::new(cache),
"test://primary",
"duckdb",
);
let miss = cached.execute_sql(sql).unwrap();
assert_dataframes_equal(&golden, &miss, "adbc cache miss");
let after_miss = calls.load(Ordering::SeqCst);
assert!(after_miss >= 1, "miss should reach the ADBC primary");
let hit = cached.execute_sql(sql).unwrap();
assert_dataframes_equal(&golden, &hit, "adbc cache hit");
let after_hit = calls.load(Ordering::SeqCst);
assert_eq!(
after_miss, after_hit,
"cache hit must not round-trip to the ADBC primary"
);
}
}