use std::{collections::HashSet, sync::Arc, time::Instant};
use arrow::record_batch::RecordBatch;
use arrow_array::{Array, Decimal128Array};
use arrow_schema::SchemaRef;
use datafusion::{
datasource::DefaultTableSource,
error::DataFusionError,
execution::context::SessionContext,
logical_expr::{Expr, LogicalPlan},
};
use crate::{
memory::budgeted_session_context,
runtime_metrics::op_stats,
supertable::{
error::QueryError,
handle::{Supertable, SupertableReader},
options::SupertableOptions,
query::{
covered_agg::CoveredAggregateRewrite,
exec::{
fts_exec::register_bm25, hybrid_exec::register_hybrid_search,
match_exec::register_match, vector_exec::register_vector_search,
},
provider::{SupertableProvider, TABLE_NAME, view_string_schema},
},
reader_cache::disk::ForegroundQueryGuard,
},
};
#[derive(Clone)]
pub(crate) struct SqlSchemas {
scalar: SchemaRef,
scan: SchemaRef,
}
impl SqlSchemas {
pub(crate) fn scalar(&self) -> &SchemaRef {
&self.scalar
}
pub(crate) fn scan(&self) -> &SchemaRef {
&self.scan
}
}
pub(crate) fn build_sql_schemas(options: &SupertableOptions) -> SqlSchemas {
let scalar = options.scalar_schema();
let fts: HashSet<&str> = options
.fts_columns
.iter()
.map(|c| c.column.as_str())
.collect();
let scan = view_string_schema(&scalar, &fts);
SqlSchemas { scalar, scan }
}
const SQL_LOGICAL_PLAN_CACHE_ENTRIES: usize = 64;
fn cacheable_scalar_plan(plan: &LogicalPlan) -> bool {
fn visit(plan: &LogicalPlan, found_scan: &mut bool) -> bool {
if let LogicalPlan::TableScan(scan) = plan {
let Some(source) = scan.source.downcast_ref::<DefaultTableSource>() else {
return false;
};
if source
.table_provider
.downcast_ref::<SupertableProvider>()
.is_none()
{
return false;
}
*found_scan = true;
}
plan.inputs()
.into_iter()
.all(|input| visit(input, found_scan))
}
let mut found_scan = false;
visit(plan, &mut found_scan) && found_scan
}
fn exec_query_error(e: DataFusionError) -> QueryError {
match e {
DataFusionError::ResourcesExhausted(msg) => QueryError::OverBudget(msg),
other => QueryError::Execute(other.to_string()),
}
}
impl SupertableReader {
fn cached_sql_logical_plan(&self, sql: &str) -> Option<LogicalPlan> {
let guard = self
.sql_logical_plan_cache()
.lock()
.expect("sql logical-plan cache mutex poisoned");
let (manifest, plans) = guard.as_ref()?;
Arc::ptr_eq(manifest, self.manifest())
.then(|| plans.get(sql).cloned())
.flatten()
}
fn cache_sql_logical_plan(&self, sql: String, plan: LogicalPlan) {
let mut guard = self
.sql_logical_plan_cache()
.lock()
.expect("sql logical-plan cache mutex poisoned");
if guard
.as_ref()
.is_none_or(|(manifest, _)| !Arc::ptr_eq(manifest, self.manifest()))
{
*guard = Some((Arc::clone(self.manifest()), Default::default()));
}
let (_, plans) = guard.as_mut().expect("cache initialized above");
if plans.len() >= SQL_LOGICAL_PLAN_CACHE_ENTRIES && !plans.contains_key(&sql) {
plans.clear();
}
plans.insert(sql, plan);
}
#[cfg(any(test, feature = "test-helpers"))]
#[cfg_attr(
feature = "detailed-tracing",
tracing::instrument(skip_all, fields(sql = sql))
)]
pub fn query_sql(&self, sql: &str) -> Result<Vec<RecordBatch>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
let ctx = self.sql_session_context()?;
let tombstone_prefetch = self.tombstone_cache.as_ref().and_then(|cache| {
let entries = self.manifest().complete_flat_superfiles()?;
let ids: Vec<_> = entries.iter().map(|entry| entry.superfile_id).collect();
Some((Arc::clone(cache), ids))
});
let cached_plan = self.cached_sql_logical_plan(sql);
let cache_reader = self.clone();
let sql = sql.to_owned();
let drive = async move {
if let Some((cache, ids)) = tombstone_prefetch {
cache.prefetch(&ids, Instant::now()).await;
}
let df = match cached_plan {
Some(plan) => ctx
.execute_logical_plan(plan)
.await
.map_err(|e| QueryError::Plan(e.to_string()))?,
None => {
let df = ctx
.sql(&sql)
.await
.map_err(|e| QueryError::Plan(e.to_string()))?;
let plan = df.logical_plan().clone();
if cacheable_scalar_plan(&plan) {
cache_reader.cache_sql_logical_plan(sql.clone(), plan);
}
df
}
};
df.collect().await.map_err(exec_query_error)
};
self.block_on(drive)
}
#[cfg_attr(feature = "detailed-tracing", tracing::instrument(skip_all))]
fn sql_session_context(&self) -> Result<SessionContext, QueryError> {
let mut detached = self.clone();
detached.op_stats = None;
let reader = Arc::new(detached);
let manifest = Arc::clone(reader.manifest());
let mut guard = self
.sql_session_cache()
.lock()
.expect("sql_session_cache mutex poisoned");
if let Some((cached, ctx)) = &*guard
&& Arc::ptr_eq(cached, &manifest)
{
return Ok(ctx.clone());
}
let store = Arc::clone(&self.options().store);
let disk_cache = self.options().disk_cache.as_ref().map(Arc::clone);
let schemas = self.sql_schemas();
let provider = op_stats::suppressed(|| {
SupertableProvider::new(
schemas.scan().clone(),
Arc::clone(&manifest),
store,
disk_cache,
reader.tombstone_cache.clone(),
)
});
let ctx = budgeted_session_context(&self.options().connection_memory_budget)
.map_err(|e| QueryError::Plan(e.to_string()))?;
ctx.add_optimizer_rule(Arc::new(CoveredAggregateRewrite));
ctx.register_table(TABLE_NAME, Arc::new(provider))
.map_err(|e| QueryError::Plan(e.to_string()))?;
register_vector_search(&ctx, Arc::clone(&reader), schemas.scalar().clone());
register_bm25(&ctx, Arc::clone(&reader), schemas.scalar().clone());
register_match(&ctx, Arc::clone(&reader), schemas.scalar().clone());
register_hybrid_search(&ctx, Arc::clone(&reader), schemas.scalar().clone());
*guard = Some((Arc::clone(&manifest), ctx.clone()));
Ok(ctx)
}
pub(crate) fn scan_ids_matching(&self, expr: Expr) -> Result<Vec<i128>, QueryError> {
let _foreground = ForegroundQueryGuard::enter();
let ctx = self.sql_session_context()?;
let id_column = self.options().id_column.clone();
let drive = async move {
let df = ctx
.table(TABLE_NAME)
.await
.map_err(|e| QueryError::Plan(e.to_string()))?
.filter(expr)
.map_err(|e| QueryError::Plan(e.to_string()))?
.select_columns(&[id_column.as_str()])
.map_err(|e| QueryError::Plan(e.to_string()))?;
let batches = df.collect().await.map_err(exec_query_error)?;
extract_id_column(&batches)
};
self.block_on(drive)
}
}
impl Supertable {
pub(crate) fn register_into(
&self,
ctx: &SessionContext,
name: &str,
) -> Result<Arc<SupertableReader>, QueryError> {
let reader = Arc::new(self.reader().map_err(QueryError::ManifestLoad)?);
let manifest = Arc::clone(reader.manifest());
let store = Arc::clone(&self.options().store);
let disk_cache = self.options().disk_cache.as_ref().map(Arc::clone);
let provider = SupertableProvider::new(
self.sql_schemas().scan().clone(),
manifest,
store,
disk_cache,
reader.tombstone_cache.clone(),
);
ctx.register_table(name, Arc::new(provider))
.map_err(|e| QueryError::Plan(e.to_string()))?;
Ok(reader)
}
}
fn extract_id_column(batches: &[RecordBatch]) -> Result<Vec<i128>, QueryError> {
let mut out: Vec<i128> = Vec::new();
for batch in batches {
if batch.num_columns() != 1 {
return Err(QueryError::Plan(format!(
"scan_ids_matching: expected 1-column batch, got {}",
batch.num_columns()
)));
}
let col = batch.column(0);
let arr = col
.as_any()
.downcast_ref::<Decimal128Array>()
.ok_or_else(|| {
QueryError::Plan("scan_ids_matching: _id column not Decimal128".into())
})?;
for i in 0..arr.len() {
if arr.is_null(i) {
continue;
}
out.push(arr.value(i));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow_array::{
Array, Decimal128Array, FixedSizeListArray, Float32Array, Int64Array, LargeStringArray,
RecordBatch, StringArray, StringViewArray,
};
use arrow_schema::{DataType, Field, Schema};
use crate::{
memory::ConnectionMemoryBudget,
storage::{LocalFsStorageProvider, StorageProvider},
superfile::{
builder::{FtsConfig, VectorConfig},
vector::{distance::Metric, rerank_codec::RerankCodec},
},
supertable::{
Supertable, SupertableOptions, error::QueryError, query::sql::build_sql_schemas,
},
test_helpers::default_tokenizer as tok,
};
const HIGH_CARDINALITY_ROWS: usize = 257;
fn schema_id_cat_title() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("category", DataType::LargeUtf8, false),
Field::new("title", DataType::LargeUtf8, false),
]))
}
fn options_id_cat_title() -> SupertableOptions {
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("rayon pool"),
);
SupertableOptions::new(
schema_id_cat_title(),
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool)
}
fn zero_gate_reader_after_ingest(batch: &RecordBatch) -> (tempfile::TempDir, Supertable) {
let dir = tempfile::tempdir().expect("tempdir");
let storage: Arc<dyn StorageProvider> =
Arc::new(LocalFsStorageProvider::new(dir.path()).expect("localfs"));
let ingest = Supertable::create(options_id_cat_title().with_storage(Arc::clone(&storage)))
.expect("create");
let mut w = ingest.writer().expect("writer");
w.append(batch).expect("append");
w.commit().expect("commit");
let mut qopts = options_id_cat_title().with_storage(storage);
qopts.connection_memory_budget = ConnectionMemoryBudget::with_limit(1);
(dir, Supertable::open(qopts).expect("open"))
}
fn build_cat_batch(_start: u64, cats: &[&str], titles: &[&str]) -> RecordBatch {
assert_eq!(cats.len(), titles.len());
let cat_arr = LargeStringArray::from(cats.to_vec());
let title_arr = LargeStringArray::from(titles.to_vec());
RecordBatch::try_new(
schema_id_cat_title(),
vec![Arc::new(cat_arr), Arc::new(title_arr)],
)
.expect("build batch")
}
fn seeded(cats: &[&str], titles: &[&str]) -> Supertable {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, cats, titles)).expect("append");
w.commit().expect("commit");
st
}
fn rating_table(ratings: &[i64]) -> Supertable {
let schema = Arc::new(Schema::new(vec![Field::new(
"rating",
DataType::Int64,
false,
)]));
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("rayon pool"),
);
let options = SupertableOptions::new(schema.clone(), vec![], vec![], None)
.expect("rating options")
.with_writer_pool(pool);
let table = Supertable::create(options).expect("create rating table");
let mut writer = table.writer().expect("rating writer");
let batch =
RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(ratings.to_vec()))])
.expect("rating batch");
writer.append(&batch).expect("append ratings");
writer.commit().expect("commit ratings");
drop(writer);
table
}
fn run_count(st: &Supertable, sql: &str) -> i64 {
let batches = st
.reader()
.expect("reader")
.query_sql(sql)
.expect("query_sql ok");
assert!(!batches.is_empty(), "expected at least one result batch");
let n = batches[0]
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("count column is Int64");
n.value(0)
}
#[test]
fn extract_id_column_reads_decimal128_and_rejects_multi_column() {
use arrow_array::ArrayRef;
let ids: ArrayRef = Arc::new(
Decimal128Array::from(vec![Some(1i128), Some(2), None, Some(3)])
.with_precision_and_scale(38, 0)
.expect("decimal"),
);
let schema = Arc::new(Schema::new(vec![Field::new(
"_id",
DataType::Decimal128(38, 0),
true,
)]));
let batch = RecordBatch::try_new(schema, vec![ids]).expect("batch");
assert_eq!(
super::extract_id_column(&[batch]).expect("ids"),
vec![1i128, 2, 3]
);
let two = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("a", DataType::Int64, false),
Field::new("b", DataType::Int64, false),
])),
vec![
Arc::new(Int64Array::from(vec![1])) as ArrayRef,
Arc::new(Int64Array::from(vec![2])) as ArrayRef,
],
)
.expect("two-col batch");
assert!(super::extract_id_column(&[two]).is_err());
}
#[test]
fn query_sql_count_star_returns_zero_on_empty_supertable() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let n = run_count(&st, "SELECT COUNT(*) FROM supertable");
assert_eq!(n, 0);
}
#[test]
fn query_sql_count_star_returns_total_doc_count() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "rust", "python"],
&["a", "b", "c"],
))
.expect("append");
w.commit().expect("commit");
let n = run_count(&st, "SELECT COUNT(*) FROM supertable");
assert_eq!(n, 3);
}
#[test]
fn query_sql_caches_scalar_plan_but_not_search_tvf_plan() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut writer = st.writer().expect("writer");
writer
.append(&build_cat_batch(0, &["rust"], &["searchable"]))
.expect("append");
writer.commit().expect("commit");
let reader = st.reader().expect("reader");
let scalar_sql = "SELECT COUNT(*) FROM supertable";
reader.query_sql(scalar_sql).expect("first scalar query");
reader.query_sql(scalar_sql).expect("cached scalar query");
{
let guard = reader
.sql_logical_plan_cache()
.lock()
.expect("plan cache lock");
let (_, plans) = guard.as_ref().expect("scalar plan cached");
assert_eq!(plans.len(), 1);
assert!(plans.contains_key(scalar_sql));
}
reader
.query_sql("SELECT _id FROM bm25_search('title', 'searchable', 10)")
.expect("search TVF query");
let guard = reader
.sql_logical_plan_cache()
.lock()
.expect("plan cache lock");
let (_, plans) = guard.as_ref().expect("scalar plan remains cached");
assert_eq!(
plans.len(),
1,
"search TVF plans hold readers and must not enter the inner cache"
);
}
#[test]
fn query_sql_session_cache_does_not_leak_consumer() {
let weak = {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["rust"], &["a"]))
.expect("append");
w.commit().expect("commit");
assert_eq!(run_count(&st, "SELECT COUNT(*) FROM supertable"), 1);
let weak = Arc::downgrade(st.inner());
drop(w);
drop(st);
weak
};
assert!(
weak.upgrade().is_none(),
"SQL session cache leaked the consumer — the \
inner -> SessionContext -> TVF -> reader -> inner cycle was not broken",
);
}
#[test]
fn query_sql_filter_predicate_applied_above_mem_table() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "rust", "python", "rust", "go"],
&["a", "b", "c", "d", "e"],
))
.expect("append");
w.commit().expect("commit");
let n = run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE category = 'rust'",
);
assert_eq!(n, 3);
}
#[test]
fn query_sql_range_count_uses_exact_value_frequencies() {
let table = rating_table(&[0, 5, 9, 10, 10, 20, 99]);
assert_eq!(
run_count(&table, "SELECT COUNT(*) FROM supertable WHERE rating < 10"),
3
);
assert_eq!(
run_count(
&table,
"SELECT COUNT(*) FROM supertable WHERE rating BETWEEN 10 AND 20"
),
3
);
assert_eq!(
run_count(&table, "SELECT COUNT(*) FROM supertable WHERE rating > 100"),
0
);
}
#[test]
fn query_sql_range_count_falls_back_above_value_count_cap() {
let ratings: Vec<i64> = (0..HIGH_CARDINALITY_ROWS)
.map(|value| value as i64)
.collect();
let table = rating_table(&ratings);
assert_eq!(
run_count(&table, "SELECT COUNT(*) FROM supertable WHERE rating < 10"),
10
);
}
#[test]
fn query_sql_group_by_over_budget_is_refused() {
let categories: Vec<String> = (0..HIGH_CARDINALITY_ROWS)
.map(|value| format!("category-{value}"))
.collect();
let category_refs: Vec<&str> = categories.iter().map(String::as_str).collect();
let titles = vec!["title"; HIGH_CARDINALITY_ROWS];
let (_dir, st) =
zero_gate_reader_after_ingest(&build_cat_batch(0, &category_refs, &titles));
let err = st
.reader()
.expect("reader")
.query_sql("SELECT category, COUNT(*) FROM supertable GROUP BY category")
.expect_err("0-byte gate refuses the aggregate");
assert!(matches!(err, QueryError::OverBudget(_)), "got {err:?}");
}
#[test]
fn query_sql_streaming_scan_is_not_refused_under_a_zero_gate() {
let (_dir, st) =
zero_gate_reader_after_ingest(&build_cat_batch(0, &["rust", "python"], &["a", "b"]));
let rows: usize = st
.reader()
.expect("reader")
.query_sql("SELECT title FROM supertable")
.expect("a streaming scan is not gated")
.iter()
.map(|b| b.num_rows())
.sum();
assert_eq!(rows, 2);
}
#[test]
fn query_sql_group_by_returns_correct_per_category_counts() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "rust", "python", "rust", "python", "go"],
&["a", "b", "c", "d", "e", "f"],
))
.expect("append");
w.commit().expect("commit");
let batches = st
.reader()
.expect("reader")
.query_sql(
"SELECT category, COUNT(*) AS n FROM supertable \
GROUP BY category ORDER BY category",
)
.expect("group-by query");
assert_eq!(batches.len(), 1);
let cat_col = batches[0].column(0);
let counts = batches[0]
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("count is Int64");
let extract = |i: usize| -> String {
if let Some(a) = cat_col.as_any().downcast_ref::<LargeStringArray>() {
a.value(i).to_string()
} else if let Some(a) = cat_col.as_any().downcast_ref::<StringArray>() {
a.value(i).to_string()
} else if let Some(a) = cat_col.as_any().downcast_ref::<StringViewArray>() {
a.value(i).to_string()
} else {
panic!("unexpected category column type: {:?}", cat_col.data_type())
}
};
let mut got: Vec<(String, i64)> = (0..cat_col.len())
.map(|i| (extract(i), counts.value(i)))
.collect();
got.sort();
assert_eq!(
got,
vec![
("go".to_string(), 1),
("python".to_string(), 2),
("rust".to_string(), 3),
]
);
}
#[test]
fn query_sql_string_group_by_key_is_large_utf8_not_view() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category FROM supertable GROUP BY category")
.expect("group-by");
let col = batches[0].column(0);
assert_eq!(
col.data_type(),
&DataType::LargeUtf8,
"public result must be LargeUtf8, not Utf8View"
);
assert!(
col.as_any().downcast_ref::<LargeStringArray>().is_some(),
"category should downcast to LargeStringArray"
);
assert!(
col.as_any().downcast_ref::<StringViewArray>().is_none(),
"Utf8View must not leak to the caller"
);
}
#[test]
fn query_sql_ordered_string_projection_is_large_utf8_and_sorted() {
let st = seeded(&["rust", "go", "python"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category FROM supertable ORDER BY category")
.expect("order-by");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("category is LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "python", "rust"]);
}
#[test]
fn query_sql_grouped_min_string_is_large_utf8() {
let st = seeded(&["rust", "rust", "go", "go"], &["b", "a", "d", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql(
"SELECT category, MIN(title) AS m FROM supertable \
GROUP BY category ORDER BY category",
)
.expect("grouped min");
let cat = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("category is LargeUtf8");
let m = batches[0]
.column(1)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("MIN(title) is LargeUtf8");
let got: Vec<(&str, &str)> = (0..cat.len()).map(|i| (cat.value(i), m.value(i))).collect();
assert_eq!(got, vec![("go", "c"), ("rust", "a")]);
}
#[test]
fn query_sql_ungrouped_min_string() {
let st = seeded(&["rust", "go", "python"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT MIN(category) AS m FROM supertable")
.expect("ungrouped min");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("MIN(string) is LargeUtf8");
assert_eq!(col.value(0), "go");
}
#[test]
fn build_sql_schemas_views_scan_and_keeps_scalar() {
let s = build_sql_schemas(&options_id_cat_title());
assert_eq!(
s.scan()
.field_with_name("category")
.expect("category")
.data_type(),
&DataType::Utf8View,
);
assert_eq!(
s.scan()
.field_with_name("title")
.expect("title")
.data_type(),
&DataType::LargeUtf8,
"FTS column stays LargeUtf8 in the scan schema",
);
assert_eq!(
s.scalar()
.field_with_name("category")
.expect("category")
.data_type(),
&DataType::LargeUtf8,
);
}
#[test]
fn sql_schemas_is_memoized_across_calls() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let a = st.sql_schemas();
let b = st.sql_schemas();
assert!(
Arc::ptr_eq(&a, &b),
"sql_schemas must be cached (same Arc), not recomputed per call",
);
}
#[test]
fn query_sql_null_string_survives() {
let schema = Arc::new(Schema::new(vec![
Field::new("category", DataType::LargeUtf8, true), Field::new("title", DataType::LargeUtf8, false),
]));
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("rayon pool"),
);
let opts = SupertableOptions::new(
Arc::clone(&schema),
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool);
let st = Supertable::create(opts).expect("create");
let mut w = st.writer().expect("writer");
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(LargeStringArray::from(vec![Some("rust"), None, Some("go")])),
Arc::new(LargeStringArray::from(vec!["a", "b", "c"])),
],
)
.expect("batch");
w.append(&batch).expect("append");
w.commit().expect("commit");
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category FROM supertable")
.expect("select");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("category is LargeUtf8");
assert_eq!(col.null_count(), 1, "the NULL survives the view + coercion");
}
#[test]
fn query_sql_declared_utf8view_column_returns_large_utf8() {
let schema = Arc::new(Schema::new(vec![
Field::new("category", DataType::Utf8View, false), Field::new("title", DataType::LargeUtf8, false), ]));
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("rayon pool"),
);
let opts = SupertableOptions::new(
Arc::clone(&schema),
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool);
let st = Supertable::create(opts).expect("create");
let mut w = st.writer().expect("writer");
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(StringViewArray::from(vec!["rust", "go", "rust"])),
Arc::new(LargeStringArray::from(vec!["a", "b", "c"])),
],
)
.expect("batch");
w.append(&batch).expect("append");
w.commit().expect("commit");
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category FROM supertable GROUP BY category")
.expect("group-by");
assert_eq!(
batches[0].column(0).data_type(),
&DataType::LargeUtf8,
"views are internal; SQL results expose LargeUtf8, not Utf8View"
);
}
#[test]
fn query_sql_aliased_string_column_is_large_utf8() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category AS c FROM supertable GROUP BY c ORDER BY c")
.expect("alias");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("aliased column is LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "rust"]);
}
#[test]
fn query_sql_cte_string_column_is_declared_type() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql(
"WITH t AS (SELECT category FROM supertable) \
SELECT category FROM t GROUP BY category ORDER BY category",
)
.expect("cte");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("CTE column is LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "rust"]);
}
#[test]
fn query_sql_subquery_string_column_is_declared_type() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql(
"SELECT category FROM (SELECT category FROM supertable) sub \
GROUP BY category ORDER BY category",
)
.expect("subquery");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("subquery column is LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "rust"]);
}
#[test]
fn query_sql_string_values_survive_view_and_coercion() {
let vals = [
"", "short", "sixteen_byte_val", "prefabricated_alpha", "prefabricated_omega", "café_ünïcode_日本語_str", "sixteen_byte_val", ];
let titles: Vec<&str> = (0..vals.len()).map(|_| "t").collect();
let st = seeded(&vals, &titles);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT category FROM supertable GROUP BY category ORDER BY category")
.expect("group-by over layout-stressing values");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("category is LargeUtf8");
let mut got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
got.sort_unstable();
let mut want: Vec<&str> = vec![
"",
"café_ünïcode_日本語_str",
"prefabricated_alpha",
"prefabricated_omega",
"short",
"sixteen_byte_val",
];
want.sort_unstable();
assert_eq!(got, want);
}
#[test]
fn query_sql_distinct_string_is_declared_type() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT DISTINCT category FROM supertable ORDER BY category")
.expect("distinct");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("distinct column is LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "rust"]);
}
#[test]
fn query_sql_self_join_on_string_key() {
let st = seeded(&["rust", "go", "rust"], &["a", "b", "c"]);
let batches = st
.reader()
.expect("reader")
.query_sql(
"SELECT a.category AS cat FROM supertable a \
JOIN supertable b ON a.category = b.category \
GROUP BY a.category ORDER BY a.category",
)
.expect("self-join on string key");
let col = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("join key projects as LargeUtf8");
let got: Vec<&str> = (0..col.len()).map(|i| col.value(i)).collect();
assert_eq!(got, vec!["go", "rust"]);
}
#[test]
fn query_sql_group_by_with_nulls_falls_back_and_keeps_null_group() {
let schema = Arc::new(Schema::new(vec![Field::new(
"category",
DataType::LargeUtf8,
true,
)]));
let options = SupertableOptions::new(schema.clone(), vec![], vec![], None)
.expect("nullable category options");
let table = Supertable::create(options).expect("create");
let mut writer = table.writer().expect("writer");
let categories = LargeStringArray::from(vec![Some("rust"), None, Some("rust")]);
let batch = RecordBatch::try_new(schema, vec![Arc::new(categories)]).expect("batch");
writer.append(&batch).expect("append");
writer.commit().expect("commit");
let batches = table
.reader()
.expect("reader")
.query_sql(
"SELECT category, COUNT(*) AS n FROM supertable \
GROUP BY category ORDER BY category NULLS FIRST",
)
.expect("group by nullable category");
let categories = batches[0]
.column(0)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("large utf8 category");
let counts = batches[0]
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("int64 count");
assert!(categories.is_null(0));
assert_eq!(counts.value(0), 1);
assert_eq!(categories.value(1), "rust");
assert_eq!(counts.value(1), 2);
}
#[test]
fn query_sql_scans_across_multiple_superfiles() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["rust", "rust"], &["a", "b"]))
.expect("a1");
w.commit().expect("c1");
w.append(&build_cat_batch(10, &["python"], &["c"]))
.expect("a2");
w.commit().expect("c2");
w.append(&build_cat_batch(20, &["rust", "go"], &["d", "e"]))
.expect("a3");
w.commit().expect("c3");
assert_eq!(st.reader().expect("reader").n_superfiles(), 3);
let n_total = run_count(&st, "SELECT COUNT(*) FROM supertable");
assert_eq!(n_total, 5);
let n_rust = run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE category = 'rust'",
);
assert_eq!(n_rust, 3);
}
#[test]
fn query_sql_equality_on_fts_column_across_superfiles_is_correct() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["x"], &["alpha"]))
.expect("a1");
w.commit().expect("c1");
w.append(&build_cat_batch(10, &["y"], &["bravo"]))
.expect("a2");
w.commit().expect("c2");
w.append(&build_cat_batch(20, &["z"], &["charlie"]))
.expect("a3");
w.commit().expect("c3");
assert_eq!(st.reader().expect("reader").n_superfiles(), 3);
assert_eq!(
run_count(&st, "SELECT COUNT(*) FROM supertable WHERE title = 'bravo'"),
1
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'nonexistent'"
),
0
);
}
#[test]
fn query_sql_multiword_equality_on_fts_column_is_correct() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["lang"], &["rust async runtime"]))
.expect("a1");
w.commit().expect("c1");
w.append(&build_cat_batch(10, &["lang"], &["python data science"]))
.expect("a2");
w.commit().expect("c2");
assert_eq!(st.reader().expect("reader").n_superfiles(), 2);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'rust async runtime'"
),
1
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'rust async'"
),
0
);
}
#[test]
fn query_sql_fts_equality_superset_is_narrowed_to_exact_match() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["x", "y"],
&["rust async", "rust async runtime"],
))
.expect("append");
w.commit().expect("commit");
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'rust async'",
),
1,
);
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT title FROM supertable WHERE title = 'rust async'")
.expect("query");
let total: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total, 1);
}
#[test]
fn query_sql_fts_or_and_in_are_exact() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "python", "rust", "go"],
&["alpha", "beta", "gamma", "delta"],
))
.expect("append");
w.commit().expect("commit");
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'alpha' OR title = 'beta'",
),
2,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable \
WHERE title = 'alpha' AND category = 'rust'",
),
1,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable \
WHERE title = 'alpha' AND category = 'python'",
),
0,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title IN ('alpha', 'delta', 'zzz')",
),
2,
);
}
#[test]
fn query_sql_not_predicates_are_exact() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "python", "rust", "go"],
&["alpha", "beta", "alpha", "delta"],
))
.expect("append");
w.commit().expect("commit");
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE NOT (title = 'alpha')",
),
2,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title != 'alpha'"
),
2,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable \
WHERE title = 'alpha' AND category != 'rust'",
),
0,
);
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable \
WHERE title = 'alpha' AND category != 'python'",
),
2,
);
}
#[test]
fn query_sql_or_with_non_fts_branch_matches_full_scan() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(
0,
&["rust", "python", "go", "go"],
&["alpha", "beta", "gamma", "delta"],
))
.expect("append");
w.commit().expect("commit");
assert_eq!(
run_count(
&st,
"SELECT COUNT(*) FROM supertable WHERE title = 'alpha' OR category = 'go'",
),
3,
);
}
#[test]
fn query_sql_select_orders_ids_across_superfiles() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(100, &["a", "b"], &["t1", "t2"]))
.expect("a1");
w.commit().expect("c1");
w.append(&build_cat_batch(200, &["c"], &["t3"]))
.expect("a2");
w.commit().expect("c2");
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT _id FROM supertable ORDER BY _id")
.expect("query");
let ids: Vec<i128> = batches
.iter()
.flat_map(|b| {
let a = b
.column(0)
.as_any()
.downcast_ref::<Decimal128Array>()
.expect("_id is Decimal128");
(0..a.len()).map(|i| a.value(i)).collect::<Vec<_>>()
})
.collect();
assert_eq!(ids.len(), 3);
for w in ids.windows(2) {
assert!(w[0] < w[1], "expected strictly increasing _id");
}
}
#[test]
fn query_sql_select_star_exposes_only_user_columns_plus_id() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["x"], &["t"])).expect("a");
w.commit().expect("c");
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT * FROM supertable LIMIT 1")
.expect("query");
let schema = batches[0].schema();
let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(names, vec!["_id", "category", "title"]);
}
#[test]
fn query_sql_runtime_is_cached_across_calls() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_cat_batch(0, &["x"], &["t"])).expect("a");
w.commit().expect("c");
for _ in 0..3 {
let n = run_count(&st, "SELECT COUNT(*) FROM supertable");
assert_eq!(n, 1);
}
}
#[test]
fn query_sql_invalid_sql_returns_plan_error() {
let st = Supertable::create(options_id_cat_title()).expect("create");
let err = st
.reader()
.expect("reader")
.query_sql("SELECT NOT_A_REAL_FN(*) FROM supertable")
.expect_err("expected a plan error");
assert!(
matches!(err, QueryError::Plan(_)),
"expected Plan variant; got {err:?}"
);
}
fn schema_with_vector(dim: usize) -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("title", DataType::LargeUtf8, false),
Field::new(
"emb",
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
dim as i32,
),
false,
),
]))
}
fn options_with_vector(dim: usize) -> SupertableOptions {
let pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.expect("rayon pool"),
);
SupertableOptions::new(
schema_with_vector(dim),
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![VectorConfig {
column: "emb".into(),
dim,
rot_seed: 0,
metric: Metric::Cosine,
rerank_codec: RerankCodec::Fp32,
provided_centroids: None,
}],
Some(tok()),
)
.expect("valid options")
.with_writer_pool(pool)
}
fn build_vector_batch(_start: u64, n: usize, dim: usize) -> RecordBatch {
let titles = LargeStringArray::from((0..n).map(|i| format!("doc {i}")).collect::<Vec<_>>());
let mut flat = Vec::<f32>::with_capacity(n * dim);
for i in 0..n {
for d in 0..dim {
flat.push(((i + d) as f32) / 100.0);
}
}
let item_field = Arc::new(Field::new("item", DataType::Float32, true));
let values = Float32Array::from(flat);
let emb = FixedSizeListArray::try_new(
item_field,
dim as i32,
Arc::new(values) as Arc<dyn Array>,
None,
)
.expect("FixedSizeList build");
RecordBatch::try_new(
schema_with_vector(dim),
vec![Arc::new(titles), Arc::new(emb)],
)
.expect("build batch")
}
#[test]
fn query_sql_hides_vector_columns_from_sql_surface() {
let st = Supertable::create(options_with_vector(16)).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_vector_batch(0, 8, 16)).expect("append");
w.commit().expect("commit");
let batches = st
.reader()
.expect("reader")
.query_sql("SELECT * FROM supertable LIMIT 1")
.expect("query");
let schema = batches[0].schema();
let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(names, vec!["_id", "title"]);
}
#[test]
fn query_sql_referencing_vector_column_returns_plan_error() {
let st = Supertable::create(options_with_vector(16)).expect("create");
let mut w = st.writer().expect("writer");
w.append(&build_vector_batch(0, 8, 16)).expect("append");
w.commit().expect("commit");
let err = st
.reader()
.expect("reader")
.query_sql("SELECT emb FROM supertable")
.expect_err("vector column should not be in the SQL schema");
assert!(
matches!(err, QueryError::Plan(_)),
"expected Plan variant; got {err:?}"
);
}
}