Skip to content

Commit 57f2a25

Browse files
BtXinclaude
andauthored
fix(providers): handle empty projection pushdown for count(*) (#97) (#102)
DataFusion's projection pushdown passes `Some([])` to `TableProvider::scan` for queries like `SELECT count(*) FROM t` that only need the row count. The three hand-rolled providers each mishandled this: - SQLite's `build_sql` emitted `SELECT FROM "t"` (empty column list), which SQLite rejected with a parse error. `execute` also lost the row count for zero-column batches. - Mongo and Redis built zero-column RecordBatches via `RecordBatch::try_new`, which Arrow rejects with "must either specify a row count or at least one column". SQLite now emits `SELECT 1 FROM t` for empty projections, and all three providers pass the real row count through `RecordBatchOptions::with_row_count` so aggregates see the correct input cardinality. Delegated providers (Postgres / MySQL / SeekDB via `datafusion-table-providers`, plus Lance and Iceberg) are unaffected — upstream already handles this. Regression tests (integration, `#[ignore]`d) cover bare `count(*)`, `count(*)` with a filter, and `count(*)` on an empty table. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8d67bd6 commit 57f2a25

3 files changed

Lines changed: 208 additions & 13 deletions

File tree

crates/skardi/src/sources/providers/mongo/mod.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ pub mod fts_table_function;
33

44
use anyhow::{Context, Result};
55
use arrow::array::{
6-
ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, RecordBatch, StringArray,
6+
ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, RecordBatch, RecordBatchOptions,
7+
StringArray,
78
};
89
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
910
use async_trait::async_trait;
@@ -423,7 +424,17 @@ impl TableProvider for MongoTableProvider {
423424
let batch = if let Some(proj) = projection {
424425
let projected_schema = Arc::new(self.schema.project(proj)?);
425426
let columns: Vec<ArrayRef> = proj.iter().map(|&i| batch.column(i).clone()).collect();
426-
RecordBatch::try_new(projected_schema, columns)?
427+
if proj.is_empty() {
428+
// DataFusion pushes an empty projection for `count(*)`-style
429+
// queries where only the row count matters. `RecordBatch::try_new`
430+
// rejects a zero-column batch unless we supply the row count
431+
// explicitly, so pass it through so aggregates see the real
432+
// input cardinality.
433+
let options = RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
434+
RecordBatch::try_new_with_options(projected_schema, columns, &options)?
435+
} else {
436+
RecordBatch::try_new(projected_schema, columns)?
437+
}
427438
} else {
428439
batch
429440
};
@@ -1989,6 +2000,49 @@ mod tests {
19892000
assert!(total_rows(&batches) >= 2); // at least Electronics and Furniture
19902001
}
19912002

2003+
/// Regression test for #97 (Mongo half): projection pushdown emits
2004+
/// `Some([])` for `count(*)` without a filter, and the Mongo provider
2005+
/// previously built a zero-column batch with `RecordBatch::try_new`, which
2006+
/// Arrow rejects with "must either specify a row count or at least one
2007+
/// column". Needs a dedicated collection so a bare `count(*)` has a known
2008+
/// value regardless of what other parallel tests do to `products`.
2009+
#[tokio::test]
2010+
#[ignore]
2011+
async fn test_count_star_pushdown() {
2012+
// Seed a scratch collection with a known row count, registering it
2013+
// *after* seeding so the provider's PRAGMA-equivalent picks up the rows.
2014+
let raw_uri = "mongodb://root:rootpass@127.0.0.1:27017";
2015+
let seed_client = Client::with_uri_str(raw_uri)
2016+
.await
2017+
.expect("connect to mongo");
2018+
let scratch = seed_client
2019+
.database("mydb")
2020+
.collection::<Document>("count_star_scratch");
2021+
scratch.drop().await.expect("drop scratch");
2022+
scratch
2023+
.insert_many(vec![
2024+
doc! { "_id": "A", "product_id": "A", "name": "a", "price": 1.0 },
2025+
doc! { "_id": "B", "product_id": "B", "name": "b", "price": 2.0 },
2026+
doc! { "_id": "C", "product_id": "C", "name": "c", "price": 3.0 },
2027+
])
2028+
.await
2029+
.expect("seed scratch");
2030+
2031+
let mut ctx = SessionContext::new();
2032+
register_ci_collection(&mut ctx, "count_star_scratch", "product_id").await;
2033+
2034+
let batches = query_all(&ctx, "SELECT count(*) FROM count_star_scratch").await;
2035+
assert_eq!(total_rows(&batches), 1);
2036+
let counts = batches[0]
2037+
.column(0)
2038+
.as_any()
2039+
.downcast_ref::<Int64Array>()
2040+
.unwrap();
2041+
assert_eq!(counts.value(0), 3);
2042+
2043+
scratch.drop().await.expect("drop scratch");
2044+
}
2045+
19922046
// ─── Filter pushdown tests (integration) ────────────────────────────
19932047

19942048
#[tokio::test]

crates/skardi/src/sources/providers/redis/datasource.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use std::{
88
use anyhow::Result;
99
use arrow::{
1010
array::{
11-
ArrayRef, RecordBatch, StringBuilder, UInt64Array, as_boolean_array, as_largestring_array,
12-
as_string_array,
11+
ArrayRef, RecordBatch, RecordBatchOptions, StringBuilder, UInt64Array, as_boolean_array,
12+
as_largestring_array, as_string_array,
1313
},
1414
datatypes::{DataType, Field, Schema, SchemaRef},
1515
};
@@ -822,8 +822,21 @@ where
822822
.map(|mut b| Arc::new(b.finish()) as ArrayRef)
823823
.collect();
824824

825-
RecordBatch::try_new(self.projected_schema.clone(), arrays)
826-
.map_err(|e| DataFusionError::Execution(format!("Error building RecordBatch: {}", e)))
825+
if arrays.is_empty() {
826+
// DataFusion pushes an empty projection for `count(*)`-style queries
827+
// where only the row count matters. `RecordBatch::try_new` rejects a
828+
// zero-column batch unless we supply the row count explicitly, so
829+
// pass `count` through so aggregates see the real input cardinality.
830+
let options = RecordBatchOptions::new().with_row_count(Some(count));
831+
RecordBatch::try_new_with_options(self.projected_schema.clone(), arrays, &options)
832+
.map_err(|e| {
833+
DataFusionError::Execution(format!("Error building RecordBatch: {}", e))
834+
})
835+
} else {
836+
RecordBatch::try_new(self.projected_schema.clone(), arrays).map_err(|e| {
837+
DataFusionError::Execution(format!("Error building RecordBatch: {}", e))
838+
})
839+
}
827840
}
828841
}
829842

@@ -2102,6 +2115,45 @@ mod tests {
21022115
assert!(ci_total_rows(&batches) >= 2); // at least Electronics and Furniture
21032116
}
21042117

2118+
/// Regression test for #97 (Redis half): projection pushdown emits
2119+
/// `Some([])` for `count(*)`, and `fetch_partition` previously built a
2120+
/// zero-column batch via `RecordBatch::try_new`, which Arrow rejects with
2121+
/// "must either specify a row count or at least one column". Uses a
2122+
/// dedicated table so the bare `count(*)` has a known value regardless of
2123+
/// what other parallel tests do to `products`.
2124+
#[tokio::test]
2125+
#[ignore]
2126+
async fn test_count_star_pushdown_live() {
2127+
let table_name = "count_star_scratch_live";
2128+
clear_ci_table(table_name);
2129+
2130+
let mut ctx = SessionContext::new();
2131+
let mut extra_options = HashMap::new();
2132+
extra_options.insert("columns".to_string(), "product_id,name,price".to_string());
2133+
register_ci_table_with_options(&mut ctx, table_name, Some(&extra_options));
2134+
2135+
ctx.sql(&format!(
2136+
"INSERT INTO {table_name} (product_id, name, price)
2137+
VALUES ('A', 'a', '1.0'), ('B', 'b', '2.0'), ('C', 'c', '3.0')"
2138+
))
2139+
.await
2140+
.expect("parse insert")
2141+
.collect()
2142+
.await
2143+
.expect("execute insert");
2144+
2145+
let batches = ci_query_all(&ctx, &format!("SELECT count(*) FROM {table_name}")).await;
2146+
assert_eq!(ci_total_rows(&batches), 1);
2147+
let counts = batches[0]
2148+
.column(0)
2149+
.as_any()
2150+
.downcast_ref::<arrow::array::Int64Array>()
2151+
.unwrap();
2152+
assert_eq!(counts.value(0), 3);
2153+
2154+
clear_ci_table(table_name);
2155+
}
2156+
21052157
#[tokio::test]
21062158
#[ignore]
21072159
async fn test_empty_table_declared_schema_live() {

crates/skardi/src/sources/providers/sqlite/mod.rs

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use vec_to_binary::register_vec_to_binary_udf;
1111
use anyhow::{Context, Result};
1212
use arrow::array::{
1313
ArrayRef, BinaryArray, BooleanArray, FixedSizeListArray, Float32Array, Float64Array,
14-
Int64Array, ListArray, RecordBatch, StringArray, UInt64Array,
14+
Int64Array, ListArray, RecordBatch, RecordBatchOptions, StringArray, UInt64Array,
1515
};
1616
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
1717
use async_trait::async_trait;
@@ -877,9 +877,18 @@ impl SqliteScanExec {
877877
.map(|n| format!(" LIMIT {n}"))
878878
.unwrap_or_default();
879879

880-
Ok(format!(
881-
"SELECT {} FROM {table}{where_clause}{limit_clause}",
880+
// DataFusion pushes down an empty projection for queries like `count(*)`
881+
// where only the row count is needed. SQLite rejects `SELECT FROM t`, so
882+
// emit a constant projection that preserves row count (`SELECT 1 FROM t`)
883+
// and let `execute` return a zero-column batch with the correct row count.
884+
let projection_clause = if columns.is_empty() {
885+
"1".to_string()
886+
} else {
882887
columns.join(", ")
888+
};
889+
890+
Ok(format!(
891+
"SELECT {projection_clause} FROM {table}{where_clause}{limit_clause}"
883892
))
884893
}
885894
}
@@ -940,12 +949,13 @@ impl ExecutionPlan for SqliteScanExec {
940949
.collect();
941950

942951
let future = async move {
943-
let batch: Vec<Vec<tokio_rusqlite::rusqlite::types::Value>> = conn
944-
.call(
952+
let (batch, row_count): (Vec<Vec<tokio_rusqlite::rusqlite::types::Value>>, usize) =
953+
conn.call(
945954
move |conn| -> std::result::Result<_, tokio_rusqlite::rusqlite::Error> {
946955
let mut stmt = conn.prepare(&sql)?;
947956
let mut col_values: Vec<Vec<tokio_rusqlite::rusqlite::types::Value>> =
948957
(0..num_cols).map(|_| Vec::new()).collect();
958+
let mut row_count: usize = 0;
949959

950960
let mut rows = stmt.query([])?;
951961
while let Some(row) = rows.next()? {
@@ -954,9 +964,10 @@ impl ExecutionPlan for SqliteScanExec {
954964
row.get(col_idx)?;
955965
col_values[col_idx].push(val);
956966
}
967+
row_count += 1;
957968
}
958969

959-
Ok(col_values)
970+
Ok((col_values, row_count))
960971
},
961972
)
962973
.await
@@ -969,7 +980,16 @@ impl ExecutionPlan for SqliteScanExec {
969980
.map(|(values, data_type)| sqlite_values_to_arrow(&values, data_type))
970981
.collect();
971982

972-
RecordBatch::try_new(output_schema, arrays).map_err(DataFusionError::from)
983+
if num_cols == 0 {
984+
// Zero-column batch (e.g. from `count(*)` projection pushdown).
985+
// RecordBatch::try_new would return a 0-row batch; pass the row
986+
// count explicitly so aggregates see the real input cardinality.
987+
let options = RecordBatchOptions::new().with_row_count(Some(row_count));
988+
RecordBatch::try_new_with_options(output_schema, arrays, &options)
989+
.map_err(DataFusionError::from)
990+
} else {
991+
RecordBatch::try_new(output_schema, arrays).map_err(DataFusionError::from)
992+
}
973993
};
974994

975995
Ok(Box::pin(RecordBatchStreamAdapter::new(
@@ -1732,6 +1752,75 @@ mod tests {
17321752
assert_eq!(total_rows(&batches), 2);
17331753
}
17341754

1755+
/// Regression test for #97: `count(*)` was rewritten to an empty projection
1756+
/// (`SELECT FROM "t"`), which SQLite rejects with a syntax error.
1757+
#[tokio::test]
1758+
#[ignore]
1759+
async fn test_count_star_pushdown() {
1760+
let db_path = create_test_db().await;
1761+
let db = db_path.to_str().unwrap();
1762+
let mut ctx = SessionContext::new();
1763+
register_test_table(&mut ctx, db).await;
1764+
1765+
let batches = query_all(&ctx, "SELECT count(*) FROM test_items").await;
1766+
assert_eq!(total_rows(&batches), 1);
1767+
1768+
let counts = batches[0]
1769+
.column(0)
1770+
.as_any()
1771+
.downcast_ref::<Int64Array>()
1772+
.unwrap();
1773+
assert_eq!(counts.value(0), 3);
1774+
}
1775+
1776+
/// `count(*)` combined with a WHERE clause still needs the projection pushdown
1777+
/// to produce the correct row count after filtering.
1778+
#[tokio::test]
1779+
#[ignore]
1780+
async fn test_count_star_with_filter() {
1781+
let db_path = create_test_db().await;
1782+
let db = db_path.to_str().unwrap();
1783+
let mut ctx = SessionContext::new();
1784+
register_test_table(&mut ctx, db).await;
1785+
1786+
let batches = query_all(&ctx, "SELECT count(*) FROM test_items WHERE id > 1").await;
1787+
assert_eq!(total_rows(&batches), 1);
1788+
1789+
let counts = batches[0]
1790+
.column(0)
1791+
.as_any()
1792+
.downcast_ref::<Int64Array>()
1793+
.unwrap();
1794+
assert_eq!(counts.value(0), 2);
1795+
}
1796+
1797+
/// `count(*)` over an empty table must return 0, not a SQLite syntax error.
1798+
#[tokio::test]
1799+
#[ignore]
1800+
async fn test_count_star_empty_table() {
1801+
let db_path = create_test_db().await;
1802+
let db = db_path.to_str().unwrap();
1803+
let mut ctx = SessionContext::new();
1804+
register_test_table(&mut ctx, db).await;
1805+
1806+
ctx.sql("DELETE FROM test_items")
1807+
.await
1808+
.expect("parse delete")
1809+
.collect()
1810+
.await
1811+
.expect("execute delete");
1812+
1813+
let batches = query_all(&ctx, "SELECT count(*) FROM test_items").await;
1814+
assert_eq!(total_rows(&batches), 1);
1815+
1816+
let counts = batches[0]
1817+
.column(0)
1818+
.as_any()
1819+
.downcast_ref::<Int64Array>()
1820+
.unwrap();
1821+
assert_eq!(counts.value(0), 0);
1822+
}
1823+
17351824
// ─── Insert test ────────────────────────────────────────────────────
17361825

17371826
#[tokio::test]

0 commit comments

Comments
 (0)