@@ -385,10 +385,10 @@ fn postgres_between_finds_integers_in_an_inclusive_range() {
385385 . collect ( ) ;
386386 assert ! ( none. is_empty( ) ) ;
387387
388- // A range over the other term has no `between` for the column, so the
389- // call fails at plan time. PostgreSQL reports it as ambiguous: with no
390- // exact match it treats both domains as `jsonb`, which every
391- // ` between` overload would take .
388+ // A range over the other term has no `between` for the column. The
389+ // statement fails when it is parsed, with 42725 ("not unique"):
390+ // PostgreSQL resolves a function's domain arguments as their base
391+ // type, `jsonb`, which every ` between` overload takes .
392392 let err = db
393393 . query (
394394 "SELECT n FROM v4_int_range WHERE eql_v4.between(ore, $1::text::jsonb::eql_v4.query_integer_range_ope)" ,
@@ -621,3 +621,286 @@ fn postgres_range_domains_check_their_shape() {
621621 }
622622 }
623623}
624+
625+ /// Five plaintexts of `family`, strictly ascending, as cipherstash-client's
626+ /// indexers take them: what the smoke test stores and ranges over. A new
627+ /// ordered family must add a row here, or the test fails naming it.
628+ fn ascending ( family : & str ) -> [ Plaintext ; 5 ] {
629+ use chrono:: { DateTime , NaiveDate } ;
630+ use rust_decimal:: Decimal ;
631+ let date = |y, m, d| Plaintext :: NaiveDate ( NaiveDate :: from_ymd_opt ( y, m, d) ) ;
632+ let at = |secs| Plaintext :: Timestamp ( DateTime :: from_timestamp ( secs, 0 ) ) ;
633+ let dec = |n, scale| Plaintext :: Decimal ( Some ( Decimal :: new ( n, scale) ) ) ;
634+ let text = |s : & str | Plaintext :: Text ( Some ( s. to_owned ( ) ) ) ;
635+ match family {
636+ "integer" => [ -20 , -1 , 0 , 7 , 100 ] . map ( |n| Plaintext :: Int ( Some ( n) ) ) ,
637+ "smallint" => [ -300 , -1 , 0 , 5 , 300 ] . map ( |n| Plaintext :: SmallInt ( Some ( n) ) ) ,
638+ "bigint" => [ i64:: MIN + 1 , -1 , 0 , 1 << 40 , i64:: MAX ] . map ( |n| Plaintext :: BigInt ( Some ( n) ) ) ,
639+ "date" => [
640+ date ( 1969 , 12 , 31 ) ,
641+ date ( 1970 , 1 , 1 ) ,
642+ date ( 2000 , 2 , 29 ) ,
643+ date ( 2024 , 6 , 1 ) ,
644+ date ( 2099 , 12 , 31 ) ,
645+ ] ,
646+ "timestamp" => [ -86_400 , 0 , 951_782_400 , 1_717_200_000 , 4_102_444_800 ] . map ( at) ,
647+ "numeric" => [
648+ dec ( -15 , 1 ) ,
649+ dec ( 0 , 0 ) ,
650+ dec ( 25 , 2 ) ,
651+ dec ( 314_159 , 5 ) ,
652+ dec ( 1000 , 0 ) ,
653+ ] ,
654+ "text" => [ "apple" , "banana" , "cherry" , "damson" , "elderberry" ] . map ( text) ,
655+ // `real` values are exact in f32; both float widths encrypt as f64.
656+ "real" => [ -2.5 , -0.5 , 0.0 , 1.25 , 1e10 ] . map ( |f| Plaintext :: Float ( Some ( f) ) ) ,
657+ "double" => [ -1e300 , -1.0 , 0.0 , 2.5 , 1e300 ] . map ( |f| Plaintext :: Float ( Some ( f) ) ) ,
658+ other => panic ! ( "no smoke-test values for the ordered family {other:?}: add them here" ) ,
659+ }
660+ }
661+
662+ /// The text a smoke-test value seals as for its `c` (and, for text, its
663+ /// `hm` and `bf`): `between` never reads them, and most ordered families
664+ /// have no producible type to seal the value itself.
665+ fn display ( plaintext : & Plaintext ) -> String {
666+ match plaintext {
667+ Plaintext :: Text ( Some ( s) ) => s. clone ( ) ,
668+ other => format ! ( "{other:?}" ) ,
669+ }
670+ }
671+
672+ /// Needs an EQL v4 installation. A smoke test over every ordered family
673+ /// and every `between` overload, generated from the catalog: five ascending
674+ /// rows per family in each column domain that carries an ordering term, and
675+ /// a range from the second value to the fourth, which must find exactly the
676+ /// rows at both bounds and the one inside, and neither row outside. For text
677+ /// it also checks that an order-term index serves `between`, for ORE on
678+ /// `_ord_ore` and for OPE on `_search`.
679+ #[ test]
680+ #[ ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database" ]
681+ fn postgres_between_smoke_test_over_every_ordered_family ( ) {
682+ let runtime = runtime ( ) ;
683+ let key = runtime. block_on ( index_key ( ) ) ;
684+ let ( cipher, _) = runtime. block_on ( common:: cipher ( ) ) ;
685+ let keyset = cipher. default_keyset ( ) ;
686+ let mut db = connect ( ) ;
687+ let ranges = v4:: all_range_query ( ) ;
688+ let mut checked = 0 ;
689+
690+ for family in eql_domains:: CATALOG
691+ . iter ( )
692+ . filter ( |f| !f. range_terms ( ) . is_empty ( ) )
693+ {
694+ let values = ascending ( family. name ) ;
695+ let table = format ! ( "v4_smoke_{}" , family. name) ;
696+ let columns: Vec < & eql_domains:: Domain > = family
697+ . domains
698+ . iter ( )
699+ . filter ( |d| d. terms . iter ( ) . any ( |t| t. range_suffix ( ) . is_some ( ) ) )
700+ . collect ( ) ;
701+ db. batch_execute ( & format ! (
702+ "CREATE TEMP TABLE {table} (id int PRIMARY KEY, {})" ,
703+ columns
704+ . iter( )
705+ . map( |d| format!( "c_{} public.eql_v4_{}" , d. name, d. full_name( family. name) ) )
706+ . collect:: <Vec <_>>( )
707+ . join( ", " )
708+ ) )
709+ . unwrap ( ) ;
710+
711+ for ( id, value) in values. iter ( ) . enumerate ( ) {
712+ let text = display ( value) ;
713+ let ( eq, matched) = runtime. block_on ( async {
714+ let eq: text:: TextEq = keyset. encrypt_as ( & text, column ( ) ) . await . unwrap ( ) ;
715+ let matched = match family. name {
716+ "text" => Some (
717+ keyset
718+ . encrypt_as :: < _ , text:: TextMatch > ( & text, column ( ) )
719+ . await
720+ . unwrap ( ) ,
721+ ) ,
722+ _ => None ,
723+ } ;
724+ ( eq, matched)
725+ } ) ;
726+ let eq = serde_json:: to_value ( & eq) . unwrap ( ) ;
727+ let payloads: Vec < String > = columns
728+ . iter ( )
729+ . map ( |d| {
730+ let mut p = json ! ( { "v" : eq[ "v" ] , "i" : eq[ "i" ] , "c" : eq[ "c" ] } ) ;
731+ for term in d. terms {
732+ match term {
733+ eql_domains:: Term :: Hm => p[ "hm" ] = eq[ "hm" ] . clone ( ) ,
734+ eql_domains:: Term :: Bloom => {
735+ let m = matched. as_ref ( ) . expect ( "only text has a match term" ) ;
736+ p[ "bf" ] = serde_json:: to_value ( m) . unwrap ( ) [ "bf" ] . clone ( ) ;
737+ }
738+ eql_domains:: Term :: Ore => p[ "ob" ] = ob ( & key, value. clone ( ) , false ) ,
739+ eql_domains:: Term :: Ope => p[ "op" ] = op ( & key, value. clone ( ) ) ,
740+ }
741+ }
742+ p. to_string ( )
743+ } )
744+ . collect ( ) ;
745+ let placeholders: Vec < String > = columns
746+ . iter ( )
747+ . enumerate ( )
748+ . map ( |( i, d) | {
749+ format ! (
750+ "${}::text::jsonb::public.eql_v4_{}" ,
751+ i + 2 ,
752+ d. full_name( family. name)
753+ )
754+ } )
755+ . collect ( ) ;
756+ let id = id as i32 ;
757+ let mut params: Vec < & ( dyn postgres:: types:: ToSql + Sync ) > = vec ! [ & id] ;
758+ params. extend (
759+ payloads
760+ . iter ( )
761+ . map ( |p| p as & ( dyn postgres:: types:: ToSql + Sync ) ) ,
762+ ) ;
763+ db. execute (
764+ & format ! (
765+ "INSERT INTO {table} VALUES ($1, {})" ,
766+ placeholders. join( ", " )
767+ ) ,
768+ & params,
769+ )
770+ . unwrap ( ) ;
771+ }
772+
773+ for term in family. range_terms ( ) {
774+ let bound = |v : & Plaintext | match term {
775+ eql_domains:: Term :: Ore => ob ( & key, v. clone ( ) , true ) ,
776+ eql_domains:: Term :: Ope => op ( & key, v. clone ( ) ) ,
777+ other => unreachable ! ( "{other:?} has no range" ) ,
778+ } ;
779+ let domain = family. range_query_name ( term) ;
780+ let range = json ! ( { "v" : 4 , "i" : identifier( ) , "lo" : bound( & values[ 1 ] ) , "hi" : bound( & values[ 3 ] ) } ) ;
781+ // The binding for the domain accepts the range.
782+ ranges
783+ . iter ( )
784+ . find ( |r| r. sql_domain ( ) == format ! ( "eql_v4.{domain}" ) )
785+ . unwrap_or_else ( || panic ! ( "no binding for {domain}" ) )
786+ . parse_value ( & range)
787+ . unwrap ( ) ;
788+ for d in family. range_columns ( term) {
789+ let found: Vec < i32 > = db
790+ . query (
791+ & format ! (
792+ "SELECT id FROM {table} \
793+ WHERE eql_v4.between(c_{}, $1::text::jsonb::eql_v4.{domain}) ORDER BY id",
794+ d. name
795+ ) ,
796+ & [ & range. to_string ( ) ] ,
797+ )
798+ . unwrap ( )
799+ . iter ( )
800+ . map ( |r| r. get ( 0 ) )
801+ . collect ( ) ;
802+ assert_eq ! (
803+ found,
804+ [ 1 , 2 , 3 ] ,
805+ "{}: between(_{}, {domain})" ,
806+ family. name,
807+ d. name
808+ ) ;
809+ checked += 1 ;
810+ }
811+ }
812+
813+ if family. name == "text" {
814+ let range = |term : eql_domains:: Term | {
815+ let bound = |v : & Plaintext | match term {
816+ eql_domains:: Term :: Ore => ob ( & key, v. clone ( ) , true ) ,
817+ _ => op ( & key, v. clone ( ) ) ,
818+ } ;
819+ json ! ( { "v" : 4 , "i" : identifier( ) , "lo" : bound( & values[ 1 ] ) , "hi" : bound( & values[ 3 ] ) } )
820+ . to_string ( )
821+ } ;
822+ db. batch_execute ( & format ! (
823+ "CREATE INDEX {table}_ore ON {table} USING btree (eql_v4.ord_term_ore(c_ord_ore));
824+ CREATE INDEX {table}_search ON {table} USING btree (eql_v4.ord_term(c_search));
825+ ANALYZE {table};
826+ SET enable_seqscan = off;"
827+ ) )
828+ . unwrap ( ) ;
829+ for ( column, domain, index, value) in [
830+ (
831+ "c_ord_ore" ,
832+ "query_text_range_ore" ,
833+ format ! ( "{table}_ore" ) ,
834+ range ( eql_domains:: Term :: Ore ) ,
835+ ) ,
836+ (
837+ "c_search" ,
838+ "query_text_range_ope" ,
839+ format ! ( "{table}_search" ) ,
840+ range ( eql_domains:: Term :: Ope ) ,
841+ ) ,
842+ ] {
843+ let plan = explain (
844+ & mut db,
845+ & format ! (
846+ "SELECT id FROM {table} WHERE eql_v4.between({column}, '{}'::jsonb::eql_v4.{domain})" ,
847+ value. replace( '\'' , "''" )
848+ ) ,
849+ ) ;
850+ let mut scans = Vec :: new ( ) ;
851+ index_scans ( & plan, & mut scans) ;
852+ let ( _, cond) = scans
853+ . iter ( )
854+ . find ( |( name, _) | * name == index)
855+ . unwrap_or_else ( || {
856+ panic ! ( "text {column}: no index scan on {index}:\n {plan:#}" )
857+ } ) ;
858+ assert ! (
859+ cond. contains( ">=" ) && cond. contains( "<=" ) ,
860+ "text {column}: {cond}"
861+ ) ;
862+ }
863+ db. batch_execute ( "RESET enable_seqscan;" ) . unwrap ( ) ;
864+ }
865+ }
866+ // 8 numeric and temporal families with 3 overloads each, and text's 5.
867+ assert_eq ! ( checked, 29 , "every between overload was exercised" ) ;
868+ }
869+
870+ /// Needs an EQL v4 installation. The range parameter must be cast to its
871+ /// exact range domain. An uncast `$1`, a `jsonb` parameter, or a range of
872+ /// another family matches several `between` overloads (PostgreSQL resolves
873+ /// a function's domain arguments as their base type, `jsonb`), so preparing
874+ /// the statement fails with 42725, before any value is bound. The
875+ /// single-bound operators resolve an uncast `$1` on the same column.
876+ #[ test]
877+ #[ ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database" ]
878+ fn postgres_between_needs_the_exact_range_domain ( ) {
879+ let mut db = connect ( ) ;
880+ db. batch_execute ( "CREATE TEMP TABLE v4_range_cast (ore public.eql_v4_integer_ord_ore);" )
881+ . unwrap ( ) ;
882+ for range in [
883+ "$1" ,
884+ "$1::jsonb" ,
885+ "$1::eql_v4.query_bigint_range_ore" ,
886+ "$1::eql_v4.query_integer_range_ope" ,
887+ ] {
888+ let err = db
889+ . prepare ( & format ! (
890+ "SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, {range})"
891+ ) )
892+ . unwrap_err ( ) ;
893+ assert_eq ! ( sqlstate( & err) , "42725" , "{range}: {err}" ) ;
894+ assert ! (
895+ err. as_db_error( )
896+ . is_some_and( |e| e. message( ) . contains( "is not unique" ) ) ,
897+ "{range}: {err}"
898+ ) ;
899+ }
900+ db. prepare (
901+ "SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, $1::eql_v4.query_integer_range_ore)" ,
902+ )
903+ . unwrap ( ) ;
904+ db. prepare ( "SELECT 1 FROM v4_range_cast WHERE ore < $1" )
905+ . unwrap ( ) ;
906+ }
0 commit comments