@@ -371,4 +371,169 @@ mod tests {
371371 assert ! ( pruned. contains( & shard_a) ) ;
372372 assert ! ( pruned. contains( & shard_b) ) ; // kept because no field values
373373 }
374+
375+ #[ test]
376+ fn test_and_conjunction_extracts_sharding_column ( ) {
377+ let spec = bucket_spec ( "id" , 8 ) ;
378+ let shard_a = Uuid :: new_v4 ( ) ;
379+ let shard_b = Uuid :: new_v4 ( ) ;
380+
381+ let bucket_for_1 = hash_scalar_to_bucket ( & ScalarValue :: Int32 ( Some ( 1 ) ) , 8 ) . unwrap ( ) ;
382+ let other_bucket = ( 0 ..8 ) . find ( |b| * b != bucket_for_1) . unwrap ( ) ;
383+
384+ let snapshots = vec ! [
385+ snapshot_with_bucket( shard_a, "bucket" , bucket_for_1) ,
386+ snapshot_with_bucket( shard_b, "bucket" , other_bucket) ,
387+ ] ;
388+
389+ // Filter: id = 1 AND name = "foo"
390+ // Only id = 1 should be extracted; name is not the sharding column.
391+ let filter = col ( "id" ) . eq ( lit ( 1i32 ) ) . and ( col ( "name" ) . eq ( lit ( "foo" ) ) ) ;
392+ let result = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , None ) ;
393+
394+ let pruned = result. expect ( "should prune using id = 1 from AND" ) ;
395+ assert ! ( pruned. contains( & shard_a) ) ;
396+ assert ! ( !pruned. contains( & shard_b) ) ;
397+ }
398+
399+ #[ test]
400+ fn test_or_disjunction_returns_none ( ) {
401+ let spec = bucket_spec ( "id" , 8 ) ;
402+ let shard_a = Uuid :: new_v4 ( ) ;
403+
404+ let bucket_for_1 = hash_scalar_to_bucket ( & ScalarValue :: Int32 ( Some ( 1 ) ) , 8 ) . unwrap ( ) ;
405+ let snapshots = vec ! [ snapshot_with_bucket( shard_a, "bucket" , bucket_for_1) ] ;
406+
407+ // Filter: id = 1 OR id = 2 -- OR is not handled, should return None.
408+ let filter = col ( "id" ) . eq ( lit ( 1i32 ) ) . or ( col ( "id" ) . eq ( lit ( 2i32 ) ) ) ;
409+ let result = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , None ) ;
410+
411+ assert ! ( result. is_none( ) , "OR filters should not be prunable" ) ;
412+ }
413+
414+ #[ test]
415+ fn test_not_in_returns_none ( ) {
416+ let spec = bucket_spec ( "id" , 8 ) ;
417+ let shard_a = Uuid :: new_v4 ( ) ;
418+
419+ let bucket_for_1 = hash_scalar_to_bucket ( & ScalarValue :: Int32 ( Some ( 1 ) ) , 8 ) . unwrap ( ) ;
420+ let snapshots = vec ! [ snapshot_with_bucket( shard_a, "bucket" , bucket_for_1) ] ;
421+
422+ // Filter: id NOT IN (1, 2) -- negated InList should return None.
423+ let filter = col ( "id" ) . in_list ( vec ! [ lit( 1i32 ) , lit( 2i32 ) ] , true ) ;
424+ let result = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , None ) ;
425+
426+ assert ! ( result. is_none( ) , "NOT IN should not be prunable" ) ;
427+ }
428+
429+ #[ test]
430+ fn test_type_coercion_int64_to_int32 ( ) {
431+ use std:: sync:: Arc ;
432+
433+ use arrow_schema:: { DataType , Field , Schema as ArrowSchema } ;
434+
435+ let num_buckets = 8 ;
436+ let spec = bucket_spec ( "id" , num_buckets) ;
437+ let shard_a = Uuid :: new_v4 ( ) ;
438+ let shard_b = Uuid :: new_v4 ( ) ;
439+
440+ // Snapshots store bucket values computed from Int32(1) -- matches write path.
441+ let bucket_for_int32 =
442+ hash_scalar_to_bucket ( & ScalarValue :: Int32 ( Some ( 1 ) ) , num_buckets) . unwrap ( ) ;
443+ let bucket_for_int64 =
444+ hash_scalar_to_bucket ( & ScalarValue :: Int64 ( Some ( 1 ) ) , num_buckets) . unwrap ( ) ;
445+ // Precondition: Int32 and Int64 must hash to different buckets for this
446+ // test to be meaningful. Murmur3 uses hash_int vs hash_long code paths.
447+ assert_ne ! (
448+ bucket_for_int32, bucket_for_int64,
449+ "Int32 and Int64 should hash to different buckets for value 1"
450+ ) ;
451+
452+ let snapshots = vec ! [
453+ snapshot_with_bucket( shard_a, "bucket" , bucket_for_int32) ,
454+ snapshot_with_bucket( shard_b, "bucket" , bucket_for_int64) ,
455+ ] ;
456+
457+ // Filter uses Int64 literal (as SQL parsing typically produces).
458+ let filter = col ( "id" ) . eq ( lit ( 1i64 ) ) ;
459+
460+ // WITH base_schema: Int64 coerced to Int32 before hashing -> matches shard_a.
461+ let schema: SchemaRef = Arc :: new ( ArrowSchema :: new ( vec ! [ Field :: new(
462+ "id" ,
463+ DataType :: Int32 ,
464+ false ,
465+ ) ] ) ) ;
466+ let with_schema = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , Some ( & schema) ) ;
467+ let pruned = with_schema. expect ( "coercion should enable pruning" ) ;
468+ assert ! (
469+ pruned. contains( & shard_a) ,
470+ "with coercion, Int64 -> Int32 should match the Int32-hashed shard"
471+ ) ;
472+ assert ! (
473+ !pruned. contains( & shard_b) ,
474+ "with coercion, should not match the Int64-hashed shard"
475+ ) ;
476+
477+ // WITHOUT base_schema: Int64 stays Int64, hashes differently -> misses shard_a.
478+ let without_schema = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , None ) ;
479+ let pruned_raw = without_schema. expect ( "should still return Some (literals extracted)" ) ;
480+ assert ! (
481+ !pruned_raw. contains( & shard_a) ,
482+ "without coercion, Int64 hash differs from Int32 -- should miss shard_a"
483+ ) ;
484+ assert ! (
485+ pruned_raw. contains( & shard_b) ,
486+ "without coercion, Int64 hash matches shard_b (stored with Int64 bucket)"
487+ ) ;
488+ }
489+
490+ #[ test]
491+ fn test_multi_field_spec_uses_first_field ( ) {
492+ let spec = ShardingSpec {
493+ spec_id : 1 ,
494+ fields : vec ! [
495+ ShardingField {
496+ field_id: "bucket" . to_string( ) ,
497+ source_ids: vec![ ] ,
498+ transform: Some ( "bucket" . to_string( ) ) ,
499+ expression: None ,
500+ result_type: "int32" . to_string( ) ,
501+ parameters: HashMap :: from( [
502+ ( "num_buckets" . to_string( ) , "4" . to_string( ) ) ,
503+ ( "column" . to_string( ) , "id" . to_string( ) ) ,
504+ ] ) ,
505+ } ,
506+ ShardingField {
507+ field_id: "second_field" . to_string( ) ,
508+ source_ids: vec![ ] ,
509+ transform: Some ( "bucket" . to_string( ) ) ,
510+ expression: None ,
511+ result_type: "int32" . to_string( ) ,
512+ parameters: HashMap :: from( [
513+ ( "num_buckets" . to_string( ) , "4" . to_string( ) ) ,
514+ ( "column" . to_string( ) , "region" . to_string( ) ) ,
515+ ] ) ,
516+ } ,
517+ ] ,
518+ } ;
519+
520+ let shard_a = Uuid :: new_v4 ( ) ;
521+ let bucket_for_1 = hash_scalar_to_bucket ( & ScalarValue :: Int32 ( Some ( 1 ) ) , 4 ) . unwrap ( ) ;
522+ let snapshots = vec ! [ snapshot_with_bucket( shard_a, "bucket" , bucket_for_1) ] ;
523+
524+ // Filter on the first field's column -- should work without panic.
525+ let filter = col ( "id" ) . eq ( lit ( 1i32 ) ) ;
526+ let result = prune_shards ( & filter, & spec, & snapshots, & HashMap :: new ( ) , None ) ;
527+ let pruned = result. expect ( "should prune using first field" ) ;
528+ assert ! ( pruned. contains( & shard_a) ) ;
529+ }
530+
531+ #[ test]
532+ fn test_empty_snapshots_returns_empty_set ( ) {
533+ let spec = bucket_spec ( "id" , 4 ) ;
534+ let filter = col ( "id" ) . eq ( lit ( 1i32 ) ) ;
535+ let result = prune_shards ( & filter, & spec, & [ ] , & HashMap :: new ( ) , None ) ;
536+ let pruned = result. expect ( "should return Some even with empty snapshots" ) ;
537+ assert ! ( pruned. is_empty( ) , "empty snapshots should yield empty set" ) ;
538+ }
374539}
0 commit comments