@@ -722,6 +722,128 @@ mod tests {
722722 Ok ( ( ) )
723723 }
724724
725+ #[ mz_ore:: test( tokio:: test) ]
726+ #[ cfg_attr( miri, ignore) ]
727+ async fn test_live_mysql_latin1_charset ( ) -> Result < ( ) , anyhow:: Error > {
728+ let Some ( mut conn) = connect ( ) . await ? else {
729+ return Ok ( ( ) ) ;
730+ } ;
731+ const DB : & str = "mz_probe_latin1_test" ;
732+ // The column charset differs from the utf8mb4 connection charset, so
733+ // every value crosses a conversion. é and ñ are one character and one
734+ // byte in latin1 but two bytes in the UTF-8 we receive, making
735+ // character counts and byte counts diverge. Under latin1_swedish_ci
736+ // é collates with e and ñ with n, so no key may be a case or accent
737+ // variant of another.
738+ let keys = [ "a" , "é" , "éa" , "éb" , "ñ" , "ña" , "z" ] ;
739+ let table = setup_table ( & mut conn, DB , "latin1_swedish_ci" , & keys) . await ?;
740+
741+ let p = & mut KeyProber :: new ( & mut conn, table, "id" ) ;
742+ // Prefixes count characters in the column's charset and come back
743+ // converted, so a one-character prefix of é is the whole é.
744+ assert_eq ! (
745+ prefix_of_first_key_in_range( p, None , None , 1 ) . await ,
746+ some( "a" )
747+ ) ;
748+ assert_eq ! (
749+ prefix_of_first_row_not_matching_prefix( p, "a" , None , 1 ) . await ,
750+ some( "é" )
751+ ) ;
752+ assert_eq ! (
753+ prefix_of_first_row_not_matching_prefix( p, "é" , None , 1 ) . await ,
754+ some( "ñ" )
755+ ) ;
756+ assert_eq ! (
757+ prefix_of_first_row_not_matching_prefix( p, "ñ" , None , 1 ) . await ,
758+ some( "z" )
759+ ) ;
760+ assert_eq ! (
761+ prefix_of_first_row_not_matching_prefix( p, "z" , None , 1 ) . await ,
762+ None
763+ ) ;
764+
765+ // The exclusive bound skips the exact key é (one character in
766+ // latin1, two UTF-8 bytes here), and its extensions surface as
767+ // their own prefixes.
768+ assert_eq ! (
769+ prefix_of_first_key_in_range( p, Some ( "é" ) , Some ( "ñ" ) , 2 ) . await ,
770+ some( "éa" )
771+ ) ;
772+ assert_eq ! (
773+ prefix_of_first_row_not_matching_prefix( p, "éa" , Some ( "ñ" ) , 2 ) . await ,
774+ some( "éb" )
775+ ) ;
776+ assert_eq ! (
777+ prefix_of_first_row_not_matching_prefix( p, "éb" , Some ( "ñ" ) , 2 ) . await ,
778+ None
779+ ) ;
780+ // Every key matching 'é%' is covered by the prefix match.
781+ assert_eq ! (
782+ prefix_of_first_row_not_matching_prefix( p, "é" , Some ( "ñ" ) , 2 ) . await ,
783+ None
784+ ) ;
785+
786+ drop_db ( & mut conn, DB ) . await ?;
787+ conn. disconnect ( ) . await ?;
788+ Ok ( ( ) )
789+ }
790+
791+ #[ mz_ore:: test( tokio:: test) ]
792+ #[ cfg_attr( miri, ignore) ]
793+ async fn test_live_mysql_invalid_utf8_keys ( ) -> Result < ( ) , anyhow:: Error > {
794+ let Some ( mut conn) = connect ( ) . await ? else {
795+ return Ok ( ( ) ) ;
796+ } ;
797+ const DB : & str = "mz_probe_binary_test" ;
798+ recreate_db ( & mut conn, DB ) . await ?;
799+ // A binary key column passes bytes through unconverted, so this is
800+ // the one way invalid UTF-8 can reach the client. Production filters
801+ // these columns out before probing, and falls back to a
802+ // single-partition snapshot if one slips through.
803+ #[ allow( clippy:: disallowed_methods) ]
804+ conn. query_drop ( format ! (
805+ "CREATE TABLE {DB}.t (id VARBINARY(36) PRIMARY KEY NOT NULL)"
806+ ) )
807+ . await ?;
808+ let keys: Vec < Vec < u8 > > = vec ! [ b"a1" . to_vec( ) , b"a2" . to_vec( ) , vec![ 0xff , 0xfe , 0x31 ] ] ;
809+ conn. exec_batch (
810+ format ! ( "INSERT INTO {DB}.t VALUES (?)" ) ,
811+ keys. iter ( ) . map ( |k| ( Value :: Bytes ( k. clone ( ) ) , ) ) ,
812+ )
813+ . await ?;
814+ #[ allow( clippy:: disallowed_methods) ]
815+ conn. query_drop ( format ! ( "ANALYZE TABLE {DB}.t" ) ) . await ?;
816+ let table = QualifiedTableRef {
817+ schema_name : DB ,
818+ table_name : "t" ,
819+ } ;
820+ let mut p = KeyProber :: new ( & mut conn, table, "id" ) ;
821+
822+ // Estimates never decode key values, they keep working.
823+ assert ! ( p. estimate_range_rows( None , None ) . await . is_ok( ) ) ;
824+
825+ // ASCII keys order before the 0xff key and decode fine.
826+ assert_eq ! (
827+ prefix_of_first_key_in_range( & mut p, None , None , 2 ) . await ,
828+ some( "a1" )
829+ ) ;
830+ assert_eq ! (
831+ prefix_of_first_row_not_matching_prefix( & mut p, "a1" , None , 2 ) . await ,
832+ some( "a2" )
833+ ) ;
834+ // The next key is invalid UTF-8. The probe reports it as a named
835+ // error so callers can log it and fall back.
836+ let err = p
837+ . prefix_of_first_row_not_matching_prefix ( "a2" , None , 2 )
838+ . await
839+ . unwrap_err ( ) ;
840+ assert ! ( matches!( err, MySqlError :: NonUtf8KeyValue { .. } ) , "{err:?}" ) ;
841+
842+ drop_db ( & mut conn, DB ) . await ?;
843+ conn. disconnect ( ) . await ?;
844+ Ok ( ( ) )
845+ }
846+
725847 #[ mz_ore:: test( tokio:: test) ]
726848 #[ cfg_attr( miri, ignore) ]
727849 async fn test_live_mysql_stale_statistics ( ) -> Result < ( ) , anyhow:: Error > {
@@ -884,7 +1006,7 @@ mod tests {
8841006 }
8851007
8861008 /// Recreates scratch database `db` holding one table `t` whose string
887- /// primary key `id` is pinned to the given utf8mb4 `collation`, containing
1009+ /// primary key `id` is pinned to the given `collation`, containing
8881010 /// `keys`, with fresh statistics. Returns a ref for [`KeyProber::new`].
8891011 async fn setup_table < ' a > (
8901012 conn : & mut mysql_async:: Conn ,
@@ -893,9 +1015,12 @@ mod tests {
8931015 keys : & [ impl AsRef < str > + Sync ] ,
8941016 ) -> Result < QualifiedTableRef < ' a > , anyhow:: Error > {
8951017 recreate_db ( conn, db) . await ?;
1018+ // MySQL collation names start with their character set's name, so
1019+ // the charset is pinned explicitly without a second parameter.
1020+ let charset = collation. split ( '_' ) . next ( ) . expect ( "nonempty collation" ) ;
8961021 #[ allow( clippy:: disallowed_methods) ]
8971022 conn. query_drop ( format ! (
898- "CREATE TABLE {db}.t (id VARCHAR(36) CHARACTER SET utf8mb4 \
1023+ "CREATE TABLE {db}.t (id VARCHAR(36) CHARACTER SET {charset} \
8991024 COLLATE {collation} PRIMARY KEY NOT NULL)"
9001025 ) )
9011026 . await ?;
0 commit comments