1pub mod proto;
2mod prune;
3pub mod server;
4
5mod aggregate;
6mod builder;
7mod codec;
8mod diagnostics;
9mod filter;
10mod predicate;
11mod scan;
12mod schema;
13mod types;
14mod writer;
15
16pub use aggregate::KvAggregateExtensionPlanner;
17pub use schema::KvSchema;
18pub use server::{query_context_with_min_sequence, sql_connect_stack, SqlConnect, SqlServer};
19pub use types::default_orders_index_specs;
20pub use types::{
21 CellValue, IndexBackfillEvent, IndexBackfillOptions, IndexBackfillReport, IndexLayout,
22 IndexSpec, TableColumnConfig,
23};
24pub use writer::{BatchReceipt, BatchWriter, PreparedBatch, TableWriter};
25
26pub fn session_context() -> datafusion::prelude::SessionContext {
28 datafusion::prelude::SessionContext::new_with_state(session_state_builder().build())
29}
30
31pub fn session_state_builder() -> datafusion::execution::session_state::SessionStateBuilder {
40 datafusion::execution::session_state::SessionStateBuilder::new_with_default_features()
41 .with_optimizer_rule(std::sync::Arc::new(
42 aggregate::KvAggregatePushdownRule::new(),
43 ))
44 .with_query_planner(std::sync::Arc::new(aggregate::KvQueryPlanner))
45}
46
47#[cfg(test)]
48mod tests {
49 #![allow(refining_impl_trait)]
50
51 use super::aggregate::*;
52 use super::builder::*;
53 use super::codec::*;
54 use super::diagnostics::*;
55 use super::filter::*;
56 use super::predicate::*;
57 use super::scan::*;
58 use super::types::*;
59 use super::writer::*;
60 use super::*;
61 use commonware_codec::Encode;
62 use datafusion::arrow::array::{
63 BinaryViewArray, Float64Array, Int64Array, LargeBinaryArray, LargeStringArray,
64 StringViewArray,
65 };
66 use datafusion::arrow::datatypes::{i256, DataType, TimeUnit};
67 use datafusion::arrow::record_batch::RecordBatch;
68 use datafusion::common::{config::ConfigOptions, ScalarValue};
69 use datafusion::logical_expr::{Expr, Operator};
70 use datafusion::physical_optimizer::limit_pushdown::LimitPushdown;
71 use datafusion::physical_optimizer::PhysicalOptimizerRule;
72 use datafusion::physical_plan::limit::GlobalLimitExec;
73 use datafusion::physical_plan::ExecutionPlan;
74 use datafusion::prelude::SessionContext;
75 use exoware_sdk::keys::{Key, Prefix};
76 use exoware_sdk::kv_codec::{decode_stored_row, KvReducedValue, StoredRow};
77 use exoware_sdk::{PrefixedStoreClient, StoreBatchUpload, StoreClient};
78 use std::collections::{BTreeMap, HashSet};
79 use std::ops::Bound::{Included, Unbounded};
80 use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering as AtomicOrdering};
81 use std::sync::{Arc, Mutex};
82 use std::time::Duration;
83
84 use axum::Router;
85 use bytes::Bytes;
86 use connectrpc::{ConnectError, ConnectRpcService, RequestContext as Context, ServiceRequest};
87 use exoware_sdk::common::kv::v1::Entry as ProtoEntry;
88 use exoware_sdk::connect_compression_registry;
89 use exoware_sdk::log::ingest::v1::{
90 PutRequest as ProtoPutRequest, PutResponse as ProtoPutResponse, Service as IngestService,
91 ServiceServer as IngestServiceServer,
92 };
93 use exoware_sdk::store::query::v1::{
94 GetManyFrame as ProtoGetManyFrame, GetManyRequest as ProtoGetManyRequest,
95 GetRequest as ProtoGetRequest, GetResponse as ProtoGetResponse,
96 RangeFrame as ProtoRangeFrame, RangeRequest as ProtoRangeRequest,
97 ReduceRequest as ProtoReduceRequest, ReduceResponse as ProtoReduceResponse,
98 Service as QueryService, ServiceServer as QueryServiceServer,
99 };
100 use exoware_sdk::RangeMode;
101 use exoware_sdk::{parse_range_traversal_direction, RangeTraversalDirection};
102 use exoware_server::{Query, QueryExtra, QueryState, RangeScan, RangeScanBatch, Sequence};
103 use futures::{stream, TryStreamExt};
104 use tokio::sync::{mpsc, oneshot, Notify};
105
106 fn assert_explain_includes_query_stats_surface(
108 explain: &str,
109 surface: QueryStatsExplainSurface,
110 ) {
111 let expected = format!("query_stats={}", format_query_stats_explain(surface));
112 assert!(
113 explain.contains(&expected),
114 "expected EXPLAIN output to include `{expected}`\n{explain}"
115 );
116 }
117
118 fn simple_int64_model(prefix: u8) -> TableModel {
119 let config = KvTableConfig::new(
120 prefix,
121 vec![TableColumnConfig::new("id", DataType::Int64, false)],
122 vec!["id".to_string()],
123 vec![],
124 )
125 .unwrap();
126 TableModel::from_config(&config).unwrap()
127 }
128
129 fn key_payload(prefix: &Prefix, key: &Key, offset: usize, len: usize) -> Vec<u8> {
130 let payload = prefix.strip(key).expect("key payload");
131 payload[offset..offset + len].to_vec()
132 }
133
134 fn primary_payload(model: &TableModel, key: &Key, offset: usize, len: usize) -> Vec<u8> {
135 key_payload(&model.primary_key_prefix, key, offset, len)
136 }
137
138 fn index_payload(spec: &ResolvedIndexSpec, key: &Key, offset: usize, len: usize) -> Vec<u8> {
139 key_payload(&spec.prefix, key, offset, len)
140 }
141
142 fn matches_primary_key(table_prefix: u8, key: &Key) -> bool {
143 primary_key_prefix(table_prefix)
144 .expect("primary prefix")
145 .matches(key)
146 }
147
148 fn matches_secondary_index_key(table_prefix: u8, index_id: u8, key: &Key) -> bool {
149 secondary_index_prefix(table_prefix, index_id)
150 .expect("secondary prefix")
151 .matches(key)
152 }
153
154 fn test_model() -> (TableModel, Vec<ResolvedIndexSpec>) {
155 let config = KvTableConfig::new(
156 0,
157 vec![
158 TableColumnConfig::new("region", DataType::Utf8, false),
159 TableColumnConfig::new("customer_id", DataType::Int64, false),
160 TableColumnConfig::new("order_id", DataType::Int64, false),
161 TableColumnConfig::new("amount_cents", DataType::Int64, false),
162 TableColumnConfig::new("status", DataType::Utf8, false),
163 ],
164 vec!["order_id".to_string()],
165 vec![
166 IndexSpec::new(
167 "region_customer",
168 vec!["region".to_string(), "customer_id".to_string()],
169 )
170 .expect("valid"),
171 IndexSpec::new(
172 "status_customer",
173 vec!["status".to_string(), "customer_id".to_string()],
174 )
175 .expect("valid"),
176 ],
177 )
178 .expect("valid config");
179 let model = TableModel::from_config(&config).expect("model");
180 let specs = model
181 .resolve_index_specs(&config.index_specs)
182 .expect("specs");
183 (model, specs)
184 }
185
186 fn zorder_test_model() -> (TableModel, Vec<ResolvedIndexSpec>) {
187 let config = KvTableConfig::new(
188 0,
189 vec![
190 TableColumnConfig::new("x", DataType::Int64, false),
191 TableColumnConfig::new("y", DataType::Int64, false),
192 TableColumnConfig::new("id", DataType::Int64, false),
193 TableColumnConfig::new("value", DataType::Int64, false),
194 ],
195 vec!["id".to_string()],
196 vec![
197 IndexSpec::new("xy_lex", vec!["x".to_string(), "y".to_string()])
198 .expect("valid")
199 .with_cover_columns(vec!["value".to_string()]),
200 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
201 .expect("valid")
202 .with_cover_columns(vec!["value".to_string()]),
203 ],
204 )
205 .expect("valid config");
206 let model = TableModel::from_config(&config).expect("model");
207 let specs = model
208 .resolve_index_specs(&config.index_specs)
209 .expect("specs");
210 (model, specs)
211 }
212
213 pub(super) fn decode_reduce_request(body: &[u8]) -> exoware_sdk::query::ReduceRequest {
214 use buffa::Message;
215
216 let envelope = connectrpc::envelope::Envelope::decode(&mut bytes::BytesMut::from(body))
217 .unwrap()
218 .unwrap();
219 assert!(!envelope.is_compressed());
220 exoware_sdk::query::ReduceRequest::decode_from_slice(&envelope.data).unwrap()
221 }
222
223 #[derive(Clone)]
224 struct MockState {
225 kv: Arc<Mutex<BTreeMap<Key, Bytes>>>,
226 range_calls: Arc<AtomicUsize>,
227 range_reduce_calls: Arc<AtomicUsize>,
228 sequence_number: Arc<AtomicU64>,
229 }
230
231 impl Sequence for MockState {
232 fn current_sequence(&self) -> u64 {
233 self.sequence_number.load(AtomicOrdering::Relaxed)
234 }
235 }
236
237 struct MockRangeScan(std::vec::IntoIter<(Bytes, Bytes)>);
238
239 impl RangeScan for MockRangeScan {
240 async fn next_batch(&mut self, max_items: usize) -> Result<RangeScanBatch, String> {
241 Ok(RangeScanBatch {
242 rows: self.0.by_ref().take(max_items).collect(),
243 extra: QueryExtra::new(),
244 })
245 }
246 }
247
248 impl Query for MockState {
249 type RangeScan = MockRangeScan;
250
251 async fn get(&self, key: Bytes) -> Result<(Option<Bytes>, QueryExtra), String> {
252 Ok((
253 self.kv.lock().unwrap().get(&key).cloned(),
254 QueryExtra::new(),
255 ))
256 }
257
258 async fn get_many(
259 &self,
260 keys: Vec<Bytes>,
261 ) -> Result<(Vec<(Bytes, Option<Bytes>)>, QueryExtra), String> {
262 let values = self.kv.lock().unwrap();
263 Ok((
264 keys.into_iter()
265 .map(|key| {
266 let value = values.get(&key).cloned();
267 (key, value)
268 })
269 .collect(),
270 QueryExtra::new(),
271 ))
272 }
273
274 async fn range_scan(
275 &self,
276 start: Bytes,
277 end: Bytes,
278 limit: usize,
279 forward: bool,
280 ) -> Result<MockRangeScan, String> {
281 let values = self.kv.lock().unwrap();
282 let range = values.range((
283 Included(start),
284 if end.is_empty() {
285 Unbounded
286 } else {
287 Included(end)
288 },
289 ));
290 let rows = if forward {
291 range
292 .take(limit)
293 .map(|(key, value)| (key.clone(), value.clone()))
294 .collect::<Vec<_>>()
295 } else {
296 range
297 .rev()
298 .take(limit)
299 .map(|(key, value)| (key.clone(), value.clone()))
300 .collect()
301 };
302 Ok(MockRangeScan(rows.into_iter()))
303 }
304 }
305
306 fn proto_range_entries_frame(results: Vec<(Key, Vec<u8>)>) -> ProtoRangeFrame {
307 ProtoRangeFrame {
308 results: results
309 .into_iter()
310 .map(|(key, value)| ProtoEntry {
311 key: key.to_vec(),
312 value: value.into(),
313 ..Default::default()
314 })
315 .collect(),
316 detail: Some(query_detail(7)).into(),
317 ..Default::default()
318 }
319 }
320
321 fn query_detail(sequence_number: u64) -> exoware_sdk::store::query::v1::Detail {
322 exoware_sdk::store::query::v1::Detail {
323 sequence_number,
324 extra: Default::default(),
325 ..Default::default()
326 }
327 }
328
329 #[derive(Clone)]
330 struct MockIngestConnect {
331 state: MockState,
332 }
333
334 impl IngestService for MockIngestConnect {
335 async fn put(
336 &self,
337 _ctx: Context,
338 request: ServiceRequest<'_, ProtoPutRequest>,
339 ) -> connectrpc::ServiceResult<ProtoPutResponse> {
340 let mut parsed = Vec::<(Key, Bytes)>::new();
341 let wire = request.bytes();
342 for kv in request.kvs.iter() {
343 parsed.push((wire.slice_ref(kv.key), wire.slice_ref(kv.value)));
344 }
345 let mut guard = self.state.kv.lock().expect("kv mutex poisoned");
346 for (key, value) in parsed.iter() {
347 guard.insert(key.clone(), value.clone());
348 }
349 let seq = self
350 .state
351 .sequence_number
352 .fetch_add(1, AtomicOrdering::SeqCst)
353 + 1;
354 connectrpc::Response::ok(ProtoPutResponse {
355 sequence_number: seq,
356 ..Default::default()
357 })
358 }
359 }
360
361 async fn track_query_calls(
362 axum::extract::State(state): axum::extract::State<MockState>,
363 request: axum::extract::Request,
364 next: axum::middleware::Next,
365 ) -> axum::response::Response {
366 match request.uri().path().rsplit('/').next() {
367 Some("Range") => {
368 state.range_calls.fetch_add(1, AtomicOrdering::SeqCst);
369 }
370 Some("Reduce") => {
371 state
372 .range_reduce_calls
373 .fetch_add(1, AtomicOrdering::SeqCst);
374 }
375 _ => {}
376 }
377 next.run(request).await
378 }
379
380 async fn spawn_mock_server(state: MockState) -> (String, oneshot::Sender<()>) {
381 let connect = ConnectRpcService::new(IngestServiceServer::new(MockIngestConnect {
382 state: state.clone(),
383 }))
384 .with_compression(connect_compression_registry());
385 let app = Router::new()
386 .route_service(
387 "/store.query.v1.Service/{method}",
388 exoware_server::query_service(QueryState::new(Arc::new(state.clone()))),
389 )
390 .fallback_service(connect)
391 .layer(axum::middleware::from_fn_with_state(
392 state,
393 track_query_calls,
394 ));
395
396 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
397 .await
398 .expect("bind mock server");
399 let addr = listener.local_addr().expect("local addr");
400 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
401 tokio::spawn(async move {
402 axum::serve(listener, app)
403 .with_graceful_shutdown(async move {
404 let _ = shutdown_rx.await;
405 })
406 .await
407 .expect("mock server should run");
408 });
409 (format!("http://{addr}"), shutdown_tx)
410 }
411
412 fn assert_count_scalar(batch: &RecordBatch, col_idx: usize, row_idx: usize, expected: u64) {
413 let scalar = ScalarValue::try_from_array(batch.column(col_idx), row_idx)
414 .expect("count scalar should decode");
415 match scalar {
416 ScalarValue::UInt64(Some(value)) => assert_eq!(value, expected),
417 ScalarValue::Int64(Some(value)) => assert_eq!(value, expected as i64),
418 other => panic!("unexpected count scalar: {other:?}"),
419 }
420 }
421
422 fn collect_i64_column(batches: &[RecordBatch], col_idx: usize) -> Vec<i64> {
423 let mut values = Vec::new();
424 for batch in batches {
425 for row_idx in 0..batch.num_rows() {
426 match ScalarValue::try_from_array(batch.column(col_idx), row_idx)
427 .expect("int64 scalar should decode")
428 {
429 ScalarValue::Int64(Some(value)) => values.push(value),
430 other => panic!("unexpected int64 scalar: {other:?}"),
431 }
432 }
433 }
434 values
435 }
436
437 async fn explain_plan_rows(ctx: &SessionContext, sql: &str) -> Vec<(String, String)> {
438 let batches = ctx
439 .sql(&format!("EXPLAIN {sql}"))
440 .await
441 .expect("explain query")
442 .collect()
443 .await
444 .expect("explain collect");
445 let mut rows = Vec::new();
446 for batch in batches {
447 for row_idx in 0..batch.num_rows() {
448 let plan_type = scalar_to_string(
449 &ScalarValue::try_from_array(batch.column(0), row_idx).expect("plan type"),
450 )
451 .expect("plan type string");
452 let plan = scalar_to_string(
453 &ScalarValue::try_from_array(batch.column(1), row_idx).expect("plan"),
454 )
455 .expect("plan string");
456 rows.push((plan_type, plan));
457 }
458 }
459 rows
460 }
461
462 fn physical_plan_text(rows: &[(String, String)]) -> String {
463 rows.iter()
464 .filter(|(plan_type, _)| plan_type.contains("physical_plan"))
465 .map(|(_, plan)| plan.as_str())
466 .collect::<Vec<_>>()
467 .join("\n")
468 }
469
470 #[tokio::test]
471 async fn explain_reports_full_scan_like_primary_key_scan() {
472 let state = MockState {
473 kv: Arc::new(Mutex::new(BTreeMap::new())),
474 range_calls: Arc::new(AtomicUsize::new(0)),
475 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
476 sequence_number: Arc::new(AtomicU64::new(0)),
477 };
478 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
479 let client = StoreClient::new(&base_url);
480
481 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
482 .table(
483 "orders",
484 vec![
485 TableColumnConfig::new("id", DataType::Int64, false),
486 TableColumnConfig::new("status", DataType::Utf8, false),
487 TableColumnConfig::new("amount_cents", DataType::Int64, false),
488 ],
489 vec!["id".to_string()],
490 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
491 .expect("valid")
492 .with_cover_columns(vec!["amount_cents".to_string()])],
493 )
494 .expect("schema");
495 let ctx = session_context();
496 schema.register_all(&ctx).expect("register");
497
498 let explain =
499 physical_plan_text(&explain_plan_rows(&ctx, "SELECT id, status FROM orders").await);
500 assert!(explain.contains("KvScanExec:"));
501 assert!(explain.contains("mode=primary_key"));
502 assert!(explain.contains("predicate=<none>"));
503 assert!(explain.contains("row_recheck=false"));
504 assert!(explain.contains("full_scan_like=true"));
505 assert_explain_includes_query_stats_surface(
506 &explain,
507 QueryStatsExplainSurface::StreamedRangeDetail,
508 );
509
510 let _ = shutdown_tx.send(());
511 }
512
513 #[tokio::test]
514 async fn explain_reports_secondary_index_scan_and_row_recheck() {
515 let state = MockState {
516 kv: Arc::new(Mutex::new(BTreeMap::new())),
517 range_calls: Arc::new(AtomicUsize::new(0)),
518 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
519 sequence_number: Arc::new(AtomicU64::new(0)),
520 };
521 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
522 let client = StoreClient::new(&base_url);
523
524 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
525 .table(
526 "orders",
527 vec![
528 TableColumnConfig::new("id", DataType::Int64, false),
529 TableColumnConfig::new("status", DataType::Utf8, false),
530 TableColumnConfig::new("amount_cents", DataType::Int64, false),
531 ],
532 vec!["id".to_string()],
533 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
534 .expect("valid")
535 .with_cover_columns(vec!["amount_cents".to_string()])],
536 )
537 .expect("schema");
538 let ctx = session_context();
539 schema.register_all(&ctx).expect("register");
540
541 let explain = physical_plan_text(
542 &explain_plan_rows(
543 &ctx,
544 "SELECT id, status, amount_cents FROM orders \
545 WHERE status = 'open' AND amount_cents >= 5",
546 )
547 .await,
548 );
549 assert!(explain.contains("KvScanExec:"));
550 assert!(explain.contains("mode=secondary_index(status_idx, lexicographic)"));
551 assert!(explain.contains("predicate=status = 'open' AND amount_cents >= 5"));
552 assert!(explain.contains("exact=false"));
553 assert!(explain.contains("row_recheck=true"));
554 assert!(explain.contains("full_scan_like=false"));
555 assert_explain_includes_query_stats_surface(
556 &explain,
557 QueryStatsExplainSurface::StreamedRangeDetail,
558 );
559
560 let _ = shutdown_tx.send(());
561 }
562
563 #[tokio::test]
564 async fn explain_reports_zorder_secondary_index_scan() {
565 let state = MockState {
566 kv: Arc::new(Mutex::new(BTreeMap::new())),
567 range_calls: Arc::new(AtomicUsize::new(0)),
568 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
569 sequence_number: Arc::new(AtomicU64::new(0)),
570 };
571 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
572 let client = StoreClient::new(&base_url);
573
574 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
575 .table(
576 "points",
577 vec![
578 TableColumnConfig::new("x", DataType::Int64, false),
579 TableColumnConfig::new("y", DataType::Int64, false),
580 TableColumnConfig::new("id", DataType::Int64, false),
581 TableColumnConfig::new("value", DataType::Int64, false),
582 ],
583 vec!["id".to_string()],
584 vec![
585 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
586 .expect("valid")
587 .with_cover_columns(vec!["value".to_string()]),
588 ],
589 )
590 .expect("schema");
591 let ctx = session_context();
592 schema.register_all(&ctx).expect("register");
593
594 let explain = physical_plan_text(
595 &explain_plan_rows(
596 &ctx,
597 "SELECT id, value FROM points \
598 WHERE x >= 1 AND x <= 2 AND y >= 1 AND y <= 2",
599 )
600 .await,
601 );
602 assert!(explain.contains("KvScanExec:"));
603 assert!(explain.contains("mode=secondary_index(xy_z, z_order)"));
604 assert!(explain.contains("exact=false"));
605 assert!(explain.contains("row_recheck=true"));
606
607 let _ = shutdown_tx.send(());
608 }
609
610 #[tokio::test]
611 async fn explain_reports_aggregate_pushdown_access_path_details() {
612 let state = MockState {
613 kv: Arc::new(Mutex::new(BTreeMap::new())),
614 range_calls: Arc::new(AtomicUsize::new(0)),
615 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
616 sequence_number: Arc::new(AtomicU64::new(0)),
617 };
618 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
619 let client = StoreClient::new(&base_url);
620
621 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
622 .table(
623 "orders",
624 vec![
625 TableColumnConfig::new("id", DataType::Int64, false),
626 TableColumnConfig::new("status", DataType::Utf8, false),
627 TableColumnConfig::new("amount_cents", DataType::Int64, false),
628 ],
629 vec!["id".to_string()],
630 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
631 .expect("valid")
632 .with_cover_columns(vec!["amount_cents".to_string()])],
633 )
634 .expect("schema");
635 let ctx = session_context();
636 schema.register_all(&ctx).expect("register");
637
638 let explain = physical_plan_text(
639 &explain_plan_rows(
640 &ctx,
641 "SELECT status, SUM(amount_cents) AS total_cents \
642 FROM orders WHERE status = 'open' GROUP BY status",
643 )
644 .await,
645 );
646 assert!(explain.contains("KvAggregateExec:"));
647 assert!(explain.contains("grouped=true"));
648 assert!(explain.contains("job0{mode=secondary_index(status_idx, lexicographic)"));
649 assert!(explain.contains("predicate=status = 'open'"));
650 assert!(explain.contains("exact=false"));
651 assert!(explain.contains("row_recheck=true"));
652 assert_explain_includes_query_stats_surface(
653 &explain,
654 QueryStatsExplainSurface::RangeReduceDetail,
655 );
656
657 let _ = shutdown_tx.send(());
658 }
659
660 #[test]
661 fn index_spec_constructor_sets_name_and_keys() {
662 let spec = IndexSpec::new(
663 "status_customer",
664 vec!["status".to_string(), "customer_id".to_string()],
665 )
666 .expect("valid index spec");
667 assert_eq!(spec.name(), "status_customer");
668 assert_eq!(spec.key_columns(), &["status", "customer_id"]);
669 assert!(spec.cover_columns().is_empty());
670 }
671
672 #[test]
673 fn index_spec_cover_columns_are_configurable_in_code() {
674 let spec = IndexSpec::new("status_customer", vec!["status".to_string()])
675 .expect("valid")
676 .with_cover_columns(vec!["amount_cents".to_string()]);
677 assert_eq!(spec.key_columns(), &["status"]);
678 assert_eq!(spec.cover_columns(), &["amount_cents"]);
679 }
680
681 #[test]
682 fn describe_in_list_places_truncation_ellipsis_inside_parentheses() {
683 let rendered = describe_in_list((1..=6).map(|v| v.to_string()));
684 assert_eq!(rendered, "IN (1, 2, 3, 4, 5, ...)");
685 }
686
687 #[test]
688 fn normalize_sum_case_then_one_preserves_sum_semantics() {
689 let (model, _) = test_model();
690 let argument = normalize_case_then_expr(
691 AggregatePushdownFunction::Sum,
692 &Expr::Literal(ScalarValue::Int64(Some(1)), None),
693 &model,
694 )
695 .expect("normalize");
696 assert_eq!(
697 argument,
698 AggregatePushdownArgument::Expr(PushdownValueExpr::Literal(KvReducedValue::Int64(1)))
699 );
700 }
701
702 #[test]
703 fn normalize_count_case_then_literal_uses_countall_optimization() {
704 use datafusion::logical_expr::col;
705
706 let (model, _) = test_model();
707 let case_expr = Expr::Case(datafusion::logical_expr::expr::Case {
708 expr: None,
709 when_then_expr: vec![(
710 Box::new(col("status").eq(Expr::Literal(
711 ScalarValue::Utf8(Some("open".to_string())),
712 None,
713 ))),
714 Box::new(Expr::Literal(
715 ScalarValue::Utf8(Some("yes".to_string())),
716 None,
717 )),
718 )],
719 else_expr: Some(Box::new(Expr::Literal(ScalarValue::Utf8(None), None))),
720 });
721
722 let (func, argument, filter) =
723 normalize_count_aggregate_argument(&case_expr, &model).expect("normalize");
724 assert_eq!(func, AggregatePushdownFunction::Count);
725 assert_eq!(argument, AggregatePushdownArgument::CountAll);
726 assert!(filter.is_some());
727 }
728
729 #[test]
730 fn reduced_value_to_scalar_preserves_timestamp_timezone_label() {
731 let tz: Arc<str> = Arc::from("America/New_York");
732 let scalar = reduced_value_to_scalar(
733 Some(KvReducedValue::Timestamp(1_700_000_000_000_000)),
734 &DataType::Timestamp(TimeUnit::Microsecond, Some(tz.clone())),
735 )
736 .expect("timestamp scalar");
737 assert_eq!(
738 scalar,
739 ScalarValue::TimestampMicrosecond(Some(1_700_000_000_000_000), Some(tz))
740 );
741 }
742
743 #[test]
744 fn index_spec_cover_pk_column_is_rejected() {
745 let config = KvTableConfig::new(
746 0,
747 vec![
748 TableColumnConfig::new("id", DataType::Int64, false),
749 TableColumnConfig::new("status", DataType::Utf8, false),
750 ],
751 vec!["id".to_string()],
752 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
753 .expect("valid")
754 .with_cover_columns(vec!["id".to_string()])],
755 )
756 .expect("valid config");
757 let model = TableModel::from_config(&config).expect("model");
758 let err = model
759 .resolve_index_specs(&config.index_specs)
760 .expect_err("covering a PK column must be rejected");
761 assert!(err.contains("primary key column"));
762 }
763
764 #[test]
765 fn access_plan_requires_cover_columns_for_index_scan() {
766 let (model, _) = test_model();
767 let predicate = QueryPredicate::default();
768 let projection = Some(vec![
769 *model.columns_by_name.get("order_id").unwrap(),
770 *model.columns_by_name.get("amount_cents").unwrap(),
771 ]);
772 let plan = ScanAccessPlan::new(&model, &projection, &predicate);
773
774 let no_cover = IndexSpec::new("status_idx", vec!["status".to_string()]).unwrap();
775 let with_cover = IndexSpec::new("status_idx", vec!["status".to_string()])
776 .unwrap()
777 .with_cover_columns(vec!["amount_cents".to_string()]);
778 let no_cover_resolved = model.resolve_index_specs(&[no_cover]).unwrap();
779 let with_cover_resolved = model.resolve_index_specs(&[with_cover]).unwrap();
780
781 assert!(!plan.index_covers_required_non_pk(&no_cover_resolved[0]));
782 assert!(plan.index_covers_required_non_pk(&with_cover_resolved[0]));
783 }
784
785 #[test]
786 fn choose_index_plan_prefers_longer_prefix() {
787 let (model, specs) = test_model();
788 let region_idx = *model.columns_by_name.get("region").unwrap();
789 let customer_idx = *model.columns_by_name.get("customer_id").unwrap();
790 let mut predicate = QueryPredicate::default();
791 predicate.constraints.insert(
792 region_idx,
793 PredicateConstraint::StringEq("us-east".to_string()),
794 );
795 predicate.constraints.insert(
796 customer_idx,
797 PredicateConstraint::IntRange {
798 min: Some(10),
799 max: Some(20),
800 },
801 );
802 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &predicate);
803 let plan = predicate
804 .choose_index_plan(&model, &specs, &access_plan)
805 .expect("plan")
806 .expect("exists");
807 assert_eq!(plan.spec_idx, 0);
808 assert_eq!(plan.constrained_prefix_len, 2);
809 }
810
811 #[test]
812 fn choose_index_plan_prefers_covering_index_when_prefix_strength_ties() {
813 let config = KvTableConfig::new(
814 0,
815 vec![
816 TableColumnConfig::new("id", DataType::Int64, false),
817 TableColumnConfig::new("status", DataType::Utf8, false),
818 TableColumnConfig::new("amount_cents", DataType::Int64, false),
819 ],
820 vec!["id".to_string()],
821 vec![
822 IndexSpec::new("status_plain", vec!["status".to_string()]).expect("valid"),
823 IndexSpec::new("status_covering", vec!["status".to_string()])
824 .expect("valid")
825 .with_cover_columns(vec!["amount_cents".to_string()]),
826 ],
827 )
828 .expect("config");
829 let model = TableModel::from_config(&config).expect("model");
830 let specs = model
831 .resolve_index_specs(&config.index_specs)
832 .expect("specs");
833 let status_idx = *model.columns_by_name.get("status").unwrap();
834 let amount_idx = *model.columns_by_name.get("amount_cents").unwrap();
835 let mut predicate = QueryPredicate::default();
836 predicate.constraints.insert(
837 status_idx,
838 PredicateConstraint::StringEq("open".to_string()),
839 );
840 predicate.constraints.insert(
841 amount_idx,
842 PredicateConstraint::IntRange {
843 min: Some(10),
844 max: None,
845 },
846 );
847
848 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &predicate);
849 let plan = predicate
850 .choose_index_plan(&model, &specs, &access_plan)
851 .expect("plan")
852 .expect("exists");
853 assert_eq!(specs[plan.spec_idx].name, "status_covering");
854 }
855
856 #[test]
857 fn choose_index_plan_prefers_zorder_for_multi_column_box_constraints() {
858 let (model, specs) = zorder_test_model();
859 let x_idx = *model.columns_by_name.get("x").unwrap();
860 let y_idx = *model.columns_by_name.get("y").unwrap();
861 let mut predicate = QueryPredicate::default();
862 predicate.constraints.insert(
863 x_idx,
864 PredicateConstraint::IntRange {
865 min: Some(1),
866 max: Some(2),
867 },
868 );
869 predicate.constraints.insert(
870 y_idx,
871 PredicateConstraint::IntRange {
872 min: Some(1),
873 max: Some(2),
874 },
875 );
876
877 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &predicate);
878 let plan = predicate
879 .choose_index_plan(&model, &specs, &access_plan)
880 .expect("plan")
881 .expect("exists");
882 assert_eq!(specs[plan.spec_idx].name, "xy_z");
883 assert_eq!(specs[plan.spec_idx].layout, IndexLayout::ZOrder);
884 assert_eq!(plan.constrained_column_count, 2);
885 }
886
887 #[test]
888 fn secondary_index_key_round_trip() {
889 let (model, specs) = test_model();
890 let row = KvRow {
891 values: vec![
892 CellValue::Utf8("us-east".to_string()),
893 CellValue::Int64(42),
894 CellValue::Int64(9001),
895 CellValue::Int64(1500),
896 CellValue::Utf8("open".to_string()),
897 ],
898 };
899 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
900 .expect("encode");
901 let decoded = decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key)
902 .expect("decode");
903 let region_idx = *model.columns_by_name.get("region").unwrap();
904 let customer_idx = *model.columns_by_name.get("customer_id").unwrap();
905 assert!(matches!(
906 decoded.values.get(®ion_idx),
907 Some(CellValue::Utf8(v)) if v == "us-east"
908 ));
909 assert!(matches!(
910 decoded.values.get(&customer_idx),
911 Some(CellValue::Int64(v)) if *v == 42
912 ));
913 assert!(matches!(
914 &decoded.primary_key_values[0],
915 CellValue::Int64(9001)
916 ));
917 let expected_pk = encode_primary_key_from_row(model.table_prefix, &row, &model)
918 .expect("primary key should encode");
919 assert_eq!(decoded.primary_key, expected_pk);
920 }
921
922 #[test]
923 fn zorder_secondary_index_key_round_trip() {
924 let (model, specs) = zorder_test_model();
925 let row = KvRow {
926 values: vec![
927 CellValue::Int64(2),
928 CellValue::Int64(1),
929 CellValue::Int64(42),
930 CellValue::Int64(900),
931 ],
932 };
933 let key = encode_secondary_index_key(model.table_prefix, &specs[1], &model, &row)
934 .expect("encode");
935 let decoded = decode_secondary_index_key(model.table_prefix, &specs[1], &model, &key)
936 .expect("decode");
937 let x_idx = *model.columns_by_name.get("x").unwrap();
938 let y_idx = *model.columns_by_name.get("y").unwrap();
939 assert!(matches!(
940 decoded.values.get(&x_idx),
941 Some(CellValue::Int64(v)) if *v == 2
942 ));
943 assert!(matches!(
944 decoded.values.get(&y_idx),
945 Some(CellValue::Int64(v)) if *v == 1
946 ));
947 assert!(matches!(
948 &decoded.primary_key_values[0],
949 CellValue::Int64(42)
950 ));
951 }
952
953 #[test]
954 fn table_config_supports_non_orders_schema() {
955 let config = KvTableConfig::new(
956 0,
957 vec![
958 TableColumnConfig::new("tenant", DataType::Utf8, false),
959 TableColumnConfig::new("id", DataType::Int64, false),
960 TableColumnConfig::new("score", DataType::Int64, false),
961 ],
962 vec!["id".to_string()],
963 vec![IndexSpec::new(
964 "tenant_score",
965 vec!["tenant".to_string(), "score".to_string()],
966 )
967 .expect("valid")],
968 )
969 .expect("schema agnostic config should be valid");
970 assert_eq!(config.primary_key_columns, vec!["id".to_string()]);
971 assert_eq!(config.columns.len(), 3);
972 }
973
974 #[test]
975 fn table_config_accepts_float64_column() {
976 let config = KvTableConfig::new(
977 0,
978 vec![
979 TableColumnConfig::new("id", DataType::Int64, false),
980 TableColumnConfig::new("price", DataType::Float64, false),
981 ],
982 vec!["id".to_string()],
983 vec![],
984 )
985 .expect("Float64 column should be accepted");
986 assert_eq!(config.columns.len(), 2);
987 }
988
989 #[test]
990 fn table_config_accepts_boolean_column() {
991 let config = KvTableConfig::new(
992 0,
993 vec![
994 TableColumnConfig::new("id", DataType::Int64, false),
995 TableColumnConfig::new("active", DataType::Boolean, false),
996 ],
997 vec!["id".to_string()],
998 vec![],
999 )
1000 .expect("Boolean column should be accepted");
1001 assert_eq!(config.columns.len(), 2);
1002 }
1003
1004 #[test]
1005 fn build_projected_batch_uses_large_utf8_type() {
1006 let config = KvTableConfig::new(
1007 0,
1008 vec![
1009 TableColumnConfig::new("id", DataType::Int64, false),
1010 TableColumnConfig::new("name", DataType::LargeUtf8, false),
1011 ],
1012 vec!["id".to_string()],
1013 vec![],
1014 )
1015 .unwrap();
1016 let model = TableModel::from_config(&config).unwrap();
1017 let rows = vec![KvRow {
1018 values: vec![CellValue::Int64(1), CellValue::Utf8("hello".to_string())],
1019 }];
1020 let batch = build_projected_batch(&rows, &model, &model.schema, &None).unwrap();
1021 assert_eq!(batch.column(1).data_type(), &DataType::LargeUtf8);
1022 let values = batch
1023 .column(1)
1024 .as_any()
1025 .downcast_ref::<LargeStringArray>()
1026 .expect("must build LargeStringArray");
1027 assert_eq!(values.value(0), "hello");
1028 }
1029
1030 #[test]
1031 fn build_projected_batch_uses_utf8_view_type() {
1032 let config = KvTableConfig::new(
1033 0,
1034 vec![
1035 TableColumnConfig::new("id", DataType::Int64, false),
1036 TableColumnConfig::new("name", DataType::Utf8View, false),
1037 ],
1038 vec!["id".to_string()],
1039 vec![],
1040 )
1041 .unwrap();
1042 let model = TableModel::from_config(&config).unwrap();
1043 let rows = vec![KvRow {
1044 values: vec![CellValue::Int64(1), CellValue::Utf8("hello".to_string())],
1045 }];
1046 let batch = build_projected_batch(&rows, &model, &model.schema, &None).unwrap();
1047 assert_eq!(batch.column(1).data_type(), &DataType::Utf8View);
1048 let values = batch
1049 .column(1)
1050 .as_any()
1051 .downcast_ref::<StringViewArray>()
1052 .expect("must build StringViewArray");
1053 assert_eq!(values.value(0), "hello");
1054 }
1055
1056 #[test]
1057 fn build_projected_batch_uses_large_binary_type() {
1058 let config = KvTableConfig::new(
1059 0,
1060 vec![
1061 TableColumnConfig::new("id", DataType::Int64, false),
1062 TableColumnConfig::new("payload", DataType::LargeBinary, false),
1063 ],
1064 vec!["id".to_string()],
1065 vec![],
1066 )
1067 .unwrap();
1068 let model = TableModel::from_config(&config).unwrap();
1069 let rows = vec![KvRow {
1070 values: vec![CellValue::Int64(1), CellValue::Binary(vec![1, 2, 3])],
1071 }];
1072 let batch = build_projected_batch(&rows, &model, &model.schema, &None).unwrap();
1073 assert_eq!(batch.column(1).data_type(), &DataType::LargeBinary);
1074 let values = batch
1075 .column(1)
1076 .as_any()
1077 .downcast_ref::<LargeBinaryArray>()
1078 .expect("must build LargeBinaryArray");
1079 assert_eq!(values.value(0), &[1, 2, 3]);
1080 }
1081
1082 #[test]
1083 fn build_projected_batch_uses_binary_view_type() {
1084 let config = KvTableConfig::new(
1085 0,
1086 vec![
1087 TableColumnConfig::new("id", DataType::Int64, false),
1088 TableColumnConfig::new("payload", DataType::BinaryView, false),
1089 ],
1090 vec!["id".to_string()],
1091 vec![],
1092 )
1093 .unwrap();
1094 let model = TableModel::from_config(&config).unwrap();
1095 let rows = vec![KvRow {
1096 values: vec![CellValue::Int64(1), CellValue::Binary(vec![1, 2, 3])],
1097 }];
1098 let batch = build_projected_batch(&rows, &model, &model.schema, &None).unwrap();
1099 assert_eq!(batch.column(1).data_type(), &DataType::BinaryView);
1100 let values = batch
1101 .column(1)
1102 .as_any()
1103 .downcast_ref::<BinaryViewArray>()
1104 .expect("must build BinaryViewArray");
1105 assert_eq!(values.value(0), &[1, 2, 3]);
1106 }
1107
1108 #[test]
1109 fn binary_column_rejected_in_primary_key() {
1110 let error = KvTableConfig::new(
1111 0,
1112 vec![
1113 TableColumnConfig::new("id", DataType::Binary, false),
1114 TableColumnConfig::new("data", DataType::Utf8, true),
1115 ],
1116 vec!["id".to_string()],
1117 vec![],
1118 )
1119 .expect_err("Binary primary key must be rejected");
1120 assert!(
1121 error.contains("must be Int64, UInt64, Utf8, or FixedSizeBinary"),
1122 "unexpected error: {error}"
1123 );
1124 }
1125
1126 #[test]
1127 fn binary_column_rejected_in_index() {
1128 let result = KvTableConfig::new(
1129 0,
1130 vec![
1131 TableColumnConfig::new("id", DataType::Int64, false),
1132 TableColumnConfig::new("body", DataType::Binary, false),
1133 ],
1134 vec!["id".to_string()],
1135 vec![IndexSpec::new("body_idx", vec!["body".to_string()]).unwrap()],
1136 );
1137 assert!(
1138 result.is_err() || {
1139 let config = result.unwrap();
1140 let model = TableModel::from_config(&config).unwrap();
1141 model.resolve_index_specs(&config.index_specs).is_err()
1142 }
1143 );
1144 }
1145
1146 #[test]
1149 fn binary_filters_are_not_pushed_down() {
1150 let config = KvTableConfig::new(
1151 0,
1152 vec![
1153 TableColumnConfig::new("id", DataType::Int64, false),
1154 TableColumnConfig::new("body", DataType::Binary, false),
1155 ],
1156 vec!["id".to_string()],
1157 vec![],
1158 )
1159 .unwrap();
1160 let model = TableModel::from_config(&config).unwrap();
1161
1162 use datafusion::logical_expr::col;
1163 let filter = col("body").eq(Expr::Literal(
1164 ScalarValue::Binary(Some(vec![0xAB, 0xCD])),
1165 None,
1166 ));
1167
1168 assert!(!QueryPredicate::supports_filter(&filter, &model));
1169 let pred = QueryPredicate::from_filters(&[filter], &model);
1170 assert!(pred.constraints.is_empty());
1171 }
1172
1173 #[test]
1176 fn binary_has_no_kv_aggregate_field_kind() {
1177 assert!(kv_field_kind(ColumnKind::Binary).is_none());
1178 }
1179
1180 #[test]
1183 fn binary_cells_decode_from_archived_non_pk_values() {
1184 let config = KvTableConfig::new(
1185 0,
1186 vec![
1187 TableColumnConfig::new("id", DataType::UInt64, false),
1188 TableColumnConfig::new("body", DataType::Binary, false),
1189 ],
1190 vec!["id".to_string()],
1191 vec![],
1192 )
1193 .unwrap();
1194 let model = TableModel::from_config(&config).unwrap();
1195 let body_idx = *model.columns_by_name.get("body").expect("body column");
1196
1197 let bodies: Vec<Vec<u8>> = vec![vec![], vec![0xAB], vec![0xCD; 300]];
1198 for body in bodies {
1199 let row = KvRow {
1200 values: vec![CellValue::UInt64(1), CellValue::Binary(body.clone())],
1201 };
1202 let encoded = encode_base_row_value(&row, &model).expect("encode row");
1203 let stored = decode_stored_row(&encoded).expect("decode stored row");
1204 let cell = cell_value_from_archived_non_pk(
1205 &model.columns[body_idx],
1206 stored.values[body_idx].as_ref(),
1207 )
1208 .expect("decode archived binary")
1209 .expect("value is not null");
1210 assert!(
1211 matches!(&cell, CellValue::Binary(v) if *v == body),
1212 "wrong cell for {}-byte body: {cell:?}",
1213 body.len()
1214 );
1215 }
1216 }
1217
1218 #[test]
1219 fn f64_ordered_encoding_preserves_order() {
1220 let values = [
1221 f64::NEG_INFINITY,
1222 f64::MIN,
1223 -1000.0,
1224 -1.0,
1225 -0.001,
1226 0.0,
1227 0.001,
1228 1.0,
1229 1000.0,
1230 f64::MAX,
1231 f64::INFINITY,
1232 ];
1233 let encoded: Vec<[u8; 8]> = values.iter().map(|v| encode_f64_ordered(*v)).collect();
1234 for i in 0..encoded.len() - 1 {
1235 assert!(
1236 encoded[i] < encoded[i + 1],
1237 "encode_f64_ordered({}) >= encode_f64_ordered({})",
1238 values[i],
1239 values[i + 1]
1240 );
1241 }
1242 }
1243
1244 #[test]
1245 fn f64_ordered_encoding_round_trip() {
1246 let values = [
1247 f64::MIN,
1248 -42.5,
1249 -0.0,
1250 0.0,
1251 3.125,
1252 f64::MAX,
1253 f64::INFINITY,
1254 f64::NEG_INFINITY,
1255 ];
1256 for v in values {
1257 let encoded = encode_f64_ordered(v);
1258 let decoded = decode_f64_ordered(encoded);
1259 assert!(
1260 v.to_bits() == decoded.to_bits(),
1261 "round-trip failed for {v}: got {decoded}"
1262 );
1263 }
1264 }
1265
1266 fn mixed_model() -> (TableModel, Vec<ResolvedIndexSpec>) {
1267 let config = KvTableConfig::new(
1268 0,
1269 vec![
1270 TableColumnConfig::new("id", DataType::Int64, false),
1271 TableColumnConfig::new("label", DataType::Utf8, false),
1272 TableColumnConfig::new("score", DataType::Float64, false),
1273 TableColumnConfig::new("active", DataType::Boolean, false),
1274 ],
1275 vec!["id".to_string()],
1276 vec![
1277 IndexSpec::new(
1278 "active_score",
1279 vec!["active".to_string(), "score".to_string()],
1280 )
1281 .expect("valid"),
1282 IndexSpec::new("label_idx", vec!["label".to_string()]).expect("valid"),
1283 ],
1284 )
1285 .expect("valid config");
1286 let model = TableModel::from_config(&config).expect("model");
1287 let specs = model
1288 .resolve_index_specs(&config.index_specs)
1289 .expect("specs");
1290 (model, specs)
1291 }
1292
1293 #[test]
1294 fn secondary_index_key_round_trip_with_float64_and_boolean() {
1295 let (model, specs) = mixed_model();
1296 let row = KvRow {
1297 values: vec![
1298 CellValue::Int64(100),
1299 CellValue::Utf8("hello".to_string()),
1300 CellValue::Float64(3.125),
1301 CellValue::Boolean(true),
1302 ],
1303 };
1304 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
1305 .expect("encode");
1306 let decoded = decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key)
1307 .expect("decode");
1308 let active_idx = *model.columns_by_name.get("active").unwrap();
1309 let score_idx = *model.columns_by_name.get("score").unwrap();
1310 assert!(matches!(
1311 decoded.values.get(&active_idx),
1312 Some(CellValue::Boolean(true))
1313 ));
1314 assert!(
1315 matches!(decoded.values.get(&score_idx), Some(CellValue::Float64(v)) if (*v - 3.125).abs() < f64::EPSILON)
1316 );
1317 assert!(matches!(
1318 &decoded.primary_key_values[0],
1319 CellValue::Int64(100)
1320 ));
1321 }
1322
1323 #[test]
1324 fn base_row_round_trip_with_float64_and_boolean() {
1325 let (model, _specs) = mixed_model();
1326 let row = KvRow {
1327 values: vec![
1328 CellValue::Int64(42),
1329 CellValue::Utf8("world".to_string()),
1330 CellValue::Float64(-99.5),
1331 CellValue::Boolean(false),
1332 ],
1333 };
1334 let encoded = encode_base_row_value(&row, &model).expect("encode");
1335 let decoded =
1336 decode_base_row(vec![CellValue::Int64(42)], &encoded, &model).expect("decode");
1337 assert!(matches!(&decoded.values[0], CellValue::Int64(42)));
1338 assert!(matches!(&decoded.values[1], CellValue::Utf8(v) if v == "world"));
1339 assert!(
1340 matches!(&decoded.values[2], CellValue::Float64(v) if (*v - (-99.5)).abs() < f64::EPSILON)
1341 );
1342 assert!(matches!(&decoded.values[3], CellValue::Boolean(false)));
1343 }
1344
1345 #[test]
1346 fn predicate_bool_eq_matches() {
1347 let (model, _specs) = mixed_model();
1348 let active_idx = *model.columns_by_name.get("active").unwrap();
1349 let mut pred = QueryPredicate::default();
1350 pred.constraints
1351 .insert(active_idx, PredicateConstraint::BoolEq(true));
1352 let row_true = KvRow {
1353 values: vec![
1354 CellValue::Int64(1),
1355 CellValue::Utf8("a".to_string()),
1356 CellValue::Float64(1.0),
1357 CellValue::Boolean(true),
1358 ],
1359 };
1360 let row_false = KvRow {
1361 values: vec![
1362 CellValue::Int64(2),
1363 CellValue::Utf8("b".to_string()),
1364 CellValue::Float64(2.0),
1365 CellValue::Boolean(false),
1366 ],
1367 };
1368 assert!(pred.matches_row(&row_true));
1369 assert!(!pred.matches_row(&row_false));
1370 }
1371
1372 #[test]
1373 fn predicate_float_range_matches() {
1374 let (model, _specs) = mixed_model();
1375 let score_idx = *model.columns_by_name.get("score").unwrap();
1376 let mut pred = QueryPredicate::default();
1377 pred.constraints.insert(
1378 score_idx,
1379 PredicateConstraint::FloatRange {
1380 min: Some((2.0, true)),
1381 max: Some((5.0, false)),
1382 },
1383 );
1384 let make_row = |score: f64| KvRow {
1385 values: vec![
1386 CellValue::Int64(1),
1387 CellValue::Utf8("a".to_string()),
1388 CellValue::Float64(score),
1389 CellValue::Boolean(true),
1390 ],
1391 };
1392 assert!(!pred.matches_row(&make_row(1.99)));
1393 assert!(pred.matches_row(&make_row(2.0)));
1394 assert!(pred.matches_row(&make_row(3.5)));
1395 assert!(pred.matches_row(&make_row(4.99)));
1396 assert!(!pred.matches_row(&make_row(5.0)));
1397 assert!(!pred.matches_row(&make_row(5.01)));
1398 }
1399
1400 #[test]
1401 fn index_plan_with_boolean_prefix() {
1402 let (model, specs) = mixed_model();
1403 let active_idx = *model.columns_by_name.get("active").unwrap();
1404 let mut pred = QueryPredicate::default();
1405 pred.constraints
1406 .insert(active_idx, PredicateConstraint::BoolEq(true));
1407 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &pred);
1408 let plan = pred
1409 .choose_index_plan(&model, &specs, &access_plan)
1410 .expect("plan")
1411 .expect("should find index");
1412 assert_eq!(plan.spec_idx, 0);
1413 assert_eq!(plan.constrained_prefix_len, 1);
1414 }
1415
1416 #[test]
1417 fn float_constraint_contradiction() {
1418 let mut lo: Option<(f64, bool)> = None;
1419 let mut hi: Option<(f64, bool)> = None;
1420 let mut contradiction = false;
1421 apply_float_constraint(&mut lo, &mut hi, Operator::Gt, 10.0, &mut contradiction);
1422 assert!(!contradiction);
1423 apply_float_constraint(&mut lo, &mut hi, Operator::Lt, 5.0, &mut contradiction);
1424 assert!(contradiction);
1425 }
1426
1427 #[test]
1428 fn float_constraint_eq_then_range_contradicts() {
1429 let mut lo: Option<(f64, bool)> = None;
1430 let mut hi: Option<(f64, bool)> = None;
1431 let mut contradiction = false;
1432 apply_float_constraint(&mut lo, &mut hi, Operator::Eq, 5.0, &mut contradiction);
1433 assert!(!contradiction);
1434 apply_float_constraint(&mut lo, &mut hi, Operator::Gt, 5.0, &mut contradiction);
1435 assert!(contradiction);
1436 }
1437
1438 #[test]
1439 fn null_predicate_merges_are_order_independent() {
1440 use datafusion::logical_expr::col;
1441 let config = KvTableConfig::new(
1442 0,
1443 vec![
1444 TableColumnConfig::new("id", DataType::Int64, false),
1445 TableColumnConfig::new("label", DataType::Utf8, true),
1446 ],
1447 vec!["id".to_string()],
1448 vec![],
1449 )
1450 .unwrap();
1451 let model = TableModel::from_config(&config).unwrap();
1452 let label_idx = *model.columns_by_name.get("label").unwrap();
1453 let eq_foo = col("label").eq(Expr::Literal(
1454 ScalarValue::Utf8(Some("foo".to_string())),
1455 None,
1456 ));
1457 let is_null = col("label").is_null();
1458 let is_not_null = col("label").is_not_null();
1459 let row_foo = KvRow {
1460 values: vec![CellValue::Int64(1), CellValue::Utf8("foo".to_string())],
1461 };
1462
1463 for filters in [[&eq_foo, &is_not_null], [&is_not_null, &eq_foo]] {
1466 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1467 let pred = QueryPredicate::from_filters(&owned, &model);
1468 assert!(!pred.contradiction);
1469 assert!(matches!(
1470 pred.constraints.get(&label_idx),
1471 Some(PredicateConstraint::StringEq(s)) if s == "foo"
1472 ));
1473 assert!(pred.matches_row(&row_foo));
1474 }
1475
1476 for filters in [[&eq_foo, &is_null], [&is_null, &eq_foo]] {
1478 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1479 assert!(QueryPredicate::from_filters(&owned, &model).contradiction);
1480 }
1481 }
1482
1483 #[test]
1484 fn non_nullable_null_predicates_simplify() {
1485 use datafusion::logical_expr::col;
1486 let config = KvTableConfig::new(
1487 0,
1488 vec![
1489 TableColumnConfig::new("id", DataType::Int64, false),
1490 TableColumnConfig::new("label", DataType::Utf8, true),
1491 ],
1492 vec!["id".to_string()],
1493 vec![],
1494 )
1495 .unwrap();
1496 let model = TableModel::from_config(&config).unwrap();
1497
1498 let is_null = col("id").is_null();
1499 assert!(QueryPredicate::supports_filter(&is_null, &model));
1500 assert!(QueryPredicate::from_filters(&[is_null], &model).contradiction);
1501
1502 let is_not_null = col("id").is_not_null();
1503 assert!(QueryPredicate::supports_filter(&is_not_null, &model));
1504 let pred = QueryPredicate::from_filters(&[is_not_null], &model);
1505 assert!(!pred.contradiction);
1506 assert!(pred.constraints.is_empty());
1507 }
1508
1509 #[test]
1510 fn null_literal_comparisons_are_contradictions() {
1511 use datafusion::logical_expr::col;
1512 let config = KvTableConfig::new(
1513 0,
1514 vec![
1515 TableColumnConfig::new("id", DataType::Int64, false),
1516 TableColumnConfig::new("label", DataType::Utf8, true),
1517 ],
1518 vec!["id".to_string()],
1519 vec![],
1520 )
1521 .unwrap();
1522 let model = TableModel::from_config(&config).unwrap();
1523
1524 for filter in [
1525 col("label").eq(Expr::Literal(ScalarValue::Utf8(None), None)),
1526 col("id").gt(Expr::Literal(ScalarValue::Int64(None), None)),
1527 ] {
1528 assert!(QueryPredicate::supports_filter(&filter, &model));
1529 assert!(QueryPredicate::from_filters(&[filter], &model).contradiction);
1530 }
1531 }
1532
1533 #[test]
1534 fn in_predicate_merges_with_comparisons_order_independent() {
1535 use datafusion::logical_expr::{col, in_list};
1536 let config = KvTableConfig::new(
1537 0,
1538 vec![
1539 TableColumnConfig::new("id", DataType::Int64, false),
1540 TableColumnConfig::new("label", DataType::Utf8, true),
1541 TableColumnConfig::new("score", DataType::Int64, true),
1542 TableColumnConfig::new("version", DataType::UInt64, true),
1543 TableColumnConfig::new("hash", DataType::FixedSizeBinary(2), true),
1544 ],
1545 vec!["id".to_string()],
1546 vec![],
1547 )
1548 .unwrap();
1549 let model = TableModel::from_config(&config).unwrap();
1550 let label_idx = *model.columns_by_name.get("label").unwrap();
1551 let score_idx = *model.columns_by_name.get("score").unwrap();
1552 let version_idx = *model.columns_by_name.get("version").unwrap();
1553 let hash_idx = *model.columns_by_name.get("hash").unwrap();
1554
1555 let label_in = in_list(
1556 col("label"),
1557 vec![
1558 Expr::Literal(ScalarValue::Utf8(Some("foo".to_string())), None),
1559 Expr::Literal(ScalarValue::Utf8(Some("bar".to_string())), None),
1560 ],
1561 false,
1562 );
1563 let label_eq = col("label").eq(Expr::Literal(
1564 ScalarValue::Utf8(Some("foo".to_string())),
1565 None,
1566 ));
1567 for filters in [[&label_in, &label_eq], [&label_eq, &label_in]] {
1568 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1569 let pred = QueryPredicate::from_filters(&owned, &model);
1570 assert!(!pred.contradiction);
1571 assert!(matches!(
1572 pred.constraints.get(&label_idx),
1573 Some(PredicateConstraint::StringEq(v)) if v == "foo"
1574 ));
1575 }
1576
1577 let label_miss = col("label").eq(Expr::Literal(
1578 ScalarValue::Utf8(Some("baz".to_string())),
1579 None,
1580 ));
1581 for filters in [[&label_in, &label_miss], [&label_miss, &label_in]] {
1582 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1583 assert!(QueryPredicate::from_filters(&owned, &model).contradiction);
1584 }
1585
1586 let score_in = in_list(
1587 col("score"),
1588 vec![
1589 Expr::Literal(ScalarValue::Int64(Some(1)), None),
1590 Expr::Literal(ScalarValue::Int64(Some(2)), None),
1591 Expr::Literal(ScalarValue::Int64(Some(3)), None),
1592 ],
1593 false,
1594 );
1595 let score_gt = col("score").gt(Expr::Literal(ScalarValue::Int64(Some(1)), None));
1596 for filters in [[&score_in, &score_gt], [&score_gt, &score_in]] {
1597 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1598 let pred = QueryPredicate::from_filters(&owned, &model);
1599 assert!(!pred.contradiction);
1600 assert!(matches!(
1601 pred.constraints.get(&score_idx),
1602 Some(PredicateConstraint::IntIn(v)) if v == &vec![2, 3]
1603 ));
1604 }
1605
1606 let version_in = in_list(
1607 col("version"),
1608 vec![
1609 Expr::Literal(ScalarValue::UInt64(Some(1)), None),
1610 Expr::Literal(ScalarValue::UInt64(Some(2)), None),
1611 Expr::Literal(ScalarValue::UInt64(Some(3)), None),
1612 ],
1613 false,
1614 );
1615 let version_lt = col("version").lt(Expr::Literal(ScalarValue::UInt64(Some(3)), None));
1616 for filters in [[&version_in, &version_lt], [&version_lt, &version_in]] {
1617 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1618 let pred = QueryPredicate::from_filters(&owned, &model);
1619 assert!(!pred.contradiction);
1620 assert!(matches!(
1621 pred.constraints.get(&version_idx),
1622 Some(PredicateConstraint::UInt64In(v)) if v == &vec![1, 2]
1623 ));
1624 }
1625
1626 let hash_in = in_list(
1627 col("hash"),
1628 vec![
1629 Expr::Literal(
1630 ScalarValue::FixedSizeBinary(2, Some(vec![0xAA, 0xAA])),
1631 None,
1632 ),
1633 Expr::Literal(
1634 ScalarValue::FixedSizeBinary(2, Some(vec![0xBB, 0xBB])),
1635 None,
1636 ),
1637 ],
1638 false,
1639 );
1640 let hash_eq = col("hash").eq(Expr::Literal(
1641 ScalarValue::FixedSizeBinary(2, Some(vec![0xAA, 0xAA])),
1642 None,
1643 ));
1644 for filters in [[&hash_in, &hash_eq], [&hash_eq, &hash_in]] {
1645 let owned: Vec<Expr> = filters.iter().map(|e| (*e).clone()).collect();
1646 let pred = QueryPredicate::from_filters(&owned, &model);
1647 assert!(!pred.contradiction);
1648 assert!(matches!(
1649 pred.constraints.get(&hash_idx),
1650 Some(PredicateConstraint::FixedBinaryEq(v)) if v == &vec![0xAA, 0xAA]
1651 ));
1652 }
1653 }
1654
1655 #[test]
1656 fn empty_and_null_in_lists_are_exact() {
1657 use datafusion::logical_expr::{col, in_list};
1658 let config = KvTableConfig::new(
1659 0,
1660 vec![
1661 TableColumnConfig::new("id", DataType::Int64, false),
1662 TableColumnConfig::new("label", DataType::Utf8, true),
1663 TableColumnConfig::new("score", DataType::Float64, true),
1664 TableColumnConfig::new("version", DataType::UInt64, true),
1665 TableColumnConfig::new("hash", DataType::FixedSizeBinary(2), true),
1666 ],
1667 vec!["id".to_string()],
1668 vec![],
1669 )
1670 .unwrap();
1671 let model = TableModel::from_config(&config).unwrap();
1672 let id_idx = *model.columns_by_name.get("id").unwrap();
1673
1674 for filter in [
1675 in_list(col("id"), vec![], false),
1676 in_list(
1677 col("id"),
1678 vec![Expr::Literal(ScalarValue::Int64(None), None)],
1679 false,
1680 ),
1681 in_list(
1682 col("label"),
1683 vec![Expr::Literal(ScalarValue::Utf8(None), None)],
1684 false,
1685 ),
1686 in_list(
1687 col("score"),
1688 vec![Expr::Literal(ScalarValue::Float64(None), None)],
1689 false,
1690 ),
1691 in_list(
1692 col("version"),
1693 vec![Expr::Literal(ScalarValue::UInt64(None), None)],
1694 false,
1695 ),
1696 in_list(
1697 col("hash"),
1698 vec![Expr::Literal(ScalarValue::FixedSizeBinary(2, None), None)],
1699 false,
1700 ),
1701 ] {
1702 assert!(QueryPredicate::supports_filter(&filter, &model));
1703 assert!(QueryPredicate::from_filters(&[filter], &model).contradiction);
1704 }
1705
1706 let filter = in_list(
1707 col("id"),
1708 vec![
1709 Expr::Literal(ScalarValue::Int64(Some(7)), None),
1710 Expr::Literal(ScalarValue::Int64(None), None),
1711 ],
1712 false,
1713 );
1714 assert!(QueryPredicate::supports_filter(&filter, &model));
1715 let pred = QueryPredicate::from_filters(&[filter], &model);
1716 assert!(!pred.contradiction);
1717 assert!(matches!(
1718 pred.constraints.get(&id_idx),
1719 Some(PredicateConstraint::IntRange {
1720 min: Some(7),
1721 max: Some(7)
1722 })
1723 ));
1724 }
1725
1726 #[test]
1727 fn table_config_accepts_date32_column() {
1728 let config = KvTableConfig::new(
1729 0,
1730 vec![
1731 TableColumnConfig::new("id", DataType::Int64, false),
1732 TableColumnConfig::new("created", DataType::Date32, false),
1733 ],
1734 vec!["id".to_string()],
1735 vec![],
1736 )
1737 .expect("Date32 column should be accepted");
1738 assert_eq!(config.columns.len(), 2);
1739 }
1740
1741 #[test]
1742 fn table_config_accepts_timestamp_column() {
1743 let config = KvTableConfig::new(
1744 0,
1745 vec![
1746 TableColumnConfig::new("id", DataType::Int64, false),
1747 TableColumnConfig::new(
1748 "ts",
1749 DataType::Timestamp(TimeUnit::Microsecond, None),
1750 false,
1751 ),
1752 ],
1753 vec!["id".to_string()],
1754 vec![],
1755 )
1756 .expect("Timestamp column should be accepted");
1757 let schema = config.to_schema();
1758 assert!(matches!(
1759 schema.field(1).data_type(),
1760 DataType::Timestamp(TimeUnit::Microsecond, _)
1761 ));
1762 }
1763
1764 #[test]
1765 fn table_config_normalizes_timestamp_to_microsecond() {
1766 let config = KvTableConfig::new(
1767 0,
1768 vec![
1769 TableColumnConfig::new("id", DataType::Int64, false),
1770 TableColumnConfig::new(
1771 "ts",
1772 DataType::Timestamp(TimeUnit::Nanosecond, None),
1773 false,
1774 ),
1775 ],
1776 vec!["id".to_string()],
1777 vec![],
1778 )
1779 .expect("Nanosecond timestamp should be accepted");
1780 let schema = config.to_schema();
1781 assert!(matches!(
1782 schema.field(1).data_type(),
1783 DataType::Timestamp(TimeUnit::Microsecond, _)
1784 ));
1785 }
1786
1787 #[test]
1788 fn table_config_accepts_decimal128_column() {
1789 let config = KvTableConfig::new(
1790 0,
1791 vec![
1792 TableColumnConfig::new("id", DataType::Int64, false),
1793 TableColumnConfig::new("price", DataType::Decimal128(10, 2), false),
1794 ],
1795 vec!["id".to_string()],
1796 vec![],
1797 )
1798 .expect("Decimal128 column should be accepted");
1799 assert_eq!(config.columns.len(), 2);
1800 }
1801
1802 #[test]
1803 fn table_config_accepts_list_column() {
1804 use datafusion::arrow::datatypes::Field;
1805
1806 let config = KvTableConfig::new(
1807 0,
1808 vec![
1809 TableColumnConfig::new("id", DataType::Int64, false),
1810 TableColumnConfig::new(
1811 "tags",
1812 DataType::List(Arc::new(Field::new("item", DataType::Utf8, false))),
1813 false,
1814 ),
1815 ],
1816 vec!["id".to_string()],
1817 vec![],
1818 )
1819 .expect("List<Utf8> column should be accepted");
1820 assert_eq!(config.columns.len(), 2);
1821 }
1822
1823 #[test]
1824 fn list_column_rejected_in_index() {
1825 use datafusion::arrow::datatypes::Field;
1826
1827 let result = KvTableConfig::new(
1828 0,
1829 vec![
1830 TableColumnConfig::new("id", DataType::Int64, false),
1831 TableColumnConfig::new(
1832 "tags",
1833 DataType::List(Arc::new(Field::new("item", DataType::Utf8, false))),
1834 false,
1835 ),
1836 ],
1837 vec!["id".to_string()],
1838 vec![IndexSpec::new("tags_idx", vec!["tags".to_string()]).unwrap()],
1839 );
1840 assert!(
1841 result.is_err() || {
1842 let config = result.unwrap();
1843 let model = TableModel::from_config(&config).unwrap();
1844 model.resolve_index_specs(&config.index_specs).is_err()
1845 }
1846 );
1847 }
1848
1849 #[test]
1850 fn i32_ordered_encoding_round_trip() {
1851 let values = [i32::MIN, -1000, -1, 0, 1, 1000, i32::MAX];
1852 for v in values {
1853 assert_eq!(decode_i32_ordered(encode_i32_ordered(v)), v);
1854 }
1855 let encoded: Vec<[u8; 4]> = values.iter().map(|v| encode_i32_ordered(*v)).collect();
1856 for i in 0..encoded.len() - 1 {
1857 assert!(encoded[i] < encoded[i + 1]);
1858 }
1859 }
1860
1861 #[test]
1862 fn i128_ordered_encoding_round_trip() {
1863 let values = [i128::MIN, -1, 0, 1, 1234567890123456789, i128::MAX];
1864 for v in values {
1865 assert_eq!(decode_i128_ordered(encode_i128_ordered(v)), v);
1866 }
1867 let encoded: Vec<[u8; 16]> = values.iter().map(|v| encode_i128_ordered(*v)).collect();
1868 for i in 0..encoded.len() - 1 {
1869 assert!(encoded[i] < encoded[i + 1]);
1870 }
1871 }
1872
1873 fn extended_model() -> (TableModel, Vec<ResolvedIndexSpec>) {
1874 let config = KvTableConfig::new(
1875 0,
1876 vec![
1877 TableColumnConfig::new("id", DataType::Int64, false),
1878 TableColumnConfig::new("created", DataType::Date32, false),
1879 TableColumnConfig::new(
1880 "ts",
1881 DataType::Timestamp(TimeUnit::Microsecond, None),
1882 false,
1883 ),
1884 TableColumnConfig::new("price", DataType::Decimal128(10, 2), false),
1885 TableColumnConfig::new("label", DataType::Utf8, false),
1886 ],
1887 vec!["id".to_string()],
1888 vec![
1889 IndexSpec::new(
1890 "date_label",
1891 vec!["created".to_string(), "label".to_string()],
1892 )
1893 .expect("valid"),
1894 IndexSpec::new("price_idx", vec!["price".to_string()]).expect("valid"),
1895 ],
1896 )
1897 .expect("valid config");
1898 let model = TableModel::from_config(&config).expect("model");
1899 let specs = model
1900 .resolve_index_specs(&config.index_specs)
1901 .expect("specs");
1902 (model, specs)
1903 }
1904
1905 #[test]
1906 fn secondary_index_key_round_trip_date32_and_decimal128() {
1907 let (model, specs) = extended_model();
1908 let row = KvRow {
1909 values: vec![
1910 CellValue::Int64(42),
1911 CellValue::Date32(19000),
1912 CellValue::Timestamp(1_700_000_000_000_000),
1913 CellValue::Decimal128(123456),
1914 CellValue::Utf8("hello".to_string()),
1915 ],
1916 };
1917 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
1918 .expect("encode");
1919 let decoded = decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key)
1920 .expect("decode");
1921 let created_idx = *model.columns_by_name.get("created").unwrap();
1922 let label_idx = *model.columns_by_name.get("label").unwrap();
1923 assert!(matches!(
1924 decoded.values.get(&created_idx),
1925 Some(CellValue::Date32(19000))
1926 ));
1927 assert!(matches!(
1928 decoded.values.get(&label_idx),
1929 Some(CellValue::Utf8(v)) if v == "hello"
1930 ));
1931 assert!(matches!(
1932 &decoded.primary_key_values[0],
1933 CellValue::Int64(42)
1934 ));
1935
1936 let key2 = encode_secondary_index_key(model.table_prefix, &specs[1], &model, &row)
1937 .expect("encode");
1938 let decoded2 = decode_secondary_index_key(model.table_prefix, &specs[1], &model, &key2)
1939 .expect("decode");
1940 let price_idx = *model.columns_by_name.get("price").unwrap();
1941 assert!(matches!(
1942 decoded2.values.get(&price_idx),
1943 Some(CellValue::Decimal128(123456))
1944 ));
1945 assert!(matches!(
1946 &decoded2.primary_key_values[0],
1947 CellValue::Int64(42)
1948 ));
1949 }
1950
1951 #[test]
1952 fn base_row_round_trip_with_date32_timestamp_decimal128() {
1953 let (model, _specs) = extended_model();
1954 let row = KvRow {
1955 values: vec![
1956 CellValue::Int64(7),
1957 CellValue::Date32(19500),
1958 CellValue::Timestamp(1_700_000_000_000_000),
1959 CellValue::Decimal128(-9876543),
1960 CellValue::Utf8("world".to_string()),
1961 ],
1962 };
1963 let encoded = encode_base_row_value(&row, &model).expect("encode");
1964 let decoded = decode_base_row(vec![CellValue::Int64(7)], &encoded, &model).expect("decode");
1965 assert!(matches!(&decoded.values[0], CellValue::Int64(7)));
1966 assert!(matches!(&decoded.values[1], CellValue::Date32(19500)));
1967 assert!(matches!(
1968 &decoded.values[2],
1969 CellValue::Timestamp(1_700_000_000_000_000)
1970 ));
1971 assert!(matches!(
1972 &decoded.values[3],
1973 CellValue::Decimal128(-9876543)
1974 ));
1975 assert!(matches!(&decoded.values[4], CellValue::Utf8(v) if v == "world"));
1976 }
1977
1978 #[test]
1979 fn base_row_round_trip_with_list() {
1980 use datafusion::arrow::datatypes::Field;
1981
1982 let config = KvTableConfig::new(
1983 0,
1984 vec![
1985 TableColumnConfig::new("id", DataType::Int64, false),
1986 TableColumnConfig::new(
1987 "tags",
1988 DataType::List(Arc::new(Field::new("item", DataType::Utf8, false))),
1989 false,
1990 ),
1991 TableColumnConfig::new(
1992 "scores",
1993 DataType::List(Arc::new(Field::new("item", DataType::Int64, false))),
1994 false,
1995 ),
1996 ],
1997 vec!["id".to_string()],
1998 vec![],
1999 )
2000 .expect("valid");
2001 let model = TableModel::from_config(&config).expect("model");
2002 let row = KvRow {
2003 values: vec![
2004 CellValue::Int64(1),
2005 CellValue::List(vec![
2006 CellValue::Utf8("a".to_string()),
2007 CellValue::Utf8("b".to_string()),
2008 ]),
2009 CellValue::List(vec![CellValue::Int64(10), CellValue::Int64(20)]),
2010 ],
2011 };
2012 let encoded = encode_base_row_value(&row, &model).expect("encode");
2013 let decoded = decode_base_row(vec![CellValue::Int64(1)], &encoded, &model).expect("decode");
2014 assert!(matches!(&decoded.values[0], CellValue::Int64(1)));
2015 match &decoded.values[1] {
2016 CellValue::List(items) => {
2017 assert_eq!(items.len(), 2);
2018 assert!(matches!(&items[0], CellValue::Utf8(v) if v == "a"));
2019 assert!(matches!(&items[1], CellValue::Utf8(v) if v == "b"));
2020 }
2021 _ => panic!("expected List"),
2022 }
2023 match &decoded.values[2] {
2024 CellValue::List(items) => {
2025 assert_eq!(items.len(), 2);
2026 assert!(matches!(&items[0], CellValue::Int64(10)));
2027 assert!(matches!(&items[1], CellValue::Int64(20)));
2028 }
2029 _ => panic!("expected List"),
2030 }
2031 }
2032
2033 #[test]
2034 fn decimal128_constraint_range() {
2035 let mut min: Option<i128> = None;
2036 let mut max: Option<i128> = None;
2037 let mut contradiction = false;
2038 apply_integral_constraint(&mut min, &mut max, Operator::GtEq, 100, &mut contradiction);
2039 assert!(!contradiction);
2040 apply_integral_constraint(&mut min, &mut max, Operator::LtEq, 200, &mut contradiction);
2041 assert!(!contradiction);
2042 assert_eq!(min, Some(100));
2043 assert_eq!(max, Some(200));
2044 assert!(in_i128_bounds(150, min, max));
2045 assert!(!in_i128_bounds(99, min, max));
2046 assert!(!in_i128_bounds(201, min, max));
2047 }
2048
2049 #[test]
2050 fn date32_index_bound_clamps_on_i64_overflow() {
2051 let config = KvTableConfig::new(
2052 0,
2053 vec![
2054 TableColumnConfig::new("id", DataType::Int64, false),
2055 TableColumnConfig::new("created", DataType::Date32, false),
2056 ],
2057 vec!["id".to_string()],
2058 vec![IndexSpec::new("created_idx", vec!["created".to_string()]).unwrap()],
2059 )
2060 .unwrap();
2061 let model = TableModel::from_config(&config).unwrap();
2062 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2063
2064 let created_idx = *model.columns_by_name.get("created").unwrap();
2065 let mut pred = QueryPredicate::default();
2066 pred.constraints.insert(
2067 created_idx,
2068 PredicateConstraint::IntRange {
2069 min: Some(i32::MAX as i64 + 1),
2070 max: None,
2071 },
2072 );
2073
2074 let start = pred
2075 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2076 .unwrap();
2077 let end = pred
2078 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2079 .unwrap();
2080
2081 assert!(
2082 start <= end,
2083 "lower bound must not exceed upper bound (was wrapping via as i32)"
2084 );
2085
2086 let lower_payload = specs[0].prefix.strip(&start).unwrap();
2087 let encoded_lower: [u8; 4] = lower_payload[..4].try_into().unwrap();
2088 let decoded_lower = decode_i32_ordered(encoded_lower);
2089 assert_eq!(
2090 decoded_lower,
2091 i32::MAX,
2092 "out-of-range i64 must clamp to i32::MAX, not wrap"
2093 );
2094 }
2095
2096 #[test]
2097 fn overlong_utf8_index_prefix_errors_instead_of_panicking() {
2098 let config = KvTableConfig::new(
2099 0,
2100 vec![
2101 TableColumnConfig::new("s", DataType::Utf8, false),
2102 TableColumnConfig::new("b", DataType::FixedSizeBinary(16), false),
2103 TableColumnConfig::new("pk", DataType::Int64, false),
2104 ],
2105 vec!["pk".to_string()],
2106 vec![IndexSpec::new("s_b", vec!["s".to_string(), "b".to_string()]).unwrap()],
2107 )
2108 .unwrap();
2109 let model = TableModel::from_config(&config).unwrap();
2110 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2111
2112 let s_idx = *model.columns_by_name.get("s").unwrap();
2117 let mut pred = QueryPredicate::default();
2118 pred.constraints
2119 .insert(s_idx, PredicateConstraint::StringEq("a".repeat(245)));
2120 assert!(pred
2121 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2122 .is_err());
2123 assert!(pred
2124 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2125 .is_err());
2126 }
2127
2128 #[test]
2129 fn overlong_utf8_index_prefix_with_fixed_pk_errors() {
2130 let config = KvTableConfig::new(
2134 0,
2135 vec![
2136 TableColumnConfig::new("s", DataType::Utf8, false),
2137 TableColumnConfig::new("pk", DataType::FixedSizeBinary(16), false),
2138 ],
2139 vec!["pk".to_string()],
2140 vec![IndexSpec::new("s_idx", vec!["s".to_string()]).unwrap()],
2141 )
2142 .unwrap();
2143 let model = TableModel::from_config(&config).unwrap();
2144 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2145
2146 let s_idx = *model.columns_by_name.get("s").unwrap();
2147 let mut pred = QueryPredicate::default();
2148 pred.constraints
2149 .insert(s_idx, PredicateConstraint::StringEq("a".repeat(245)));
2150 assert!(pred
2151 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2152 .is_err());
2153 }
2154
2155 #[test]
2156 fn short_utf8_index_prefix_still_produces_valid_bounds() {
2157 let config = KvTableConfig::new(
2158 0,
2159 vec![
2160 TableColumnConfig::new("s", DataType::Utf8, false),
2161 TableColumnConfig::new("b", DataType::FixedSizeBinary(16), false),
2162 TableColumnConfig::new("pk", DataType::Int64, false),
2163 ],
2164 vec!["pk".to_string()],
2165 vec![IndexSpec::new("s_b", vec!["s".to_string(), "b".to_string()]).unwrap()],
2166 )
2167 .unwrap();
2168 let model = TableModel::from_config(&config).unwrap();
2169 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2170
2171 let s_idx = *model.columns_by_name.get("s").unwrap();
2174 let mut pred = QueryPredicate::default();
2175 pred.constraints
2176 .insert(s_idx, PredicateConstraint::StringEq("hello".to_string()));
2177 let start = pred
2178 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2179 .expect("lower bound should encode");
2180 let end = pred
2181 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2182 .expect("upper bound should encode");
2183 assert!(start <= end, "lower bound must not exceed upper bound");
2184 }
2185
2186 #[test]
2193 fn index_lower_bound_includes_boundary_row() {
2194 let config = KvTableConfig::new(
2195 0,
2196 vec![
2197 TableColumnConfig::new("s", DataType::Utf8, false),
2198 TableColumnConfig::new("id", DataType::Int64, false),
2199 ],
2200 vec!["id".to_string()],
2201 vec![IndexSpec::new("s_idx", vec!["s".to_string()]).unwrap()],
2202 )
2203 .unwrap();
2204 let model = TableModel::from_config(&config).unwrap();
2205 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2206
2207 let s_idx = *model.columns_by_name.get("s").unwrap();
2209 let id_idx = *model.columns_by_name.get("id").unwrap();
2210 let mut pred = QueryPredicate::default();
2211 pred.constraints
2212 .insert(s_idx, PredicateConstraint::StringEq("a".to_string()));
2213 pred.constraints.insert(
2214 id_idx,
2215 PredicateConstraint::IntRange {
2216 min: Some(5),
2217 max: None,
2218 },
2219 );
2220 let start = pred
2221 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2222 .expect("lower bound");
2223 let end = pred
2224 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2225 .expect("upper bound");
2226
2227 let mut payload = Vec::new();
2230 payload.extend_from_slice(&encode_string_variable("a").unwrap());
2231 payload.extend_from_slice(&encode_i64_ordered(5));
2232 let row_key = specs[0].prefix.encode(&payload).unwrap();
2233
2234 assert!(start <= end);
2235 assert!(
2236 row_key >= start && row_key <= end,
2237 "boundary row must fall inside the index range"
2238 );
2239 }
2240
2241 #[test]
2242 fn long_utf8_equality_produces_usable_index_bounds() {
2243 let config = KvTableConfig::new(
2244 0,
2245 vec![
2246 TableColumnConfig::new("s", DataType::Utf8, false),
2247 TableColumnConfig::new("id", DataType::Int64, false),
2248 ],
2249 vec!["id".to_string()],
2250 vec![IndexSpec::new("s_idx", vec!["s".to_string()]).unwrap()],
2251 )
2252 .unwrap();
2253 let model = TableModel::from_config(&config).unwrap();
2254 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2255
2256 let needle = "a".repeat(30);
2260 let s_idx = *model.columns_by_name.get("s").unwrap();
2261 let mut pred = QueryPredicate::default();
2262 pred.constraints
2263 .insert(s_idx, PredicateConstraint::StringEq(needle.clone()));
2264 let start = pred
2265 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2266 .expect("lower bound");
2267 let end = pred
2268 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2269 .expect("upper bound");
2270
2271 let mut payload = Vec::new();
2272 payload.extend_from_slice(&encode_string_variable(&needle).unwrap());
2273 payload.extend_from_slice(&encode_i64_ordered(7));
2274 let row_key = specs[0].prefix.encode(&payload).unwrap();
2275
2276 assert!(start <= end);
2277 assert!(row_key >= start && row_key <= end);
2278 }
2279
2280 #[test]
2281 fn index_lower_bound_includes_empty_utf8_pk_row() {
2282 let config = KvTableConfig::new(
2283 0,
2284 vec![
2285 TableColumnConfig::new("a", DataType::Int64, false),
2286 TableColumnConfig::new("k", DataType::Utf8, false),
2287 ],
2288 vec!["k".to_string()],
2289 vec![IndexSpec::new("a_idx", vec!["a".to_string()]).unwrap()],
2290 )
2291 .unwrap();
2292 let model = TableModel::from_config(&config).unwrap();
2293 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2294
2295 let a_idx = *model.columns_by_name.get("a").unwrap();
2298 let mut pred = QueryPredicate::default();
2299 pred.constraints.insert(
2300 a_idx,
2301 PredicateConstraint::IntRange {
2302 min: Some(5),
2303 max: Some(5),
2304 },
2305 );
2306 let start = pred
2307 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2308 .expect("lower bound");
2309 let end = pred
2310 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2311 .expect("upper bound");
2312
2313 let mut payload = Vec::new();
2314 payload.extend_from_slice(&encode_i64_ordered(5));
2315 payload.extend_from_slice(&encode_string_variable("").unwrap());
2316 let row_key = specs[0].prefix.encode(&payload).unwrap();
2317
2318 assert!(start <= end);
2319 assert!(row_key >= start && row_key <= end);
2320 }
2321
2322 #[test]
2323 fn zorder_index_range_includes_short_utf8_pk_rows() {
2324 let config = KvTableConfig::new(
2329 0,
2330 vec![
2331 TableColumnConfig::new("name", DataType::Utf8, false),
2332 TableColumnConfig::new("x", DataType::Int64, false),
2333 TableColumnConfig::new("y", DataType::Int64, false),
2334 TableColumnConfig::new("value", DataType::Int64, false),
2335 ],
2336 vec!["name".to_string()],
2337 vec![
2338 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
2339 .expect("valid")
2340 .with_cover_columns(vec!["value".to_string()]),
2341 ],
2342 )
2343 .expect("valid config");
2344 let model = TableModel::from_config(&config).expect("model");
2345 let specs = model
2346 .resolve_index_specs(&config.index_specs)
2347 .expect("specs");
2348 let x_idx = *model.columns_by_name.get("x").unwrap();
2349 let y_idx = *model.columns_by_name.get("y").unwrap();
2350
2351 let mut pred = QueryPredicate::default();
2353 pred.constraints.insert(
2354 x_idx,
2355 PredicateConstraint::IntRange {
2356 min: Some(5),
2357 max: Some(5),
2358 },
2359 );
2360 pred.constraints.insert(
2361 y_idx,
2362 PredicateConstraint::IntRange {
2363 min: Some(7),
2364 max: Some(7),
2365 },
2366 );
2367 let ranges = pred
2368 .expand_zorder_index_ranges(model.table_prefix, &model, &specs[0])
2369 .expect("ranges");
2370 assert_eq!(ranges.len(), 1);
2371
2372 for name in ["", "bob"] {
2375 let row = KvRow {
2376 values: vec![
2377 CellValue::Utf8(name.to_string()),
2378 CellValue::Int64(5),
2379 CellValue::Int64(7),
2380 CellValue::Int64(100),
2381 ],
2382 };
2383 let stored = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2384 .expect("stored");
2385 assert!(
2386 ranges[0].start <= stored && stored <= ranges[0].end,
2387 "zorder range for x=5,y=7 must include stored entry with pk '{name}'"
2388 );
2389 }
2390 }
2391
2392 #[test]
2393 fn utf8_pk_behind_lex_index_range_includes_short_pk_rows() {
2394 let config = KvTableConfig::new(
2399 0,
2400 vec![
2401 TableColumnConfig::new("name", DataType::Utf8, false),
2402 TableColumnConfig::new("age", DataType::Int64, false),
2403 ],
2404 vec!["name".to_string()],
2405 vec![IndexSpec::new("age_idx", vec!["age".to_string()]).unwrap()],
2406 )
2407 .unwrap();
2408 let model = TableModel::from_config(&config).unwrap();
2409 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2410 let age_idx = *model.columns_by_name.get("age").unwrap();
2411
2412 let mut pred = QueryPredicate::default();
2413 pred.constraints.insert(
2414 age_idx,
2415 PredicateConstraint::IntRange {
2416 min: Some(5),
2417 max: Some(5),
2418 },
2419 );
2420 let start = pred
2421 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2422 .unwrap();
2423 let end = pred
2424 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2425 .unwrap();
2426
2427 for name in ["", "bob"] {
2428 let row = KvRow {
2429 values: vec![CellValue::Utf8(name.to_string()), CellValue::Int64(5)],
2430 };
2431 let stored = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2432 .expect("stored index key encodes");
2433 assert!(
2434 start <= stored && stored <= end,
2435 "index range for age=5 must include stored entry with pk '{name}'"
2436 );
2437 }
2438 }
2439
2440 #[test]
2441 fn multi_column_index_utf8_prefix_range_includes_min_suffix_rows() {
2442 let config = KvTableConfig::new(
2446 0,
2447 vec![
2448 TableColumnConfig::new("id", DataType::Int64, false),
2449 TableColumnConfig::new("a", DataType::Utf8, false),
2450 TableColumnConfig::new("b", DataType::Int64, false),
2451 ],
2452 vec!["id".to_string()],
2453 vec![IndexSpec::new("ab_idx", vec!["a".to_string(), "b".to_string()]).unwrap()],
2454 )
2455 .unwrap();
2456 let model = TableModel::from_config(&config).unwrap();
2457 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2458 let a_idx = *model.columns_by_name.get("a").unwrap();
2459
2460 let mut pred = QueryPredicate::default();
2461 pred.constraints
2462 .insert(a_idx, PredicateConstraint::StringEq("x".to_string()));
2463 let start = pred
2464 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2465 .unwrap();
2466 let end = pred
2467 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2468 .unwrap();
2469
2470 for (b, id) in [(i64::MIN, i64::MIN), (0, 0), (i64::MAX, i64::MAX)] {
2471 let row = KvRow {
2472 values: vec![
2473 CellValue::Int64(id),
2474 CellValue::Utf8("x".to_string()),
2475 CellValue::Int64(b),
2476 ],
2477 };
2478 let stored = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2479 .expect("stored index key encodes");
2480 assert!(
2481 start <= stored && stored <= end,
2482 "index range for a='x' must include stored entry with b={b}, id={id}"
2483 );
2484 }
2485
2486 let row = KvRow {
2487 values: vec![
2488 CellValue::Int64(0),
2489 CellValue::Utf8("y".to_string()),
2490 CellValue::Int64(0),
2491 ],
2492 };
2493 let excluded = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2494 .expect("stored index key encodes");
2495 assert!(
2496 excluded > end,
2497 "index range for a='x' must exclude entries for a='y'"
2498 );
2499 }
2500
2501 #[test]
2502 fn variable_width_column_rejected_in_zorder_index() {
2503 let config = KvTableConfig::new(
2508 0,
2509 vec![
2510 TableColumnConfig::new("id", DataType::Int64, false),
2511 TableColumnConfig::new("x", DataType::Int64, false),
2512 TableColumnConfig::new("region", DataType::Utf8, false),
2513 ],
2514 vec!["id".to_string()],
2515 vec![IndexSpec::z_order("xr_z", vec!["x".to_string(), "region".to_string()]).unwrap()],
2516 )
2517 .unwrap();
2518 let model = TableModel::from_config(&config).unwrap();
2519 let result = model.resolve_index_specs(&config.index_specs);
2520 assert!(result.is_err());
2521 assert!(result.unwrap_err().contains("z-order key column"));
2522
2523 let lex_config = KvTableConfig::new(
2525 0,
2526 vec![
2527 TableColumnConfig::new("id", DataType::Int64, false),
2528 TableColumnConfig::new("x", DataType::Int64, false),
2529 TableColumnConfig::new("region", DataType::Utf8, false),
2530 ],
2531 vec!["id".to_string()],
2532 vec![IndexSpec::new("r_idx", vec!["region".to_string()]).unwrap()],
2533 )
2534 .unwrap();
2535 let lex_model = TableModel::from_config(&lex_config).unwrap();
2536 assert!(lex_model
2537 .resolve_index_specs(&lex_config.index_specs)
2538 .is_ok());
2539 }
2540
2541 #[test]
2542 fn utf8_pk_point_query_range_includes_short_string_row() {
2543 let config = KvTableConfig::new(
2548 0,
2549 vec![
2550 TableColumnConfig::new("name", DataType::Utf8, false),
2551 TableColumnConfig::new("age", DataType::Int64, false),
2552 ],
2553 vec!["name".to_string()],
2554 vec![],
2555 )
2556 .unwrap();
2557 let model = TableModel::from_config(&config).unwrap();
2558 let name_idx = *model.columns_by_name.get("name").unwrap();
2559
2560 for name in [
2561 "alice",
2562 "",
2563 "a",
2564 "exactly15chars!",
2565 "longer-than-sixteen-bytes",
2566 ] {
2567 let value = CellValue::Utf8(name.to_string());
2568 let stored = encode_primary_key(0, &[&value], &model).expect("stored key encodes");
2569
2570 let mut pred = QueryPredicate::default();
2571 pred.constraints
2572 .insert(name_idx, PredicateConstraint::StringEq(name.to_string()));
2573 let ranges = pred
2574 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
2575 .unwrap();
2576 assert_eq!(
2577 ranges.len(),
2578 1,
2579 "point query on '{name}' must yield one range"
2580 );
2581 assert!(
2582 ranges[0].start <= stored,
2583 "lower bound must not exceed stored key for '{name}'"
2584 );
2585 assert!(
2586 stored <= ranges[0].end,
2587 "stored key must not exceed upper bound for '{name}'"
2588 );
2589 assert_eq!(
2590 ranges[0].start, stored,
2591 "point lower bound must equal the stored key for '{name}'"
2592 );
2593 }
2594
2595 let mut pred = QueryPredicate::default();
2597 pred.constraints
2598 .insert(name_idx, PredicateConstraint::StringEq("al".to_string()));
2599 let ranges = pred
2600 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
2601 .unwrap();
2602 let alice = CellValue::Utf8("alice".to_string());
2603 let alice_key = encode_primary_key(0, &[&alice], &model).expect("stored key encodes");
2604 assert!(
2605 alice_key > ranges[0].end,
2606 "range for 'al' must exclude the stored key for 'alice'"
2607 );
2608 }
2609
2610 #[test]
2611 fn utf8_pk_prefix_query_range_includes_composite_rows() {
2612 let config = KvTableConfig::new(
2616 0,
2617 vec![
2618 TableColumnConfig::new("name", DataType::Utf8, false),
2619 TableColumnConfig::new("id", DataType::Int64, false),
2620 ],
2621 vec!["name".to_string(), "id".to_string()],
2622 vec![],
2623 )
2624 .unwrap();
2625 let model = TableModel::from_config(&config).unwrap();
2626 let name_idx = *model.columns_by_name.get("name").unwrap();
2627
2628 let mut pred = QueryPredicate::default();
2629 pred.constraints
2630 .insert(name_idx, PredicateConstraint::StringEq("al".to_string()));
2631 let ranges = pred
2632 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
2633 .unwrap();
2634 assert_eq!(ranges.len(), 1);
2635
2636 for id in [i64::MIN, 0, 42, i64::MAX] {
2637 let name = CellValue::Utf8("al".to_string());
2638 let id_value = CellValue::Int64(id);
2639 let stored =
2640 encode_primary_key(0, &[&name, &id_value], &model).expect("stored key encodes");
2641 assert!(
2642 ranges[0].start <= stored && stored <= ranges[0].end,
2643 "range for name='al' must include stored key with id={id}"
2644 );
2645 }
2646
2647 let other = CellValue::Utf8("alx".to_string());
2648 let id_value = CellValue::Int64(0);
2649 let excluded =
2650 encode_primary_key(0, &[&other, &id_value], &model).expect("stored key encodes");
2651 assert!(
2652 excluded > ranges[0].end,
2653 "range for name='al' must exclude rows with name='alx'"
2654 );
2655 }
2656
2657 #[test]
2658 fn utf8_index_point_query_range_includes_min_pk_row() {
2659 let config = KvTableConfig::new(
2665 0,
2666 vec![
2667 TableColumnConfig::new("id", DataType::Int64, false),
2668 TableColumnConfig::new("region", DataType::Utf8, false),
2669 ],
2670 vec!["id".to_string()],
2671 vec![IndexSpec::new("region_idx", vec!["region".to_string()]).unwrap()],
2672 )
2673 .unwrap();
2674 let model = TableModel::from_config(&config).unwrap();
2675 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2676 let region_idx = *model.columns_by_name.get("region").unwrap();
2677
2678 let mut pred = QueryPredicate::default();
2679 pred.constraints
2680 .insert(region_idx, PredicateConstraint::StringEq("us".to_string()));
2681 let start = pred
2682 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, false)
2683 .unwrap();
2684 let end = pred
2685 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 1, true)
2686 .unwrap();
2687
2688 for id in [i64::MIN, 0, i64::MAX] {
2689 let row = KvRow {
2690 values: vec![CellValue::Int64(id), CellValue::Utf8("us".to_string())],
2691 };
2692 let stored = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2693 .expect("stored index key encodes");
2694 assert!(
2695 start <= stored && stored <= end,
2696 "index range for region='us' must include stored entry with id={id}"
2697 );
2698 }
2699
2700 let row = KvRow {
2701 values: vec![CellValue::Int64(0), CellValue::Utf8("ut".to_string())],
2702 };
2703 let excluded = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
2704 .expect("stored index key encodes");
2705 assert!(
2706 excluded > end,
2707 "index range for region='us' must exclude entries for region='ut'"
2708 );
2709 }
2710
2711 #[tokio::test]
2712 async fn utf8_pk_point_query_returns_row() {
2713 let state = MockState {
2717 kv: Arc::new(Mutex::new(BTreeMap::new())),
2718 range_calls: Arc::new(AtomicUsize::new(0)),
2719 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
2720 sequence_number: Arc::new(AtomicU64::new(0)),
2721 };
2722 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
2723 let client = StoreClient::new(&base_url);
2724
2725 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
2726 .table(
2727 "users",
2728 vec![
2729 TableColumnConfig::new("name", DataType::Utf8, false),
2730 TableColumnConfig::new("age", DataType::Int64, false),
2731 ],
2732 vec!["name".to_string()],
2733 vec![],
2734 )
2735 .expect("schema");
2736 let mut writer = schema.batch_writer();
2737 for (name, age) in [("alice", 30i64), ("bob", 25), ("", 99)] {
2738 writer
2739 .insert(
2740 "users",
2741 vec![CellValue::Utf8(name.to_string()), CellValue::Int64(age)],
2742 )
2743 .expect("row");
2744 }
2745 writer.flush().await.expect("flush");
2746
2747 let ctx = session_context();
2748 schema.register_all(&ctx).expect("register");
2749
2750 for (name, expected_age) in [("alice", 30i64), ("bob", 25), ("", 99)] {
2751 let df = ctx
2752 .sql(&format!("SELECT age FROM users WHERE name = '{name}'"))
2753 .await
2754 .expect("plan");
2755 let batches = df.collect().await.expect("collect");
2756 let ages: Vec<i64> = batches
2757 .iter()
2758 .flat_map(|b| {
2759 b.column(0)
2760 .as_any()
2761 .downcast_ref::<datafusion::arrow::array::Int64Array>()
2762 .unwrap()
2763 .iter()
2764 .map(|v| v.unwrap())
2765 })
2766 .collect();
2767 assert_eq!(
2768 ages,
2769 vec![expected_age],
2770 "point query on name='{name}' must return exactly its row"
2771 );
2772 }
2773
2774 let df = ctx
2775 .sql("SELECT age FROM users WHERE name = 'al'")
2776 .await
2777 .expect("plan");
2778 let batches = df.collect().await.expect("collect");
2779 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
2780 assert_eq!(total, 0, "prefix of a stored name must match nothing");
2781
2782 let _ = shutdown_tx.send(());
2783 }
2784
2785 #[tokio::test]
2786 async fn utf8_index_point_query_returns_min_pk_row() {
2787 let state = MockState {
2792 kv: Arc::new(Mutex::new(BTreeMap::new())),
2793 range_calls: Arc::new(AtomicUsize::new(0)),
2794 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
2795 sequence_number: Arc::new(AtomicU64::new(0)),
2796 };
2797 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
2798 let client = StoreClient::new(&base_url);
2799
2800 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
2801 .table(
2802 "orders_nc",
2803 vec![
2804 TableColumnConfig::new("id", DataType::Int64, false),
2805 TableColumnConfig::new("region", DataType::Utf8, false),
2806 TableColumnConfig::new("amount", DataType::Int64, false),
2807 ],
2808 vec!["id".to_string()],
2809 vec![IndexSpec::new("region_idx", vec!["region".to_string()]).expect("valid")],
2810 )
2811 .expect("schema")
2812 .table(
2813 "orders_cov",
2814 vec![
2815 TableColumnConfig::new("id", DataType::Int64, false),
2816 TableColumnConfig::new("region", DataType::Utf8, false),
2817 TableColumnConfig::new("amount", DataType::Int64, false),
2818 ],
2819 vec!["id".to_string()],
2820 vec![IndexSpec::new("region_idx", vec!["region".to_string()])
2821 .expect("valid")
2822 .with_cover_columns(vec!["amount".to_string()])],
2823 )
2824 .expect("schema");
2825 let mut writer = schema.batch_writer();
2826 for table in ["orders_nc", "orders_cov"] {
2827 writer
2828 .insert(
2829 table,
2830 vec![
2831 CellValue::Int64(i64::MIN),
2832 CellValue::Utf8("us".to_string()),
2833 CellValue::Int64(7),
2834 ],
2835 )
2836 .expect("row");
2837 writer
2838 .insert(
2839 table,
2840 vec![
2841 CellValue::Int64(1),
2842 CellValue::Utf8("eu".to_string()),
2843 CellValue::Int64(8),
2844 ],
2845 )
2846 .expect("row");
2847 }
2848 writer.flush().await.expect("flush");
2849
2850 let ctx = session_context();
2851 schema.register_all(&ctx).expect("register");
2852
2853 for table in ["orders_nc", "orders_cov"] {
2854 let df = ctx
2855 .sql(&format!("SELECT amount FROM {table} WHERE region = 'us'"))
2856 .await
2857 .expect("plan");
2858 let batches = df.collect().await.expect("collect");
2859 let amounts: Vec<i64> = batches
2860 .iter()
2861 .flat_map(|b| {
2862 b.column(0)
2863 .as_any()
2864 .downcast_ref::<datafusion::arrow::array::Int64Array>()
2865 .unwrap()
2866 .iter()
2867 .map(|v| v.unwrap())
2868 })
2869 .collect();
2870 assert_eq!(
2871 amounts,
2872 vec![7],
2873 "index lookup on {table} must return the id=i64::MIN row"
2874 );
2875 }
2876
2877 let _ = shutdown_tx.send(());
2878 }
2879
2880 #[test]
2881 fn float64_index_bounds_include_infinity() {
2882 let config = KvTableConfig::new(
2883 0,
2884 vec![
2885 TableColumnConfig::new("id", DataType::Int64, false),
2886 TableColumnConfig::new("val", DataType::Float64, false),
2887 ],
2888 vec!["id".to_string()],
2889 vec![IndexSpec::new("val_idx", vec!["val".to_string()]).unwrap()],
2890 )
2891 .unwrap();
2892 let model = TableModel::from_config(&config).unwrap();
2893 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
2894
2895 let pred = QueryPredicate::default();
2896 let start = pred
2897 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 0, false)
2898 .unwrap();
2899 let end = pred
2900 .encode_index_bound_key(model.table_prefix, &model, &specs[0], 0, true)
2901 .unwrap();
2902
2903 let neg_inf_row = KvRow {
2904 values: vec![CellValue::Int64(1), CellValue::Float64(f64::NEG_INFINITY)],
2905 };
2906 let pos_inf_row = KvRow {
2907 values: vec![CellValue::Int64(2), CellValue::Float64(f64::INFINITY)],
2908 };
2909
2910 let neg_inf_key =
2911 encode_secondary_index_key(model.table_prefix, &specs[0], &model, &neg_inf_row)
2912 .unwrap();
2913 let pos_inf_key =
2914 encode_secondary_index_key(model.table_prefix, &specs[0], &model, &pos_inf_row)
2915 .unwrap();
2916
2917 assert!(
2918 neg_inf_key >= start,
2919 "NEG_INFINITY row key must be within scan start bound"
2920 );
2921 assert!(
2922 pos_inf_key <= end,
2923 "INFINITY row key must be within scan end bound"
2924 );
2925 }
2926
2927 #[test]
2928 fn distinct_table_prefixes_produce_non_overlapping_pk_ranges() {
2929 let range_a = primary_key_prefix_range(1);
2930 let range_b = primary_key_prefix_range(2);
2931 assert!(
2932 range_a.end < range_b.start,
2933 "table prefix 1 pk range must be entirely below table prefix 2"
2934 );
2935 }
2936
2937 #[test]
2938 fn distinct_table_prefixes_isolate_primary_keys() {
2939 let model_1 = simple_int64_model(1);
2940 let model_2 = simple_int64_model(2);
2941 let pk = CellValue::Int64(42);
2942 let key_a = encode_primary_key(1, &[&pk], &model_1).expect("pk key encodes");
2943 let key_b = encode_primary_key(2, &[&pk], &model_2).expect("pk key encodes");
2944 assert_ne!(key_a, key_b, "same PK under different prefixes must differ");
2945 assert!(
2946 decode_primary_key(1, &key_a, &model_1).is_some(),
2947 "key_a must decode under prefix 1"
2948 );
2949 assert!(
2950 decode_primary_key(2, &key_a, &model_2).is_none(),
2951 "key_a must NOT decode under prefix 2"
2952 );
2953 assert!(
2954 decode_primary_key(2, &key_b, &model_2).is_some(),
2955 "key_b must decode under prefix 2"
2956 );
2957 }
2958
2959 #[test]
2960 fn distinct_table_prefixes_isolate_secondary_keys() {
2961 let config_a = KvTableConfig::new(
2962 10,
2963 vec![
2964 TableColumnConfig::new("id", DataType::Int64, false),
2965 TableColumnConfig::new("name", DataType::Utf8, false),
2966 ],
2967 vec!["id".to_string()],
2968 vec![IndexSpec::new("name_idx", vec!["name".to_string()]).unwrap()],
2969 )
2970 .unwrap();
2971 let config_b = KvTableConfig::new(
2972 11,
2973 vec![
2974 TableColumnConfig::new("id", DataType::Int64, false),
2975 TableColumnConfig::new("name", DataType::Utf8, false),
2976 ],
2977 vec!["id".to_string()],
2978 vec![IndexSpec::new("name_idx", vec!["name".to_string()]).unwrap()],
2979 )
2980 .unwrap();
2981
2982 let model_a = TableModel::from_config(&config_a).unwrap();
2983 let specs_a = model_a.resolve_index_specs(&config_a.index_specs).unwrap();
2984 let model_b = TableModel::from_config(&config_b).unwrap();
2985 let specs_b = model_b.resolve_index_specs(&config_b.index_specs).unwrap();
2986
2987 let row = KvRow {
2988 values: vec![CellValue::Int64(1), CellValue::Utf8("alice".to_string())],
2989 };
2990 let key_a =
2991 encode_secondary_index_key(model_a.table_prefix, &specs_a[0], &model_a, &row).unwrap();
2992 let key_b =
2993 encode_secondary_index_key(model_b.table_prefix, &specs_b[0], &model_b, &row).unwrap();
2994
2995 assert_ne!(
2996 key_a, key_b,
2997 "same row under different prefixes must differ"
2998 );
2999 assert!(
3000 decode_secondary_index_key(model_a.table_prefix, &specs_a[0], &model_a, &key_a)
3001 .is_some()
3002 );
3003 assert!(
3004 decode_secondary_index_key(model_a.table_prefix, &specs_a[0], &model_a, &key_b)
3005 .is_none(),
3006 "key from table B must not decode under table A's prefix"
3007 );
3008 }
3009
3010 #[test]
3011 fn table_prefix_stored_in_model() {
3012 let config = KvTableConfig::new(
3013 12,
3014 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3015 vec!["id".to_string()],
3016 vec![],
3017 )
3018 .unwrap();
3019 let model = TableModel::from_config(&config).unwrap();
3020 assert_eq!(model.table_prefix, 12);
3021 }
3022
3023 #[test]
3024 fn codec_layout_places_payload_bytes_after_family_prefix() {
3025 let config = KvTableConfig::new(
3026 0,
3027 vec![
3028 TableColumnConfig::new("id", DataType::FixedSizeBinary(16), false),
3029 TableColumnConfig::new("bucket", DataType::FixedSizeBinary(16), false),
3030 ],
3031 vec!["id".to_string()],
3032 vec![IndexSpec::new("bucket_idx", vec!["bucket".to_string()]).unwrap()],
3033 )
3034 .unwrap();
3035 let model = TableModel::from_config(&config).unwrap();
3036 let spec = model
3037 .resolve_index_specs(&config.index_specs)
3038 .unwrap()
3039 .remove(0);
3040
3041 let primary_prefix = primary_key_prefix(model.table_prefix).unwrap();
3042 let index_prefix = secondary_index_prefix(model.table_prefix, spec.id).unwrap();
3043
3044 let mut primary_payload_bytes = HashSet::new();
3045 let mut secondary_payload_bytes = HashSet::new();
3046
3047 for first_byte in 0u8..=255 {
3048 let mut id = vec![0u8; 16];
3049 id[0] = first_byte;
3050 let mut bucket = vec![0u8; 16];
3051 bucket[0] = first_byte;
3052
3053 let pk = CellValue::FixedBinary(id.clone());
3054 let primary = encode_primary_key(model.table_prefix, &[&pk], &model).unwrap();
3055 assert_eq!(
3058 &primary[..FAMILY_PREFIX_LEN],
3059 primary_prefix.as_bytes().as_ref()
3060 );
3061 primary_payload_bytes.insert(primary[FAMILY_PREFIX_LEN]);
3062
3063 let row = KvRow {
3064 values: vec![
3065 CellValue::FixedBinary(id),
3066 CellValue::FixedBinary(bucket.clone()),
3067 ],
3068 };
3069 let index =
3070 encode_secondary_index_key(model.table_prefix, &spec, &model, &row).unwrap();
3071 assert_eq!(
3072 &index[..FAMILY_PREFIX_LEN],
3073 index_prefix.as_bytes().as_ref()
3074 );
3075 secondary_payload_bytes.insert(index[FAMILY_PREFIX_LEN]);
3076 }
3077
3078 assert_eq!(primary_payload_bytes.len(), 256);
3082 assert_eq!(secondary_payload_bytes.len(), 256);
3083 }
3084
3085 #[test]
3086 fn kv_schema_auto_assigns_sequential_prefixes() {
3087 let client = StoreClient::new("http://localhost:10000");
3088 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3089 .table(
3090 "alpha",
3091 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3092 vec!["id".to_string()],
3093 vec![],
3094 )
3095 .unwrap()
3096 .table(
3097 "beta",
3098 vec![
3099 TableColumnConfig::new("id", DataType::Int64, false),
3100 TableColumnConfig::new("name", DataType::Utf8, false),
3101 ],
3102 vec!["id".to_string()],
3103 vec![],
3104 )
3105 .unwrap()
3106 .table(
3107 "gamma",
3108 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3109 vec!["id".to_string()],
3110 vec![],
3111 )
3112 .unwrap();
3113
3114 assert_eq!(schema.table_count(), 3);
3115 }
3116
3117 #[test]
3118 fn kv_schema_allows_max_codec_table_count_and_rejects_overflow() {
3119 let client = StoreClient::new("http://localhost:10000");
3120 let mut schema = KvSchema::new(PrefixedStoreClient::empty(client));
3121 for idx in 0..MAX_TABLES {
3122 schema = schema
3123 .table(
3124 format!("t{idx}"),
3125 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3126 vec!["id".to_string()],
3127 vec![],
3128 )
3129 .expect("tables up to codec capacity should be accepted");
3130 }
3131 assert_eq!(schema.table_count(), MAX_TABLES);
3132
3133 let overflow = schema.table(
3134 "overflow",
3135 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3136 vec!["id".to_string()],
3137 vec![],
3138 );
3139 match overflow {
3140 Ok(_) => panic!("overflow table should be rejected"),
3141 Err(err) => assert!(
3142 err.contains(&format!(
3143 "too many tables for key layout (max {MAX_TABLES})"
3144 )),
3145 "overflow table should be rejected with key-layout error"
3146 ),
3147 }
3148 }
3149
3150 #[test]
3151 fn sequential_prefixes_produce_non_overlapping_pk_ranges() {
3152 let range_a = primary_key_prefix_range(0);
3153 let range_b = primary_key_prefix_range(1);
3154 let range_c = primary_key_prefix_range(2);
3155 assert!(range_a.end < range_b.start);
3156 assert!(range_b.end < range_c.start);
3157 }
3158
3159 #[test]
3160 fn sequential_prefixes_isolate_primary_keys() {
3161 let model_0 = simple_int64_model(0);
3162 let model_1 = simple_int64_model(1);
3163 let pk = CellValue::Int64(42);
3164 let key_a = encode_primary_key(0, &[&pk], &model_0).expect("pk key encodes");
3165 let key_b = encode_primary_key(1, &[&pk], &model_1).expect("pk key encodes");
3166 assert_ne!(key_a, key_b);
3167 assert!(decode_primary_key(0, &key_a, &model_0).is_some());
3168 assert!(decode_primary_key(1, &key_a, &model_1).is_none());
3169 assert!(decode_primary_key(0, &key_b, &model_0).is_none());
3170 assert!(decode_primary_key(1, &key_b, &model_1).is_some());
3171 }
3172
3173 #[test]
3174 fn sequential_prefixes_isolate_secondary_keys() {
3175 let config_a = KvTableConfig::new(
3176 0,
3177 vec![
3178 TableColumnConfig::new("id", DataType::Int64, false),
3179 TableColumnConfig::new("name", DataType::Utf8, false),
3180 ],
3181 vec!["id".to_string()],
3182 vec![IndexSpec::new("name_idx", vec!["name".to_string()]).unwrap()],
3183 )
3184 .unwrap();
3185 let config_b = KvTableConfig::new(
3186 1,
3187 vec![
3188 TableColumnConfig::new("id", DataType::Int64, false),
3189 TableColumnConfig::new("name", DataType::Utf8, false),
3190 ],
3191 vec!["id".to_string()],
3192 vec![IndexSpec::new("name_idx", vec!["name".to_string()]).unwrap()],
3193 )
3194 .unwrap();
3195
3196 let model_a = TableModel::from_config(&config_a).unwrap();
3197 let specs_a = model_a.resolve_index_specs(&config_a.index_specs).unwrap();
3198 let model_b = TableModel::from_config(&config_b).unwrap();
3199 let specs_b = model_b.resolve_index_specs(&config_b.index_specs).unwrap();
3200
3201 let row = KvRow {
3202 values: vec![CellValue::Int64(1), CellValue::Utf8("alice".to_string())],
3203 };
3204 let key_a =
3205 encode_secondary_index_key(model_a.table_prefix, &specs_a[0], &model_a, &row).unwrap();
3206 let key_b =
3207 encode_secondary_index_key(model_b.table_prefix, &specs_b[0], &model_b, &row).unwrap();
3208 assert_ne!(key_a, key_b);
3209 assert!(
3210 decode_secondary_index_key(model_a.table_prefix, &specs_a[0], &model_a, &key_b)
3211 .is_none(),
3212 "key from prefix 1 must not decode under prefix 0"
3213 );
3214 }
3215
3216 #[tokio::test]
3217 async fn kv_schema_register_all_enables_join() {
3218 let ctx = SessionContext::new();
3219 let client = StoreClient::new("http://localhost:10000");
3220
3221 let result = KvSchema::new(PrefixedStoreClient::empty(client))
3222 .table(
3223 "customers",
3224 vec![
3225 TableColumnConfig::new("customer_id", DataType::Int64, false),
3226 TableColumnConfig::new("name", DataType::Utf8, false),
3227 ],
3228 vec!["customer_id".to_string()],
3229 vec![],
3230 )
3231 .unwrap()
3232 .table(
3233 "orders",
3234 vec![
3235 TableColumnConfig::new("order_id", DataType::Int64, false),
3236 TableColumnConfig::new("customer_id", DataType::Int64, false),
3237 TableColumnConfig::new("amount", DataType::Int64, false),
3238 ],
3239 vec!["order_id".to_string()],
3240 vec![IndexSpec::new("cust_idx", vec!["customer_id".to_string()]).unwrap()],
3241 )
3242 .unwrap()
3243 .register_all(&ctx);
3244
3245 assert!(
3246 result.is_ok(),
3247 "register_all must succeed: {:?}",
3248 result.err()
3249 );
3250
3251 let plan = ctx
3252 .sql(
3253 "SELECT c.name, o.order_id, o.amount \
3254 FROM orders o \
3255 JOIN customers c ON o.customer_id = c.customer_id",
3256 )
3257 .await;
3258 assert!(
3259 plan.is_ok(),
3260 "JOIN query must plan successfully: {:?}",
3261 plan.err()
3262 );
3263 }
3264
3265 #[tokio::test]
3266 async fn kv_schema_three_way_join() {
3267 let ctx = session_context();
3268 let client = StoreClient::new("http://localhost:10000");
3269
3270 KvSchema::new(PrefixedStoreClient::empty(client))
3271 .table(
3272 "products",
3273 vec![
3274 TableColumnConfig::new("product_id", DataType::Int64, false),
3275 TableColumnConfig::new("name", DataType::Utf8, false),
3276 TableColumnConfig::new("price", DataType::Int64, false),
3277 ],
3278 vec!["product_id".to_string()],
3279 vec![],
3280 )
3281 .unwrap()
3282 .table(
3283 "line_items",
3284 vec![
3285 TableColumnConfig::new("item_id", DataType::Int64, false),
3286 TableColumnConfig::new("order_id", DataType::Int64, false),
3287 TableColumnConfig::new("product_id", DataType::Int64, false),
3288 TableColumnConfig::new("qty", DataType::Int64, false),
3289 ],
3290 vec!["item_id".to_string()],
3291 vec![
3292 IndexSpec::new("prod_idx", vec!["product_id".to_string()]).unwrap(),
3293 IndexSpec::new("order_idx", vec!["order_id".to_string()]).unwrap(),
3294 ],
3295 )
3296 .unwrap()
3297 .table(
3298 "orders",
3299 vec![
3300 TableColumnConfig::new("order_id", DataType::Int64, false),
3301 TableColumnConfig::new("customer", DataType::Utf8, false),
3302 ],
3303 vec!["order_id".to_string()],
3304 vec![],
3305 )
3306 .unwrap()
3307 .register_all(&ctx)
3308 .unwrap();
3309
3310 let plan = ctx
3311 .sql(
3312 "SELECT o.customer, p.name, li.qty \
3313 FROM line_items li \
3314 JOIN products p ON li.product_id = p.product_id \
3315 JOIN orders o ON li.order_id = o.order_id",
3316 )
3317 .await;
3318 assert!(plan.is_ok(), "three-way JOIN must plan: {:?}", plan.err());
3319 }
3320
3321 #[test]
3322 fn kv_schema_orders_table_convenience() {
3323 let client = StoreClient::new("http://localhost:10000");
3324 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3325 .orders_table(
3326 "my_orders",
3327 vec![IndexSpec::new(
3328 "region_customer",
3329 vec!["region".to_string(), "customer_id".to_string()],
3330 )
3331 .unwrap()],
3332 )
3333 .unwrap();
3334 assert_eq!(schema.table_count(), 1);
3335 }
3336
3337 #[test]
3338 fn nullable_column_accepted_in_config() {
3339 let config = KvTableConfig::new(
3340 0,
3341 vec![
3342 TableColumnConfig::new("id", DataType::Int64, false),
3343 TableColumnConfig::new("name", DataType::Utf8, true),
3344 ],
3345 vec!["id".to_string()],
3346 vec![],
3347 );
3348 assert!(config.is_ok());
3349 }
3350
3351 #[test]
3352 fn nullable_column_rejected_in_index() {
3353 let config = KvTableConfig::new(
3354 0,
3355 vec![
3356 TableColumnConfig::new("id", DataType::Int64, false),
3357 TableColumnConfig::new("name", DataType::Utf8, true),
3358 ],
3359 vec!["id".to_string()],
3360 vec![IndexSpec::new("name_idx", vec!["name".to_string()]).unwrap()],
3361 )
3362 .unwrap();
3363 let model = TableModel::from_config(&config).unwrap();
3364 let result = model.resolve_index_specs(&config.index_specs);
3365 assert!(result.is_err());
3366 assert!(result.unwrap_err().contains("nullable"));
3367 }
3368
3369 #[test]
3370 fn base_row_round_trip_with_null() {
3371 let config = KvTableConfig::new(
3372 0,
3373 vec![
3374 TableColumnConfig::new("id", DataType::Int64, false),
3375 TableColumnConfig::new("label", DataType::Utf8, true),
3376 TableColumnConfig::new("score", DataType::Int64, true),
3377 ],
3378 vec!["id".to_string()],
3379 vec![],
3380 )
3381 .unwrap();
3382 let model = TableModel::from_config(&config).unwrap();
3383 let row = KvRow {
3384 values: vec![CellValue::Int64(1), CellValue::Null, CellValue::Int64(42)],
3385 };
3386 let encoded = encode_base_row_value(&row, &model).unwrap();
3387 let decoded = decode_base_row(vec![CellValue::Int64(1)], &encoded, &model).unwrap();
3388 assert!(matches!(&decoded.values[0], CellValue::Int64(1)));
3389 assert!(matches!(&decoded.values[1], CellValue::Null));
3390 assert!(matches!(&decoded.values[2], CellValue::Int64(42)));
3391 }
3392
3393 #[test]
3394 fn null_does_not_match_equality_constraint() {
3395 assert!(!matches_constraint(
3396 &CellValue::Null,
3397 &PredicateConstraint::StringEq("x".to_string())
3398 ));
3399 assert!(!matches_constraint(
3400 &CellValue::Null,
3401 &PredicateConstraint::IntRange {
3402 min: Some(0),
3403 max: Some(10)
3404 }
3405 ));
3406 }
3407
3408 #[test]
3409 fn is_null_constraint_matches() {
3410 assert!(matches_constraint(
3411 &CellValue::Null,
3412 &PredicateConstraint::IsNull
3413 ));
3414 assert!(!matches_constraint(
3415 &CellValue::Utf8("x".to_string()),
3416 &PredicateConstraint::IsNull
3417 ));
3418 assert!(!matches_constraint(
3419 &CellValue::Null,
3420 &PredicateConstraint::IsNotNull
3421 ));
3422 assert!(matches_constraint(
3423 &CellValue::Int64(5),
3424 &PredicateConstraint::IsNotNull
3425 ));
3426 }
3427
3428 #[test]
3429 fn string_in_constraint_matches() {
3430 let constraint =
3431 PredicateConstraint::StringIn(vec!["us-east".to_string(), "us-west".to_string()]);
3432 assert!(matches_constraint(
3433 &CellValue::Utf8("us-east".to_string()),
3434 &constraint,
3435 ));
3436 assert!(matches_constraint(
3437 &CellValue::Utf8("us-west".to_string()),
3438 &constraint,
3439 ));
3440 assert!(!matches_constraint(
3441 &CellValue::Utf8("eu-central".to_string()),
3442 &constraint,
3443 ));
3444 }
3445
3446 #[test]
3447 fn int_in_constraint_matches() {
3448 let constraint = PredicateConstraint::IntIn(vec![1, 2, 3]);
3449 assert!(matches_constraint(&CellValue::Int64(1), &constraint));
3450 assert!(matches_constraint(&CellValue::Int64(3), &constraint));
3451 assert!(!matches_constraint(&CellValue::Int64(4), &constraint));
3452 }
3453
3454 #[test]
3455 fn oversized_utf8_primary_key_constraint_is_empty_terminal_range() {
3456 let too_large = "a".repeat(exoware_sdk::keys::MAX_KEY_LEN + 1);
3457 match primary_key_range_constraint(
3458 ColumnKind::Utf8,
3459 &PredicateConstraint::StringEq(too_large),
3460 ) {
3461 PrimaryKeyRangeConstraint::Terminal(spans) => assert!(
3462 spans.is_empty(),
3463 "unencodable UTF-8 PK equality should be impossible"
3464 ),
3465 other => panic!("unexpected oversized UTF-8 PK constraint: {other:?}"),
3466 }
3467 }
3468
3469 #[test]
3470 fn in_predicate_generates_multiple_index_ranges() {
3471 let (model, specs) = test_model();
3472 let region_idx = *model.columns_by_name.get("region").unwrap();
3473 let mut pred = QueryPredicate::default();
3474 pred.constraints.insert(
3475 region_idx,
3476 PredicateConstraint::StringIn(vec!["us-east".to_string(), "us-west".to_string()]),
3477 );
3478 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &pred);
3479 let plan = pred
3480 .choose_index_plan(&model, &specs, &access_plan)
3481 .expect("plan")
3482 .expect("should find index");
3483 assert_eq!(plan.ranges.len(), 2);
3484 }
3485
3486 #[test]
3487 fn int_in_generates_multiple_pk_ranges() {
3488 let (model, _specs) = test_model();
3489 let mut pred = QueryPredicate::default();
3490 pred.constraints.insert(
3491 model.primary_key_indices[0],
3492 PredicateConstraint::IntIn(vec![200, 100, 300]),
3493 );
3494 let ranges = pred
3495 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
3496 .unwrap();
3497 assert_eq!(ranges.len(), 3);
3498 let expected_starts = [100, 200, 300]
3499 .into_iter()
3500 .map(|id| {
3501 let value = CellValue::Int64(id);
3502 encode_primary_key_bound(model.table_prefix, &[&value], &model, false)
3503 .expect("primary key lower bound")
3504 })
3505 .collect::<Vec<_>>();
3506 assert_eq!(
3507 ranges
3508 .iter()
3509 .map(|range| range.start.clone())
3510 .collect::<Vec<_>>(),
3511 expected_starts,
3512 "primary_key_ranges should sort terminal IN ranges by encoded start key"
3513 );
3514 }
3515
3516 #[test]
3517 fn duplicate_int_in_values_deduplicated() {
3518 let (model, _specs) = test_model();
3519 let filter = Expr::InList(datafusion::logical_expr::expr::InList {
3521 expr: Box::new(Expr::Column(datafusion::common::Column::new_unqualified(
3522 "order_id",
3523 ))),
3524 list: vec![
3525 Expr::Literal(ScalarValue::Int64(Some(5)), None),
3526 Expr::Literal(ScalarValue::Int64(Some(5)), None),
3527 Expr::Literal(ScalarValue::Int64(Some(10)), None),
3528 ],
3529 negated: false,
3530 });
3531 let pred = QueryPredicate::from_filters(&[filter], &model);
3532 let ranges = pred
3533 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
3534 .unwrap();
3535 assert_eq!(
3536 ranges.len(),
3537 2,
3538 "duplicate IN values must be deduped, producing 2 ranges not 3"
3539 );
3540 }
3541
3542 #[test]
3543 fn duplicate_uint64_in_values_deduplicated() {
3544 let config = KvTableConfig::new(
3545 0,
3546 vec![
3547 TableColumnConfig::new("id", DataType::UInt64, false),
3548 TableColumnConfig::new("name", DataType::Utf8, false),
3549 ],
3550 vec!["id".to_string()],
3551 vec![],
3552 )
3553 .unwrap();
3554 let model = TableModel::from_config(&config).unwrap();
3555 let filter = Expr::InList(datafusion::logical_expr::expr::InList {
3556 expr: Box::new(Expr::Column(datafusion::common::Column::new_unqualified(
3557 "id",
3558 ))),
3559 list: vec![
3560 Expr::Literal(ScalarValue::UInt64(Some(100)), None),
3561 Expr::Literal(ScalarValue::UInt64(Some(100)), None),
3562 Expr::Literal(ScalarValue::UInt64(Some(200)), None),
3563 ],
3564 negated: false,
3565 });
3566 let pred = QueryPredicate::from_filters(&[filter], &model);
3567 let ranges = pred
3568 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
3569 .unwrap();
3570 assert_eq!(
3571 ranges.len(),
3572 2,
3573 "duplicate UInt64 IN values must be deduped"
3574 );
3575 }
3576
3577 #[test]
3578 fn duplicate_fixed_binary_in_values_deduplicated() {
3579 let config = KvTableConfig::new(
3580 0,
3581 vec![
3582 TableColumnConfig::new("hash", DataType::FixedSizeBinary(16), false),
3583 TableColumnConfig::new("val", DataType::Int64, false),
3584 ],
3585 vec!["hash".to_string()],
3586 vec![],
3587 )
3588 .unwrap();
3589 let model = TableModel::from_config(&config).unwrap();
3590 let dup_val = vec![0xAA; 16];
3591 let other_val = vec![0xBB; 16];
3592 let filter = Expr::InList(datafusion::logical_expr::expr::InList {
3593 expr: Box::new(Expr::Column(datafusion::common::Column::new_unqualified(
3594 "hash",
3595 ))),
3596 list: vec![
3597 Expr::Literal(
3598 ScalarValue::FixedSizeBinary(16, Some(dup_val.clone())),
3599 None,
3600 ),
3601 Expr::Literal(ScalarValue::FixedSizeBinary(16, Some(dup_val)), None),
3602 Expr::Literal(ScalarValue::FixedSizeBinary(16, Some(other_val)), None),
3603 ],
3604 negated: false,
3605 });
3606 let pred = QueryPredicate::from_filters(&[filter], &model);
3607 let ranges = pred
3608 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
3609 .unwrap();
3610 assert_eq!(
3611 ranges.len(),
3612 2,
3613 "duplicate FixedBinary IN values must be deduped"
3614 );
3615 }
3616
3617 #[test]
3618 fn composite_utf8_primary_key_prefix_overflow_produces_no_ranges() {
3619 let config = KvTableConfig::new(
3620 0,
3621 vec![
3622 TableColumnConfig::new("a", DataType::Utf8, false),
3623 TableColumnConfig::new("b", DataType::Utf8, false),
3624 ],
3625 vec!["a".to_string(), "b".to_string()],
3626 vec![],
3627 )
3628 .unwrap();
3629 let model = TableModel::from_config(&config).unwrap();
3630 let capacity = model.primary_key_prefix.max_payload_len();
3631 let a = "a".repeat(capacity - 10);
3632 let b = "b".repeat(10);
3633 let a_width = encode_string_variable(&a).expect("a should encode").len();
3634 let b_width = encode_string_variable(&b).expect("b should encode").len();
3635 assert!(a_width <= capacity, "first component should fit alone");
3636 assert!(
3637 a_width + b_width > capacity,
3638 "composite UTF-8 prefix should overflow"
3639 );
3640
3641 let mut pred = QueryPredicate::default();
3642 pred.constraints.insert(0, PredicateConstraint::StringEq(a));
3643 pred.constraints.insert(1, PredicateConstraint::StringEq(b));
3644
3645 let ranges = pred
3646 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
3647 .unwrap();
3648 assert!(
3649 ranges.is_empty(),
3650 "overflowing composite UTF-8 PK equality can never match"
3651 );
3652 }
3653
3654 #[test]
3655 fn or_equalities_extracted_as_in_list() {
3656 let (model, _) = test_model();
3657 let expr = Expr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
3658 left: Box::new(Expr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
3659 left: Box::new(Expr::Column(datafusion::common::Column::new_unqualified(
3660 "region",
3661 ))),
3662 op: Operator::Eq,
3663 right: Box::new(Expr::Literal(
3664 ScalarValue::Utf8(Some("us-east".to_string())),
3665 None,
3666 )),
3667 })),
3668 op: Operator::Or,
3669 right: Box::new(Expr::BinaryExpr(datafusion::logical_expr::BinaryExpr {
3670 left: Box::new(Expr::Column(datafusion::common::Column::new_unqualified(
3671 "region",
3672 ))),
3673 op: Operator::Eq,
3674 right: Box::new(Expr::Literal(
3675 ScalarValue::Utf8(Some("us-west".to_string())),
3676 None,
3677 )),
3678 })),
3679 });
3680 let result = extract_or_in_column(&expr, &model);
3681 assert!(result.is_some());
3682 let (col, vals) = result.unwrap();
3683 assert_eq!(col, "region");
3684 assert_eq!(vals.len(), 2);
3685 }
3686
3687 #[test]
3688 fn or_equalities_on_float64_are_not_pushdown_supported() {
3689 let config = KvTableConfig::new(
3690 0,
3691 vec![
3692 TableColumnConfig::new("id", DataType::Int64, false),
3693 TableColumnConfig::new("score", DataType::Float64, false),
3694 ],
3695 vec!["id".to_string()],
3696 vec![],
3697 )
3698 .unwrap();
3699 let model = TableModel::from_config(&config).unwrap();
3700
3701 use datafusion::logical_expr::col;
3702 let filter = col("score")
3703 .eq(Expr::Literal(ScalarValue::Float64(Some(1.0)), None))
3704 .or(col("score").eq(Expr::Literal(ScalarValue::Float64(Some(2.0)), None)));
3705
3706 assert!(
3707 !QueryPredicate::supports_filter(&filter, &model),
3708 "OR-equality pushdown should be disabled for Float64 because apply_in_list cannot enforce it"
3709 );
3710
3711 let pred = QueryPredicate::from_filters(&[filter], &model);
3712 assert!(!pred.contradiction);
3713 assert!(
3714 pred.constraints.is_empty(),
3715 "unsupported OR predicate must not contribute pushdown constraints"
3716 );
3717 }
3718
3719 #[test]
3720 fn batch_writer_encodes_rows_across_tables() {
3721 let client = StoreClient::new("http://localhost:10000");
3722 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3723 .table(
3724 "customers",
3725 vec![
3726 TableColumnConfig::new("customer_id", DataType::Int64, false),
3727 TableColumnConfig::new("name", DataType::Utf8, false),
3728 ],
3729 vec!["customer_id".to_string()],
3730 vec![],
3731 )
3732 .unwrap()
3733 .table(
3734 "orders",
3735 vec![
3736 TableColumnConfig::new("order_id", DataType::Int64, false),
3737 TableColumnConfig::new("customer_id", DataType::Int64, false),
3738 TableColumnConfig::new("amount", DataType::Int64, false),
3739 ],
3740 vec!["order_id".to_string()],
3741 vec![IndexSpec::new("cust_idx", vec!["customer_id".to_string()]).unwrap()],
3742 )
3743 .unwrap();
3744
3745 let mut batch = schema.batch_writer();
3746 batch
3747 .insert(
3748 "customers",
3749 vec![CellValue::Int64(1), CellValue::Utf8("Alice".to_string())],
3750 )
3751 .unwrap();
3752 batch
3753 .insert(
3754 "orders",
3755 vec![
3756 CellValue::Int64(100),
3757 CellValue::Int64(1),
3758 CellValue::Int64(4999),
3759 ],
3760 )
3761 .unwrap();
3762 batch
3763 .insert(
3764 "orders",
3765 vec![
3766 CellValue::Int64(101),
3767 CellValue::Int64(1),
3768 CellValue::Int64(2999),
3769 ],
3770 )
3771 .unwrap();
3772
3773 assert_eq!(batch.pending_count(), 5);
3775 }
3776
3777 #[test]
3778 fn batch_writer_rejects_unknown_table() {
3779 let client = StoreClient::new("http://localhost:10000");
3780 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3781 .table(
3782 "t1",
3783 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3784 vec!["id".to_string()],
3785 vec![],
3786 )
3787 .unwrap();
3788
3789 let mut batch = schema.batch_writer();
3790 let result = batch.insert("nonexistent", vec![CellValue::Int64(1)]);
3791 assert!(result.is_err());
3792 assert!(result.unwrap_err().contains("unknown table"));
3793 }
3794
3795 #[test]
3796 fn batch_writer_rejects_wrong_column_count() {
3797 let client = StoreClient::new("http://localhost:10000");
3798 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3799 .table(
3800 "t1",
3801 vec![
3802 TableColumnConfig::new("id", DataType::Int64, false),
3803 TableColumnConfig::new("name", DataType::Utf8, false),
3804 ],
3805 vec!["id".to_string()],
3806 vec![],
3807 )
3808 .unwrap();
3809
3810 let mut batch = schema.batch_writer();
3811 let result = batch.insert("t1", vec![CellValue::Int64(1)]);
3812 assert!(result.is_err());
3813 assert!(result.unwrap_err().contains("expected 2"));
3814 }
3815
3816 #[test]
3817 fn batch_writer_rejects_non_pk_type_mismatch() {
3818 let client = StoreClient::new("http://localhost:10000");
3819 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3820 .table(
3821 "t1",
3822 vec![
3823 TableColumnConfig::new("id", DataType::Int64, false),
3824 TableColumnConfig::new("amount", DataType::Int64, false),
3825 ],
3826 vec!["id".to_string()],
3827 vec![],
3828 )
3829 .unwrap();
3830
3831 let mut batch = schema.batch_writer();
3832 let result = batch.insert(
3833 "t1",
3834 vec![CellValue::Int64(1), CellValue::Utf8("bad".to_string())],
3835 );
3836 assert!(result.is_err());
3837 assert!(
3838 result.unwrap_err().contains("type mismatch"),
3839 "non-PK schema-invalid values must be rejected at insert-time"
3840 );
3841 }
3842
3843 #[test]
3844 fn batch_writer_entries_use_distinct_table_prefixes() {
3845 let client = StoreClient::new("http://localhost:10000");
3846 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3847 .table(
3848 "a",
3849 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3850 vec!["id".to_string()],
3851 vec![],
3852 )
3853 .unwrap()
3854 .table(
3855 "b",
3856 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3857 vec!["id".to_string()],
3858 vec![],
3859 )
3860 .unwrap();
3861
3862 let mut batch = schema.batch_writer();
3863 batch.insert("a", vec![CellValue::Int64(42)]).unwrap();
3864 batch.insert("b", vec![CellValue::Int64(42)]).unwrap();
3865
3866 assert_eq!(batch.pending_count(), 2);
3867 assert_ne!(
3868 batch.pending_keys[0], batch.pending_keys[1],
3869 "same PK in different tables must produce different keys"
3870 );
3871 assert_ne!(
3872 batch.pending_keys[0][0], batch.pending_keys[1][0],
3873 "table prefix byte must differ"
3874 );
3875 }
3876
3877 #[tokio::test]
3878 async fn batch_writer_trait_failure_requeues_prepared_before_new_pending() {
3879 let client = StoreClient::new("http://localhost:10000");
3880 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3881 .table(
3882 "t",
3883 vec![TableColumnConfig::new("id", DataType::Int64, false)],
3884 vec!["id".to_string()],
3885 vec![],
3886 )
3887 .unwrap();
3888
3889 let mut batch = schema.batch_writer();
3890 batch.insert("t", vec![CellValue::Int64(1)]).unwrap();
3891 let prepared = batch.prepare_flush().unwrap().expect("prepared row");
3892 assert_eq!(prepared.request_id(), 0);
3893 assert_eq!(prepared.entry_count(), 1);
3894
3895 batch.insert("t", vec![CellValue::Int64(2)]).unwrap();
3896 StoreBatchUpload::mark_upload_failed(&batch, prepared, "commit failed".to_string()).await;
3897 assert_eq!(batch.pending_count(), 2);
3898
3899 let retry = batch.prepare_flush().unwrap().expect("retry row");
3900 assert_eq!(retry.request_id(), 0);
3901 assert_eq!(retry.entry_count(), 1);
3902 let next = batch.prepare_flush().unwrap().expect("new pending row");
3903 assert_eq!(next.request_id(), 1);
3904 assert_eq!(next.entry_count(), 1);
3905 }
3906
3907 #[test]
3908 fn batch_writer_supports_nullable_columns() {
3909 let client = StoreClient::new("http://localhost:10000");
3910 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3911 .table(
3912 "t",
3913 vec![
3914 TableColumnConfig::new("id", DataType::Int64, false),
3915 TableColumnConfig::new("note", DataType::Utf8, true),
3916 ],
3917 vec!["id".to_string()],
3918 vec![],
3919 )
3920 .unwrap();
3921
3922 let mut batch = schema.batch_writer();
3923 batch
3924 .insert("t", vec![CellValue::Int64(1), CellValue::Null])
3925 .unwrap();
3926 assert_eq!(batch.pending_count(), 1);
3927 }
3928
3929 #[test]
3930 fn non_nullable_column_rejects_null_in_batch_writer() {
3931 let client = StoreClient::new("http://localhost:10000");
3932 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
3933 .table(
3934 "t",
3935 vec![
3936 TableColumnConfig::new("id", DataType::Int64, false),
3937 TableColumnConfig::new("name", DataType::Utf8, false),
3938 TableColumnConfig::new("note", DataType::Utf8, true),
3939 ],
3940 vec!["id".to_string()],
3941 vec![],
3942 )
3943 .unwrap();
3944
3945 let mut batch = schema.batch_writer();
3947 let result = batch.insert(
3948 "t",
3949 vec![
3950 CellValue::Int64(1),
3951 CellValue::Null,
3952 CellValue::Utf8("ok".to_string()),
3953 ],
3954 );
3955 assert!(result.is_err());
3956 assert!(
3957 result.unwrap_err().contains("not nullable"),
3958 "error should mention non-nullable constraint"
3959 );
3960
3961 let mut batch = schema.batch_writer();
3963 batch
3964 .insert(
3965 "t",
3966 vec![
3967 CellValue::Int64(1),
3968 CellValue::Utf8("Alice".to_string()),
3969 CellValue::Null,
3970 ],
3971 )
3972 .unwrap();
3973 assert_eq!(batch.pending_count(), 1);
3974
3975 let mut batch = schema.batch_writer();
3977 batch
3978 .insert(
3979 "t",
3980 vec![
3981 CellValue::Int64(1),
3982 CellValue::Utf8("Alice".to_string()),
3983 CellValue::Utf8("hello".to_string()),
3984 ],
3985 )
3986 .unwrap();
3987 assert_eq!(batch.pending_count(), 1);
3988 }
3989
3990 #[test]
3991 fn uint64_column_accepted() {
3992 let config = KvTableConfig::new(
3993 0,
3994 vec![
3995 TableColumnConfig::new("id", DataType::UInt64, false),
3996 TableColumnConfig::new("name", DataType::Utf8, false),
3997 ],
3998 vec!["id".to_string()],
3999 vec![],
4000 );
4001 assert!(config.is_ok());
4002 }
4003
4004 #[test]
4005 fn uint64_primary_key_round_trip() {
4006 let config = KvTableConfig::new(
4007 0,
4008 vec![
4009 TableColumnConfig::new("id", DataType::UInt64, false),
4010 TableColumnConfig::new("label", DataType::Utf8, false),
4011 ],
4012 vec!["id".to_string()],
4013 vec![],
4014 )
4015 .unwrap();
4016 let model = TableModel::from_config(&config).unwrap();
4017 let row = KvRow {
4018 values: vec![
4019 CellValue::UInt64(u64::MAX),
4020 CellValue::Utf8("max".to_string()),
4021 ],
4022 };
4023 let encoded = encode_base_row_value(&row, &model).unwrap();
4024 let pk = row
4025 .primary_key_values(&model)
4026 .into_iter()
4027 .cloned()
4028 .collect::<Vec<_>>();
4029 let decoded = decode_base_row(pk, &encoded, &model).unwrap();
4030 assert!(matches!(&decoded.values[0], CellValue::UInt64(v) if *v == u64::MAX));
4031 assert!(matches!(&decoded.values[1], CellValue::Utf8(v) if v == "max"));
4032 }
4033
4034 #[test]
4035 fn string_primary_key_accepted() {
4036 let config = KvTableConfig::new(
4037 0,
4038 vec![
4039 TableColumnConfig::new("code", DataType::Utf8, false),
4040 TableColumnConfig::new("value", DataType::Int64, false),
4041 ],
4042 vec!["code".to_string()],
4043 vec![],
4044 );
4045 assert!(config.is_ok());
4046 }
4047
4048 #[test]
4049 fn fixed_binary_primary_key_round_trip() {
4050 let config = KvTableConfig::new(
4051 0,
4052 vec![
4053 TableColumnConfig::new("hash", DataType::FixedSizeBinary(32), false),
4054 TableColumnConfig::new("amount", DataType::Int64, false),
4055 ],
4056 vec!["hash".to_string()],
4057 vec![],
4058 )
4059 .unwrap();
4060 let model = TableModel::from_config(&config).unwrap();
4061 let hash_val = vec![0xABu8; 32];
4062 let row = KvRow {
4063 values: vec![
4064 CellValue::FixedBinary(hash_val.clone()),
4065 CellValue::Int64(100),
4066 ],
4067 };
4068 let encoded = encode_base_row_value(&row, &model).unwrap();
4069 let pk = row
4070 .primary_key_values(&model)
4071 .into_iter()
4072 .cloned()
4073 .collect::<Vec<_>>();
4074 let decoded = decode_base_row(pk, &encoded, &model).unwrap();
4075 assert!(matches!(&decoded.values[0], CellValue::FixedBinary(v) if *v == hash_val));
4076 }
4077
4078 #[test]
4079 fn fixed_binary_key_rejects_wrong_length() {
4080 let config = KvTableConfig::new(
4081 0,
4082 vec![
4083 TableColumnConfig::new("hash", DataType::FixedSizeBinary(16), false),
4084 TableColumnConfig::new("amount", DataType::Int64, false),
4085 ],
4086 vec!["hash".to_string()],
4087 vec![],
4088 )
4089 .unwrap();
4090 let model = TableModel::from_config(&config).unwrap();
4091
4092 let short_row = KvRow {
4094 values: vec![CellValue::FixedBinary(vec![0xAB; 10]), CellValue::Int64(1)],
4095 };
4096 let result = encode_primary_key_from_row(model.table_prefix, &short_row, &model);
4097 assert!(result.is_err());
4098 assert!(
4099 result.unwrap_err().contains("requires exactly 16 bytes"),
4100 "should mention exact width requirement"
4101 );
4102
4103 let long_row = KvRow {
4105 values: vec![CellValue::FixedBinary(vec![0xCD; 20]), CellValue::Int64(2)],
4106 };
4107 let result = encode_primary_key_from_row(model.table_prefix, &long_row, &model);
4108 assert!(result.is_err());
4109 assert!(result.unwrap_err().contains("requires exactly 16 bytes"));
4110
4111 let ok_row = KvRow {
4113 values: vec![CellValue::FixedBinary(vec![0xEF; 16]), CellValue::Int64(3)],
4114 };
4115 assert!(encode_primary_key_from_row(model.table_prefix, &ok_row, &model).is_ok());
4116 }
4117
4118 #[test]
4119 fn fixed_binary_index_key_rejects_wrong_length() {
4120 let config = KvTableConfig::new(
4121 0,
4122 vec![
4123 TableColumnConfig::new("id", DataType::Int64, false),
4124 TableColumnConfig::new("tag", DataType::FixedSizeBinary(8), false),
4125 ],
4126 vec!["id".to_string()],
4127 vec![IndexSpec::new("tag_idx", vec!["tag".to_string()]).unwrap()],
4128 )
4129 .unwrap();
4130 let model = TableModel::from_config(&config).unwrap();
4131 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
4132
4133 let bad_row = KvRow {
4135 values: vec![CellValue::Int64(1), CellValue::FixedBinary(vec![0x01; 4])],
4136 };
4137 let result = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &bad_row);
4138 assert!(result.is_err());
4139 assert!(result.unwrap_err().contains("requires exactly 8 bytes"));
4140
4141 let ok_row = KvRow {
4143 values: vec![CellValue::Int64(1), CellValue::FixedBinary(vec![0x02; 8])],
4144 };
4145 assert!(encode_secondary_index_key(model.table_prefix, &specs[0], &model, &ok_row).is_ok());
4146 }
4147
4148 #[test]
4149 fn decimal256_column_round_trip() {
4150 let config = KvTableConfig::new(
4151 0,
4152 vec![
4153 TableColumnConfig::new("id", DataType::Int64, false),
4154 TableColumnConfig::new("balance", DataType::Decimal256(76, 0), false),
4155 ],
4156 vec!["id".to_string()],
4157 vec![],
4158 )
4159 .unwrap();
4160 let model = TableModel::from_config(&config).unwrap();
4161 let big_val = i256::from(123456789012345i64);
4162 let row = KvRow {
4163 values: vec![CellValue::Int64(1), CellValue::Decimal256(big_val)],
4164 };
4165 let encoded = encode_base_row_value(&row, &model).unwrap();
4166 let pk = row
4167 .primary_key_values(&model)
4168 .into_iter()
4169 .cloned()
4170 .collect::<Vec<_>>();
4171 let decoded = decode_base_row(pk, &encoded, &model).unwrap();
4172 assert!(matches!(&decoded.values[1], CellValue::Decimal256(v) if *v == big_val));
4173 }
4174
4175 #[test]
4176 fn float64_primary_key_rejected() {
4177 let config = KvTableConfig::new(
4178 0,
4179 vec![TableColumnConfig::new("id", DataType::Float64, false)],
4180 vec!["id".to_string()],
4181 vec![],
4182 );
4183 assert!(config.is_err());
4184 }
4185
4186 #[test]
4187 fn i256_ordered_encoding_round_trip() {
4188 let values = [
4189 i256::from_i128(i128::MIN),
4190 i256::from(-1i64),
4191 i256::from(0i64),
4192 i256::from(1i64),
4193 i256::from_i128(i128::MAX),
4194 ];
4195 for v in values {
4196 assert_eq!(decode_i256_ordered(encode_i256_ordered(v)), v);
4197 }
4198 let encoded: Vec<[u8; 32]> = values.iter().map(|v| encode_i256_ordered(*v)).collect();
4199 for i in 0..encoded.len() - 1 {
4200 assert!(encoded[i] < encoded[i + 1]);
4201 }
4202 }
4203
4204 #[test]
4205 fn uint64_primary_key_encode_decode() {
4206 let config = KvTableConfig::new(
4207 5,
4208 vec![
4209 TableColumnConfig::new("id", DataType::UInt64, false),
4210 TableColumnConfig::new("name", DataType::Utf8, false),
4211 ],
4212 vec!["id".to_string()],
4213 vec![],
4214 )
4215 .unwrap();
4216 let model = TableModel::from_config(&config).unwrap();
4217 let pk = CellValue::UInt64(12345);
4218 let key = encode_primary_key(5, &[&pk], &model).expect("pk key encodes");
4219 let decoded = decode_primary_key(5, &key, &model).unwrap();
4220 assert!(matches!(&decoded[0], CellValue::UInt64(12345)));
4221 }
4222
4223 #[test]
4224 fn utf8_primary_key_encode_decode() {
4225 let config = KvTableConfig::new(
4226 3,
4227 vec![
4228 TableColumnConfig::new("code", DataType::Utf8, false),
4229 TableColumnConfig::new("val", DataType::Int64, false),
4230 ],
4231 vec!["code".to_string()],
4232 vec![],
4233 )
4234 .unwrap();
4235 let model = TableModel::from_config(&config).unwrap();
4236 let pk = CellValue::Utf8("HELLO".to_string());
4237 let key = encode_primary_key(3, &[&pk], &model).expect("pk key encodes");
4238 let decoded = decode_primary_key(3, &key, &model).unwrap();
4239 assert!(matches!(&decoded[0], CellValue::Utf8(v) if v == "HELLO"));
4240 }
4241
4242 #[test]
4243 fn fixed_binary_primary_key_encode_decode() {
4244 let config = KvTableConfig::new(
4245 7,
4246 vec![
4247 TableColumnConfig::new("hash", DataType::FixedSizeBinary(16), false),
4248 TableColumnConfig::new("val", DataType::Int64, false),
4249 ],
4250 vec!["hash".to_string()],
4251 vec![],
4252 )
4253 .unwrap();
4254 let model = TableModel::from_config(&config).unwrap();
4255 let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
4256 let pk = CellValue::FixedBinary(data.clone());
4257 let key = encode_primary_key(7, &[&pk], &model).expect("pk key encodes");
4258 let decoded = decode_primary_key(7, &key, &model).unwrap();
4259 assert!(matches!(&decoded[0], CellValue::FixedBinary(v) if *v == data));
4260 }
4261
4262 #[test]
4263 fn secondary_index_with_uint64_column() {
4264 let config = KvTableConfig::new(
4265 0,
4266 vec![
4267 TableColumnConfig::new("id", DataType::Int64, false),
4268 TableColumnConfig::new("counter", DataType::UInt64, false),
4269 ],
4270 vec!["id".to_string()],
4271 vec![IndexSpec::new("counter_idx", vec!["counter".to_string()]).unwrap()],
4272 )
4273 .unwrap();
4274 let model = TableModel::from_config(&config).unwrap();
4275 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
4276 let row = KvRow {
4277 values: vec![CellValue::Int64(1), CellValue::UInt64(999)],
4278 };
4279 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row).unwrap();
4280 let decoded =
4281 decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key).unwrap();
4282 let counter_idx = *model.columns_by_name.get("counter").unwrap();
4283 assert!(matches!(
4284 decoded.values.get(&counter_idx),
4285 Some(CellValue::UInt64(999))
4286 ));
4287 assert!(matches!(
4288 &decoded.primary_key_values[0],
4289 CellValue::Int64(1)
4290 ));
4291 }
4292
4293 #[test]
4294 fn secondary_index_with_decimal256_column() {
4295 let config = KvTableConfig::new(
4296 0,
4297 vec![
4298 TableColumnConfig::new("id", DataType::Int64, false),
4299 TableColumnConfig::new("big_val", DataType::Decimal256(76, 0), false),
4300 ],
4301 vec!["id".to_string()],
4302 vec![IndexSpec::new("big_idx", vec!["big_val".to_string()]).unwrap()],
4303 )
4304 .unwrap();
4305 let model = TableModel::from_config(&config).unwrap();
4306 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
4307 let val = i256::from(42i64);
4308 let row = KvRow {
4309 values: vec![CellValue::Int64(1), CellValue::Decimal256(val)],
4310 };
4311 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row).unwrap();
4312 let decoded =
4313 decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key).unwrap();
4314 let big_idx = *model.columns_by_name.get("big_val").unwrap();
4315 assert!(matches!(
4316 decoded.values.get(&big_idx),
4317 Some(CellValue::Decimal256(v)) if *v == val
4318 ));
4319 }
4320
4321 #[test]
4326 fn composite_pk_config_accepted() {
4327 let config = KvTableConfig::new(
4328 0,
4329 vec![
4330 TableColumnConfig::new("entity", DataType::FixedSizeBinary(32), false),
4331 TableColumnConfig::new("version", DataType::UInt64, false),
4332 TableColumnConfig::new("data", DataType::Utf8, true),
4333 ],
4334 vec!["entity".to_string(), "version".to_string()],
4335 vec![],
4336 );
4337 assert!(config.is_ok());
4338 let c = config.unwrap();
4339 assert_eq!(c.primary_key_columns, vec!["entity", "version"]);
4340 }
4341
4342 #[test]
4343 fn composite_pk_rejects_unsupported_type() {
4344 let result = KvTableConfig::new(
4345 0,
4346 vec![
4347 TableColumnConfig::new("entity", DataType::FixedSizeBinary(32), false),
4348 TableColumnConfig::new("score", DataType::Float64, false),
4349 ],
4350 vec!["entity".to_string(), "score".to_string()],
4351 vec![],
4352 );
4353 assert!(result.is_err());
4354 let err = result.unwrap_err();
4355 assert!(
4356 err.contains("must be Int64") || err.contains("must be"),
4357 "expected PK type error, got: {err}"
4358 );
4359 }
4360
4361 #[test]
4362 fn composite_pk_rejects_too_wide() {
4363 let config = KvTableConfig::new(
4364 0,
4365 vec![
4366 TableColumnConfig::new("big", DataType::FixedSizeBinary(60), false),
4367 TableColumnConfig::new("ver", DataType::UInt64, false),
4368 ],
4369 vec!["big".to_string(), "ver".to_string()],
4370 vec![],
4371 )
4372 .expect("variable-length keys should allow wider composite PKs");
4373 let model = TableModel::from_config(&config).expect("model");
4374 assert_eq!(model.primary_key_width, 68);
4375 }
4376
4377 #[test]
4378 fn composite_pk_encode_decode_round_trip() {
4379 let config = KvTableConfig::new(
4380 1,
4381 vec![
4382 TableColumnConfig::new("entity", DataType::FixedSizeBinary(32), false),
4383 TableColumnConfig::new("version", DataType::UInt64, false),
4384 TableColumnConfig::new("title", DataType::Utf8, true),
4385 ],
4386 vec!["entity".to_string(), "version".to_string()],
4387 vec![],
4388 )
4389 .unwrap();
4390 let model = TableModel::from_config(&config).unwrap();
4391
4392 let entity = vec![0xAA; 32];
4393 let pk_entity = CellValue::FixedBinary(entity.clone());
4394 let pk_version = CellValue::UInt64(42);
4395 let key =
4396 encode_primary_key(1, &[&pk_entity, &pk_version], &model).expect("pk key encodes");
4397
4398 let decoded = decode_primary_key(1, &key, &model).unwrap();
4399 assert_eq!(decoded.len(), 2);
4400 assert!(matches!(&decoded[0], CellValue::FixedBinary(v) if *v == entity));
4401 assert!(matches!(&decoded[1], CellValue::UInt64(42)));
4402 }
4403
4404 #[test]
4405 fn composite_pk_version_sort_order() {
4406 let config = KvTableConfig::new(
4407 0,
4408 vec![
4409 TableColumnConfig::new("entity", DataType::FixedSizeBinary(32), false),
4410 TableColumnConfig::new("version", DataType::UInt64, false),
4411 ],
4412 vec!["entity".to_string(), "version".to_string()],
4413 vec![],
4414 )
4415 .unwrap();
4416 let model = TableModel::from_config(&config).unwrap();
4417
4418 let entity = vec![0xBB; 32];
4419 let pk_entity = CellValue::FixedBinary(entity.clone());
4420
4421 let key_v1 = encode_primary_key(0, &[&pk_entity, &CellValue::UInt64(1)], &model)
4422 .expect("pk key encodes");
4423 let key_v10 = encode_primary_key(0, &[&pk_entity, &CellValue::UInt64(10)], &model)
4424 .expect("pk key encodes");
4425 let key_v100 = encode_primary_key(0, &[&pk_entity, &CellValue::UInt64(100)], &model)
4426 .expect("pk key encodes");
4427
4428 assert!(key_v1 < key_v10);
4430 assert!(key_v10 < key_v100);
4431 }
4432
4433 #[test]
4434 fn composite_pk_value_excludes_all_pk_columns() {
4435 let config = KvTableConfig::new(
4436 0,
4437 vec![
4438 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4439 TableColumnConfig::new("version", DataType::UInt64, false),
4440 TableColumnConfig::new("data", DataType::Utf8, true),
4441 ],
4442 vec!["entity".to_string(), "version".to_string()],
4443 vec![],
4444 )
4445 .unwrap();
4446 let model = TableModel::from_config(&config).unwrap();
4447
4448 let row = KvRow {
4449 values: vec![
4450 CellValue::FixedBinary(vec![0xCC; 16]),
4451 CellValue::UInt64(7),
4452 CellValue::Utf8("hello".to_string()),
4453 ],
4454 };
4455 let encoded = encode_base_row_value(&row, &model).unwrap();
4456 let decoded = decode_base_row(
4458 vec![CellValue::FixedBinary(vec![0xCC; 16]), CellValue::UInt64(7)],
4459 &encoded,
4460 &model,
4461 )
4462 .unwrap();
4463 assert!(matches!(&decoded.values[0], CellValue::FixedBinary(v) if v.len() == 16));
4464 assert!(matches!(&decoded.values[1], CellValue::UInt64(7)));
4465 assert!(matches!(&decoded.values[2], CellValue::Utf8(v) if v == "hello"));
4466 }
4467
4468 #[test]
4469 fn composite_pk_secondary_index_appends_all_pk_columns() {
4470 let config = KvTableConfig::new(
4471 0,
4472 vec![
4473 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4474 TableColumnConfig::new("version", DataType::UInt64, false),
4475 TableColumnConfig::new("tag", DataType::Int64, false),
4476 ],
4477 vec!["entity".to_string(), "version".to_string()],
4478 vec![IndexSpec::new("tag_idx", vec!["tag".to_string()]).unwrap()],
4479 )
4480 .unwrap();
4481 let model = TableModel::from_config(&config).unwrap();
4482 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
4483
4484 let entity_data = vec![0xDD; 16];
4485 let row = KvRow {
4486 values: vec![
4487 CellValue::FixedBinary(entity_data.clone()),
4488 CellValue::UInt64(99),
4489 CellValue::Int64(42),
4490 ],
4491 };
4492 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row).unwrap();
4493 let decoded =
4494 decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key).unwrap();
4495
4496 assert_eq!(decoded.primary_key_values.len(), 2);
4497 assert!(matches!(
4498 &decoded.primary_key_values[0],
4499 CellValue::FixedBinary(v) if *v == entity_data
4500 ));
4501 assert!(matches!(
4502 &decoded.primary_key_values[1],
4503 CellValue::UInt64(99)
4504 ));
4505 let tag_idx = *model.columns_by_name.get("tag").unwrap();
4506 assert!(matches!(
4507 decoded.values.get(&tag_idx),
4508 Some(CellValue::Int64(42))
4509 ));
4510 }
4511
4512 #[test]
4513 fn table_versioned_convenience() {
4514 let client = StoreClient::new("http://localhost:10000");
4515 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
4516 .table_versioned(
4517 "documents",
4518 vec![
4519 TableColumnConfig::new("doc_id", DataType::FixedSizeBinary(32), false),
4520 TableColumnConfig::new("version", DataType::UInt64, false),
4521 TableColumnConfig::new("title", DataType::Utf8, false),
4522 ],
4523 "doc_id",
4524 "version",
4525 vec![],
4526 )
4527 .unwrap();
4528 assert_eq!(schema.table_count(), 1);
4529 }
4530
4531 #[test]
4532 fn single_column_pk_backward_compat() {
4533 let config = KvTableConfig::new(
4535 0,
4536 vec![
4537 TableColumnConfig::new("id", DataType::Int64, false),
4538 TableColumnConfig::new("name", DataType::Utf8, true),
4539 ],
4540 vec!["id".to_string()],
4541 vec![],
4542 )
4543 .unwrap();
4544 let model = TableModel::from_config(&config).unwrap();
4545 assert_eq!(model.primary_key_indices.len(), 1);
4546 assert_eq!(model.primary_key_indices[0], 0);
4547 assert_eq!(model.primary_key_width, 8);
4548
4549 let pk = CellValue::Int64(42);
4550 let key = encode_primary_key(0, &[&pk], &model).expect("pk key encodes");
4551 let decoded = decode_primary_key(0, &key, &model).unwrap();
4552 assert_eq!(decoded.len(), 1);
4553 assert!(matches!(&decoded[0], CellValue::Int64(42)));
4554 }
4555
4556 #[test]
4557 fn partial_prefix_upper_bound_fills_trailing_pk_bytes() {
4558 let config = KvTableConfig::new(
4563 0,
4564 vec![
4565 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4566 TableColumnConfig::new("version", DataType::UInt64, false),
4567 ],
4568 vec!["entity".to_string(), "version".to_string()],
4569 vec![],
4570 )
4571 .unwrap();
4572 let model = TableModel::from_config(&config).unwrap();
4573 assert_eq!(model.primary_key_width, 24); let entity = CellValue::FixedBinary(vec![0xAA; 16]);
4576 let upper =
4578 encode_primary_key_bound(0, &[&entity], &model, true).expect("pk bound encodes");
4579
4580 assert_eq!(primary_payload(&model, &upper, 0, 16), vec![0xAA; 16]);
4582 assert_eq!(
4584 primary_payload(&model, &upper, 16, 8),
4585 vec![0xFF; 8],
4586 "trailing PK column (version) must be 0xFF for upper bound"
4587 );
4588 assert!(primary_payload(
4590 &model,
4591 &upper,
4592 24,
4593 model.primary_key_prefix.max_payload_len() - 24
4594 )
4595 .iter()
4596 .all(|&b| b == 0xFF));
4597
4598 let lower =
4600 encode_primary_key_bound(0, &[&entity], &model, false).expect("pk bound encodes");
4601 assert_eq!(primary_payload(&model, &lower, 0, 16), vec![0xAA; 16]);
4602 assert_eq!(
4603 lower,
4604 model
4605 .primary_key_prefix
4606 .encode(&[0xAA; 16])
4607 .expect("encode entity prefix"),
4608 "lower bound must be exactly the encoded prefix"
4609 );
4610 }
4611
4612 #[test]
4617 fn composite_pk_range_pushdown_entity_eq_version_lte() {
4618 let config = KvTableConfig::new(
4622 0,
4623 vec![
4624 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4625 TableColumnConfig::new("version", DataType::UInt64, false),
4626 TableColumnConfig::new("data", DataType::Utf8, true),
4627 ],
4628 vec!["entity".to_string(), "version".to_string()],
4629 vec![],
4630 )
4631 .unwrap();
4632 let model = TableModel::from_config(&config).unwrap();
4633
4634 let mut pred = QueryPredicate::default();
4636 pred.constraints
4637 .insert(0, PredicateConstraint::FixedBinaryEq(vec![0xCC; 16]));
4638 pred.constraints.insert(
4639 1,
4640 PredicateConstraint::UInt64Range {
4641 min: None,
4642 max: Some(42),
4643 },
4644 );
4645
4646 let ranges = pred
4647 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
4648 .unwrap();
4649 assert_eq!(ranges.len(), 1, "should produce exactly one range");
4650
4651 let range = &ranges[0];
4652
4653 let expected_start = encode_primary_key(
4655 0,
4656 &[
4657 &CellValue::FixedBinary(vec![0xCC; 16]),
4658 &CellValue::UInt64(0),
4659 ],
4660 &model,
4661 )
4662 .expect("pk key encodes");
4663 assert_eq!(
4664 range.start, expected_start,
4665 "start should be entity=CC..CC, version=0"
4666 );
4667
4668 let expected_end_prefix = encode_primary_key(
4670 0,
4671 &[
4672 &CellValue::FixedBinary(vec![0xCC; 16]),
4673 &CellValue::UInt64(42),
4674 ],
4675 &model,
4676 )
4677 .expect("pk key encodes");
4678 assert_eq!(
4680 primary_payload(&model, &range.end, 0, model.primary_key_width),
4681 primary_payload(&model, &expected_end_prefix, 0, model.primary_key_width),
4682 "end prefix should be entity=CC..CC, version=42"
4683 );
4684 assert!(
4686 primary_payload(
4687 &model,
4688 &range.end,
4689 model.primary_key_width,
4690 model.primary_key_prefix.max_payload_len() - model.primary_key_width
4691 )
4692 .iter()
4693 .all(|&b| b == 0xFF),
4694 "end trailing bytes should be 0xFF"
4695 );
4696
4697 let full_range = primary_key_prefix_range(0);
4699 assert_ne!(
4700 range.start, full_range.start,
4701 "range must not be a full table scan"
4702 );
4703 }
4704
4705 #[test]
4706 fn composite_pk_range_pushdown_entity_eq_only() {
4707 let config = KvTableConfig::new(
4711 0,
4712 vec![
4713 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4714 TableColumnConfig::new("version", DataType::UInt64, false),
4715 ],
4716 vec!["entity".to_string(), "version".to_string()],
4717 vec![],
4718 )
4719 .unwrap();
4720 let model = TableModel::from_config(&config).unwrap();
4721
4722 let mut pred = QueryPredicate::default();
4723 pred.constraints
4724 .insert(0, PredicateConstraint::FixedBinaryEq(vec![0xDD; 16]));
4725
4726 let ranges = pred
4727 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
4728 .unwrap();
4729 assert_eq!(ranges.len(), 1);
4730
4731 let range = &ranges[0];
4732 assert_eq!(primary_payload(&model, &range.start, 0, 16), vec![0xDD; 16]);
4734 assert_eq!(primary_payload(&model, &range.end, 0, 16), vec![0xDD; 16]);
4736 assert!(
4737 primary_payload(
4738 &model,
4739 &range.end,
4740 16,
4741 model.primary_key_prefix.max_payload_len() - 16
4742 )
4743 .iter()
4744 .all(|&b| b == 0xFF),
4745 "after entity bytes, everything should be 0xFF"
4746 );
4747 }
4748
4749 #[test]
4750 fn fixed_binary_eq_constraint_extracted() {
4751 let config = KvTableConfig::new(
4752 0,
4753 vec![
4754 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4755 TableColumnConfig::new("version", DataType::UInt64, false),
4756 ],
4757 vec!["entity".to_string(), "version".to_string()],
4758 vec![],
4759 )
4760 .unwrap();
4761 let model = TableModel::from_config(&config).unwrap();
4762
4763 use datafusion::logical_expr::col;
4765 let entity_literal =
4766 Expr::Literal(ScalarValue::FixedSizeBinary(16, Some(vec![0xAA; 16])), None);
4767 let filter = col("entity").eq(entity_literal);
4768
4769 assert!(
4770 QueryPredicate::supports_filter(&filter, &model),
4771 "FixedSizeBinary equality should be supported"
4772 );
4773
4774 let pred = QueryPredicate::from_filters(&[filter], &model);
4775 assert!(
4776 matches!(
4777 pred.constraints.get(&0),
4778 Some(PredicateConstraint::FixedBinaryEq(v)) if *v == vec![0xAA; 16]
4779 ),
4780 "should extract FixedBinaryEq constraint"
4781 );
4782 }
4783
4784 #[test]
4785 fn uint64_range_constraint_extracted() {
4786 let config = KvTableConfig::new(
4787 0,
4788 vec![
4789 TableColumnConfig::new("version", DataType::UInt64, false),
4790 TableColumnConfig::new("data", DataType::Utf8, true),
4791 ],
4792 vec!["version".to_string()],
4793 vec![],
4794 )
4795 .unwrap();
4796 let model = TableModel::from_config(&config).unwrap();
4797
4798 use datafusion::logical_expr::col;
4799 let filter = col("version").lt_eq(Expr::Literal(ScalarValue::UInt64(Some(42)), None));
4800
4801 assert!(
4802 QueryPredicate::supports_filter(&filter, &model),
4803 "UInt64 range should be supported"
4804 );
4805
4806 let pred = QueryPredicate::from_filters(&[filter], &model);
4807 assert!(
4808 matches!(
4809 pred.constraints.get(&0),
4810 Some(PredicateConstraint::UInt64Range {
4811 min: None,
4812 max: Some(42)
4813 })
4814 ),
4815 "should extract UInt64Range with max=42"
4816 );
4817 }
4818
4819 #[test]
4820 fn uint64_range_constraint_supports_values_above_i64_max() {
4821 let config = KvTableConfig::new(
4822 0,
4823 vec![
4824 TableColumnConfig::new("version", DataType::UInt64, false),
4825 TableColumnConfig::new("data", DataType::Utf8, true),
4826 ],
4827 vec!["version".to_string()],
4828 vec![],
4829 )
4830 .unwrap();
4831 let model = TableModel::from_config(&config).unwrap();
4832
4833 let threshold = (1u64 << 63) + 5;
4834 use datafusion::logical_expr::col;
4835 let filter =
4836 col("version").gt_eq(Expr::Literal(ScalarValue::UInt64(Some(threshold)), None));
4837
4838 assert!(QueryPredicate::supports_filter(&filter, &model));
4839
4840 let pred = QueryPredicate::from_filters(&[filter], &model);
4841 assert!(matches!(
4842 pred.constraints.get(&0),
4843 Some(PredicateConstraint::UInt64Range {
4844 min: Some(v),
4845 max: None
4846 }) if *v == threshold
4847 ));
4848 }
4849
4850 #[test]
4851 fn unsupported_uint64_comparison_does_not_force_contradiction() {
4852 let config = KvTableConfig::new(
4853 0,
4854 vec![
4855 TableColumnConfig::new("version", DataType::UInt64, false),
4856 TableColumnConfig::new("data", DataType::Utf8, true),
4857 ],
4858 vec!["version".to_string()],
4859 vec![],
4860 )
4861 .unwrap();
4862 let model = TableModel::from_config(&config).unwrap();
4863
4864 use datafusion::logical_expr::col;
4865 let unsupported = col("version").gt(Expr::Literal(ScalarValue::Int64(Some(-1)), None));
4866
4867 assert!(
4868 !QueryPredicate::supports_filter(&unsupported, &model),
4869 "negative Int64 literal on UInt64 column should not be pushdown-supported"
4870 );
4871
4872 let pred = QueryPredicate::from_filters(&[unsupported], &model);
4873 assert!(
4874 !pred.contradiction,
4875 "unsupported filter must not collapse scan to empty result"
4876 );
4877 assert!(
4878 pred.constraints.is_empty(),
4879 "unsupported filter must not contribute pushed constraints"
4880 );
4881 }
4882
4883 #[test]
4884 fn unsupported_uint64_comparison_in_and_keeps_supported_sibling() {
4885 let config = KvTableConfig::new(
4886 0,
4887 vec![
4888 TableColumnConfig::new("version", DataType::UInt64, false),
4889 TableColumnConfig::new("data", DataType::Utf8, true),
4890 ],
4891 vec!["version".to_string()],
4892 vec![],
4893 )
4894 .unwrap();
4895 let model = TableModel::from_config(&config).unwrap();
4896
4897 use datafusion::logical_expr::col;
4898 let supported = col("version").gt_eq(Expr::Literal(ScalarValue::UInt64(Some(10)), None));
4899 let unsupported = col("version").gt(Expr::Literal(ScalarValue::Int64(Some(-1)), None));
4900 let filter = supported.and(unsupported);
4901
4902 for filter in [filter.clone(), filter.alias("predicate")] {
4903 assert!(
4904 !QueryPredicate::supports_filter(&filter, &model),
4905 "mixed AND should not be marked fully pushdown-supported"
4906 );
4907
4908 let pred = QueryPredicate::from_filters(&[filter], &model);
4909 assert!(!pred.contradiction);
4910 assert!(matches!(
4911 pred.constraints.get(&0),
4912 Some(PredicateConstraint::UInt64Range {
4913 min: Some(10),
4914 max: None
4915 })
4916 ));
4917 }
4918 }
4919
4920 #[test]
4921 fn uint64_in_list_pushdown() {
4922 let config = KvTableConfig::new(
4923 0,
4924 vec![
4925 TableColumnConfig::new("version", DataType::UInt64, false),
4926 TableColumnConfig::new("data", DataType::Utf8, true),
4927 ],
4928 vec!["version".to_string()],
4929 vec![],
4930 )
4931 .unwrap();
4932 let model = TableModel::from_config(&config).unwrap();
4933
4934 use datafusion::logical_expr::{col, in_list};
4935 let filter = in_list(
4936 col("version"),
4937 vec![
4938 Expr::Literal(ScalarValue::UInt64(Some(1)), None),
4939 Expr::Literal(ScalarValue::UInt64(Some(5)), None),
4940 Expr::Literal(ScalarValue::UInt64(Some(10)), None),
4941 ],
4942 false,
4943 );
4944
4945 assert!(QueryPredicate::supports_filter(&filter, &model));
4946 let pred = QueryPredicate::from_filters(&[filter], &model);
4947 assert!(
4948 matches!(pred.constraints.get(&0), Some(PredicateConstraint::UInt64In(v)) if v.len() == 3),
4949 "should extract UInt64In with 3 values"
4950 );
4951 }
4952
4953 #[test]
4954 fn uint64_in_list_pushdown_supports_values_above_i64_max() {
4955 let config = KvTableConfig::new(
4956 0,
4957 vec![
4958 TableColumnConfig::new("version", DataType::UInt64, false),
4959 TableColumnConfig::new("data", DataType::Utf8, true),
4960 ],
4961 vec!["version".to_string()],
4962 vec![],
4963 )
4964 .unwrap();
4965 let model = TableModel::from_config(&config).unwrap();
4966
4967 let huge = 1u64 << 63;
4968 use datafusion::logical_expr::{col, in_list};
4969 let filter = in_list(
4970 col("version"),
4971 vec![
4972 Expr::Literal(ScalarValue::UInt64(Some(1)), None),
4973 Expr::Literal(ScalarValue::UInt64(Some(huge)), None),
4974 ],
4975 false,
4976 );
4977
4978 assert!(QueryPredicate::supports_filter(&filter, &model));
4979 let pred = QueryPredicate::from_filters(&[filter], &model);
4980 assert!(matches!(
4981 pred.constraints.get(&0),
4982 Some(PredicateConstraint::UInt64In(v)) if v.contains(&huge) && v.len() == 2
4983 ));
4984 }
4985
4986 #[test]
4987 fn fixed_binary_in_list_pushdown() {
4988 let config = KvTableConfig::new(
4989 0,
4990 vec![
4991 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
4992 TableColumnConfig::new("data", DataType::Utf8, true),
4993 ],
4994 vec!["entity".to_string()],
4995 vec![],
4996 )
4997 .unwrap();
4998 let model = TableModel::from_config(&config).unwrap();
4999
5000 use datafusion::logical_expr::{col, in_list};
5001 let filter = in_list(
5002 col("entity"),
5003 vec![
5004 Expr::Literal(ScalarValue::FixedSizeBinary(16, Some(vec![0xAA; 16])), None),
5005 Expr::Literal(ScalarValue::FixedSizeBinary(16, Some(vec![0xBB; 16])), None),
5006 ],
5007 false,
5008 );
5009
5010 assert!(QueryPredicate::supports_filter(&filter, &model));
5011 let pred = QueryPredicate::from_filters(&[filter], &model);
5012 assert!(
5013 matches!(
5014 pred.constraints.get(&0),
5015 Some(PredicateConstraint::FixedBinaryIn(v)) if v.len() == 2
5016 ),
5017 "should extract FixedBinaryIn with 2 values"
5018 );
5019
5020 let ranges = pred
5022 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
5023 .unwrap();
5024 assert_eq!(ranges.len(), 2, "should produce one range per entity");
5025 }
5026
5027 #[test]
5028 fn decimal256_range_pushdown() {
5029 let config = KvTableConfig::new(
5030 0,
5031 vec![
5032 TableColumnConfig::new("id", DataType::Int64, false),
5033 TableColumnConfig::new("big_val", DataType::Decimal256(76, 0), false),
5034 ],
5035 vec!["id".to_string()],
5036 vec![IndexSpec::new("big_idx", vec!["big_val".to_string()]).unwrap()],
5037 )
5038 .unwrap();
5039 let model = TableModel::from_config(&config).unwrap();
5040
5041 use datafusion::logical_expr::col;
5042 let filter = col("big_val").gt_eq(Expr::Literal(
5043 ScalarValue::Decimal256(Some(i256::from(100i64)), 76, 0),
5044 None,
5045 ));
5046
5047 assert!(
5048 QueryPredicate::supports_filter(&filter, &model),
5049 "Decimal256 range should be supported"
5050 );
5051
5052 let pred = QueryPredicate::from_filters(&[filter], &model);
5053 let big_idx = *model.columns_by_name.get("big_val").unwrap();
5054 assert!(
5055 matches!(
5056 pred.constraints.get(&big_idx),
5057 Some(PredicateConstraint::Decimal256Range {
5058 min: Some(_),
5059 max: None
5060 })
5061 ),
5062 "should extract Decimal256Range with min=100, no max"
5063 );
5064
5065 let val_in = CellValue::Decimal256(i256::from(200i64));
5067 let val_out = CellValue::Decimal256(i256::from(50i64));
5068 let constraint = pred.constraints.get(&big_idx).unwrap();
5069 assert!(matches_constraint(&val_in, constraint));
5070 assert!(!matches_constraint(&val_out, constraint));
5071 }
5072
5073 #[test]
5074 fn uint64_constraint_matching_does_not_wrap_large_values() {
5075 let gt_zero = PredicateConstraint::UInt64Range {
5076 min: Some(1),
5077 max: None,
5078 };
5079 assert!(matches_constraint(&CellValue::UInt64(1u64 << 63), >_zero));
5080 assert!(!matches_constraint(&CellValue::UInt64(0), >_zero));
5081
5082 let in_list = PredicateConstraint::UInt64In(vec![1, 2, 3]);
5083 assert!(matches_constraint(&CellValue::UInt64(2), &in_list));
5084 assert!(!matches_constraint(
5085 &CellValue::UInt64(1u64 << 63),
5086 &in_list
5087 ));
5088 }
5089
5090 #[test]
5091 fn uint64_empty_range_produces_no_pk_ranges() {
5092 let config = KvTableConfig::new(
5093 0,
5094 vec![TableColumnConfig::new("version", DataType::UInt64, false)],
5095 vec!["version".to_string()],
5096 vec![],
5097 )
5098 .unwrap();
5099 let model = TableModel::from_config(&config).unwrap();
5100 let mut pred = QueryPredicate::default();
5101 pred.constraints.insert(
5102 0,
5103 PredicateConstraint::UInt64Range {
5104 min: Some(10),
5105 max: Some(9),
5106 },
5107 );
5108
5109 let ranges = pred
5110 .primary_key_ranges(&model, exoware_sdk::keys::MAX_KEY_LEN)
5111 .unwrap();
5112 assert!(ranges.is_empty());
5113 }
5114
5115 #[test]
5116 fn utf8_primary_key_encoding_supports_unicode_and_long_values() {
5117 let config = KvTableConfig::new(
5118 0,
5119 vec![TableColumnConfig::new("id", DataType::Utf8, false)],
5120 vec!["id".to_string()],
5121 vec![],
5122 )
5123 .unwrap();
5124 let model = TableModel::from_config(&config).unwrap();
5125
5126 let row_non_ascii = KvRow {
5127 values: vec![CellValue::Utf8("naive-cafe-e9".replace("e9", "\u{00E9}"))],
5128 };
5129 let key_non_ascii = encode_primary_key_from_row(model.table_prefix, &row_non_ascii, &model)
5130 .expect("non-ascii PK should encode");
5131 let decoded_non_ascii = decode_primary_key(model.table_prefix, &key_non_ascii, &model)
5132 .expect("non-ascii PK should decode");
5133 assert!(matches!(
5134 decoded_non_ascii.as_slice(),
5135 [CellValue::Utf8(value)] if value == "naive-cafe-\u{00E9}"
5136 ));
5137
5138 let row_too_long = KvRow {
5139 values: vec![CellValue::Utf8("abcdefghijklmnopq".to_string())],
5140 };
5141 let key_too_long = encode_primary_key_from_row(model.table_prefix, &row_too_long, &model)
5142 .expect("long UTF-8 PK should encode");
5143 let decoded_too_long = decode_primary_key(model.table_prefix, &key_too_long, &model)
5144 .expect("long UTF-8 PK should decode");
5145 assert!(matches!(
5146 decoded_too_long.as_slice(),
5147 [CellValue::Utf8(value)] if value == "abcdefghijklmnopq"
5148 ));
5149 }
5150
5151 #[test]
5152 fn utf8_primary_key_encodes_at_max_key_payload_and_rejects_overflow() {
5153 let config = KvTableConfig::new(
5154 0,
5155 vec![TableColumnConfig::new("id", DataType::Utf8, false)],
5156 vec!["id".to_string()],
5157 vec![],
5158 )
5159 .unwrap();
5160 let model = TableModel::from_config(&config).unwrap();
5161 let max_payload = model.primary_key_prefix.max_payload_len();
5162 let max_value = "a".repeat(max_payload - 1);
5163 let overflow_value = "a".repeat(max_payload);
5164
5165 let key = encode_primary_key_from_row(
5166 model.table_prefix,
5167 &KvRow {
5168 values: vec![CellValue::Utf8(max_value.clone())],
5169 },
5170 &model,
5171 )
5172 .expect("max-length UTF-8 PK should encode");
5173 assert_eq!(key.len(), exoware_sdk::keys::MAX_KEY_LEN);
5174 let decoded = decode_primary_key(model.table_prefix, &key, &model)
5175 .expect("max-length PK should decode");
5176 assert!(matches!(
5177 decoded.as_slice(),
5178 [CellValue::Utf8(value)] if value == &max_value
5179 ));
5180
5181 let err = encode_primary_key_from_row(
5182 model.table_prefix,
5183 &KvRow {
5184 values: vec![CellValue::Utf8(overflow_value)],
5185 },
5186 &model,
5187 )
5188 .expect_err("UTF-8 PK exceeding key payload should be rejected");
5189 assert!(err.contains("failed to encode primary key: key length 255 exceeds max 254"));
5190 }
5191
5192 #[test]
5193 fn utf8_primary_key_round_trips_embedded_nul() {
5194 let config = KvTableConfig::new(
5195 0,
5196 vec![TableColumnConfig::new("id", DataType::Utf8, false)],
5197 vec!["id".to_string()],
5198 vec![],
5199 )
5200 .unwrap();
5201 let model = TableModel::from_config(&config).unwrap();
5202 let row = KvRow {
5203 values: vec![CellValue::Utf8("AB\0CD".to_string())],
5204 };
5205
5206 let key = encode_primary_key_from_row(model.table_prefix, &row, &model)
5207 .expect("embedded NUL in key text must encode");
5208 let decoded =
5209 decode_primary_key(model.table_prefix, &key, &model).expect("embedded NUL must decode");
5210 assert!(matches!(
5211 decoded.as_slice(),
5212 [CellValue::Utf8(value)] if value == "AB\0CD"
5213 ));
5214 }
5215
5216 #[test]
5217 fn utf8_index_key_round_trips_embedded_nul() {
5218 let config = KvTableConfig::new(
5219 0,
5220 vec![
5221 TableColumnConfig::new("id", DataType::Int64, false),
5222 TableColumnConfig::new("tag", DataType::Utf8, false),
5223 ],
5224 vec!["id".to_string()],
5225 vec![IndexSpec::new("tag_idx", vec!["tag".to_string()]).unwrap()],
5226 )
5227 .unwrap();
5228 let model = TableModel::from_config(&config).unwrap();
5229 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
5230 let row = KvRow {
5231 values: vec![CellValue::Int64(1), CellValue::Utf8("AB\0CD".to_string())],
5232 };
5233
5234 let key = encode_secondary_index_key(model.table_prefix, &specs[0], &model, &row)
5235 .expect("embedded NUL in index key text must encode");
5236 let decoded = decode_secondary_index_key(model.table_prefix, &specs[0], &model, &key)
5237 .expect("embedded NUL index key must decode");
5238 assert!(matches!(
5239 decoded.values.get(&1),
5240 Some(CellValue::Utf8(value)) if value == "AB\0CD"
5241 ));
5242 }
5243
5244 #[test]
5245 fn secondary_index_with_long_utf8_primary_key_encodes_at_max_payload_and_rejects_overflow() {
5246 let config = KvTableConfig::new(
5247 0,
5248 vec![
5249 TableColumnConfig::new("id", DataType::Utf8, false),
5250 TableColumnConfig::new("tag", DataType::Utf8, false),
5251 ],
5252 vec!["id".to_string()],
5253 vec![IndexSpec::new("tag_idx", vec!["tag".to_string()]).unwrap()],
5254 )
5255 .unwrap();
5256 let model = TableModel::from_config(&config).unwrap();
5257 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
5258 let spec = &specs[0];
5259 let max_payload = spec.prefix.max_payload_len();
5260 let max_tag = "t".to_string();
5261 let max_id = "i".repeat(max_payload - encode_string_variable(&max_tag).unwrap().len() - 1);
5262 let overflow_id = format!("{max_id}x");
5263
5264 let key = encode_secondary_index_key(
5265 model.table_prefix,
5266 spec,
5267 &model,
5268 &KvRow {
5269 values: vec![
5270 CellValue::Utf8(max_id.clone()),
5271 CellValue::Utf8(max_tag.clone()),
5272 ],
5273 },
5274 )
5275 .expect("secondary key at max payload should encode");
5276 assert_eq!(key.len(), exoware_sdk::keys::MAX_KEY_LEN);
5277 let decoded =
5278 decode_secondary_index_key(model.table_prefix, spec, &model, &key).expect("decode");
5279 assert!(matches!(
5280 decoded.values.get(&1),
5281 Some(CellValue::Utf8(value)) if value == &max_tag
5282 ));
5283 assert!(matches!(
5284 decoded.primary_key_values.as_slice(),
5285 [CellValue::Utf8(value)] if value == &max_id
5286 ));
5287
5288 let err = encode_secondary_index_key(
5289 model.table_prefix,
5290 spec,
5291 &model,
5292 &KvRow {
5293 values: vec![CellValue::Utf8(overflow_id), CellValue::Utf8(max_tag)],
5294 },
5295 )
5296 .expect_err("secondary key exceeding max payload should be rejected");
5297 assert!(
5298 err.contains("failed to encode index 'tag_idx' key: key length 255 exceeds max 254")
5299 );
5300 }
5301
5302 #[test]
5303 fn secondary_index_from_parts_with_long_utf8_primary_key_rejects_overflow() {
5304 let config = KvTableConfig::new(
5305 0,
5306 vec![
5307 TableColumnConfig::new("id", DataType::Utf8, false),
5308 TableColumnConfig::new("tag", DataType::Utf8, false),
5309 ],
5310 vec!["id".to_string()],
5311 vec![IndexSpec::new("tag_idx", vec!["tag".to_string()]).unwrap()],
5312 )
5313 .unwrap();
5314 let model = TableModel::from_config(&config).unwrap();
5315 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
5316 let spec = &specs[0];
5317 let max_payload = spec.prefix.max_payload_len();
5318 let max_tag = "t".to_string();
5319 let max_id = "i".repeat(max_payload - encode_string_variable(&max_tag).unwrap().len() - 1);
5320 let overflow_id = format!("{max_id}x");
5321 let max_row = KvRow {
5322 values: vec![
5323 CellValue::Utf8(max_id.clone()),
5324 CellValue::Utf8(max_tag.clone()),
5325 ],
5326 };
5327 let encoded_row = encode_base_row_value(&max_row, &model).expect("encode row");
5328 let archived = decode_stored_row(&encoded_row).expect("archive row");
5329
5330 let key = encode_secondary_index_key_from_parts(
5331 model.table_prefix,
5332 spec,
5333 &model,
5334 &[CellValue::Utf8(max_id.clone())],
5335 &archived,
5336 )
5337 .expect("backfill path should encode max payload");
5338 assert_eq!(key.len(), exoware_sdk::keys::MAX_KEY_LEN);
5339
5340 let err = encode_secondary_index_key_from_parts(
5341 model.table_prefix,
5342 spec,
5343 &model,
5344 &[CellValue::Utf8(overflow_id)],
5345 &archived,
5346 )
5347 .expect_err("backfill path overflow should be rejected");
5348 assert!(err
5349 .to_string()
5350 .contains("failed to encode index 'tag_idx' key: key length 255 exceeds max 254"));
5351 }
5352
5353 #[test]
5354 fn primary_key_type_mismatch_returns_error_instead_of_panicking() {
5355 let config = KvTableConfig::new(
5356 0,
5357 vec![TableColumnConfig::new("id", DataType::UInt64, false)],
5358 vec!["id".to_string()],
5359 vec![],
5360 )
5361 .unwrap();
5362 let model = TableModel::from_config(&config).unwrap();
5363 let row = KvRow {
5364 values: vec![CellValue::Int64(7)],
5365 };
5366
5367 let err = encode_primary_key_from_row(model.table_prefix, &row, &model)
5368 .expect_err("mismatched PK type should return an error");
5369 assert!(err.contains("type mismatch while encoding key value"));
5370 }
5371
5372 #[test]
5373 fn choose_index_plan_uses_fixed_binary_leading_constraint() {
5374 let config = KvTableConfig::new(
5375 0,
5376 vec![
5377 TableColumnConfig::new("id", DataType::Int64, false),
5378 TableColumnConfig::new("entity", DataType::FixedSizeBinary(16), false),
5379 ],
5380 vec!["id".to_string()],
5381 vec![IndexSpec::new("entity_idx", vec!["entity".to_string()]).unwrap()],
5382 )
5383 .unwrap();
5384 let model = TableModel::from_config(&config).unwrap();
5385 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
5386
5387 use datafusion::logical_expr::col;
5388 let filter = col("entity").eq(Expr::Literal(
5389 ScalarValue::FixedSizeBinary(16, Some(vec![0xAB; 16])),
5390 None,
5391 ));
5392 let pred = QueryPredicate::from_filters(&[filter], &model);
5393 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &pred);
5394 let plan = pred
5395 .choose_index_plan(&model, &specs, &access_plan)
5396 .unwrap()
5397 .expect("fixed-binary equality should choose an index");
5398
5399 assert_eq!(plan.constrained_prefix_len, 1);
5400 assert_eq!(plan.ranges.len(), 1);
5401 let range = &plan.ranges[0];
5402 assert_eq!(
5403 index_payload(&specs[0], &range.start, 0, 16),
5404 vec![0xAB; 16]
5405 );
5406 assert_eq!(index_payload(&specs[0], &range.end, 0, 16), vec![0xAB; 16]);
5407 }
5408
5409 #[test]
5410 fn choose_index_plan_uses_decimal256_leading_constraint() {
5411 let config = KvTableConfig::new(
5412 0,
5413 vec![
5414 TableColumnConfig::new("id", DataType::Int64, false),
5415 TableColumnConfig::new("big_val", DataType::Decimal256(76, 0), false),
5416 ],
5417 vec!["id".to_string()],
5418 vec![IndexSpec::new("big_idx", vec!["big_val".to_string()]).unwrap()],
5419 )
5420 .unwrap();
5421 let model = TableModel::from_config(&config).unwrap();
5422 let specs = model.resolve_index_specs(&config.index_specs).unwrap();
5423
5424 use datafusion::logical_expr::col;
5425 let filter = col("big_val").gt_eq(Expr::Literal(
5426 ScalarValue::Decimal256(Some(i256::from(100i64)), 76, 0),
5427 None,
5428 ));
5429 let pred = QueryPredicate::from_filters(&[filter], &model);
5430 let access_plan = ScanAccessPlan::new(&model, &Some(vec![]), &pred);
5431 let plan = pred
5432 .choose_index_plan(&model, &specs, &access_plan)
5433 .unwrap()
5434 .expect("decimal256 range should choose an index");
5435
5436 assert_eq!(plan.constrained_prefix_len, 1);
5437 assert_eq!(plan.ranges.len(), 1);
5438 let range = &plan.ranges[0];
5439 assert_eq!(
5440 index_payload(&specs[0], &range.start, 0, 32),
5441 encode_i256_ordered(i256::from(100i64)).to_vec()
5442 );
5443 }
5444
5445 #[tokio::test]
5446 async fn backfill_added_indexes_writes_entries_for_existing_rows() {
5447 let state = MockState {
5448 kv: Arc::new(Mutex::new(BTreeMap::new())),
5449 range_calls: Arc::new(AtomicUsize::new(0)),
5450 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
5451 sequence_number: Arc::new(AtomicU64::new(0)),
5452 };
5453 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
5454 let client = StoreClient::new(&base_url);
5455
5456 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5457 .table(
5458 "orders",
5459 vec![
5460 TableColumnConfig::new("id", DataType::Int64, false),
5461 TableColumnConfig::new("status", DataType::Utf8, false),
5462 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5463 ],
5464 vec!["id".to_string()],
5465 vec![],
5466 )
5467 .expect("seed schema");
5468 let mut writer = seed_schema.batch_writer();
5469 for i in 0..6i64 {
5470 writer
5471 .insert(
5472 "orders",
5473 vec![
5474 CellValue::Int64(i),
5475 CellValue::Utf8(if i % 2 == 0 { "open" } else { "closed" }.to_string()),
5476 CellValue::Int64(i * 10),
5477 ],
5478 )
5479 .expect("seed row");
5480 }
5481 writer.flush().await.expect("seed flush");
5482
5483 {
5484 let guard = state.kv.lock().expect("kv mutex poisoned");
5485 let base_rows = guard
5486 .keys()
5487 .filter(|key| matches_primary_key(0, key))
5488 .count();
5489 let index_rows = guard
5490 .keys()
5491 .filter(|key| matches_secondary_index_key(0, 1, key))
5492 .count();
5493 assert_eq!(base_rows, 6);
5494 assert_eq!(index_rows, 0);
5495 }
5496
5497 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5498 .table(
5499 "orders",
5500 vec![
5501 TableColumnConfig::new("id", DataType::Int64, false),
5502 TableColumnConfig::new("status", DataType::Utf8, false),
5503 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5504 ],
5505 vec!["id".to_string()],
5506 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
5507 .expect("valid index")
5508 .with_cover_columns(vec!["amount_cents".to_string()])],
5509 )
5510 .expect("backfill schema");
5511 let report = backfill_schema
5512 .backfill_added_indexes_with_options(
5513 "orders",
5514 &[],
5515 IndexBackfillOptions {
5516 row_batch_size: 2,
5517 start_from_primary_key: None,
5518 },
5519 )
5520 .await
5521 .expect("backfill should succeed");
5522 assert_eq!(report.scanned_rows, 6);
5523 assert_eq!(report.indexes_backfilled, 1);
5524 assert_eq!(report.index_entries_written, 6);
5525
5526 {
5527 let guard = state.kv.lock().expect("kv mutex poisoned");
5528 let index_rows = guard
5529 .keys()
5530 .filter(|key| matches_secondary_index_key(0, 1, key))
5531 .count();
5532 assert_eq!(index_rows, 6);
5533 let (_, sample_value) = guard
5534 .iter()
5535 .find(|(key, _)| matches_secondary_index_key(0, 1, key))
5536 .expect("backfill should create index entry");
5537 let archived = decode_stored_row(sample_value.as_ref())
5538 .expect("covering value must be valid codec");
5539 assert_eq!(archived.values.len(), 3);
5540 }
5541
5542 let _ = shutdown_tx.send(());
5543 }
5544
5545 #[tokio::test]
5546 async fn backfill_added_indexes_writes_zorder_entries_for_existing_rows() {
5547 let state = MockState {
5548 kv: Arc::new(Mutex::new(BTreeMap::new())),
5549 range_calls: Arc::new(AtomicUsize::new(0)),
5550 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
5551 sequence_number: Arc::new(AtomicU64::new(0)),
5552 };
5553 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
5554 let client = StoreClient::new(&base_url);
5555
5556 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5557 .table(
5558 "points",
5559 vec![
5560 TableColumnConfig::new("x", DataType::Int64, false),
5561 TableColumnConfig::new("y", DataType::Int64, false),
5562 TableColumnConfig::new("id", DataType::Int64, false),
5563 TableColumnConfig::new("value", DataType::Int64, false),
5564 ],
5565 vec!["id".to_string()],
5566 vec![],
5567 )
5568 .expect("seed schema");
5569 let mut writer = seed_schema.batch_writer();
5570 for (x, y, id, value) in [(1, 1, 11, 110), (1, 2, 12, 120), (2, 1, 21, 210)] {
5571 writer
5572 .insert(
5573 "points",
5574 vec![
5575 CellValue::Int64(x),
5576 CellValue::Int64(y),
5577 CellValue::Int64(id),
5578 CellValue::Int64(value),
5579 ],
5580 )
5581 .expect("seed row");
5582 }
5583 writer.flush().await.expect("seed flush");
5584
5585 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5586 .table(
5587 "points",
5588 vec![
5589 TableColumnConfig::new("x", DataType::Int64, false),
5590 TableColumnConfig::new("y", DataType::Int64, false),
5591 TableColumnConfig::new("id", DataType::Int64, false),
5592 TableColumnConfig::new("value", DataType::Int64, false),
5593 ],
5594 vec!["id".to_string()],
5595 vec![
5596 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
5597 .expect("valid index")
5598 .with_cover_columns(vec!["value".to_string()]),
5599 ],
5600 )
5601 .expect("backfill schema");
5602 let report = backfill_schema
5603 .backfill_added_indexes_with_options(
5604 "points",
5605 &[],
5606 IndexBackfillOptions {
5607 row_batch_size: 2,
5608 start_from_primary_key: None,
5609 },
5610 )
5611 .await
5612 .expect("backfill should succeed");
5613 assert_eq!(report.scanned_rows, 3);
5614 assert_eq!(report.index_entries_written, 3);
5615
5616 let guard = state.kv.lock().expect("kv mutex poisoned");
5617 let index_entry = guard
5618 .keys()
5619 .find(|key| matches_secondary_index_key(0, 1, key))
5620 .cloned()
5621 .expect("z-order backfill should create index entry");
5622 let config = KvTableConfig::new(
5623 0,
5624 vec![
5625 TableColumnConfig::new("x", DataType::Int64, false),
5626 TableColumnConfig::new("y", DataType::Int64, false),
5627 TableColumnConfig::new("id", DataType::Int64, false),
5628 TableColumnConfig::new("value", DataType::Int64, false),
5629 ],
5630 vec!["id".to_string()],
5631 vec![
5632 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()]).expect("valid"),
5633 ],
5634 )
5635 .expect("config");
5636 let model = TableModel::from_config(&config).expect("model");
5637 let spec = model
5638 .resolve_index_specs(&config.index_specs)
5639 .expect("specs")
5640 .remove(0);
5641 let decoded = decode_secondary_index_key(model.table_prefix, &spec, &model, &index_entry)
5642 .expect("decode z-order key");
5643 let x_idx = *model.columns_by_name.get("x").unwrap();
5644 let y_idx = *model.columns_by_name.get("y").unwrap();
5645 assert!(matches!(
5646 decoded.values.get(&x_idx),
5647 Some(CellValue::Int64(_))
5648 ));
5649 assert!(matches!(
5650 decoded.values.get(&y_idx),
5651 Some(CellValue::Int64(_))
5652 ));
5653
5654 let _ = shutdown_tx.send(());
5655 }
5656
5657 #[tokio::test]
5658 async fn backfill_added_indexes_requires_append_only_index_evolution() {
5659 let client = StoreClient::new("http://127.0.0.1:1");
5660 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
5661 .table(
5662 "orders",
5663 vec![
5664 TableColumnConfig::new("id", DataType::Int64, false),
5665 TableColumnConfig::new("status", DataType::Utf8, false),
5666 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5667 ],
5668 vec!["id".to_string()],
5669 vec![
5670 IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid"),
5671 IndexSpec::new("amount_idx", vec!["amount_cents".to_string()]).expect("valid"),
5672 ],
5673 )
5674 .expect("schema");
5675
5676 let previous_specs =
5677 vec![IndexSpec::new("amount_idx", vec!["amount_cents".to_string()]).expect("valid")];
5678 let err = schema
5679 .backfill_added_indexes("orders", &previous_specs)
5680 .await
5681 .expect_err("non-append-only evolution should be rejected");
5682 assert!(err
5683 .to_string()
5684 .contains("index evolution must be append-only"));
5685 }
5686
5687 #[tokio::test]
5688 async fn backfill_added_indexes_is_noop_when_no_new_indexes() {
5689 let client = StoreClient::new("http://127.0.0.1:1");
5690 let existing = IndexSpec::new("status_idx", vec!["status".to_string()])
5691 .expect("valid")
5692 .with_cover_columns(vec!["amount_cents".to_string()]);
5693 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
5694 .table(
5695 "orders",
5696 vec![
5697 TableColumnConfig::new("id", DataType::Int64, false),
5698 TableColumnConfig::new("status", DataType::Utf8, false),
5699 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5700 ],
5701 vec!["id".to_string()],
5702 vec![existing.clone()],
5703 )
5704 .expect("schema");
5705
5706 let report = schema
5707 .backfill_added_indexes("orders", &[existing])
5708 .await
5709 .expect("no-op backfill should succeed");
5710 assert_eq!(report, IndexBackfillReport::default());
5711 }
5712
5713 #[tokio::test]
5714 async fn backfill_added_indexes_rejects_zero_row_batch_size() {
5715 let client = StoreClient::new("http://127.0.0.1:1");
5716 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
5717 .table(
5718 "orders",
5719 vec![
5720 TableColumnConfig::new("id", DataType::Int64, false),
5721 TableColumnConfig::new("status", DataType::Utf8, false),
5722 ],
5723 vec!["id".to_string()],
5724 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
5725 )
5726 .expect("schema");
5727 let err = schema
5728 .backfill_added_indexes_with_options(
5729 "orders",
5730 &[],
5731 IndexBackfillOptions {
5732 row_batch_size: 0,
5733 start_from_primary_key: None,
5734 },
5735 )
5736 .await
5737 .expect_err("row_batch_size=0 should fail");
5738 assert!(err.to_string().contains("row_batch_size must be > 0"));
5739 }
5740
5741 #[tokio::test]
5742 async fn backfill_added_indexes_emits_progress_events() {
5743 let state = MockState {
5744 kv: Arc::new(Mutex::new(BTreeMap::new())),
5745 range_calls: Arc::new(AtomicUsize::new(0)),
5746 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
5747 sequence_number: Arc::new(AtomicU64::new(0)),
5748 };
5749 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
5750 let client = StoreClient::new(&base_url);
5751
5752 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5753 .table(
5754 "orders",
5755 vec![
5756 TableColumnConfig::new("id", DataType::Int64, false),
5757 TableColumnConfig::new("status", DataType::Utf8, false),
5758 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5759 ],
5760 vec!["id".to_string()],
5761 vec![],
5762 )
5763 .expect("seed schema");
5764 let mut writer = seed_schema.batch_writer();
5765 for i in 0..5i64 {
5766 writer
5767 .insert(
5768 "orders",
5769 vec![
5770 CellValue::Int64(i),
5771 CellValue::Utf8("open".to_string()),
5772 CellValue::Int64(i * 10),
5773 ],
5774 )
5775 .expect("seed row");
5776 }
5777 writer.flush().await.expect("seed flush");
5778
5779 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5780 .table(
5781 "orders",
5782 vec![
5783 TableColumnConfig::new("id", DataType::Int64, false),
5784 TableColumnConfig::new("status", DataType::Utf8, false),
5785 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5786 ],
5787 vec!["id".to_string()],
5788 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
5789 )
5790 .expect("backfill schema");
5791
5792 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
5793 let report = backfill_schema
5794 .backfill_added_indexes_with_options_and_progress(
5795 "orders",
5796 &[],
5797 IndexBackfillOptions {
5798 row_batch_size: 2,
5799 start_from_primary_key: None,
5800 },
5801 Some(&progress_tx),
5802 )
5803 .await
5804 .expect("backfill should succeed");
5805 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 1);
5806 drop(progress_tx);
5807
5808 let mut saw_started = false;
5809 let mut saw_completed = false;
5810 let mut progress_events = 0usize;
5811 while let Some(event) = progress_rx.recv().await {
5812 match event {
5813 IndexBackfillEvent::Started {
5814 table_name,
5815 indexes_backfilled,
5816 row_batch_size,
5817 ..
5818 } => {
5819 saw_started = true;
5820 assert_eq!(table_name, "orders");
5821 assert_eq!(indexes_backfilled, 1);
5822 assert_eq!(row_batch_size, 2);
5823 }
5824 IndexBackfillEvent::Progress {
5825 scanned_rows,
5826 index_entries_written,
5827 ..
5828 } => {
5829 progress_events += 1;
5830 assert!(scanned_rows >= 1);
5831 assert_eq!(scanned_rows, index_entries_written);
5832 }
5833 IndexBackfillEvent::Completed {
5834 report: completed_report,
5835 } => {
5836 saw_completed = true;
5837 assert_eq!(completed_report, report);
5838 }
5839 }
5840 }
5841 assert!(saw_started);
5842 assert!(saw_completed);
5843 assert!(progress_events >= 1);
5844
5845 let _ = shutdown_tx.send(());
5846 }
5847
5848 #[tokio::test]
5849 async fn backfill_resume_cursor_respects_namespace_capacity() {
5850 let state = MockState {
5851 kv: Arc::new(Mutex::new(BTreeMap::new())),
5852 range_calls: Arc::new(AtomicUsize::new(0)),
5853 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
5854 sequence_number: Arc::new(AtomicU64::new(0)),
5855 };
5856 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
5857 let prefix = exoware_sdk::StoreKeyPrefix::new(vec![42; 245]).unwrap();
5858 let client = StoreClient::new(&base_url).prefixed(prefix.clone());
5859 let columns = vec![
5860 TableColumnConfig::new("id", DataType::Int64, false),
5861 TableColumnConfig::new("tag", DataType::FixedSizeBinary(0), false),
5862 ];
5863 let seed = KvSchema::new(client.clone())
5864 .table("rows", columns.clone(), vec!["id".into()], vec![])
5865 .unwrap();
5866 let mut writer = seed.batch_writer();
5867 for id in [0, 1, i64::MAX] {
5868 writer
5869 .insert(
5870 "rows",
5871 vec![CellValue::Int64(id), CellValue::FixedBinary(vec![])],
5872 )
5873 .unwrap();
5874 }
5875 writer.flush().await.unwrap();
5876 let schema = KvSchema::new(client)
5877 .table(
5878 "rows",
5879 columns,
5880 vec!["id".into()],
5881 vec![IndexSpec::new("tag_idx", vec!["tag".into()]).unwrap()],
5882 )
5883 .unwrap();
5884 let (tx, mut rx) = mpsc::unbounded_channel();
5885 let report = schema
5886 .backfill_added_indexes_with_options_and_progress(
5887 "rows",
5888 &[],
5889 IndexBackfillOptions {
5890 row_batch_size: 1,
5891 start_from_primary_key: None,
5892 },
5893 Some(&tx),
5894 )
5895 .await
5896 .unwrap();
5897 assert_eq!(report.scanned_rows, 3);
5898 drop(tx);
5899 let mut cursors = Vec::new();
5900 while let Some(event) = rx.recv().await {
5901 if let IndexBackfillEvent::Progress { next_cursor, .. } = event {
5902 cursors.push(next_cursor);
5903 }
5904 }
5905 assert_eq!(cursors.len(), 3);
5906 let cursor = cursors[0].clone().expect("another primary key fits");
5907 prefix
5908 .encode_key(&cursor)
5909 .expect("persisted cursor must fit");
5910 assert_eq!(
5911 cursors[2], None,
5912 "last primary key has no successor in its family"
5913 );
5914 let resumed = schema
5915 .backfill_added_indexes_with_options(
5916 "rows",
5917 &[],
5918 IndexBackfillOptions {
5919 row_batch_size: 1,
5920 start_from_primary_key: Some(cursor),
5921 },
5922 )
5923 .await
5924 .unwrap();
5925 assert_eq!(resumed.scanned_rows, 2);
5926 {
5927 let rows = state.kv.lock().unwrap();
5928 assert_eq!(rows.len(), 6);
5929 assert!(rows.keys().all(|key| key.len() == 254));
5930 }
5931 let _ = shutdown_tx.send(());
5932 }
5933
5934 #[tokio::test]
5935 async fn backfill_added_indexes_can_resume_from_primary_key() {
5936 let state = MockState {
5937 kv: Arc::new(Mutex::new(BTreeMap::new())),
5938 range_calls: Arc::new(AtomicUsize::new(0)),
5939 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
5940 sequence_number: Arc::new(AtomicU64::new(0)),
5941 };
5942 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
5943 let client = StoreClient::new(&base_url);
5944
5945 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5946 .table(
5947 "orders",
5948 vec![
5949 TableColumnConfig::new("id", DataType::Int64, false),
5950 TableColumnConfig::new("status", DataType::Utf8, false),
5951 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5952 ],
5953 vec!["id".to_string()],
5954 vec![],
5955 )
5956 .expect("seed schema");
5957 let mut writer = seed_schema.batch_writer();
5958 for i in 0..6i64 {
5959 writer
5960 .insert(
5961 "orders",
5962 vec![
5963 CellValue::Int64(i),
5964 CellValue::Utf8("open".to_string()),
5965 CellValue::Int64(i * 10),
5966 ],
5967 )
5968 .expect("seed row");
5969 }
5970 writer.flush().await.expect("seed flush");
5971
5972 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
5973 .table(
5974 "orders",
5975 vec![
5976 TableColumnConfig::new("id", DataType::Int64, false),
5977 TableColumnConfig::new("status", DataType::Utf8, false),
5978 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5979 ],
5980 vec!["id".to_string()],
5981 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
5982 )
5983 .expect("backfill schema");
5984
5985 let config = KvTableConfig::new(
5986 0,
5987 vec![
5988 TableColumnConfig::new("id", DataType::Int64, false),
5989 TableColumnConfig::new("status", DataType::Utf8, false),
5990 TableColumnConfig::new("amount_cents", DataType::Int64, false),
5991 ],
5992 vec!["id".to_string()],
5993 vec![],
5994 )
5995 .expect("valid config");
5996 let model = TableModel::from_config(&config).expect("model");
5997 let resume_value = CellValue::Int64(3);
5998 let resume_key =
5999 encode_primary_key(model.table_prefix, &[&resume_value], &model).expect("resume key");
6000
6001 let report = backfill_schema
6002 .backfill_added_indexes_with_options(
6003 "orders",
6004 &[],
6005 IndexBackfillOptions {
6006 row_batch_size: 2,
6007 start_from_primary_key: Some(resume_key.clone()),
6008 },
6009 )
6010 .await
6011 .expect("resume backfill should succeed");
6012 assert_eq!(report.scanned_rows, 3);
6013 assert_eq!(report.index_entries_written, 3);
6014
6015 {
6016 let guard = state.kv.lock().expect("kv mutex poisoned");
6017 let index_rows = guard
6018 .keys()
6019 .filter(|key| matches_secondary_index_key(0, 1, key))
6020 .count();
6021 assert_eq!(index_rows, 3);
6022 }
6023
6024 let resume_payload = model
6025 .primary_key_prefix
6026 .strip(&resume_key)
6027 .expect("resume payload");
6028 let wrong_prefix = secondary_index_prefix(model.table_prefix, 1)
6029 .expect("secondary prefix")
6030 .encode(&resume_payload)
6031 .expect("wrong prefix key");
6032 let err = backfill_schema
6033 .backfill_added_indexes_with_options(
6034 "orders",
6035 &[],
6036 IndexBackfillOptions {
6037 row_batch_size: 2,
6038 start_from_primary_key: Some(wrong_prefix),
6039 },
6040 )
6041 .await
6042 .expect_err("wrong key prefix must be rejected");
6043 assert!(err.to_string().contains("primary-key prefix"));
6044
6045 let _ = shutdown_tx.send(());
6046 }
6047
6048 #[tokio::test]
6049 async fn covering_index_scan_fails_closed_when_covering_payload_missing() {
6050 let state = MockState {
6051 kv: Arc::new(Mutex::new(BTreeMap::new())),
6052 range_calls: Arc::new(AtomicUsize::new(0)),
6053 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
6054 sequence_number: Arc::new(AtomicU64::new(0)),
6055 };
6056 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
6057 let client = StoreClient::new(&base_url);
6058
6059 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6060 .table(
6061 "orders",
6062 vec![
6063 TableColumnConfig::new("id", DataType::Int64, false),
6064 TableColumnConfig::new("status", DataType::Utf8, false),
6065 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6066 ],
6067 vec!["id".to_string()],
6068 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
6069 .expect("valid")
6070 .with_cover_columns(vec!["amount_cents".to_string()])],
6071 )
6072 .expect("schema");
6073 let mut writer = schema.batch_writer();
6074 for id in 0..4i64 {
6075 writer
6076 .insert(
6077 "orders",
6078 vec![
6079 CellValue::Int64(id),
6080 CellValue::Utf8("open".to_string()),
6081 CellValue::Int64(id * 10),
6082 ],
6083 )
6084 .expect("row");
6085 }
6086 writer.flush().await.expect("flush");
6087
6088 {
6089 let mut guard = state.kv.lock().expect("kv mutex poisoned");
6090 let key = guard
6091 .keys()
6092 .find(|key| matches_secondary_index_key(0, 1, key))
6093 .expect("index row should exist")
6094 .clone();
6095 guard.insert(key, Bytes::new());
6096 }
6097
6098 let ctx = session_context();
6099 schema.register_all(&ctx).expect("register");
6100 let df = ctx
6101 .sql("SELECT amount_cents FROM orders WHERE status = 'open'")
6102 .await
6103 .expect("query should plan");
6104 let err = df
6105 .collect()
6106 .await
6107 .expect_err("missing covering payload must fail closed");
6108 assert!(err
6109 .to_string()
6110 .contains("secondary index entry missing covering payload"));
6111
6112 let _ = shutdown_tx.send(());
6113 }
6114
6115 #[tokio::test]
6116 async fn covering_index_scan_fails_closed_when_covering_payload_is_corrupt() {
6117 let state = MockState {
6118 kv: Arc::new(Mutex::new(BTreeMap::new())),
6119 range_calls: Arc::new(AtomicUsize::new(0)),
6120 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
6121 sequence_number: Arc::new(AtomicU64::new(0)),
6122 };
6123 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
6124 let client = StoreClient::new(&base_url);
6125
6126 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6127 .table(
6128 "orders",
6129 vec![
6130 TableColumnConfig::new("id", DataType::Int64, false),
6131 TableColumnConfig::new("status", DataType::Utf8, false),
6132 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6133 ],
6134 vec!["id".to_string()],
6135 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
6136 .expect("valid")
6137 .with_cover_columns(vec!["amount_cents".to_string()])],
6138 )
6139 .expect("schema");
6140 let mut writer = schema.batch_writer();
6141 for id in 0..4i64 {
6142 writer
6143 .insert(
6144 "orders",
6145 vec![
6146 CellValue::Int64(id),
6147 CellValue::Utf8("open".to_string()),
6148 CellValue::Int64(id * 10),
6149 ],
6150 )
6151 .expect("row");
6152 }
6153 writer.flush().await.expect("flush");
6154
6155 {
6156 let mut guard = state.kv.lock().expect("kv mutex poisoned");
6157 let key = guard
6158 .keys()
6159 .find(|key| matches_secondary_index_key(0, 1, key))
6160 .expect("index row should exist")
6161 .clone();
6162 guard.insert(key, Bytes::from_static(b"not-codec"));
6163 }
6164
6165 let ctx = session_context();
6166 schema.register_all(&ctx).expect("register");
6167 let df = ctx
6168 .sql("SELECT amount_cents FROM orders WHERE status = 'open'")
6169 .await
6170 .expect("query should plan");
6171 let err = df
6172 .collect()
6173 .await
6174 .expect_err("corrupt covering payload must fail closed");
6175 assert!(err.to_string().contains("invalid covering index payload"));
6176
6177 let _ = shutdown_tx.send(());
6178 }
6179
6180 #[tokio::test]
6181 async fn non_covering_index_uses_point_lookup_instead_of_full_scan() {
6182 let state = MockState {
6183 kv: Arc::new(Mutex::new(BTreeMap::new())),
6184 range_calls: Arc::new(AtomicUsize::new(0)),
6185 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
6186 sequence_number: Arc::new(AtomicU64::new(0)),
6187 };
6188 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
6189 let client = StoreClient::new(&base_url);
6190
6191 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6192 .table(
6193 "orders",
6194 vec![
6195 TableColumnConfig::new("id", DataType::Int64, false),
6196 TableColumnConfig::new("status", DataType::Utf8, false),
6197 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6198 TableColumnConfig::new("notes", DataType::Utf8, true),
6199 ],
6200 vec!["id".to_string()],
6201 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
6202 )
6203 .expect("schema");
6204 let mut writer = schema.batch_writer();
6205 writer
6206 .insert(
6207 "orders",
6208 vec![
6209 CellValue::Int64(1),
6210 CellValue::Utf8("open".to_string()),
6211 CellValue::Int64(100),
6212 CellValue::Utf8("first".to_string()),
6213 ],
6214 )
6215 .expect("row");
6216 writer
6217 .insert(
6218 "orders",
6219 vec![
6220 CellValue::Int64(2),
6221 CellValue::Utf8("closed".to_string()),
6222 CellValue::Int64(200),
6223 CellValue::Utf8("second".to_string()),
6224 ],
6225 )
6226 .expect("row");
6227 writer
6228 .insert(
6229 "orders",
6230 vec![
6231 CellValue::Int64(3),
6232 CellValue::Utf8("open".to_string()),
6233 CellValue::Int64(300),
6234 CellValue::Utf8("third".to_string()),
6235 ],
6236 )
6237 .expect("row");
6238 writer.flush().await.expect("flush");
6239
6240 let ctx = session_context();
6241 schema.register_all(&ctx).expect("register");
6242
6243 let df = ctx
6244 .sql("SELECT id, notes FROM orders WHERE status = 'open' ORDER BY id")
6245 .await
6246 .expect("plan");
6247 let batches = df.collect().await.expect("non-covering index lookup");
6248 let ids: Vec<i64> = batches
6249 .iter()
6250 .flat_map(|b| {
6251 b.column(0)
6252 .as_any()
6253 .downcast_ref::<datafusion::arrow::array::Int64Array>()
6254 .unwrap()
6255 .iter()
6256 .map(|v| v.unwrap())
6257 })
6258 .collect();
6259 let notes: Vec<String> = batches
6260 .iter()
6261 .flat_map(|b| {
6262 b.column(1)
6263 .as_any()
6264 .downcast_ref::<datafusion::arrow::array::StringArray>()
6265 .unwrap()
6266 .iter()
6267 .map(|v| v.unwrap().to_string())
6268 })
6269 .collect();
6270 assert_eq!(ids, vec![1, 3]);
6271 assert_eq!(notes, vec!["first", "third"]);
6272
6273 let _ = shutdown_tx.send(());
6274 }
6275
6276 #[tokio::test]
6277 async fn backfill_resume_cursor_can_continue_without_skips_or_duplicates() {
6278 let state = MockState {
6279 kv: Arc::new(Mutex::new(BTreeMap::new())),
6280 range_calls: Arc::new(AtomicUsize::new(0)),
6281 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
6282 sequence_number: Arc::new(AtomicU64::new(0)),
6283 };
6284 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
6285 let client = StoreClient::new(&base_url);
6286
6287 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6288 .table(
6289 "orders",
6290 vec![
6291 TableColumnConfig::new("id", DataType::Int64, false),
6292 TableColumnConfig::new("status", DataType::Utf8, false),
6293 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6294 ],
6295 vec!["id".to_string()],
6296 vec![],
6297 )
6298 .expect("seed schema");
6299 let mut writer = seed_schema.batch_writer();
6300 for i in 0..8i64 {
6301 writer
6302 .insert(
6303 "orders",
6304 vec![
6305 CellValue::Int64(i),
6306 CellValue::Utf8("open".to_string()),
6307 CellValue::Int64(i * 10),
6308 ],
6309 )
6310 .expect("seed row");
6311 }
6312 writer.flush().await.expect("seed flush");
6313
6314 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6315 .table(
6316 "orders",
6317 vec![
6318 TableColumnConfig::new("id", DataType::Int64, false),
6319 TableColumnConfig::new("status", DataType::Utf8, false),
6320 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6321 ],
6322 vec!["id".to_string()],
6323 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
6324 )
6325 .expect("backfill schema");
6326
6327 let task_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6328 .table(
6329 "orders",
6330 vec![
6331 TableColumnConfig::new("id", DataType::Int64, false),
6332 TableColumnConfig::new("status", DataType::Utf8, false),
6333 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6334 ],
6335 vec!["id".to_string()],
6336 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("valid")],
6337 )
6338 .expect("task schema");
6339 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
6340 let handle = tokio::spawn(async move {
6341 task_schema
6342 .backfill_added_indexes_with_options_and_progress(
6343 "orders",
6344 &[],
6345 IndexBackfillOptions {
6346 row_batch_size: 2,
6347 start_from_primary_key: None,
6348 },
6349 Some(&progress_tx),
6350 )
6351 .await
6352 });
6353
6354 let mut resume_cursor = None;
6355 while let Some(event) = progress_rx.recv().await {
6356 if let IndexBackfillEvent::Progress { next_cursor, .. } = event {
6357 resume_cursor = next_cursor;
6358 break;
6359 }
6360 }
6361 handle.abort();
6362 let resume_cursor =
6363 resume_cursor.expect("first progress event should provide resume cursor");
6364
6365 let report = backfill_schema
6366 .backfill_added_indexes_with_options(
6367 "orders",
6368 &[],
6369 IndexBackfillOptions {
6370 row_batch_size: 2,
6371 start_from_primary_key: Some(resume_cursor),
6372 },
6373 )
6374 .await
6375 .expect("resume backfill should succeed");
6376 assert_eq!(report.scanned_rows, 6);
6377
6378 let guard = state.kv.lock().expect("kv mutex poisoned");
6379 let base_rows = guard
6380 .keys()
6381 .filter(|key| matches_primary_key(0, key))
6382 .count();
6383 let index_rows = guard
6384 .keys()
6385 .filter(|key| matches_secondary_index_key(0, 1, key))
6386 .count();
6387 assert_eq!(base_rows, 8);
6388 assert_eq!(
6389 index_rows, 8,
6390 "resume should backfill each row exactly once"
6391 );
6392
6393 let _ = shutdown_tx.send(());
6394 }
6395
6396 #[tokio::test]
6397 async fn concurrent_writes_during_backfill_preserve_index_correctness() {
6398 let state = MockState {
6399 kv: Arc::new(Mutex::new(BTreeMap::new())),
6400 range_calls: Arc::new(AtomicUsize::new(0)),
6401 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
6402 sequence_number: Arc::new(AtomicU64::new(0)),
6403 };
6404 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
6405 let client = StoreClient::new(&base_url);
6406
6407 let seed_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6408 .table(
6409 "orders",
6410 vec![
6411 TableColumnConfig::new("id", DataType::Int64, false),
6412 TableColumnConfig::new("status", DataType::Utf8, false),
6413 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6414 ],
6415 vec!["id".to_string()],
6416 vec![],
6417 )
6418 .expect("seed schema");
6419 let mut seed_writer = seed_schema.batch_writer();
6420 for i in 0..40i64 {
6421 seed_writer
6422 .insert(
6423 "orders",
6424 vec![
6425 CellValue::Int64(i),
6426 CellValue::Utf8(if i % 2 == 0 { "open" } else { "closed" }.to_string()),
6427 CellValue::Int64(i * 10),
6428 ],
6429 )
6430 .expect("seed row");
6431 }
6432 seed_writer.flush().await.expect("seed flush");
6433
6434 let backfill_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6435 .table(
6436 "orders",
6437 vec![
6438 TableColumnConfig::new("id", DataType::Int64, false),
6439 TableColumnConfig::new("status", DataType::Utf8, false),
6440 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6441 ],
6442 vec!["id".to_string()],
6443 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
6444 .expect("valid")
6445 .with_cover_columns(vec!["amount_cents".to_string()])],
6446 )
6447 .expect("backfill schema");
6448
6449 let task_schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
6450 .table(
6451 "orders",
6452 vec![
6453 TableColumnConfig::new("id", DataType::Int64, false),
6454 TableColumnConfig::new("status", DataType::Utf8, false),
6455 TableColumnConfig::new("amount_cents", DataType::Int64, false),
6456 ],
6457 vec!["id".to_string()],
6458 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
6459 .expect("valid")
6460 .with_cover_columns(vec!["amount_cents".to_string()])],
6461 )
6462 .expect("task schema");
6463 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
6464 let handle = tokio::spawn(async move {
6465 task_schema
6466 .backfill_added_indexes_with_options_and_progress(
6467 "orders",
6468 &[],
6469 IndexBackfillOptions {
6470 row_batch_size: 5,
6471 start_from_primary_key: None,
6472 },
6473 Some(&progress_tx),
6474 )
6475 .await
6476 });
6477
6478 while let Some(event) = progress_rx.recv().await {
6479 if matches!(event, IndexBackfillEvent::Progress { .. }) {
6480 break;
6481 }
6482 }
6483
6484 let mut concurrent_writer = backfill_schema.batch_writer();
6485 for id in [100i64, 101i64] {
6486 concurrent_writer
6487 .insert(
6488 "orders",
6489 vec![
6490 CellValue::Int64(id),
6491 CellValue::Utf8("open".to_string()),
6492 CellValue::Int64(id * 10),
6493 ],
6494 )
6495 .expect("concurrent row");
6496 }
6497 concurrent_writer.flush().await.expect("concurrent flush");
6498
6499 let report = handle
6500 .await
6501 .expect("backfill task join")
6502 .expect("backfill result");
6503 assert!(
6504 report.scanned_rows >= 40,
6505 "backfill should at least scan the original historical rows"
6506 );
6507
6508 let guard = state.kv.lock().expect("kv mutex poisoned");
6509 let base_rows = guard
6510 .keys()
6511 .filter(|key| matches_primary_key(0, key))
6512 .count();
6513 let index_rows = guard
6514 .keys()
6515 .filter(|key| matches_secondary_index_key(0, 1, key))
6516 .count();
6517 assert_eq!(base_rows, 42);
6518 assert_eq!(
6519 index_rows, 42,
6520 "historical backfill plus concurrent indexed writes should leave one index row per base row"
6521 );
6522
6523 let _ = shutdown_tx.send(());
6524 }
6525
6526 #[derive(Clone)]
6527 struct DeferredChunkRangeHarness {
6528 first_chunk_sent: Arc<Notify>,
6529 release_second_chunk: Arc<Notify>,
6530 first_frame: ProtoRangeFrame,
6531 second_frame: ProtoRangeFrame,
6532 }
6533
6534 impl QueryService for DeferredChunkRangeHarness {
6535 async fn get(
6536 &self,
6537 _ctx: Context,
6538 _request: ServiceRequest<'_, ProtoGetRequest>,
6539 ) -> connectrpc::ServiceResult<ProtoGetResponse> {
6540 Err(ConnectError::unimplemented("test harness"))
6541 }
6542
6543 async fn get_many(
6544 &self,
6545 _ctx: Context,
6546 _request: ServiceRequest<'_, ProtoGetManyRequest>,
6547 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoGetManyFrame>> {
6548 Err(ConnectError::unimplemented("test harness"))
6549 }
6550
6551 async fn range(
6552 &self,
6553 _ctx: Context,
6554 _request: ServiceRequest<'_, ProtoRangeRequest>,
6555 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoRangeFrame>> {
6556 let first_chunk_sent = self.first_chunk_sent.clone();
6557 let release_second_chunk = self.release_second_chunk.clone();
6558 let first_frame = self.first_frame.clone();
6559 let second_frame = self.second_frame.clone();
6560 let stream = stream::try_unfold(0u8, move |state| {
6561 let first_chunk_sent = first_chunk_sent.clone();
6562 let release_second_chunk = release_second_chunk.clone();
6563 let first_frame = first_frame.clone();
6564 let second_frame = second_frame.clone();
6565 async move {
6566 match state {
6567 0 => {
6568 first_chunk_sent.notify_one();
6569 Ok(Some((first_frame, 1)))
6570 }
6571 1 => {
6572 release_second_chunk.notified().await;
6573 Ok(Some((second_frame, 2)))
6574 }
6575 _ => Ok(None),
6576 }
6577 }
6578 });
6579 Ok(connectrpc::Response::stream(stream))
6580 }
6581
6582 async fn reduce(
6583 &self,
6584 _ctx: Context,
6585 _request: ServiceRequest<'_, ProtoReduceRequest>,
6586 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoReduceResponse>> {
6587 Err(ConnectError::unimplemented("test harness"))
6588 }
6589 }
6590
6591 #[derive(Clone)]
6592 struct ObservedLimitRangeHarness {
6593 request_received: Arc<Notify>,
6594 release_second_chunk: Arc<Notify>,
6595 observed_limit: Arc<AtomicUsize>,
6596 observed_mode: Arc<AtomicUsize>,
6597 first_frame: ProtoRangeFrame,
6598 second_frame: ProtoRangeFrame,
6599 }
6600
6601 impl QueryService for ObservedLimitRangeHarness {
6602 async fn get(
6603 &self,
6604 _ctx: Context,
6605 _request: ServiceRequest<'_, ProtoGetRequest>,
6606 ) -> connectrpc::ServiceResult<ProtoGetResponse> {
6607 Err(ConnectError::unimplemented("test harness"))
6608 }
6609
6610 async fn get_many(
6611 &self,
6612 _ctx: Context,
6613 _request: ServiceRequest<'_, ProtoGetManyRequest>,
6614 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoGetManyFrame>> {
6615 Err(ConnectError::unimplemented("test harness"))
6616 }
6617
6618 async fn range(
6619 &self,
6620 _ctx: Context,
6621 request: ServiceRequest<'_, ProtoRangeRequest>,
6622 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoRangeFrame>> {
6623 let limit = request.limit.map(|v| v as usize).unwrap_or(usize::MAX);
6624 self.observed_limit.store(limit, AtomicOrdering::SeqCst);
6625 let mode = match parse_range_traversal_direction(request.mode) {
6626 Ok(RangeTraversalDirection::Forward) => 0,
6627 Ok(RangeTraversalDirection::Reverse) => 1,
6628 Err(_) => usize::MAX,
6629 };
6630 self.observed_mode.store(mode, AtomicOrdering::SeqCst);
6631 self.request_received.notify_one();
6632 let release_second_chunk = self.release_second_chunk.clone();
6633 let first_frame = self.first_frame.clone();
6634 let second_frame = self.second_frame.clone();
6635
6636 let stream = stream::try_unfold(0u8, move |state| {
6638 let release_second_chunk = release_second_chunk.clone();
6639 let first_frame = first_frame.clone();
6640 let second_frame = second_frame.clone();
6641 async move {
6642 match state {
6643 0 => Ok(Some((first_frame, 1))),
6644 1 => {
6645 release_second_chunk.notified().await;
6646 if limit > 1 {
6647 Ok(Some((second_frame, 2)))
6648 } else {
6649 Ok(None)
6650 }
6651 }
6652 _ => Ok(None),
6653 }
6654 }
6655 });
6656 Ok(connectrpc::Response::stream(stream))
6657 }
6658
6659 async fn reduce(
6660 &self,
6661 _ctx: Context,
6662 _request: ServiceRequest<'_, ProtoReduceRequest>,
6663 ) -> connectrpc::ServiceResult<connectrpc::ServiceStream<ProtoReduceResponse>> {
6664 Err(ConnectError::unimplemented("test harness"))
6665 }
6666 }
6667
6668 #[tokio::test]
6669 async fn kv_scan_streaming_range_reads_emit_first_batch_before_full_range_completes() {
6670 let model = Arc::new(simple_int64_model(0));
6671 let first_chunk_sent = Arc::new(Notify::new());
6672 let release_second_chunk = Arc::new(Notify::new());
6673
6674 let encoded_row = (StoredRow { values: vec![None] }).encode().to_vec();
6675
6676 let first_results = {
6677 let mut results = Vec::with_capacity(BATCH_FLUSH_ROWS);
6678 for id in 0..BATCH_FLUSH_ROWS {
6679 let key =
6680 encode_primary_key(model.table_prefix, &[&CellValue::Int64(id as i64)], &model)
6681 .expect("primary key");
6682 results.push((key, encoded_row.clone()));
6683 }
6684 results
6685 };
6686 let first_frame = proto_range_entries_frame(first_results);
6687
6688 let second_results = {
6689 let key = encode_primary_key(
6690 model.table_prefix,
6691 &[&CellValue::Int64(BATCH_FLUSH_ROWS as i64)],
6692 &model,
6693 )
6694 .expect("primary key");
6695 vec![(key, encoded_row)]
6696 };
6697 let second_frame = proto_range_entries_frame(second_results);
6698
6699 let harness = DeferredChunkRangeHarness {
6700 first_chunk_sent: first_chunk_sent.clone(),
6701 release_second_chunk: release_second_chunk.clone(),
6702 first_frame,
6703 second_frame,
6704 };
6705 let connect = ConnectRpcService::new(QueryServiceServer::new(harness))
6706 .with_compression(connect_compression_registry());
6707 let app = Router::new().fallback_service(connect);
6708
6709 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6710 .await
6711 .expect("bind test listener");
6712 let url = format!("http://{}", listener.local_addr().expect("listener addr"));
6713 tokio::spawn(async move {
6714 axum::serve(listener, app).await.expect("serve test app");
6715 });
6716
6717 let client = StoreClient::new(&url);
6718 let scan = KvScanExec::new(
6719 PrefixedStoreClient::empty(client),
6720 model.clone(),
6721 Arc::new(Vec::new()),
6722 QueryPredicate::default(),
6723 None,
6724 model.schema.clone(),
6725 None,
6726 );
6727
6728 let session_ctx = SessionContext::new();
6729 let mut stream = scan
6730 .execute(0, session_ctx.task_ctx())
6731 .expect("scan execute should start");
6732
6733 tokio::time::timeout(Duration::from_secs(1), first_chunk_sent.notified())
6734 .await
6735 .expect("server should send first range frame");
6736 let first_batch = tokio::time::timeout(Duration::from_millis(200), stream.try_next())
6737 .await
6738 .expect("first record batch should arrive before the second stream chunk is released")
6739 .expect("stream poll should succeed")
6740 .expect("expected first record batch");
6741 assert_eq!(first_batch.num_rows(), BATCH_FLUSH_ROWS);
6742
6743 release_second_chunk.notify_one();
6744
6745 let second_batch = stream
6746 .try_next()
6747 .await
6748 .expect("second poll should succeed")
6749 .expect("expected second record batch");
6750 assert_eq!(second_batch.num_rows(), 1);
6751 assert!(
6752 stream
6753 .try_next()
6754 .await
6755 .expect("stream completion poll")
6756 .is_none(),
6757 "stream should finish after the second batch"
6758 );
6759 }
6760
6761 #[tokio::test]
6762 async fn kv_scan_sql_limit_is_pushed_upstream_on_exact_streaming_scan() {
6763 let request_received = Arc::new(Notify::new());
6764 let release_second_chunk = Arc::new(Notify::new());
6765 let observed_limit = Arc::new(AtomicUsize::new(0));
6766 let observed_mode = Arc::new(AtomicUsize::new(usize::MAX));
6767 let model = simple_int64_model(0);
6768
6769 let encoded_row = (StoredRow { values: vec![None] }).encode().to_vec();
6770
6771 let first_key = encode_primary_key(model.table_prefix, &[&CellValue::Int64(1)], &model)
6772 .expect("first primary key");
6773 let second_key = encode_primary_key(model.table_prefix, &[&CellValue::Int64(2)], &model)
6774 .expect("second primary key");
6775
6776 let first_frame = proto_range_entries_frame(vec![(first_key, encoded_row.clone())]);
6777 let second_frame = proto_range_entries_frame(vec![(second_key, encoded_row)]);
6778
6779 let harness = ObservedLimitRangeHarness {
6780 request_received: request_received.clone(),
6781 release_second_chunk: release_second_chunk.clone(),
6782 observed_limit: observed_limit.clone(),
6783 observed_mode,
6784 first_frame,
6785 second_frame,
6786 };
6787 let connect = ConnectRpcService::new(QueryServiceServer::new(harness))
6788 .with_compression(connect_compression_registry());
6789 let app = Router::new().fallback_service(connect);
6790
6791 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6792 .await
6793 .expect("bind test listener");
6794 let url = format!("http://{}", listener.local_addr().expect("listener addr"));
6795 tokio::spawn(async move {
6796 axum::serve(listener, app).await.expect("serve test app");
6797 });
6798
6799 let client = StoreClient::new(&url);
6800 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
6801 .table(
6802 "items",
6803 vec![TableColumnConfig::new("id", DataType::Int64, false)],
6804 vec!["id".to_string()],
6805 vec![],
6806 )
6807 .expect("schema");
6808 let ctx = session_context();
6809 schema.register_all(&ctx).expect("register");
6810 let (batches, ()) = tokio::time::timeout(Duration::from_secs(5), async {
6811 tokio::join!(
6812 async {
6813 ctx.sql("SELECT id FROM items LIMIT 1")
6814 .await
6815 .expect("query")
6816 .collect()
6817 .await
6818 .expect("collect")
6819 },
6820 async {
6821 request_received.notified().await;
6822 assert_eq!(
6823 observed_limit.load(AtomicOrdering::SeqCst),
6824 1,
6825 "exact streaming scan should push SQL LIMIT upstream"
6826 );
6827 }
6828 )
6829 })
6830 .await
6831 .expect("query with LIMIT 1 should finish without waiting for a delayed second chunk");
6832
6833 assert_eq!(
6834 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
6835 1
6836 );
6837 release_second_chunk.notify_one();
6838 }
6839
6840 #[test]
6841 fn kv_scan_physical_limit_pushdown_sets_leaf_fetch() {
6842 let model = Arc::new(simple_int64_model(0));
6843 let scan = Arc::new(KvScanExec::new(
6844 PrefixedStoreClient::empty(StoreClient::new("http://127.0.0.1:0")),
6845 model.clone(),
6846 Arc::new(Vec::new()),
6847 QueryPredicate::default(),
6848 None,
6849 model.schema.clone(),
6850 None,
6851 ));
6852 let limited: Arc<dyn ExecutionPlan> = Arc::new(GlobalLimitExec::new(scan, 0, Some(1)));
6853
6854 let optimized = LimitPushdown::new()
6855 .optimize(limited, &ConfigOptions::new())
6856 .expect("limit pushdown");
6857
6858 let scan = optimized
6859 .downcast_ref::<KvScanExec>()
6860 .expect("physical limit should be converted to a scan fetch");
6861 assert_eq!(scan.fetch(), Some(1));
6862 }
6863
6864 #[test]
6865 fn kv_scan_sort_pushdown_preserves_limit() {
6866 use datafusion::arrow::compute::SortOptions;
6867 use datafusion::physical_expr::expressions::Column;
6868 use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
6869 use datafusion::physical_optimizer::pushdown_sort::PushdownSort;
6870 use datafusion::physical_plan::sorts::sort::SortExec;
6871
6872 let model = Arc::new(simple_int64_model(0));
6873 let scan = Arc::new(KvScanExec::new(
6874 PrefixedStoreClient::empty(StoreClient::new("http://127.0.0.1:0")),
6875 model.clone(),
6876 Arc::new(Vec::new()),
6877 QueryPredicate::default(),
6878 None,
6879 model.schema.clone(),
6880 None,
6881 ));
6882 let ordering = LexOrdering::new([PhysicalSortExpr {
6883 expr: Arc::new(Column::new("id", 0)),
6884 options: SortOptions {
6885 descending: true,
6886 nulls_first: false,
6887 },
6888 }])
6889 .expect("ordering");
6890 let sorted = Arc::new(SortExec::new(ordering, scan).with_fetch(Some(1)));
6891
6892 let optimized = PushdownSort::new()
6893 .optimize(sorted, &ConfigOptions::new())
6894 .expect("sort pushdown");
6895 let scan = optimized
6896 .downcast_ref::<KvScanExec>()
6897 .expect("sort pushdown should eliminate the sort");
6898 assert_eq!(scan.fetch(), Some(1));
6899 assert_eq!(scan.scan_direction(), RangeMode::Reverse);
6900 }
6901
6902 #[test]
6903 fn kv_scan_sort_pushdown_respects_ordering_strength() {
6904 use datafusion::arrow::compute::SortOptions;
6905 use datafusion::physical_expr::expressions::Column;
6906 use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
6907 use datafusion::physical_plan::SortOrderPushdownResult;
6908
6909 let config = KvTableConfig::new(
6910 0,
6911 vec![
6912 TableColumnConfig::new("account", DataType::Int64, false),
6913 TableColumnConfig::new("height", DataType::Int64, false),
6914 ],
6915 vec!["account".to_string(), "height".to_string()],
6916 vec![],
6917 )
6918 .expect("config");
6919 let model = Arc::new(TableModel::from_config(&config).expect("model"));
6920 let scan = KvScanExec::new(
6921 PrefixedStoreClient::empty(StoreClient::new("http://127.0.0.1:0")),
6922 model.clone(),
6923 Arc::new(Vec::new()),
6924 QueryPredicate::default(),
6925 Some(2),
6926 model.schema.clone(),
6927 None,
6928 );
6929 let ordering = LexOrdering::new([
6930 PhysicalSortExpr {
6931 expr: Arc::new(Column::new("account", 0)),
6932 options: SortOptions::default(),
6933 },
6934 PhysicalSortExpr {
6935 expr: Arc::new(Column::new("height", 1)),
6936 options: SortOptions::default(),
6937 },
6938 ])
6939 .expect("ordering");
6940
6941 let SortOrderPushdownResult::Exact { inner } = scan
6942 .try_pushdown_sort(&ordering)
6943 .expect("initial sort pushdown")
6944 else {
6945 panic!("primary-key ordering should be exact");
6946 };
6947 let ordered_scan = inner.downcast_ref::<KvScanExec>().expect("ordered scan");
6948 let prefix = [ordering.first().clone()];
6949 let SortOrderPushdownResult::Exact { inner } = ordered_scan
6950 .try_pushdown_sort(&prefix)
6951 .expect("redundant sort pushdown")
6952 else {
6953 panic!("stronger ordering should exactly satisfy its prefix");
6954 };
6955 let prefix_scan = inner.downcast_ref::<KvScanExec>().expect("ordered scan");
6956
6957 assert!(Arc::ptr_eq(
6958 &ordered_scan.properties,
6959 &prefix_scan.properties
6960 ));
6961 assert_eq!(
6962 prefix_scan
6963 .properties
6964 .output_ordering()
6965 .expect("output ordering")
6966 .len(),
6967 2
6968 );
6969
6970 let SortOrderPushdownResult::Exact { inner } = scan
6971 .try_pushdown_sort(&prefix)
6972 .expect("prefix sort pushdown")
6973 else {
6974 panic!("primary-key prefix ordering should be exact");
6975 };
6976 let prefix_scan = inner.downcast_ref::<KvScanExec>().expect("ordered scan");
6977 let SortOrderPushdownResult::Exact { inner } = prefix_scan
6978 .try_pushdown_sort(&ordering)
6979 .expect("stronger sort pushdown")
6980 else {
6981 panic!("full primary-key ordering should be exact");
6982 };
6983 let stronger_scan = inner.downcast_ref::<KvScanExec>().expect("ordered scan");
6984
6985 assert!(!Arc::ptr_eq(
6986 &prefix_scan.properties,
6987 &stronger_scan.properties
6988 ));
6989 assert_eq!(
6990 stronger_scan
6991 .properties
6992 .output_ordering()
6993 .expect("output ordering")
6994 .len(),
6995 2
6996 );
6997 assert_eq!(stronger_scan.fetch(), Some(2));
6998 assert_eq!(stronger_scan.scan_direction(), RangeMode::Forward);
6999 }
7000
7001 #[tokio::test]
7002 async fn kv_scan_reverse_sort_pushdown_survives_limit_pushdown_first() {
7003 use datafusion::physical_optimizer::pushdown_sort::PushdownSort;
7004 use datafusion::physical_plan::sorts::sort::SortExec;
7005
7006 let state = session_state_builder()
7008 .with_physical_optimizer_rules(vec![])
7009 .build();
7010 let ctx = SessionContext::new_with_state(state);
7011 let schema = KvSchema::new(PrefixedStoreClient::empty(StoreClient::new(
7012 "http://127.0.0.1:0",
7013 )))
7014 .table(
7015 "events",
7016 vec![TableColumnConfig::new("id", DataType::Int64, false)],
7017 vec!["id".to_string()],
7018 vec![],
7019 )
7020 .expect("schema");
7021 schema.register_all(&ctx).expect("register");
7022
7023 let plan = ctx
7024 .sql(
7025 "SELECT id FROM (SELECT id FROM events LIMIT 2) AS limited \
7026 ORDER BY id DESC",
7027 )
7028 .await
7029 .expect("query")
7030 .create_physical_plan()
7031 .await
7032 .expect("physical plan");
7033 let plan = LimitPushdown::new()
7034 .optimize(plan, &ConfigOptions::new())
7035 .expect("limit pushdown");
7036 let sort = plan
7037 .downcast_ref::<SortExec>()
7038 .expect("sort should remain after limit pushdown");
7039 let scan = sort
7040 .input()
7041 .downcast_ref::<KvScanExec>()
7042 .expect("inner limit should be pushed into the scan");
7043 assert_eq!(scan.fetch(), Some(2));
7044 assert_eq!(scan.scan_direction(), RangeMode::Forward);
7045 assert!(scan.properties.output_ordering().is_none());
7046
7047 let plan = PushdownSort::new()
7048 .optimize(plan, &ConfigOptions::new())
7049 .expect("sort pushdown");
7050 let scan = plan
7051 .downcast_ref::<KvScanExec>()
7052 .expect("unordered fetch must not prevent exact sort pushdown");
7053 assert_eq!(scan.fetch(), Some(2));
7054 assert_eq!(scan.scan_direction(), RangeMode::Reverse);
7055 assert!(scan.properties.output_ordering().is_some());
7056 }
7057
7058 #[tokio::test]
7059 async fn kv_scan_activity_desc_order_pushes_reverse_range_limit() {
7060 let request_received = Arc::new(Notify::new());
7061 let release_second_chunk = Arc::new(Notify::new());
7062 let observed_limit = Arc::new(AtomicUsize::new(0));
7063 let observed_mode = Arc::new(AtomicUsize::new(usize::MAX));
7064 let config = KvTableConfig::new(
7065 0,
7066 vec![
7067 TableColumnConfig::new("account", DataType::UInt64, false),
7068 TableColumnConfig::new("height", DataType::UInt64, false),
7069 TableColumnConfig::new("index", DataType::UInt64, false),
7070 TableColumnConfig::new("role", DataType::UInt64, false),
7071 ],
7072 vec![
7073 "account".to_string(),
7074 "height".to_string(),
7075 "index".to_string(),
7076 "role".to_string(),
7077 ],
7078 vec![],
7079 )
7080 .expect("config");
7081 let model = TableModel::from_config(&config).expect("model");
7082 let encoded_row = (StoredRow {
7083 values: vec![None, None, None, None],
7084 })
7085 .encode()
7086 .to_vec();
7087
7088 let receiver_key = encode_primary_key(
7089 model.table_prefix,
7090 &[
7091 &CellValue::UInt64(7),
7092 &CellValue::UInt64(9),
7093 &CellValue::UInt64(4),
7094 &CellValue::UInt64(1),
7095 ],
7096 &model,
7097 )
7098 .expect("receiver primary key");
7099 let sender_key = encode_primary_key(
7100 model.table_prefix,
7101 &[
7102 &CellValue::UInt64(7),
7103 &CellValue::UInt64(9),
7104 &CellValue::UInt64(4),
7105 &CellValue::UInt64(0),
7106 ],
7107 &model,
7108 )
7109 .expect("sender primary key");
7110
7111 let first_frame = proto_range_entries_frame(vec![(receiver_key, encoded_row.clone())]);
7112 let second_frame = proto_range_entries_frame(vec![(sender_key, encoded_row)]);
7113
7114 let harness = ObservedLimitRangeHarness {
7115 request_received: request_received.clone(),
7116 release_second_chunk: release_second_chunk.clone(),
7117 observed_limit: observed_limit.clone(),
7118 observed_mode: observed_mode.clone(),
7119 first_frame,
7120 second_frame,
7121 };
7122 let connect = ConnectRpcService::new(QueryServiceServer::new(harness))
7123 .with_compression(connect_compression_registry());
7124 let app = Router::new().fallback_service(connect);
7125
7126 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7127 .await
7128 .expect("bind test listener");
7129 let url = format!("http://{}", listener.local_addr().expect("listener addr"));
7130 tokio::spawn(async move {
7131 axum::serve(listener, app).await.expect("serve test app");
7132 });
7133
7134 let client = StoreClient::new(&url);
7135 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7136 .table(
7137 "tx_activity",
7138 vec![
7139 TableColumnConfig::new("account", DataType::UInt64, false),
7140 TableColumnConfig::new("height", DataType::UInt64, false),
7141 TableColumnConfig::new("index", DataType::UInt64, false),
7142 TableColumnConfig::new("role", DataType::UInt64, false),
7143 ],
7144 vec![
7145 "account".to_string(),
7146 "height".to_string(),
7147 "index".to_string(),
7148 "role".to_string(),
7149 ],
7150 vec![],
7151 )
7152 .expect("schema");
7153 let ctx = session_context();
7154 schema.register_all(&ctx).expect("register");
7155
7156 let (batches, ()) = tokio::time::timeout(Duration::from_secs(5), async {
7157 tokio::join!(
7158 async {
7159 ctx.sql(
7160 "SELECT height, index, role \
7161 FROM tx_activity \
7162 WHERE account = 7 \
7163 ORDER BY height DESC, index DESC, role DESC \
7164 LIMIT 1",
7165 )
7166 .await
7167 .expect("query")
7168 .collect()
7169 .await
7170 .expect("collect")
7171 },
7172 async {
7173 request_received.notified().await;
7174 assert_eq!(
7175 observed_mode.load(AtomicOrdering::SeqCst),
7176 1,
7177 "activity DESC primary-key order should use reverse range traversal"
7178 );
7179 assert_eq!(
7180 observed_limit.load(AtomicOrdering::SeqCst),
7181 1,
7182 "activity DESC LIMIT should push the top-K limit to the range request"
7183 );
7184 }
7185 )
7186 })
7187 .await
7188 .expect("activity DESC LIMIT query should not wait for a delayed second chunk");
7189
7190 assert_eq!(
7191 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
7192 1
7193 );
7194 release_second_chunk.notify_one();
7195 }
7196
7197 #[tokio::test]
7198 async fn kv_scan_activity_mixed_order_does_not_push_range_limit() {
7199 let state = MockState {
7200 kv: Arc::new(Mutex::new(BTreeMap::new())),
7201 range_calls: Arc::new(AtomicUsize::new(0)),
7202 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7203 sequence_number: Arc::new(AtomicU64::new(0)),
7204 };
7205 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7206 let client = StoreClient::new(&base_url);
7207
7208 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7209 .table(
7210 "tx_activity",
7211 vec![
7212 TableColumnConfig::new("account", DataType::UInt64, false),
7213 TableColumnConfig::new("height", DataType::UInt64, false),
7214 TableColumnConfig::new("index", DataType::UInt64, false),
7215 TableColumnConfig::new("role", DataType::UInt64, false),
7216 ],
7217 vec![
7218 "account".to_string(),
7219 "height".to_string(),
7220 "index".to_string(),
7221 "role".to_string(),
7222 ],
7223 vec![],
7224 )
7225 .expect("schema");
7226 let ctx = session_context();
7227 schema.register_all(&ctx).expect("register");
7228
7229 let explain = physical_plan_text(
7230 &explain_plan_rows(
7231 &ctx,
7232 "SELECT height, index, role \
7233 FROM tx_activity \
7234 WHERE account = 7 \
7235 ORDER BY height DESC, index DESC, role ASC \
7236 LIMIT 1",
7237 )
7238 .await,
7239 );
7240 assert!(explain.contains("KvScanExec:"));
7241 assert!(
7242 explain.contains("KvScanExec: fetch=None"),
7243 "mixed direction order cannot be a bounded key-order scan:\n{explain}"
7244 );
7245 assert!(
7246 !explain.contains("KvScanExec: fetch=Some(1)"),
7247 "mixed direction order incorrectly pushed top-K into the scan:\n{explain}"
7248 );
7249
7250 let _ = shutdown_tx.send(());
7251 }
7252
7253 #[tokio::test]
7254 async fn kv_topk_sort_pushdown_with_scan_predicate() {
7255 let state = MockState {
7256 kv: Arc::new(Mutex::new(BTreeMap::new())),
7257 range_calls: Arc::new(AtomicUsize::new(0)),
7258 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7259 sequence_number: Arc::new(AtomicU64::new(0)),
7260 };
7261 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7262 let client = StoreClient::new(&base_url);
7263
7264 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7265 .table(
7266 "tx_activity",
7267 vec![
7268 TableColumnConfig::new("account", DataType::UInt64, false),
7269 TableColumnConfig::new("height", DataType::UInt64, false),
7270 TableColumnConfig::new("index", DataType::UInt64, false),
7271 TableColumnConfig::new("role", DataType::UInt64, false),
7272 ],
7273 vec![
7274 "account".to_string(),
7275 "height".to_string(),
7276 "index".to_string(),
7277 "role".to_string(),
7278 ],
7279 vec![],
7280 )
7281 .expect("schema");
7282 let ctx = session_context();
7283 schema.register_all(&ctx).expect("register");
7284
7285 let explain = physical_plan_text(
7286 &explain_plan_rows(
7287 &ctx,
7288 "SELECT height, index, role \
7289 FROM tx_activity \
7290 WHERE account = 7 AND height + 0 > 1 \
7291 ORDER BY height DESC, index DESC, role DESC \
7292 LIMIT 1",
7293 )
7294 .await,
7295 );
7296 assert!(
7297 explain.contains("KvScanExec: fetch=Some(1), direction=Some(Reverse)"),
7298 "{explain}"
7299 );
7300 assert!(
7301 explain.contains("exact=false, row_recheck=true"),
7302 "{explain}"
7303 );
7304 assert!(!explain.contains("FilterExec"), "{explain}");
7305
7306 let _ = shutdown_tx.send(());
7307 }
7308
7309 #[tokio::test]
7310 async fn kv_topk_reverse_scan_keeps_reading_after_later_pk_filter_rejects_first_key() {
7311 let state = MockState {
7312 kv: Arc::new(Mutex::new(BTreeMap::new())),
7313 range_calls: Arc::new(AtomicUsize::new(0)),
7314 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7315 sequence_number: Arc::new(AtomicU64::new(0)),
7316 };
7317 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7318 let client = StoreClient::new(&base_url);
7319
7320 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
7321 .table(
7322 "tx_activity",
7323 vec![
7324 TableColumnConfig::new("account", DataType::UInt64, false),
7325 TableColumnConfig::new("height", DataType::UInt64, false),
7326 TableColumnConfig::new("index", DataType::UInt64, false),
7327 TableColumnConfig::new("role", DataType::UInt64, false),
7328 ],
7329 vec![
7330 "account".to_string(),
7331 "height".to_string(),
7332 "index".to_string(),
7333 "role".to_string(),
7334 ],
7335 vec![],
7336 )
7337 .expect("schema");
7338 let mut writer = schema.batch_writer();
7339 writer
7340 .insert(
7341 "tx_activity",
7342 vec![
7343 CellValue::UInt64(8),
7344 CellValue::UInt64(0),
7345 CellValue::UInt64(0),
7346 CellValue::UInt64(0),
7347 ],
7348 )
7349 .expect("non-matching higher key");
7350 writer
7351 .insert(
7352 "tx_activity",
7353 vec![
7354 CellValue::UInt64(7),
7355 CellValue::UInt64(9),
7356 CellValue::UInt64(4),
7357 CellValue::UInt64(1),
7358 ],
7359 )
7360 .expect("matching lower key");
7361 writer.flush().await.expect("seed rows");
7362
7363 let ctx = session_context();
7364 schema.register_all(&ctx).expect("register");
7365 let sql = "SELECT account, height \
7366 FROM tx_activity \
7367 WHERE account >= 7 AND height = 9 \
7368 ORDER BY account DESC \
7369 LIMIT 1";
7370 let explain = physical_plan_text(&explain_plan_rows(&ctx, sql).await);
7371 assert!(
7372 explain.contains("KvScanExec: fetch=Some(1), direction=Some(Reverse)"),
7373 "test must exercise reverse top-K scan pushdown:\n{explain}"
7374 );
7375
7376 let batches = ctx
7377 .sql(sql)
7378 .await
7379 .expect("query")
7380 .collect()
7381 .await
7382 .expect("collect");
7383 assert_eq!(
7384 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
7385 1
7386 );
7387 let batch = batches
7388 .iter()
7389 .find(|batch| batch.num_rows() > 0)
7390 .expect("non-empty result batch");
7391 assert_eq!(
7392 ScalarValue::try_from_array(batch.column(0), 0).expect("account scalar"),
7393 ScalarValue::UInt64(Some(7))
7394 );
7395 assert_eq!(
7396 ScalarValue::try_from_array(batch.column(1), 0).expect("height scalar"),
7397 ScalarValue::UInt64(Some(9))
7398 );
7399
7400 let _ = shutdown_tx.send(());
7401 }
7402
7403 #[tokio::test]
7404 async fn kv_topk_reverse_scan_orders_multiple_primary_key_ranges() {
7405 let state = MockState {
7406 kv: Arc::new(Mutex::new(BTreeMap::new())),
7407 range_calls: Arc::new(AtomicUsize::new(0)),
7408 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7409 sequence_number: Arc::new(AtomicU64::new(0)),
7410 };
7411 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
7412 let client = StoreClient::new(&base_url);
7413
7414 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
7415 .table(
7416 "events",
7417 vec![TableColumnConfig::new("id", DataType::Int64, false)],
7418 vec!["id".to_string()],
7419 vec![],
7420 )
7421 .expect("schema");
7422 let mut writer = schema.batch_writer();
7423 for id in [1, 3, 10, 20] {
7424 writer
7425 .insert("events", vec![CellValue::Int64(id)])
7426 .expect("row");
7427 }
7428 writer.flush().await.expect("seed rows");
7429
7430 let ctx = session_context();
7431 schema.register_all(&ctx).expect("register");
7432 let sql = "SELECT id \
7433 FROM events \
7434 WHERE id IN (3, 20, 1, 10) \
7435 ORDER BY id DESC \
7436 LIMIT 3";
7437 let explain = physical_plan_text(&explain_plan_rows(&ctx, sql).await);
7438 assert!(
7439 explain.contains("KvScanExec: fetch=Some(3), direction=Some(Reverse)"),
7440 "test must exercise reverse top-K scan pushdown over IN ranges:\n{explain}"
7441 );
7442
7443 state.range_calls.store(0, AtomicOrdering::SeqCst);
7444 let batches = ctx
7445 .sql(sql)
7446 .await
7447 .expect("query")
7448 .collect()
7449 .await
7450 .expect("collect");
7451 assert_eq!(collect_i64_column(&batches, 0), vec![20, 10, 3]);
7452 assert_eq!(
7453 state.range_calls.load(AtomicOrdering::SeqCst),
7454 0,
7455 "point sets use GetMany"
7456 );
7457
7458 let _ = shutdown_tx.send(());
7459 }
7460
7461 #[tokio::test]
7462 async fn kv_topk_reverse_scan_applies_offset_over_multiple_primary_key_ranges() {
7463 let state = MockState {
7464 kv: Arc::new(Mutex::new(BTreeMap::new())),
7465 range_calls: Arc::new(AtomicUsize::new(0)),
7466 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7467 sequence_number: Arc::new(AtomicU64::new(0)),
7468 };
7469 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
7470 let client = StoreClient::new(&base_url);
7471
7472 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
7473 .table(
7474 "events",
7475 vec![TableColumnConfig::new("id", DataType::Int64, false)],
7476 vec!["id".to_string()],
7477 vec![],
7478 )
7479 .expect("schema");
7480 let mut writer = schema.batch_writer();
7481 for id in [1, 3, 10, 20] {
7482 writer
7483 .insert("events", vec![CellValue::Int64(id)])
7484 .expect("row");
7485 }
7486 writer.flush().await.expect("seed rows");
7487
7488 let ctx = session_context();
7489 schema.register_all(&ctx).expect("register");
7490 let sql = "SELECT id \
7491 FROM events \
7492 WHERE id IN (3, 20, 1, 10) \
7493 ORDER BY id DESC \
7494 LIMIT 2 OFFSET 1";
7495 let explain = physical_plan_text(&explain_plan_rows(&ctx, sql).await);
7496 assert!(
7497 explain.contains("KvScanExec: fetch=Some(3), direction=Some(Reverse)"),
7498 "OFFSET requires fetching offset + limit rows from the reverse scan:\n{explain}"
7499 );
7500
7501 state.range_calls.store(0, AtomicOrdering::SeqCst);
7502 let batches = ctx
7503 .sql(sql)
7504 .await
7505 .expect("query")
7506 .collect()
7507 .await
7508 .expect("collect");
7509 assert_eq!(collect_i64_column(&batches, 0), vec![10, 3]);
7510 assert_eq!(
7511 state.range_calls.load(AtomicOrdering::SeqCst),
7512 0,
7513 "point sets use GetMany"
7514 );
7515
7516 let _ = shutdown_tx.send(());
7517 }
7518
7519 #[tokio::test]
7520 async fn kv_scan_sort_pushdown_preserves_nested_limits() {
7521 use datafusion::physical_optimizer::pushdown_sort::PushdownSort;
7522
7523 let state = MockState {
7524 kv: Arc::new(Mutex::new(BTreeMap::new())),
7525 range_calls: Arc::new(AtomicUsize::new(0)),
7526 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7527 sequence_number: Arc::new(AtomicU64::new(0)),
7528 };
7529 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7530 let client = StoreClient::new(&base_url);
7531 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7532 .table(
7533 "events",
7534 vec![TableColumnConfig::new("id", DataType::Int64, false)],
7535 vec!["id".to_string()],
7536 vec![],
7537 )
7538 .expect("schema");
7539 let mut writer = schema.batch_writer();
7540 for id in [1, 2, 3, 4] {
7541 writer
7542 .insert("events", vec![CellValue::Int64(id)])
7543 .expect("row");
7544 }
7545 writer.flush().await.expect("seed rows");
7546
7547 let ctx = session_context();
7548 schema.register_all(&ctx).expect("register");
7549
7550 for (outer, limit, expected_len) in [("DESC", 1, 1), ("ASC", 1, 1), ("ASC", 5, 2)] {
7552 let sql = format!(
7553 "SELECT id FROM (SELECT id FROM events LIMIT 2) AS limited \
7554 ORDER BY id {outer} LIMIT {limit}"
7555 );
7556 let batches = ctx
7557 .sql(&sql)
7558 .await
7559 .expect("query")
7560 .collect()
7561 .await
7562 .expect("collect");
7563 let values = collect_i64_column(&batches, 0);
7564 assert_eq!(values.len(), expected_len, "{sql}");
7565 assert!(
7566 values.iter().all(|id| (1..=4).contains(id)),
7567 "query returned a row outside the source: {sql}: {values:?}"
7568 );
7569 assert!(
7570 values.windows(2).all(|pair| match outer {
7571 "ASC" => pair[0] <= pair[1],
7572 "DESC" => pair[0] >= pair[1],
7573 _ => unreachable!("test only supplies supported ordering"),
7574 }),
7575 "query did not respect its outer ordering: {sql}: {values:?}"
7576 );
7577 }
7578
7579 for (inner, outer, limit, expected) in [
7580 ("ASC", "DESC", 1, vec![2]),
7581 ("DESC", "ASC", 1, vec![3]),
7582 ("ASC", "ASC", 5, vec![1, 2]),
7583 ("DESC", "DESC", 5, vec![4, 3]),
7584 ] {
7585 let sql = format!(
7586 "SELECT id FROM \
7587 (SELECT id FROM events ORDER BY id {inner} LIMIT 2) AS limited \
7588 ORDER BY id {outer} LIMIT {limit}"
7589 );
7590 let plan = ctx
7591 .sql(&sql)
7592 .await
7593 .expect("query")
7594 .create_physical_plan()
7595 .await
7596 .expect("physical plan");
7597
7598 let plan = PushdownSort::new()
7600 .optimize(plan, &ConfigOptions::new())
7601 .expect("sort pushdown");
7602 let batches = datafusion::physical_plan::collect(plan, ctx.task_ctx())
7603 .await
7604 .expect("collect");
7605 assert_eq!(collect_i64_column(&batches, 0), expected, "{sql}");
7606 }
7607
7608 let _ = shutdown_tx.send(());
7609 }
7610
7611 #[tokio::test]
7612 async fn kv_scan_impossible_utf8_primary_key_equality_skips_scan() {
7613 let state = MockState {
7614 kv: Arc::new(Mutex::new(BTreeMap::new())),
7615 range_calls: Arc::new(AtomicUsize::new(0)),
7616 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7617 sequence_number: Arc::new(AtomicU64::new(0)),
7618 };
7619 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
7620 let client = StoreClient::new(&base_url);
7621
7622 let schema = KvSchema::new(PrefixedStoreClient::empty(client.clone()))
7623 .table(
7624 "items",
7625 vec![TableColumnConfig::new("id", DataType::Utf8, false)],
7626 vec!["id".to_string()],
7627 vec![],
7628 )
7629 .expect("schema");
7630 let mut writer = schema.batch_writer();
7631 writer
7632 .insert("items", vec![CellValue::Utf8("present".to_string())])
7633 .expect("row");
7634 writer.flush().await.expect("seed rows");
7635
7636 let ctx = session_context();
7637 schema.register_all(&ctx).expect("register");
7638
7639 let oversized = "a".repeat(
7640 primary_key_prefix(0)
7641 .expect("primary-key prefix")
7642 .max_payload_len(),
7643 );
7644 let sql = format!("SELECT id FROM items WHERE id = '{oversized}'");
7645
7646 state.range_calls.store(0, AtomicOrdering::SeqCst);
7647 let batches = ctx
7648 .sql(&sql)
7649 .await
7650 .expect("query")
7651 .collect()
7652 .await
7653 .expect("collect");
7654 assert_eq!(
7655 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
7656 0,
7657 "an unencodable PK equality can never match any row"
7658 );
7659 assert_eq!(
7660 state.range_calls.load(AtomicOrdering::SeqCst),
7661 0,
7662 "an impossible PK equality must not issue a range scan"
7663 );
7664
7665 let _ = shutdown_tx.send(());
7666 }
7667
7668 #[tokio::test]
7669 async fn kv_scan_index_lookup_limit_counts_rows_after_residual_filtering() {
7670 let state = MockState {
7671 kv: Arc::new(Mutex::new(BTreeMap::new())),
7672 range_calls: Arc::new(AtomicUsize::new(0)),
7673 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7674 sequence_number: Arc::new(AtomicU64::new(0)),
7675 };
7676 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7677 let schema = KvSchema::new(PrefixedStoreClient::empty(StoreClient::new(&base_url)))
7678 .table(
7679 "orders",
7680 vec![
7681 TableColumnConfig::new("id", DataType::Int64, false),
7682 TableColumnConfig::new("status", DataType::Utf8, false),
7683 TableColumnConfig::new("amount_cents", DataType::Int64, false),
7684 ],
7685 vec!["id".to_string()],
7686 vec![IndexSpec::new("status_idx", vec!["status".to_string()]).expect("index")],
7687 )
7688 .expect("schema");
7689 let mut writer = schema.batch_writer();
7690 for (id, amount) in [(1, 20), (2, 0), (3, 10), (4, 20)] {
7691 writer
7692 .insert(
7693 "orders",
7694 vec![
7695 CellValue::Int64(id),
7696 CellValue::Utf8("open".to_string()),
7697 CellValue::Int64(amount),
7698 ],
7699 )
7700 .expect("row");
7701 }
7702 writer.flush().await.expect("seed rows");
7703
7704 let ctx = session_context();
7705 schema.register_all(&ctx).expect("register");
7706 for minimum in [20, 10] {
7709 let mut plan = ctx
7710 .sql(&format!(
7711 "SELECT id FROM orders WHERE status = 'open' AND amount_cents >= {minimum} LIMIT 2"
7712 ))
7713 .await
7714 .expect("query")
7715 .create_physical_plan()
7716 .await
7717 .expect("physical plan");
7718 while let Some(child) = plan.children().first() {
7720 plan = (*child).clone();
7721 }
7722 assert!(plan.downcast_ref::<KvScanExec>().is_some());
7723 assert_eq!(plan.fetch(), Some(2));
7724 let batches = datafusion::physical_plan::collect(plan, ctx.task_ctx())
7725 .await
7726 .expect("collect scan");
7727 let mut ids = collect_i64_column(&batches, 0);
7728 ids.sort_unstable();
7729 assert_eq!(ids.len(), 2, "minimum {minimum}: {ids:?}");
7730 if minimum == 20 {
7731 assert_eq!(ids, vec![1, 4]);
7732 } else {
7733 assert!(ids.iter().all(|id| [1, 3, 4].contains(id)));
7734 assert_ne!(ids[0], ids[1]);
7735 }
7736 }
7737 let _ = shutdown_tx.send(());
7738 }
7739
7740 #[tokio::test]
7741 async fn zorder_covering_index_scan_filters_false_positive_morton_span_rows() {
7742 let state = MockState {
7743 kv: Arc::new(Mutex::new(BTreeMap::new())),
7744 range_calls: Arc::new(AtomicUsize::new(0)),
7745 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7746 sequence_number: Arc::new(AtomicU64::new(0)),
7747 };
7748 let (base_url, shutdown_tx) = spawn_mock_server(state).await;
7749 let client = StoreClient::new(&base_url);
7750
7751 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7752 .table(
7753 "points",
7754 vec![
7755 TableColumnConfig::new("x", DataType::Int64, false),
7756 TableColumnConfig::new("y", DataType::Int64, false),
7757 TableColumnConfig::new("id", DataType::Int64, false),
7758 TableColumnConfig::new("value", DataType::Int64, false),
7759 ],
7760 vec!["id".to_string()],
7761 vec![
7762 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
7763 .expect("valid")
7764 .with_cover_columns(vec!["value".to_string()]),
7765 ],
7766 )
7767 .expect("schema");
7768
7769 let mut writer = schema.batch_writer();
7770 for (x, y, id, value) in [
7771 (0, 2, 2, 20),
7772 (1, 1, 11, 110),
7773 (1, 2, 12, 120),
7774 (2, 1, 21, 210),
7775 (2, 2, 22, 220),
7776 (3, 0, 30, 300),
7777 ] {
7778 writer
7779 .insert(
7780 "points",
7781 vec![
7782 CellValue::Int64(x),
7783 CellValue::Int64(y),
7784 CellValue::Int64(id),
7785 CellValue::Int64(value),
7786 ],
7787 )
7788 .expect("row");
7789 }
7790 writer.flush().await.expect("flush");
7791
7792 let ctx = session_context();
7793 schema.register_all(&ctx).expect("register");
7794
7795 let batches = ctx
7796 .sql(
7797 "SELECT id, value FROM points \
7798 WHERE x >= 1 AND x <= 2 AND y >= 1 AND y <= 2 \
7799 ORDER BY id",
7800 )
7801 .await
7802 .expect("query")
7803 .collect()
7804 .await
7805 .expect("collect");
7806
7807 let mut rows = Vec::new();
7808 for batch in &batches {
7809 let ids = batch
7810 .column(0)
7811 .as_any()
7812 .downcast_ref::<Int64Array>()
7813 .expect("id int64");
7814 let values = batch
7815 .column(1)
7816 .as_any()
7817 .downcast_ref::<Int64Array>()
7818 .expect("value int64");
7819 for row_idx in 0..batch.num_rows() {
7820 rows.push((ids.value(row_idx), values.value(row_idx)));
7821 }
7822 }
7823 assert_eq!(rows, vec![(11, 110), (12, 120), (21, 210), (22, 220)]);
7824
7825 let _ = shutdown_tx.send(());
7826 }
7827
7828 #[tokio::test]
7829 async fn aggregate_pushdown_uses_range_reduce_for_supported_global_aggregates() {
7830 let state = MockState {
7831 kv: Arc::new(Mutex::new(BTreeMap::new())),
7832 range_calls: Arc::new(AtomicUsize::new(0)),
7833 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7834 sequence_number: Arc::new(AtomicU64::new(0)),
7835 };
7836 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
7837 let client = StoreClient::new(&base_url);
7838
7839 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7840 .table(
7841 "orders",
7842 vec![
7843 TableColumnConfig::new("id", DataType::Int64, false),
7844 TableColumnConfig::new("status", DataType::Utf8, false),
7845 TableColumnConfig::new("amount_cents", DataType::Int64, false),
7846 ],
7847 vec!["id".to_string()],
7848 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
7849 .expect("valid")
7850 .with_cover_columns(vec!["amount_cents".to_string()])],
7851 )
7852 .expect("schema");
7853
7854 let mut writer = schema.batch_writer();
7855 for (id, status, amount) in [
7856 (1, "open", 10),
7857 (2, "closed", 15),
7858 (3, "open", 30),
7859 (4, "closed", 40),
7860 ] {
7861 writer
7862 .insert(
7863 "orders",
7864 vec![
7865 CellValue::Int64(id),
7866 CellValue::Utf8(status.to_string()),
7867 CellValue::Int64(amount),
7868 ],
7869 )
7870 .expect("row");
7871 }
7872 writer.flush().await.expect("flush");
7873
7874 let ctx = session_context();
7875 schema.register_all(&ctx).expect("register");
7876
7877 state.range_calls.store(0, AtomicOrdering::SeqCst);
7878 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
7879
7880 let df = ctx
7881 .sql(
7882 "SELECT COUNT(*) AS row_count, SUM(amount_cents) AS total_cents, \
7883 AVG(amount_cents) AS avg_cents \
7884 FROM orders WHERE status = 'open'",
7885 )
7886 .await
7887 .expect("query");
7888 let batches = df.collect().await.expect("collect");
7889
7890 assert_eq!(batches.len(), 1);
7891 let batch = &batches[0];
7892 let row_count = ScalarValue::try_from_array(batch.column(0), 0).expect("row_count scalar");
7893 let total = batch
7894 .column(1)
7895 .as_any()
7896 .downcast_ref::<Int64Array>()
7897 .expect("sum int64")
7898 .value(0);
7899 let avg = batch
7900 .column(2)
7901 .as_any()
7902 .downcast_ref::<Float64Array>()
7903 .expect("avg float64")
7904 .value(0);
7905 assert!(matches!(
7906 row_count,
7907 ScalarValue::UInt64(Some(2)) | ScalarValue::Int64(Some(2))
7908 ));
7909 assert_eq!(total, 40);
7910 assert_eq!(avg, 20.0);
7911 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
7912 assert!(
7913 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
7914 "supported aggregate should use range reduction path"
7915 );
7916
7917 let _ = shutdown_tx.send(());
7918 }
7919
7920 #[tokio::test]
7922 async fn primary_key_inclusive_upper_bound_streaming_scan_uses_range() {
7923 let state = MockState {
7924 kv: Arc::new(Mutex::new(BTreeMap::new())),
7925 range_calls: Arc::new(AtomicUsize::new(0)),
7926 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
7927 sequence_number: Arc::new(AtomicU64::new(0)),
7928 };
7929 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
7930 let client = StoreClient::new(&base_url);
7931
7932 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
7933 .table(
7934 "inc_pk",
7935 vec![
7936 TableColumnConfig::new("id", DataType::Int64, false),
7937 TableColumnConfig::new("amount", DataType::Int64, false),
7938 ],
7939 vec!["id".to_string()],
7940 vec![],
7941 )
7942 .expect("schema");
7943
7944 let mut writer = schema.batch_writer();
7945 for id in 1i64..=5i64 {
7946 writer
7947 .insert(
7948 "inc_pk",
7949 vec![CellValue::Int64(id), CellValue::Int64(id * 100)],
7950 )
7951 .expect("row");
7952 }
7953 writer.flush().await.expect("flush");
7954
7955 let ctx = session_context();
7956 schema.register_all(&ctx).expect("register");
7957
7958 state.range_calls.store(0, AtomicOrdering::SeqCst);
7959 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
7960
7961 let batches = ctx
7962 .sql("SELECT id FROM inc_pk WHERE id <= 3 ORDER BY id")
7963 .await
7964 .expect("lte query")
7965 .collect()
7966 .await
7967 .expect("collect");
7968 let mut ids = Vec::new();
7969 for batch in &batches {
7970 let col = batch
7971 .column(0)
7972 .as_any()
7973 .downcast_ref::<Int64Array>()
7974 .expect("id");
7975 for i in 0..batch.num_rows() {
7976 ids.push(col.value(i));
7977 }
7978 }
7979 assert_eq!(ids, vec![1, 2, 3], "id <= 3 must include id 3");
7980 assert!(
7981 state.range_calls.load(AtomicOrdering::SeqCst) >= 1,
7982 "PK bounded scan should call range"
7983 );
7984 assert_eq!(
7985 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
7986 0,
7987 "streaming scan must not use range_reduce"
7988 );
7989
7990 state.range_calls.store(0, AtomicOrdering::SeqCst);
7991 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
7992
7993 let batches = ctx
7994 .sql("SELECT id FROM inc_pk WHERE id BETWEEN 2 AND 4 ORDER BY id")
7995 .await
7996 .expect("between query")
7997 .collect()
7998 .await
7999 .expect("collect");
8000 ids.clear();
8001 for batch in &batches {
8002 let col = batch
8003 .column(0)
8004 .as_any()
8005 .downcast_ref::<Int64Array>()
8006 .expect("id");
8007 for i in 0..batch.num_rows() {
8008 ids.push(col.value(i));
8009 }
8010 }
8011 assert_eq!(ids, vec![2, 3, 4], "BETWEEN must include both endpoints");
8012 assert!(state.range_calls.load(AtomicOrdering::SeqCst) >= 1);
8013 assert_eq!(state.range_reduce_calls.load(AtomicOrdering::SeqCst), 0);
8014
8015 let _ = shutdown_tx.send(());
8016 }
8017
8018 #[tokio::test]
8019 async fn primary_key_inclusive_upper_bound_scalar_aggregates_use_range_reduce() {
8020 let state = MockState {
8021 kv: Arc::new(Mutex::new(BTreeMap::new())),
8022 range_calls: Arc::new(AtomicUsize::new(0)),
8023 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8024 sequence_number: Arc::new(AtomicU64::new(0)),
8025 };
8026 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8027 let client = StoreClient::new(&base_url);
8028
8029 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8030 .table(
8031 "inc_pk",
8032 vec![
8033 TableColumnConfig::new("id", DataType::Int64, false),
8034 TableColumnConfig::new("amount", DataType::Int64, false),
8035 ],
8036 vec!["id".to_string()],
8037 vec![],
8038 )
8039 .expect("schema");
8040
8041 let mut writer = schema.batch_writer();
8042 for id in 1i64..=5i64 {
8043 writer
8044 .insert(
8045 "inc_pk",
8046 vec![CellValue::Int64(id), CellValue::Int64(id * 100)],
8047 )
8048 .expect("row");
8049 }
8050 writer.flush().await.expect("flush");
8051
8052 let ctx = session_context();
8053 schema.register_all(&ctx).expect("register");
8054
8055 state.range_calls.store(0, AtomicOrdering::SeqCst);
8056 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8057
8058 let batches = ctx
8059 .sql("SELECT COUNT(*) AS c, SUM(amount) AS s FROM inc_pk WHERE id <= 3")
8060 .await
8061 .expect("lte agg")
8062 .collect()
8063 .await
8064 .expect("collect");
8065 assert_eq!(batches.len(), 1);
8066 let batch = &batches[0];
8067 let c = ScalarValue::try_from_array(batch.column(0), 0).expect("count");
8068 assert!(
8069 matches!(
8070 c,
8071 ScalarValue::UInt64(Some(3)) | ScalarValue::Int64(Some(3))
8072 ),
8073 "count should include id=3"
8074 );
8075 assert_eq!(
8076 batch
8077 .column(1)
8078 .as_any()
8079 .downcast_ref::<Int64Array>()
8080 .expect("sum")
8081 .value(0),
8082 100 + 200 + 300
8083 );
8084 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8085 assert!(
8086 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
8087 "scalar aggregate on PK range should use range_reduce"
8088 );
8089
8090 state.range_calls.store(0, AtomicOrdering::SeqCst);
8091 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8092
8093 let batches = ctx
8094 .sql("SELECT SUM(amount) AS s FROM inc_pk WHERE id BETWEEN 2 AND 4")
8095 .await
8096 .expect("between agg")
8097 .collect()
8098 .await
8099 .expect("collect");
8100 assert_eq!(batches.len(), 1);
8101 let batch = &batches[0];
8102 assert_eq!(
8103 batch
8104 .column(0)
8105 .as_any()
8106 .downcast_ref::<Int64Array>()
8107 .expect("sum")
8108 .value(0),
8109 200 + 300 + 400
8110 );
8111 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8112 assert!(
8113 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
8114 "BETWEEN aggregate should use range_reduce"
8115 );
8116
8117 let _ = shutdown_tx.send(());
8118 }
8119
8120 #[tokio::test]
8121 async fn aggregate_pushdown_uses_zorder_index_with_worker_filter() {
8122 let state = MockState {
8123 kv: Arc::new(Mutex::new(BTreeMap::new())),
8124 range_calls: Arc::new(AtomicUsize::new(0)),
8125 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8126 sequence_number: Arc::new(AtomicU64::new(0)),
8127 };
8128 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8129 let client = StoreClient::new(&base_url);
8130
8131 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8132 .table(
8133 "points",
8134 vec![
8135 TableColumnConfig::new("x", DataType::Int64, false),
8136 TableColumnConfig::new("y", DataType::Int64, false),
8137 TableColumnConfig::new("id", DataType::Int64, false),
8138 TableColumnConfig::new("value", DataType::Int64, false),
8139 ],
8140 vec!["id".to_string()],
8141 vec![
8142 IndexSpec::z_order("xy_z", vec!["x".to_string(), "y".to_string()])
8143 .expect("valid")
8144 .with_cover_columns(vec!["value".to_string()]),
8145 ],
8146 )
8147 .expect("schema");
8148
8149 let mut writer = schema.batch_writer();
8150 for (x, y, id, value) in [
8151 (0, 2, 2, 20),
8152 (1, 1, 11, 110),
8153 (1, 2, 12, 120),
8154 (2, 1, 21, 210),
8155 (2, 2, 22, 220),
8156 (3, 0, 30, 300),
8157 ] {
8158 writer
8159 .insert(
8160 "points",
8161 vec![
8162 CellValue::Int64(x),
8163 CellValue::Int64(y),
8164 CellValue::Int64(id),
8165 CellValue::Int64(value),
8166 ],
8167 )
8168 .expect("row");
8169 }
8170 writer.flush().await.expect("flush");
8171
8172 let ctx = session_context();
8173 schema.register_all(&ctx).expect("register");
8174
8175 state.range_calls.store(0, AtomicOrdering::SeqCst);
8176 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8177
8178 let batches = ctx
8179 .sql(
8180 "SELECT COUNT(*) AS row_count, SUM(value) AS total_value \
8181 FROM points \
8182 WHERE x >= 1 AND x <= 2 AND y >= 1 AND y <= 2",
8183 )
8184 .await
8185 .expect("query")
8186 .collect()
8187 .await
8188 .expect("collect");
8189
8190 assert_eq!(batches.len(), 1);
8191 let batch = &batches[0];
8192 let row_count = ScalarValue::try_from_array(batch.column(0), 0).expect("row_count scalar");
8193 let total = batch
8194 .column(1)
8195 .as_any()
8196 .downcast_ref::<Int64Array>()
8197 .expect("sum int64")
8198 .value(0);
8199 assert!(matches!(
8200 row_count,
8201 ScalarValue::UInt64(Some(4)) | ScalarValue::Int64(Some(4))
8202 ));
8203 assert_eq!(total, 660);
8204 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8205 assert!(
8206 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
8207 "z-order aggregate should use range reduction path"
8208 );
8209
8210 let _ = shutdown_tx.send(());
8211 }
8212
8213 #[tokio::test]
8214 async fn aggregate_pushdown_avg_merges_sum_and_count_across_multiple_ranges() {
8215 let state = MockState {
8216 kv: Arc::new(Mutex::new(BTreeMap::new())),
8217 range_calls: Arc::new(AtomicUsize::new(0)),
8218 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8219 sequence_number: Arc::new(AtomicU64::new(0)),
8220 };
8221 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8222 let client = StoreClient::new(&base_url);
8223
8224 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8225 .table(
8226 "orders",
8227 vec![
8228 TableColumnConfig::new("id", DataType::Int64, false),
8229 TableColumnConfig::new("status", DataType::Utf8, false),
8230 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8231 ],
8232 vec!["id".to_string()],
8233 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8234 .expect("valid")
8235 .with_cover_columns(vec!["amount_cents".to_string()])],
8236 )
8237 .expect("schema");
8238
8239 let mut writer = schema.batch_writer();
8240 for (id, status, amount) in [
8241 (1, "open", 10),
8242 (2, "open", 20),
8243 (3, "closed", 100),
8244 (4, "pending", 1_000),
8245 ] {
8246 writer
8247 .insert(
8248 "orders",
8249 vec![
8250 CellValue::Int64(id),
8251 CellValue::Utf8(status.to_string()),
8252 CellValue::Int64(amount),
8253 ],
8254 )
8255 .expect("row");
8256 }
8257 writer.flush().await.expect("flush");
8258
8259 let ctx = session_context();
8260 schema.register_all(&ctx).expect("register");
8261
8262 state.range_calls.store(0, AtomicOrdering::SeqCst);
8263 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8264
8265 let batches = ctx
8266 .sql(
8267 "SELECT AVG(amount_cents) AS avg_cents \
8268 FROM orders \
8269 WHERE status IN ('open', 'closed')",
8270 )
8271 .await
8272 .expect("query")
8273 .collect()
8274 .await
8275 .expect("collect");
8276
8277 assert_eq!(batches.len(), 1);
8278 let batch = &batches[0];
8279 let avg = batch
8280 .column(0)
8281 .as_any()
8282 .downcast_ref::<Float64Array>()
8283 .expect("avg float64")
8284 .value(0);
8285 let expected = 130.0 / 3.0;
8286 assert!(
8287 (avg - expected).abs() < 1e-12,
8288 "AVG should merge SUM+COUNT across unequal-count ranges: got {avg}, expected {expected}"
8289 );
8290 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8291 assert_eq!(
8292 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8293 2,
8294 "status IN (...) should expand to two pushed reduction ranges"
8295 );
8296
8297 let _ = shutdown_tx.send(());
8298 }
8299
8300 #[tokio::test]
8301 async fn aggregate_pushdown_supports_filtered_global_aggregates() {
8302 let state = MockState {
8303 kv: Arc::new(Mutex::new(BTreeMap::new())),
8304 range_calls: Arc::new(AtomicUsize::new(0)),
8305 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8306 sequence_number: Arc::new(AtomicU64::new(0)),
8307 };
8308 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8309 let client = StoreClient::new(&base_url);
8310
8311 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8312 .table(
8313 "orders",
8314 vec![
8315 TableColumnConfig::new("id", DataType::Int64, false),
8316 TableColumnConfig::new("status", DataType::Utf8, false),
8317 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8318 ],
8319 vec!["id".to_string()],
8320 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8321 .expect("valid")
8322 .with_cover_columns(vec!["amount_cents".to_string()])],
8323 )
8324 .expect("schema");
8325
8326 let mut writer = schema.batch_writer();
8327 for (id, status, amount) in [
8328 (1, "open", 10),
8329 (2, "closed", 15),
8330 (3, "open", 30),
8331 (4, "closed", 40),
8332 ] {
8333 writer
8334 .insert(
8335 "orders",
8336 vec![
8337 CellValue::Int64(id),
8338 CellValue::Utf8(status.to_string()),
8339 CellValue::Int64(amount),
8340 ],
8341 )
8342 .expect("row");
8343 }
8344 writer.flush().await.expect("flush");
8345
8346 let ctx = session_context();
8347 schema.register_all(&ctx).expect("register");
8348
8349 state.range_calls.store(0, AtomicOrdering::SeqCst);
8350 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8351
8352 let query = "SELECT COUNT(*) FILTER (WHERE status = 'open') AS open_count, \
8353 COUNT(*) FILTER (WHERE status = 'closed') AS closed_count, \
8354 AVG(amount_cents) FILTER (WHERE status = 'closed') AS closed_avg \
8355 FROM orders";
8356 let batches = ctx
8357 .sql(query)
8358 .await
8359 .expect("query")
8360 .collect()
8361 .await
8362 .expect("collect");
8363
8364 assert_eq!(batches.len(), 1);
8365 let batch = &batches[0];
8366 assert_count_scalar(batch, 0, 0, 2);
8367 assert_count_scalar(batch, 1, 0, 2);
8368 let closed_avg = batch
8369 .column(2)
8370 .as_any()
8371 .downcast_ref::<Float64Array>()
8372 .expect("avg float64")
8373 .value(0);
8374 assert_eq!(closed_avg, 27.5);
8375 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8376 assert_eq!(
8377 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8378 2,
8379 "aggregates with matching filters should share reduction jobs"
8380 );
8381
8382 let _ = shutdown_tx.send(());
8383 }
8384
8385 #[tokio::test]
8386 async fn aggregate_pushdown_supports_case_filtered_global_aggregates() {
8387 let state = MockState {
8388 kv: Arc::new(Mutex::new(BTreeMap::new())),
8389 range_calls: Arc::new(AtomicUsize::new(0)),
8390 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8391 sequence_number: Arc::new(AtomicU64::new(0)),
8392 };
8393 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8394 let client = StoreClient::new(&base_url);
8395
8396 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8397 .table(
8398 "orders",
8399 vec![
8400 TableColumnConfig::new("id", DataType::Int64, false),
8401 TableColumnConfig::new("status", DataType::Utf8, false),
8402 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8403 ],
8404 vec!["id".to_string()],
8405 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8406 .expect("valid")
8407 .with_cover_columns(vec!["amount_cents".to_string()])],
8408 )
8409 .expect("schema");
8410
8411 let mut writer = schema.batch_writer();
8412 for (id, status, amount) in [
8413 (1, "open", 10),
8414 (2, "closed", 15),
8415 (3, "open", 30),
8416 (4, "closed", 40),
8417 ] {
8418 writer
8419 .insert(
8420 "orders",
8421 vec![
8422 CellValue::Int64(id),
8423 CellValue::Utf8(status.to_string()),
8424 CellValue::Int64(amount),
8425 ],
8426 )
8427 .expect("row");
8428 }
8429 writer.flush().await.expect("flush");
8430
8431 let ctx = session_context();
8432 schema.register_all(&ctx).expect("register");
8433
8434 state.range_calls.store(0, AtomicOrdering::SeqCst);
8435 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8436
8437 let query = "SELECT SUM(CASE status WHEN 'open' THEN amount_cents END) AS open_total, \
8438 COUNT(CASE status WHEN 'closed' THEN 1 END) AS closed_count, \
8439 AVG(CASE WHEN status = 'closed' THEN amount_cents END) AS closed_avg \
8440 FROM orders";
8441 let batches = ctx
8442 .sql(query)
8443 .await
8444 .expect("query")
8445 .collect()
8446 .await
8447 .expect("collect");
8448
8449 assert_eq!(batches.len(), 1);
8450 let batch = &batches[0];
8451 assert_eq!(
8452 ScalarValue::try_from_array(batch.column(0), 0).expect("sum scalar"),
8453 ScalarValue::Int64(Some(40))
8454 );
8455 assert_count_scalar(batch, 1, 0, 2);
8456 let closed_avg = batch
8457 .column(2)
8458 .as_any()
8459 .downcast_ref::<Float64Array>()
8460 .expect("avg float64")
8461 .value(0);
8462 assert_eq!(closed_avg, 27.5);
8463 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8464 assert_eq!(
8465 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8466 2,
8467 "matching CASE filters should share reduction jobs"
8468 );
8469
8470 let _ = shutdown_tx.send(());
8471 }
8472
8473 #[tokio::test]
8474 async fn aggregate_pushdown_supports_casted_group_and_aggregate_expressions() {
8475 let state = MockState {
8476 kv: Arc::new(Mutex::new(BTreeMap::new())),
8477 range_calls: Arc::new(AtomicUsize::new(0)),
8478 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8479 sequence_number: Arc::new(AtomicU64::new(0)),
8480 };
8481 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8482 let client = StoreClient::new(&base_url);
8483
8484 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8485 .table(
8486 "orders",
8487 vec![
8488 TableColumnConfig::new("id", DataType::Int64, false),
8489 TableColumnConfig::new("status", DataType::Utf8, false),
8490 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8491 ],
8492 vec!["id".to_string()],
8493 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8494 .expect("valid")
8495 .with_cover_columns(vec!["amount_cents".to_string()])],
8496 )
8497 .expect("schema");
8498
8499 let mut writer = schema.batch_writer();
8500 for (id, status, amount) in [
8501 (1, "open", 10),
8502 (2, "open", 30),
8503 (3, "closed", 15),
8504 (4, "closed", 40),
8505 ] {
8506 writer
8507 .insert(
8508 "orders",
8509 vec![
8510 CellValue::Int64(id),
8511 CellValue::Utf8(status.to_string()),
8512 CellValue::Int64(amount),
8513 ],
8514 )
8515 .expect("row");
8516 }
8517 writer.flush().await.expect("flush");
8518
8519 let ctx = session_context();
8520 schema.register_all(&ctx).expect("register");
8521
8522 state.range_calls.store(0, AtomicOrdering::SeqCst);
8523 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8524
8525 let batches = ctx
8526 .sql(
8527 "SELECT CAST(status AS VARCHAR) AS status_text, \
8528 SUM(CAST(amount_cents AS DOUBLE)) AS total_cents \
8529 FROM orders \
8530 GROUP BY CAST(status AS VARCHAR) \
8531 ORDER BY status_text",
8532 )
8533 .await
8534 .expect("query")
8535 .collect()
8536 .await
8537 .expect("collect");
8538
8539 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
8540 let batch = &batches[0];
8541 let status = ScalarValue::try_from_array(batch.column(0), 0).expect("status scalar");
8542 assert_eq!(scalar_to_string(&status).as_deref(), Some("closed"));
8543 let closed_total = batch
8544 .column(1)
8545 .as_any()
8546 .downcast_ref::<Float64Array>()
8547 .expect("sum float64")
8548 .value(0);
8549 let open_total = batch
8550 .column(1)
8551 .as_any()
8552 .downcast_ref::<Float64Array>()
8553 .expect("sum float64")
8554 .value(1);
8555 assert_eq!(closed_total, 55.0);
8556 assert_eq!(open_total, 40.0);
8557 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8558 assert!(
8559 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
8560 "casted grouped aggregates should stay on the reduction path"
8561 );
8562
8563 let _ = shutdown_tx.send(());
8564 }
8565
8566 #[tokio::test]
8567 async fn aggregate_pushdown_supports_computed_aggregate_inputs() {
8568 let state = MockState {
8569 kv: Arc::new(Mutex::new(BTreeMap::new())),
8570 range_calls: Arc::new(AtomicUsize::new(0)),
8571 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8572 sequence_number: Arc::new(AtomicU64::new(0)),
8573 };
8574 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8575 let client = StoreClient::new(&base_url);
8576
8577 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8578 .table(
8579 "orders",
8580 vec![
8581 TableColumnConfig::new("id", DataType::Int64, false),
8582 TableColumnConfig::new("price_cents", DataType::Int64, false),
8583 TableColumnConfig::new("qty", DataType::Int64, false),
8584 TableColumnConfig::new("duration_ms", DataType::Int64, false),
8585 ],
8586 vec!["id".to_string()],
8587 vec![],
8588 )
8589 .expect("schema");
8590
8591 let mut writer = schema.batch_writer();
8592 for (id, price, qty, duration_ms) in [(1, 10, 2, 500), (2, 15, 3, 2500), (3, 7, 4, 1000)] {
8593 writer
8594 .insert(
8595 "orders",
8596 vec![
8597 CellValue::Int64(id),
8598 CellValue::Int64(price),
8599 CellValue::Int64(qty),
8600 CellValue::Int64(duration_ms),
8601 ],
8602 )
8603 .expect("row");
8604 }
8605 writer.flush().await.expect("flush");
8606
8607 let ctx = session_context();
8608 schema.register_all(&ctx).expect("register");
8609
8610 state.range_calls.store(0, AtomicOrdering::SeqCst);
8611 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8612
8613 let batches = ctx
8614 .sql(
8615 "SELECT SUM(price_cents * qty) AS total_revenue, \
8616 AVG(duration_ms / 1e3) AS avg_seconds \
8617 FROM orders",
8618 )
8619 .await
8620 .expect("query")
8621 .collect()
8622 .await
8623 .expect("collect");
8624
8625 assert_eq!(batches.len(), 1);
8626 let batch = &batches[0];
8627 assert_eq!(
8628 ScalarValue::try_from_array(batch.column(0), 0).expect("sum scalar"),
8629 ScalarValue::Int64(Some(93))
8630 );
8631 let avg_seconds = batch
8632 .column(1)
8633 .as_any()
8634 .downcast_ref::<Float64Array>()
8635 .expect("avg float64")
8636 .value(0);
8637 assert!((avg_seconds - (4.0 / 3.0)).abs() < 1e-12);
8638 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8639 assert_eq!(
8640 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8641 1,
8642 "computed aggregate inputs should share one reduction job"
8643 );
8644
8645 let _ = shutdown_tx.send(());
8646 }
8647
8648 #[tokio::test]
8649 async fn aggregate_pushdown_supports_add_and_subtract_inputs() {
8650 let state = MockState {
8651 kv: Arc::new(Mutex::new(BTreeMap::new())),
8652 range_calls: Arc::new(AtomicUsize::new(0)),
8653 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8654 sequence_number: Arc::new(AtomicU64::new(0)),
8655 };
8656 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8657 let client = StoreClient::new(&base_url);
8658
8659 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8660 .table(
8661 "orders",
8662 vec![
8663 TableColumnConfig::new("id", DataType::Int64, false),
8664 TableColumnConfig::new("price_cents", DataType::Int64, false),
8665 TableColumnConfig::new("fee_cents", DataType::Int64, false),
8666 TableColumnConfig::new("discount_cents", DataType::Int64, false),
8667 ],
8668 vec!["id".to_string()],
8669 vec![],
8670 )
8671 .expect("schema");
8672
8673 let mut writer = schema.batch_writer();
8674 for (id, price, fee, discount) in [(1, 10, 2, 1), (2, 15, 3, 4), (3, 7, 1, 2)] {
8675 writer
8676 .insert(
8677 "orders",
8678 vec![
8679 CellValue::Int64(id),
8680 CellValue::Int64(price),
8681 CellValue::Int64(fee),
8682 CellValue::Int64(discount),
8683 ],
8684 )
8685 .expect("row");
8686 }
8687 writer.flush().await.expect("flush");
8688
8689 let ctx = session_context();
8690 schema.register_all(&ctx).expect("register");
8691
8692 state.range_calls.store(0, AtomicOrdering::SeqCst);
8693 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8694
8695 let batches = ctx
8696 .sql(
8697 "SELECT SUM(price_cents + fee_cents) AS gross_plus_fee, \
8698 SUM(price_cents - discount_cents) AS net_total \
8699 FROM orders",
8700 )
8701 .await
8702 .expect("query")
8703 .collect()
8704 .await
8705 .expect("collect");
8706
8707 assert_eq!(batches.len(), 1);
8708 let batch = &batches[0];
8709 assert_eq!(
8710 ScalarValue::try_from_array(batch.column(0), 0).expect("sum scalar"),
8711 ScalarValue::Int64(Some(38))
8712 );
8713 assert_eq!(
8714 ScalarValue::try_from_array(batch.column(1), 0).expect("sum scalar"),
8715 ScalarValue::Int64(Some(25))
8716 );
8717 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8718 assert_eq!(
8719 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8720 1,
8721 "add/sub aggregate inputs should share one reduction job"
8722 );
8723
8724 let _ = shutdown_tx.send(());
8725 }
8726
8727 #[tokio::test]
8728 async fn aggregate_pushdown_supports_case_filtered_computed_aggregates() {
8729 let state = MockState {
8730 kv: Arc::new(Mutex::new(BTreeMap::new())),
8731 range_calls: Arc::new(AtomicUsize::new(0)),
8732 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8733 sequence_number: Arc::new(AtomicU64::new(0)),
8734 };
8735 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8736 let client = StoreClient::new(&base_url);
8737
8738 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8739 .table(
8740 "orders",
8741 vec![
8742 TableColumnConfig::new("id", DataType::Int64, false),
8743 TableColumnConfig::new("status", DataType::Utf8, false),
8744 TableColumnConfig::new("price_cents", DataType::Int64, false),
8745 TableColumnConfig::new("qty", DataType::Int64, false),
8746 ],
8747 vec!["id".to_string()],
8748 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8749 .expect("valid")
8750 .with_cover_columns(vec!["price_cents".to_string(), "qty".to_string()])],
8751 )
8752 .expect("schema");
8753
8754 let mut writer = schema.batch_writer();
8755 for (id, status, price, qty) in [
8756 (1, "open", 10, 2),
8757 (2, "closed", 99, 1),
8758 (3, "open", 15, 3),
8759 (4, "closed", 7, 4),
8760 ] {
8761 writer
8762 .insert(
8763 "orders",
8764 vec![
8765 CellValue::Int64(id),
8766 CellValue::Utf8(status.to_string()),
8767 CellValue::Int64(price),
8768 CellValue::Int64(qty),
8769 ],
8770 )
8771 .expect("row");
8772 }
8773 writer.flush().await.expect("flush");
8774
8775 let ctx = session_context();
8776 schema.register_all(&ctx).expect("register");
8777
8778 state.range_calls.store(0, AtomicOrdering::SeqCst);
8779 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8780
8781 let batches = ctx
8782 .sql(
8783 "SELECT SUM(CASE WHEN status = 'open' THEN price_cents * qty END) \
8784 AS open_revenue \
8785 FROM orders",
8786 )
8787 .await
8788 .expect("query")
8789 .collect()
8790 .await
8791 .expect("collect");
8792
8793 assert_eq!(batches.len(), 1);
8794 let batch = &batches[0];
8795 assert_eq!(
8796 ScalarValue::try_from_array(batch.column(0), 0).expect("sum scalar"),
8797 ScalarValue::Int64(Some(65))
8798 );
8799 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
8800 assert!(
8801 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
8802 "case-filtered computed aggregate should use reduction jobs"
8803 );
8804
8805 let _ = shutdown_tx.send(());
8806 }
8807
8808 #[tokio::test]
8809 async fn aggregate_pushdown_does_not_rewrite_sum_case_else_zero_semantics() {
8810 let state = MockState {
8811 kv: Arc::new(Mutex::new(BTreeMap::new())),
8812 range_calls: Arc::new(AtomicUsize::new(0)),
8813 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8814 sequence_number: Arc::new(AtomicU64::new(0)),
8815 };
8816 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8817 let client = StoreClient::new(&base_url);
8818
8819 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8820 .table(
8821 "orders",
8822 vec![
8823 TableColumnConfig::new("id", DataType::Int64, false),
8824 TableColumnConfig::new("region", DataType::Utf8, false),
8825 TableColumnConfig::new("status", DataType::Utf8, false),
8826 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8827 ],
8828 vec!["id".to_string()],
8829 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
8830 .expect("valid")
8831 .with_cover_columns(vec!["region".to_string(), "amount_cents".to_string()])],
8832 )
8833 .expect("schema");
8834
8835 let mut writer = schema.batch_writer();
8836 for (id, region, status, amount) in [
8837 (1, "east", "open", 10),
8838 (2, "east", "closed", 20),
8839 (3, "west", "closed", 30),
8840 ] {
8841 writer
8842 .insert(
8843 "orders",
8844 vec![
8845 CellValue::Int64(id),
8846 CellValue::Utf8(region.to_string()),
8847 CellValue::Utf8(status.to_string()),
8848 CellValue::Int64(amount),
8849 ],
8850 )
8851 .expect("row");
8852 }
8853 writer.flush().await.expect("flush");
8854
8855 let ctx = session_context();
8856 schema.register_all(&ctx).expect("register");
8857
8858 state.range_calls.store(0, AtomicOrdering::SeqCst);
8859 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8860
8861 let batches = ctx
8862 .sql(
8863 "SELECT region, \
8864 SUM(CASE WHEN status = 'open' THEN amount_cents ELSE 0 END) AS open_total \
8865 FROM orders \
8866 GROUP BY region \
8867 ORDER BY region",
8868 )
8869 .await
8870 .expect("query")
8871 .collect()
8872 .await
8873 .expect("collect");
8874
8875 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
8876 let batch = &batches[0];
8877 assert_eq!(
8878 ScalarValue::try_from_array(batch.column(0), 0).expect("region scalar"),
8879 ScalarValue::Utf8(Some("east".to_string()))
8880 );
8881 assert_eq!(
8882 ScalarValue::try_from_array(batch.column(1), 0).expect("sum scalar"),
8883 ScalarValue::Int64(Some(10))
8884 );
8885 assert_eq!(
8886 ScalarValue::try_from_array(batch.column(0), 1).expect("region scalar"),
8887 ScalarValue::Utf8(Some("west".to_string()))
8888 );
8889 assert_eq!(
8890 ScalarValue::try_from_array(batch.column(1), 1).expect("sum scalar"),
8891 ScalarValue::Int64(Some(0))
8892 );
8893 assert_eq!(
8894 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
8895 0,
8896 "SUM(CASE ... ELSE 0 END) must not push down because FILTER rewrite changes semantics"
8897 );
8898
8899 let _ = shutdown_tx.send(());
8900 }
8901
8902 #[tokio::test]
8903 async fn aggregate_pushdown_supports_computed_group_keys() {
8904 let state = MockState {
8905 kv: Arc::new(Mutex::new(BTreeMap::new())),
8906 range_calls: Arc::new(AtomicUsize::new(0)),
8907 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
8908 sequence_number: Arc::new(AtomicU64::new(0)),
8909 };
8910 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
8911 let client = StoreClient::new(&base_url);
8912
8913 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
8914 .table(
8915 "events",
8916 vec![
8917 TableColumnConfig::new("id", DataType::Int64, false),
8918 TableColumnConfig::new("country", DataType::Utf8, false),
8919 TableColumnConfig::new(
8920 "occurred_at",
8921 DataType::Timestamp(TimeUnit::Microsecond, None),
8922 false,
8923 ),
8924 TableColumnConfig::new("amount_cents", DataType::Int64, false),
8925 ],
8926 vec!["id".to_string()],
8927 vec![],
8928 )
8929 .expect("schema");
8930
8931 let day_micros = 86_400_000_000i64;
8932 let day0 = 1_700_000_000_000_000i64;
8933 let day1 = day0 + day_micros;
8934 let day0_bucket = day0.div_euclid(day_micros) * day_micros;
8935 let day1_bucket = day1.div_euclid(day_micros) * day_micros;
8936 let mut writer = schema.batch_writer();
8937 for (id, country, occurred_at, amount) in [
8938 (1, "East", day0 + 111, 10),
8939 (2, "east", day0 + 222, 30),
8940 (3, "West", day1 + 333, 7),
8941 ] {
8942 writer
8943 .insert(
8944 "events",
8945 vec![
8946 CellValue::Int64(id),
8947 CellValue::Utf8(country.to_string()),
8948 CellValue::Timestamp(occurred_at),
8949 CellValue::Int64(amount),
8950 ],
8951 )
8952 .expect("row");
8953 }
8954 writer.flush().await.expect("flush");
8955
8956 let ctx = session_context();
8957 schema.register_all(&ctx).expect("register");
8958
8959 state.range_calls.store(0, AtomicOrdering::SeqCst);
8960 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
8961
8962 let batches = ctx
8963 .sql(
8964 "SELECT lower(country) AS country_norm, \
8965 date_trunc('day', occurred_at) AS day_bucket, \
8966 SUM(amount_cents) AS total_cents \
8967 FROM events \
8968 GROUP BY lower(country), date_trunc('day', occurred_at) \
8969 ORDER BY country_norm, day_bucket",
8970 )
8971 .await
8972 .expect("query")
8973 .collect()
8974 .await
8975 .expect("collect");
8976
8977 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
8978 let batch = &batches[0];
8979 assert_eq!(
8980 scalar_to_string(
8981 &ScalarValue::try_from_array(batch.column(0), 0).expect("country scalar")
8982 )
8983 .as_deref(),
8984 Some("east")
8985 );
8986 assert_eq!(
8987 ScalarValue::try_from_array(batch.column(1), 0).expect("day scalar"),
8988 ScalarValue::TimestampMicrosecond(Some(day0_bucket), None)
8989 );
8990 assert_eq!(
8991 ScalarValue::try_from_array(batch.column(2), 0).expect("sum scalar"),
8992 ScalarValue::Int64(Some(40))
8993 );
8994 assert_eq!(
8995 scalar_to_string(
8996 &ScalarValue::try_from_array(batch.column(0), 1).expect("country scalar")
8997 )
8998 .as_deref(),
8999 Some("west")
9000 );
9001 assert_eq!(
9002 ScalarValue::try_from_array(batch.column(1), 1).expect("day scalar"),
9003 ScalarValue::TimestampMicrosecond(Some(day1_bucket), None)
9004 );
9005 assert_eq!(
9006 ScalarValue::try_from_array(batch.column(2), 1).expect("sum scalar"),
9007 ScalarValue::Int64(Some(7))
9008 );
9009 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9010 assert!(
9011 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
9012 "computed group keys should use grouped reduction path"
9013 );
9014
9015 let _ = shutdown_tx.send(());
9016 }
9017
9018 #[tokio::test]
9019 async fn aggregate_pushdown_supports_group_by_queries() {
9020 let state = MockState {
9021 kv: Arc::new(Mutex::new(BTreeMap::new())),
9022 range_calls: Arc::new(AtomicUsize::new(0)),
9023 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
9024 sequence_number: Arc::new(AtomicU64::new(0)),
9025 };
9026 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
9027 let client = StoreClient::new(&base_url);
9028
9029 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
9030 .table(
9031 "orders",
9032 vec![
9033 TableColumnConfig::new("id", DataType::Int64, false),
9034 TableColumnConfig::new("status", DataType::Utf8, false),
9035 TableColumnConfig::new("amount_cents", DataType::Int64, false),
9036 ],
9037 vec!["id".to_string()],
9038 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
9039 .expect("valid")
9040 .with_cover_columns(vec!["amount_cents".to_string()])],
9041 )
9042 .expect("schema");
9043
9044 let mut writer = schema.batch_writer();
9045 for (id, status, amount) in [
9046 (1, "open", 10),
9047 (2, "open", 30),
9048 (3, "closed", 15),
9049 (4, "closed", 40),
9050 ] {
9051 writer
9052 .insert(
9053 "orders",
9054 vec![
9055 CellValue::Int64(id),
9056 CellValue::Utf8(status.to_string()),
9057 CellValue::Int64(amount),
9058 ],
9059 )
9060 .expect("row");
9061 }
9062 writer.flush().await.expect("flush");
9063
9064 let ctx = session_context();
9065 schema.register_all(&ctx).expect("register");
9066
9067 state.range_calls.store(0, AtomicOrdering::SeqCst);
9068 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9069
9070 let batches = ctx
9071 .sql(
9072 "SELECT status, COUNT(*) AS row_count, SUM(amount_cents) AS total_cents \
9073 FROM orders GROUP BY status ORDER BY status",
9074 )
9075 .await
9076 .expect("query")
9077 .collect()
9078 .await
9079 .expect("collect");
9080
9081 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
9082 let batch = &batches[0];
9083 assert_eq!(
9084 ScalarValue::try_from_array(batch.column(0), 0).expect("status scalar"),
9085 ScalarValue::Utf8(Some("closed".to_string()))
9086 );
9087 assert_count_scalar(batch, 1, 0, 2);
9088 assert_eq!(
9089 ScalarValue::try_from_array(batch.column(2), 0).expect("sum scalar"),
9090 ScalarValue::Int64(Some(55))
9091 );
9092 assert_eq!(
9093 ScalarValue::try_from_array(batch.column(0), 1).expect("status scalar"),
9094 ScalarValue::Utf8(Some("open".to_string()))
9095 );
9096 assert_count_scalar(batch, 1, 1, 2);
9097 assert_eq!(
9098 ScalarValue::try_from_array(batch.column(2), 1).expect("sum scalar"),
9099 ScalarValue::Int64(Some(40))
9100 );
9101 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9102 assert_eq!(
9103 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
9104 1,
9105 "grouped aggregates should share one reduction job"
9106 );
9107
9108 let _ = shutdown_tx.send(());
9109 }
9110
9111 async fn aggregate_test_context() -> (SessionContext, MockState, oneshot::Sender<()>) {
9112 let state = MockState {
9113 kv: Arc::new(Mutex::new(BTreeMap::new())),
9114 range_calls: Arc::new(AtomicUsize::new(0)),
9115 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
9116 sequence_number: Arc::new(AtomicU64::new(0)),
9117 };
9118 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
9119 let schema = KvSchema::new(PrefixedStoreClient::empty(StoreClient::new(&base_url)))
9120 .table(
9121 "orders",
9122 vec![
9123 TableColumnConfig::new("id", DataType::Int64, false),
9124 TableColumnConfig::new("status", DataType::Utf8, false),
9125 TableColumnConfig::new("amount_cents", DataType::Int64, false),
9126 ],
9127 vec!["id".to_string()],
9128 vec![],
9129 )
9130 .expect("schema");
9131 let mut writer = schema.batch_writer();
9132 for (id, status, amount) in [
9133 (1, "open", 10),
9134 (2, "open", 30),
9135 (3, "closed", 15),
9136 (4, "closed", 40),
9137 ] {
9138 writer
9139 .insert(
9140 "orders",
9141 vec![
9142 CellValue::Int64(id),
9143 CellValue::Utf8(status.to_string()),
9144 CellValue::Int64(amount),
9145 ],
9146 )
9147 .expect("row");
9148 }
9149 writer.flush().await.expect("flush");
9150 let ctx = SessionContext::new_with_state(
9151 session_state_builder()
9152 .with_config(datafusion::prelude::SessionConfig::new().with_batch_size(2))
9153 .build(),
9154 );
9155 schema.register_all(&ctx).expect("register");
9156 (ctx, state, shutdown_tx)
9157 }
9158
9159 #[tokio::test]
9160 async fn aggregate_pushdown_preserves_unaliased_output_schema() {
9161 let (ctx, state, shutdown_tx) = aggregate_test_context().await;
9162 for (sql, expected) in [
9163 ("SELECT SUM(amount_cents) FROM orders", vec![95]),
9164 (
9165 "SELECT status, SUM(amount_cents) FROM orders GROUP BY status ORDER BY status",
9166 vec![55, 40],
9167 ),
9168 ] {
9169 let df = ctx.sql(sql).await.expect("query");
9170 let original_schema = df.schema().as_arrow().clone();
9171 state.range_calls.store(0, AtomicOrdering::SeqCst);
9172 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9173 let batches = df.collect().await.expect("collect");
9174 let mut sums = Vec::new();
9175 for batch in batches {
9176 assert_eq!(batch.schema().as_ref(), &original_schema);
9177 for row in 0..batch.num_rows() {
9178 sums.push(
9179 ScalarValue::try_from_array(batch.column(batch.num_columns() - 1), row)
9180 .expect("sum"),
9181 );
9182 }
9183 }
9184 assert_eq!(
9185 sums,
9186 expected
9187 .into_iter()
9188 .map(|value| ScalarValue::Int64(Some(value)))
9189 .collect::<Vec<_>>()
9190 );
9191 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9192 assert!(state.range_reduce_calls.load(AtomicOrdering::SeqCst) > 0);
9193 }
9194 let _ = shutdown_tx.send(());
9195 }
9196
9197 #[tokio::test]
9198 async fn aggregate_pushdown_preserves_dotted_aliases() {
9199 use datafusion::functions_aggregate::expr_fn::sum;
9200 use datafusion::prelude::col;
9201
9202 let (ctx, state, shutdown_tx) = aggregate_test_context().await;
9203 let rows = ctx
9204 .sql("SELECT * FROM orders")
9205 .await
9206 .expect("rows")
9207 .collect()
9208 .await
9209 .expect("collect rows");
9210 let native = SessionContext::new();
9211 native
9212 .register_table(
9213 "orders",
9214 Arc::new(
9215 datafusion::datasource::MemTable::try_new(rows[0].schema(), vec![rows])
9216 .expect("native table"),
9217 ),
9218 )
9219 .expect("register native table");
9220
9221 let mut failures = Vec::new();
9222 for aggregate in [
9223 sum(col("amount_cents")).alias("orders.status"),
9224 sum(col("amount_cents")).alias_qualified(Some("totals"), "status"),
9225 ] {
9226 let mut expected_rows = Vec::new();
9227 for (session, pushed) in [(&native, false), (&ctx, true)] {
9228 let df = session
9229 .table("orders")
9230 .await
9231 .expect("table")
9232 .aggregate(vec![col("orders.status")], vec![aggregate.clone()])
9233 .expect("valid aggregate");
9234 let original_schema = df.schema().clone();
9235 state.range_calls.store(0, AtomicOrdering::SeqCst);
9236 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9237 let plan = match df.clone().into_optimized_plan() {
9238 Ok(plan) => plan,
9239 Err(error) => {
9240 failures.push(format!("{aggregate}: {error}"));
9241 continue;
9242 }
9243 };
9244 assert_eq!(
9245 plan.schema().iter().collect::<Vec<_>>(),
9246 original_schema.iter().collect::<Vec<_>>()
9247 );
9248 let batches = df.collect().await.expect("collect aggregate");
9249 let mut rows = Vec::new();
9250 for batch in batches {
9251 assert_eq!(batch.schema().as_ref(), original_schema.as_arrow());
9252 for row in 0..batch.num_rows() {
9253 rows.push((
9254 scalar_to_string(
9255 &ScalarValue::try_from_array(batch.column(0), row).expect("status"),
9256 )
9257 .expect("status string"),
9258 ScalarValue::try_from_array(batch.column(1), row).expect("sum"),
9259 ));
9260 }
9261 }
9262 rows.sort_by(|left, right| left.0.cmp(&right.0));
9263 let filtered = session
9264 .execute_logical_plan(plan.clone())
9265 .await
9266 .expect("optimized aggregate")
9267 .filter(
9268 Expr::Column(datafusion::common::Column::from(
9269 original_schema.qualified_field(0),
9270 ))
9271 .eq(datafusion::prelude::lit("open")),
9272 )
9273 .expect("filter group output")
9274 .collect()
9275 .await
9276 .expect("collect filtered aggregate");
9277 let filtered_sums = filtered
9278 .iter()
9279 .flat_map(|batch| {
9280 (0..batch.num_rows()).map(|row| {
9281 ScalarValue::try_from_array(batch.column(1), row).expect("filtered sum")
9282 })
9283 })
9284 .collect::<Vec<_>>();
9285 assert_eq!(filtered_sums, vec![ScalarValue::Int64(Some(40))]);
9286 if pushed {
9287 assert_eq!(rows, expected_rows);
9288 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9289 assert!(state.range_reduce_calls.load(AtomicOrdering::SeqCst) > 0);
9290
9291 let projected = ctx
9292 .execute_logical_plan(plan)
9293 .await
9294 .expect("optimized aggregate")
9295 .select([Expr::Column(datafusion::common::Column::from(
9296 original_schema.qualified_field(1),
9297 ))])
9298 .expect("project aggregate output")
9299 .collect()
9300 .await
9301 .expect("collect projected aggregate");
9302 let mut sums = projected
9303 .iter()
9304 .flat_map(|batch| {
9305 (0..batch.num_rows()).map(|row| {
9306 ScalarValue::try_from_array(batch.column(0), row).expect("sum")
9307 })
9308 })
9309 .collect::<Vec<_>>();
9310 sums.sort_by(|left, right| left.partial_cmp(right).expect("comparable sums"));
9311 assert_eq!(
9312 sums,
9313 vec![ScalarValue::Int64(Some(40)), ScalarValue::Int64(Some(55))]
9314 );
9315 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9316 } else {
9317 expected_rows = rows;
9318 }
9319 }
9320 }
9321 let _ = shutdown_tx.send(());
9322 assert!(failures.is_empty(), "{}", failures.join("\n"));
9323 }
9324
9325 #[tokio::test]
9326 async fn kv_scan_preserves_row_counts_without_projected_columns() {
9327 let (ctx, state, shutdown_tx) = aggregate_test_context().await;
9328 let native = SessionContext::new();
9329 let table = ctx.table_provider("orders").await.expect("table");
9330 native
9331 .register_table("orders", table)
9332 .expect("register native fallback");
9333 for (sql, count) in [
9334 ("SELECT COUNT(*) FROM orders AS o", 4),
9335 ("SELECT COUNT(*) AS total FROM orders AS o", 4),
9336 ("SELECT COUNT(1) FROM orders AS o", 4),
9337 (
9338 "SELECT COUNT(*) FROM (SELECT id FROM orders LIMIT 3) AS limited",
9339 3,
9340 ),
9341 ("SELECT COUNT(*) FROM orders AS o WHERE id < 0", 0),
9342 ] {
9343 let batches = native
9344 .sql(sql)
9345 .await
9346 .expect("count query")
9347 .collect()
9348 .await
9349 .expect("collect count");
9350 assert_eq!(
9351 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
9352 1
9353 );
9354 assert_count_scalar(&batches[0], 0, 0, count);
9355 }
9356 assert!(state.range_calls.load(AtomicOrdering::SeqCst) > 0);
9357 assert_eq!(state.range_reduce_calls.load(AtomicOrdering::SeqCst), 0);
9358 let _ = shutdown_tx.send(());
9359 }
9360
9361 #[tokio::test]
9362 async fn aggregate_pushdown_preserves_row_counts_without_projected_columns() {
9363 let (ctx, state, shutdown_tx) = aggregate_test_context().await;
9364 for (sql, count) in [
9365 ("SELECT SUM(amount_cents) AS total FROM orders", 1),
9366 (
9367 "SELECT status, SUM(amount_cents) AS total FROM orders GROUP BY status",
9368 2,
9369 ),
9370 ] {
9371 let plan = ctx
9372 .sql(sql)
9373 .await
9374 .expect("aggregate query")
9375 .into_optimized_plan()
9376 .expect("optimize aggregate");
9377 state.range_calls.store(0, AtomicOrdering::SeqCst);
9378 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9379 let batches = ctx
9380 .execute_logical_plan(plan)
9381 .await
9382 .expect("optimized query")
9383 .select(Vec::<Expr>::new())
9384 .expect("empty projection")
9385 .collect()
9386 .await
9387 .expect("collect empty projection");
9388 assert!(batches.iter().all(|batch| batch.num_columns() == 0));
9389 assert_eq!(
9390 batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
9391 count
9392 );
9393 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9394 assert!(state.range_reduce_calls.load(AtomicOrdering::SeqCst) > 0);
9395 }
9396 let _ = shutdown_tx.send(());
9397 }
9398
9399 #[tokio::test]
9400 async fn aggregate_pushdown_supports_unaliased_aggregate_columns() {
9401 let state = MockState {
9402 kv: Arc::new(Mutex::new(BTreeMap::new())),
9403 range_calls: Arc::new(AtomicUsize::new(0)),
9404 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
9405 sequence_number: Arc::new(AtomicU64::new(0)),
9406 };
9407 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
9408 let client = StoreClient::new(&base_url);
9409
9410 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
9411 .table(
9412 "orders",
9413 vec![
9414 TableColumnConfig::new("id", DataType::Int64, false),
9415 TableColumnConfig::new("status", DataType::Utf8, false),
9416 TableColumnConfig::new("amount_cents", DataType::Int64, false),
9417 ],
9418 vec!["id".to_string()],
9419 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
9420 .expect("valid")
9421 .with_cover_columns(vec!["amount_cents".to_string()])],
9422 )
9423 .expect("schema");
9424
9425 let mut writer = schema.batch_writer();
9426 for (id, status, amount) in [(1, "open", 10), (2, "open", 30), (3, "closed", 15)] {
9427 writer
9428 .insert(
9429 "orders",
9430 vec![
9431 CellValue::Int64(id),
9432 CellValue::Utf8(status.to_string()),
9433 CellValue::Int64(amount),
9434 ],
9435 )
9436 .expect("row");
9437 }
9438 writer.flush().await.expect("flush");
9439
9440 let ctx = session_context();
9441 schema.register_all(&ctx).expect("register");
9442
9443 let grouped_sql =
9447 "SELECT status, SUM(amount_cents) FROM orders GROUP BY status ORDER BY status";
9448 let explain = physical_plan_text(&explain_plan_rows(&ctx, grouped_sql).await);
9449 assert!(explain.contains("KvAggregateExec:"), "{explain}");
9450
9451 state.range_calls.store(0, AtomicOrdering::SeqCst);
9452 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9453
9454 let batches = ctx
9455 .sql(grouped_sql)
9456 .await
9457 .expect("query")
9458 .collect()
9459 .await
9460 .expect("collect");
9461
9462 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
9463 let batch = &batches[0];
9464 assert_eq!(
9465 ScalarValue::try_from_array(batch.column(0), 0).expect("status scalar"),
9466 ScalarValue::Utf8(Some("closed".to_string()))
9467 );
9468 assert_eq!(
9469 ScalarValue::try_from_array(batch.column(1), 0).expect("sum scalar"),
9470 ScalarValue::Int64(Some(15))
9471 );
9472 assert_eq!(
9473 ScalarValue::try_from_array(batch.column(0), 1).expect("status scalar"),
9474 ScalarValue::Utf8(Some("open".to_string()))
9475 );
9476 assert_eq!(
9477 ScalarValue::try_from_array(batch.column(1), 1).expect("sum scalar"),
9478 ScalarValue::Int64(Some(40))
9479 );
9480 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9481 assert!(
9482 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
9483 "unaliased group-by aggregate should use grouped range reduction path"
9484 );
9485
9486 let total_sql = "SELECT SUM(amount_cents) FROM orders";
9487 let explain = physical_plan_text(&explain_plan_rows(&ctx, total_sql).await);
9488 assert!(explain.contains("KvAggregateExec:"), "{explain}");
9489
9490 let batches = ctx
9491 .sql(total_sql)
9492 .await
9493 .expect("query")
9494 .collect()
9495 .await
9496 .expect("collect");
9497
9498 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
9499 assert_eq!(
9500 ScalarValue::try_from_array(batches[0].column(0), 0).expect("sum scalar"),
9501 ScalarValue::Int64(Some(55))
9502 );
9503
9504 let _ = shutdown_tx.send(());
9505 }
9506
9507 #[tokio::test]
9508 async fn aggregate_pushdown_group_by_float_canonicalizes_signed_zero() {
9509 let state = MockState {
9510 kv: Arc::new(Mutex::new(BTreeMap::new())),
9511 range_calls: Arc::new(AtomicUsize::new(0)),
9512 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
9513 sequence_number: Arc::new(AtomicU64::new(0)),
9514 };
9515 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
9516 let client = StoreClient::new(&base_url);
9517
9518 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
9519 .table(
9520 "metrics",
9521 vec![
9522 TableColumnConfig::new("id", DataType::Int64, false),
9523 TableColumnConfig::new("score", DataType::Float64, false),
9524 ],
9525 vec!["id".to_string()],
9526 vec![],
9527 )
9528 .expect("schema");
9529
9530 let mut writer = schema.batch_writer();
9531 for (id, score) in [(1, -0.0), (2, 0.0), (3, 1.5)] {
9532 writer
9533 .insert(
9534 "metrics",
9535 vec![CellValue::Int64(id), CellValue::Float64(score)],
9536 )
9537 .expect("row");
9538 }
9539 writer.flush().await.expect("flush");
9540
9541 let ctx = session_context();
9542 schema.register_all(&ctx).expect("register");
9543
9544 state.range_calls.store(0, AtomicOrdering::SeqCst);
9545 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9546
9547 let batches = ctx
9548 .sql(
9549 "SELECT score, COUNT(*) AS row_count \
9550 FROM metrics GROUP BY score ORDER BY row_count DESC, score",
9551 )
9552 .await
9553 .expect("query")
9554 .collect()
9555 .await
9556 .expect("collect");
9557
9558 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 2);
9559 let batch = &batches[0];
9560 let top_score = batch
9561 .column(0)
9562 .as_any()
9563 .downcast_ref::<Float64Array>()
9564 .expect("score float64")
9565 .value(0);
9566 assert_eq!(top_score.to_bits(), 0.0f64.to_bits());
9567 assert_count_scalar(batch, 1, 0, 2);
9568 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9569 assert!(
9570 state.range_reduce_calls.load(AtomicOrdering::SeqCst) >= 1,
9571 "float group-by aggregate should stay on grouped reduction path"
9572 );
9573
9574 let _ = shutdown_tx.send(());
9575 }
9576
9577 #[tokio::test]
9578 async fn aggregate_pushdown_supports_filtered_group_by_queries() {
9579 let state = MockState {
9580 kv: Arc::new(Mutex::new(BTreeMap::new())),
9581 range_calls: Arc::new(AtomicUsize::new(0)),
9582 range_reduce_calls: Arc::new(AtomicUsize::new(0)),
9583 sequence_number: Arc::new(AtomicU64::new(0)),
9584 };
9585 let (base_url, shutdown_tx) = spawn_mock_server(state.clone()).await;
9586 let client = StoreClient::new(&base_url);
9587
9588 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
9589 .table(
9590 "orders",
9591 vec![
9592 TableColumnConfig::new("id", DataType::Int64, false),
9593 TableColumnConfig::new("region", DataType::Utf8, false),
9594 TableColumnConfig::new("status", DataType::Utf8, false),
9595 TableColumnConfig::new("amount_cents", DataType::Int64, false),
9596 ],
9597 vec!["id".to_string()],
9598 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
9599 .expect("valid")
9600 .with_cover_columns(vec!["region".to_string(), "amount_cents".to_string()])],
9601 )
9602 .expect("schema");
9603
9604 let mut writer = schema.batch_writer();
9605 for (id, region, status, amount) in [
9606 (1, "east", "open", 10),
9607 (2, "east", "closed", 20),
9608 (3, "west", "open", 30),
9609 (4, "north", "closed", 40),
9610 ] {
9611 writer
9612 .insert(
9613 "orders",
9614 vec![
9615 CellValue::Int64(id),
9616 CellValue::Utf8(region.to_string()),
9617 CellValue::Utf8(status.to_string()),
9618 CellValue::Int64(amount),
9619 ],
9620 )
9621 .expect("row");
9622 }
9623 writer.flush().await.expect("flush");
9624
9625 let ctx = session_context();
9626 schema.register_all(&ctx).expect("register");
9627
9628 state.range_calls.store(0, AtomicOrdering::SeqCst);
9629 state.range_reduce_calls.store(0, AtomicOrdering::SeqCst);
9630
9631 let batches = ctx
9632 .sql(
9633 "SELECT region, \
9634 COUNT(*) FILTER (WHERE status = 'open') AS open_count, \
9635 SUM(amount_cents) FILTER (WHERE status = 'closed') AS closed_total \
9636 FROM orders \
9637 GROUP BY region \
9638 ORDER BY region",
9639 )
9640 .await
9641 .expect("query")
9642 .collect()
9643 .await
9644 .expect("collect");
9645
9646 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
9647 let batch = &batches[0];
9648
9649 assert_eq!(
9650 ScalarValue::try_from_array(batch.column(0), 0).expect("region scalar"),
9651 ScalarValue::Utf8(Some("east".to_string()))
9652 );
9653 assert_count_scalar(batch, 1, 0, 1);
9654 assert_eq!(
9655 ScalarValue::try_from_array(batch.column(2), 0).expect("sum scalar"),
9656 ScalarValue::Int64(Some(20))
9657 );
9658
9659 assert_eq!(
9660 ScalarValue::try_from_array(batch.column(0), 1).expect("region scalar"),
9661 ScalarValue::Utf8(Some("north".to_string()))
9662 );
9663 assert_count_scalar(batch, 1, 1, 0);
9664 assert_eq!(
9665 ScalarValue::try_from_array(batch.column(2), 1).expect("sum scalar"),
9666 ScalarValue::Int64(Some(40))
9667 );
9668
9669 assert_eq!(
9670 ScalarValue::try_from_array(batch.column(0), 2).expect("region scalar"),
9671 ScalarValue::Utf8(Some("west".to_string()))
9672 );
9673 assert_count_scalar(batch, 1, 2, 1);
9674 assert_eq!(
9675 ScalarValue::try_from_array(batch.column(2), 2).expect("sum scalar"),
9676 ScalarValue::Int64(None)
9677 );
9678
9679 assert_eq!(state.range_calls.load(AtomicOrdering::SeqCst), 0);
9680 assert_eq!(
9681 state.range_reduce_calls.load(AtomicOrdering::SeqCst),
9682 1,
9683 "aggregate filters should share one grouped reduction"
9684 );
9685
9686 let _ = shutdown_tx.send(());
9687 }
9688
9689 mod e2e {
9690 use super::*;
9691 use exoware_sdk::StoreClient;
9692
9693 struct TestServers {
9694 ingest_url: String,
9695 query_url: String,
9696 }
9697
9698 impl TestServers {
9699 fn client(&self) -> StoreClient {
9700 StoreClient::builder()
9701 .health_url(&self.query_url)
9702 .ingest_url(&self.ingest_url)
9703 .query_url(&self.query_url)
9704 .prune_url(&self.ingest_url)
9705 .retention_url(&self.ingest_url)
9706 .stream_url(&self.query_url)
9707 .build()
9708 .expect("test server URLs are set")
9709 }
9710 }
9711
9712 async fn spawn_e2e_servers() -> TestServers {
9713 let (_task, url) = exoware_simulator::open_temp()
9714 .await
9715 .expect("spawn simulator");
9716 TestServers {
9717 ingest_url: url.clone(),
9718 query_url: url,
9719 }
9720 }
9721
9722 #[tokio::test]
9723 async fn sql_insert_and_select_through_real_ingest_query_workers() {
9724 let servers = spawn_e2e_servers().await;
9725 let client = servers.client();
9726
9727 let schema = KvSchema::new(PrefixedStoreClient::empty(client))
9728 .table(
9729 "orders",
9730 vec![
9731 TableColumnConfig::new("id", DataType::Int64, false),
9732 TableColumnConfig::new("status", DataType::Utf8, false),
9733 TableColumnConfig::new("amount_cents", DataType::Int64, false),
9734 ],
9735 vec!["id".to_string()],
9736 vec![IndexSpec::new("status_idx", vec!["status".to_string()])
9737 .expect("valid")
9738 .with_cover_columns(vec!["amount_cents".to_string()])],
9739 )
9740 .expect("schema");
9741
9742 let mut writer = schema.batch_writer();
9743 for (id, status, amount) in [
9744 (1i64, "open", 100i64),
9745 (2, "closed", 200),
9746 (3, "open", 300),
9747 (4, "closed", 400),
9748 (5, "open", 500),
9749 ] {
9750 writer
9751 .insert(
9752 "orders",
9753 vec![
9754 CellValue::Int64(id),
9755 CellValue::Utf8(status.to_string()),
9756 CellValue::Int64(amount),
9757 ],
9758 )
9759 .expect("insert row");
9760 }
9761 writer.flush().await.expect("flush batch");
9762
9763 let ctx = session_context();
9764 schema.register_all(&ctx).expect("register tables");
9765
9766 let batches = ctx
9767 .sql("SELECT id, amount_cents FROM orders ORDER BY id")
9768 .await
9769 .expect("full scan query")
9770 .collect()
9771 .await
9772 .expect("collect full scan");
9773 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
9774 assert_eq!(total_rows, 5, "all 5 rows returned from full scan");
9775
9776 let mut ids = Vec::new();
9777 let mut amounts = Vec::new();
9778 for batch in &batches {
9779 let id_col = batch
9780 .column(0)
9781 .as_any()
9782 .downcast_ref::<Int64Array>()
9783 .expect("id column");
9784 let amt_col = batch
9785 .column(1)
9786 .as_any()
9787 .downcast_ref::<Int64Array>()
9788 .expect("amount column");
9789 for i in 0..batch.num_rows() {
9790 ids.push(id_col.value(i));
9791 amounts.push(amt_col.value(i));
9792 }
9793 }
9794 assert_eq!(ids, vec![1, 2, 3, 4, 5]);
9795 assert_eq!(amounts, vec![100, 200, 300, 400, 500]);
9796
9797 let filtered = ctx
9798 .sql(
9799 "SELECT id, amount_cents FROM orders \
9800 WHERE status = 'open' ORDER BY id",
9801 )
9802 .await
9803 .expect("filtered query")
9804 .collect()
9805 .await
9806 .expect("collect filtered");
9807 let mut filtered_ids = Vec::new();
9808 let mut filtered_amounts = Vec::new();
9809 for batch in &filtered {
9810 let id_col = batch
9811 .column(0)
9812 .as_any()
9813 .downcast_ref::<Int64Array>()
9814 .expect("id column");
9815 let amt_col = batch
9816 .column(1)
9817 .as_any()
9818 .downcast_ref::<Int64Array>()
9819 .expect("amount column");
9820 for i in 0..batch.num_rows() {
9821 filtered_ids.push(id_col.value(i));
9822 filtered_amounts.push(amt_col.value(i));
9823 }
9824 }
9825 assert_eq!(filtered_ids, vec![1, 3, 5]);
9826 assert_eq!(filtered_amounts, vec![100, 300, 500]);
9827
9828 let agg = ctx
9829 .sql(
9830 "SELECT COUNT(*) AS cnt, SUM(amount_cents) AS total \
9831 FROM orders WHERE status = 'open'",
9832 )
9833 .await
9834 .expect("aggregate query")
9835 .collect()
9836 .await
9837 .expect("collect aggregate");
9838 assert_eq!(agg.len(), 1);
9839 let batch = &agg[0];
9840 assert_eq!(batch.num_rows(), 1);
9841 assert_count_scalar(batch, 0, 0, 3);
9842 let total = ScalarValue::try_from_array(batch.column(1), 0).expect("sum scalar");
9843 match total {
9844 ScalarValue::Int64(Some(v)) => assert_eq!(v, 900),
9845 other => panic!("unexpected sum type: {other:?}"),
9846 }
9847 }
9848 }
9849}