Skip to main content

exoware_sql/
lib.rs

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
26/// Creates a DataFusion session with Store aggregate reduction enabled.
27pub fn session_context() -> datafusion::prelude::SessionContext {
28    datafusion::prelude::SessionContext::new_with_state(session_state_builder().build())
29}
30
31/// Creates a DataFusion session builder with Store aggregate reduction enabled.
32///
33/// Configure the returned builder before passing its state to
34/// [`datafusion::prelude::SessionContext::new_with_state`], then register tables with
35/// [`KvSchema::register_all`].
36///
37/// A custom query planner must include [`KvAggregateExtensionPlanner`] in its
38/// [`datafusion::physical_planner::DefaultPhysicalPlanner`] to plan Store aggregates.
39pub 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    /// Assert EXPLAIN text includes the same `query_stats=...` suffix as [`format_query_stats_explain`].
107    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(&region_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    /// Binary predicates are never pushed down: no ordered key encoding exists
1147    /// for variable-length bytes, so filters stay with DataFusion over a scan.
1148    #[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    /// Binary columns carry no KV field kind, so they can never participate in
1174    /// KV-side aggregate pushdown.
1175    #[test]
1176    fn binary_has_no_kv_aggregate_field_kind() {
1177        assert!(kv_field_kind(ColumnKind::Binary).is_none());
1178    }
1179
1180    /// The archived read path decodes stored Binary values back into cells of
1181    /// their exact lengths, including empty payloads.
1182    #[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        // (label = 'foo') AND (label IS NOT NULL) — IS NOT NULL is implied;
1464        // predicate must reduce to StringEq('foo') in either order.
1465        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        // (label = 'foo') AND (label IS NULL) — must contradict in either order.
1477        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        // A ~245-char equality on the leading Utf8 index column advances the
2113        // encoder offset past the reserved slot for the FixedSizeBinary column.
2114        // This exact scenario panicked at predicate.rs:1502 before the fix; it
2115        // must now return Err for BOTH bounds instead of indexing out of range.
2116        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        // A FixedSizeBinary column is an accepted primary key at the schema
2131        // layer, so this exercises the primary-key `_` arm fill: an overlong
2132        // Utf8 leading index column pushes the offset past the reserved PK slot.
2133        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        // A normal short constraint must still produce valid bounds: proves the
2172        // fill-capacity fix did not break the happy path.
2173        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    // Index-bound keys must bracket every row the constraints admit. These
2187    // three pin the cases the old pre-sized-buffer encoder got wrong: a lower
2188    // bound zero-padded past a short string (excluded the row sitting exactly
2189    // on the bound), a string equality wider than the 16-byte schema estimate
2190    // (overflowed the buffer, silently discarding the index), and a
2191    // variable-width Utf8 primary key (zero-padded past an empty string).
2192    #[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        // Query shape: WHERE s = 'a' AND id >= 5.
2208        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        // Index row for exactly (s = 'a', id = 5), as encode_secondary_index_key
2228        // lays it out: escaped string + terminator, then ordered pk bytes.
2229        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        // Longer than the 16-byte Utf8 width estimate but well within
2257        // MAX_KEY_LEN; the old fixed-size buffer rejected this and the planner
2258        // silently fell back to a full scan.
2259        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        // Query shape: WHERE a = 5; the row's Utf8 pk is the empty string, so
2296        // its index key ends in a bare terminator byte.
2297        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        // Regression: the z-order lower bound zero-padded the variable-width
2325        // Utf8 pk suffix to its reserved key width (16). A stored entry whose
2326        // pk encodes below that padding (the empty string encodes to a lone
2327        // terminator byte) sorted below the bound and was missed.
2328        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        // Box query x=5, y=7.
2352        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        // The empty string is the discriminating case: its encoding is the
2373        // lexicographic minimum, so it is the pk that zero-padding excluded.
2374        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        // Regression: for a lexicographic index whose TABLE pk is Utf8, the
2395        // lower bound zero-padded the pk suffix to its reserved key width.
2396        // A stored entry with the empty-string pk (minimal encoding) sorted
2397        // below the bound and was missed.
2398        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        // Lexicographic index (a Utf8, b Int64) constrained on the Utf8
2443        // prefix only: the lower bound must admit the minimal unconstrained
2444        // suffix (b = i64::MIN encodes to all zeros) and minimal pk.
2445        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        // Z-order stored keys interleave the actual encoded bytes, but decode
2504        // and bound construction assume the reserved fixed key width, so a
2505        // Utf8 z-order key column silently matches no rows (and trips a
2506        // debug_assert in debug builds). Reject it at resolve time.
2507        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        // The same column in a lexicographic index stays valid.
2524        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        // Regression: the lower scan bound for a Utf8 primary key used to be
2544        // zero-padded to the fixed PK width (16), sorting it above the
2545        // variable-length stored key, so `WHERE name = 'alice'` returned
2546        // no rows for any name shorter than 16 encoded bytes.
2547        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        // Prefix-freeness: the range for 'al' must not admit 'alice'.
2596        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        // Composite PK (name Utf8, id Int64) constrained on name only: the
2613        // range must admit every id, including i64::MIN, whose ordered
2614        // encoding is all zeros and therefore sorts lowest.
2615        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        // Regression: the secondary-index lower bound was allocated at the
2660        // full fixed key width, leaving trailing zeros past the encoded
2661        // content. A stored entry for a short Utf8 value whose pk suffix
2662        // encodes to all zeros (id = i64::MIN) sorted below that bound and
2663        // was missed.
2664        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        // Regression: `WHERE name = 'alice'` on a Utf8 primary key returned
2714        // no rows because the lower scan bound was zero-padded past the
2715        // variable-length stored key.
2716        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        // Regression: an index lookup on a short Utf8 value missed rows whose
2788        // pk suffix encodes to all zeros (id = i64::MIN sorts lowest), because
2789        // the lower bound was zero-padded past the stored entry. Exercises
2790        // both index streams: non-covering (point lookup) and covering.
2791        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            // The family prefix bytes are fixed; the payload begins byte-aligned
3056            // immediately after them.
3057            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        // Both families use a byte-aligned one-byte packed family prefix, so
3079        // varying the first payload byte spans all 256 values in the byte right
3080        // after the prefix.
3081        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        // Use the PK column "order_id" for the IN list
3520        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        // 1 customer base row + 2 order base rows + 2 order index rows = 5
3774        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        // NULL in non-nullable column "name" must fail
3946        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        // NULL in nullable column "note" must succeed
3962        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        // All non-null values must succeed
3976        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        // Too short (10 bytes for a 16-byte column)
4093        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        // Too long (20 bytes for a 16-byte column)
4104        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        // Exact length (16 bytes) — must succeed
4112        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        // Wrong length (4 bytes for an 8-byte column)
4134        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        // Correct length (8 bytes)
4142        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    // -----------------------------------------------------------------------
4322    // Composite primary key tests
4323    // -----------------------------------------------------------------------
4324
4325    #[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        // Versions must sort numerically (big-endian U64)
4429        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        // Both PK columns should be None in stored value
4457        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        // Ensure single-column PK still works identically
4534        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        // Regression: encode_primary_key_bound with partial prefix must
4559        // fill 0xFF from the end of the encoded prefix, not from the
4560        // end of the full PK width. Otherwise trailing PK column bytes
4561        // stay 0x00, producing an end key that's too low.
4562        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); // 16 + 8
4574
4575        let entity = CellValue::FixedBinary(vec![0xAA; 16]);
4576        // Partial prefix: only entity, no version
4577        let upper =
4578            encode_primary_key_bound(0, &[&entity], &model, true).expect("pk bound encodes");
4579
4580        // Entity bytes must be encoded
4581        assert_eq!(primary_payload(&model, &upper, 0, 16), vec![0xAA; 16]);
4582        // Version bytes (8 bytes after entity) MUST be 0xFF, not 0x00
4583        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        // Everything after PK region also 0xFF
4589        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        // Lower bound: exactly the encoded prefix
4599        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    // -----------------------------------------------------------------------
4613    // Composite PK filter pushdown tests
4614    // -----------------------------------------------------------------------
4615
4616    #[test]
4617    fn composite_pk_range_pushdown_entity_eq_version_lte() {
4618        // PK = (entity: FixedSizeBinary(16), version: UInt64)
4619        // Query: entity = X'CC..CC' AND version <= 42
4620        // Should produce a TIGHT range, not a full table scan.
4621        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        // Simulate predicate: entity = X'CC..CC' AND version <= 42
4635        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        // The start key should encode entity=CC..CC, version=0
4654        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        // The end key should encode entity=CC..CC, version=42, then 0xFF tail
4669        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        // The end key has 0xFF-filled tail after the PK portion
4679        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        // Trailing bytes after PK should be 0xFF
4685        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        // Crucially, the range must NOT be a full table scan
4698        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        // PK = (entity: FixedSizeBinary(16), version: UInt64)
4708        // Query: entity = X'DD..DD' (no version constraint)
4709        // Should still produce a tight entity-prefix range.
4710        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        // Start should have entity=DD..DD, version=0x00..00
4733        assert_eq!(primary_payload(&model, &range.start, 0, 16), vec![0xDD; 16]);
4734        // End should have entity=DD..DD, then 0xFF for version + tail
4735        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        // Build an equality expression: entity = X'AA..AA'
4764        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        // Verify range generation produces 2 ranges (one per entity)
5021        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        // Verify constraint matching
5066        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), &gt_zero));
5080        assert!(!matches_constraint(&CellValue::UInt64(0), &gt_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            // Keep the response open after the first frame even when it satisfies the limit
6637            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        // Control the pass order that exposed the lost reverse pushdown.
7007        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        // Without an inner ORDER BY, LIMIT constrains the row count but not the selected rows.
7551        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            // Reapplying sort pushdown must preserve the rows selected by the inner limit.
7599            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        // The two-row index frames contain one matching row followed by a rejection,
7707        // then a row whose residual predicate can either fail or satisfy the limit
7708        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            // Read the scan directly so an outer limit cannot hide excess output
7719            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    /// Store `/v1/range` is inclusive on both ends; `id <= N` and `BETWEEN` must include the end key.
7921    #[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        // Without an alias the optimizer drops the identity projection above the
9444        // aggregate, so the pushdown rewrite becomes the plan root and must
9445        // reproduce the aggregate's output qualifiers exactly to plan at all.
9446        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}