Skip to content

Commit 3e56f33

Browse files
mysql-util: cover non-utf8mb4 charsets and invalid UTF-8 in probe tests
A latin1 table exercises the column-to-connection charset conversion, including the exact-key step on a key that is one character but two UTF-8 bytes. A binary key column pins the defensive behavior for invalid UTF-8: decode failures read as "no next prefix" and end the walk early instead of erroring. setup_table now derives the charset from the collation name instead of hardcoding utf8mb4.
1 parent ee894b2 commit 3e56f33

1 file changed

Lines changed: 127 additions & 2 deletions

File tree

src/mysql-util/src/probe.rs

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)