diff --git a/src/binlog/decimal/mod.rs b/src/binlog/decimal/mod.rs index 00d9d05..b2d7bad 100644 --- a/src/binlog/decimal/mod.rs +++ b/src/binlog/decimal/mod.rs @@ -58,7 +58,7 @@ pub const POWERS_10: [i32; DIG_PER_DEC + 1] = [ /// i.e. both `rhs` and `lhs` will be serialized into temporary buffers; /// * even though MySql's `string2decimal` function allows scientific notation, /// this implementation denies it. -#[derive(Default, Debug, Eq)] +#[derive(Default, Debug, Eq, Clone)] pub struct Decimal { /// The number of *decimal* digits (NOT number of `Digit`s!) before the point. intg: usize, @@ -75,6 +75,8 @@ impl Decimal { decimal_bin_size(self.intg + self.frac, self.frac) } + /// See [`Decimal::parse_str_bytes`]. + #[deprecated = "use parse_str_bytes"] pub fn parse_bytes(bytes: &[u8]) -> Result { match std::str::from_utf8(bytes) { Ok(string) => Decimal::from_str(string), @@ -82,6 +84,14 @@ impl Decimal { } } + /// Runs `Decimal::from_str` on the given bytes. Errors if not UTF-8 or not a decimal string. + pub fn parse_str_bytes(bytes: &[u8]) -> Result { + match std::str::from_utf8(bytes) { + Ok(string) => Decimal::from_str(string), + Err(_) => Err(ParseDecimalError), + } + } + pub fn write_bin(&self, mut output: T) -> io::Result<()> { // result bits must be inverted if the sign is negative, // we'll XOR it with `mask` to achieve this. @@ -139,6 +149,27 @@ impl Decimal { output.write_all(&out_buf) } + /// Reads packed representation of a [`Decimal`]. + /// + /// Packed representation is: + /// + /// 1. precision (u8) + /// 2. scale (u8) + /// 3. serialized decimal value (see [`Decimal::read_bin`]) + pub fn read_packed(mut input: T, keep_precision: bool) -> io::Result { + let mut precision_and_scale = [0_u8, 0_u8]; + input.read_exact(&mut precision_and_scale)?; + Self::read_bin( + input, + precision_and_scale[0] as usize, + precision_and_scale[1] as usize, + keep_precision, + ) + } + + /// Reads serialized representation of a decimal value. + /// + /// The value is usually written in the packed form (see [`Decimal::read_packed`]). pub fn read_bin( mut input: T, precision: usize, diff --git a/src/binlog/jsonb.rs b/src/binlog/jsonb.rs index f3db5ba..c1f3d67 100644 --- a/src/binlog/jsonb.rs +++ b/src/binlog/jsonb.rs @@ -10,12 +10,17 @@ use std::{ borrow::Cow, + collections::BTreeMap, convert::{TryFrom, TryInto}, fmt, io, + iter::FromIterator, marker::PhantomData, str::{from_utf8, Utf8Error}, }; +use base64::{prelude::BASE64_STANDARD, Engine}; +use serde_json::Number; + use crate::{ constants::ColumnType, io::ParseBuf, @@ -26,6 +31,8 @@ use crate::{ proto::{MyDeserialize, MySerialize}, }; +use super::{decimal::Decimal, time::MysqlTime}; + impl fmt::Debug for Value<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -119,6 +126,10 @@ impl<'a> JsonbString<'a> { pub fn into_owned(self) -> JsonbString<'static> { JsonbString(self.0.into_owned()) } + + pub fn into_raw(self) -> Cow<'a, [u8]> { + self.0 .0 + } } impl fmt::Debug for JsonbString<'_> { @@ -207,7 +218,7 @@ impl<'a, T, U> ComplexValue<'a, T, U> { } } - /// Returns the number of lements. + /// Returns the number of elements. pub fn element_count(&self) -> u32 { self.element_count } @@ -275,8 +286,8 @@ impl<'a, T: StorageFormat> ComplexValue<'a, T, Array> { impl<'a, T: StorageFormat, U: ComplexType> ComplexValue<'a, T, U> { /// Returns an element at the given position. /// - /// * for arrays returns an element at the given position in an arrary, - /// * for objects returns an element with the given key index. + /// * for arrays — returns an element at the given position, + /// * for objects — returns an element with the given key index. /// /// Returns `None` if `pos >= self.element_count()`. pub fn elem_at(&'a self, pos: u32) -> io::Result>> { @@ -416,9 +427,140 @@ impl<'a> OpaqueValue<'a> { data: self.data.into_owned(), } } + + pub fn into_data(self) -> Cow<'a, [u8]> { + self.data.0 + } } -/// Jsonb Value. +/// Structured in-memory representation of a JSON value. +/// +/// You can get this value using [`Value::parse`]. +/// +/// You can convert this into a [`serde_json::Value`] (using [`From`] impl). Opaque values will +/// be handled as follows: +/// +/// * [`ColumnType::MYSQL_TYPE_NEWDECIMAL`] — will be converted to string +/// * [`ColumnType::MYSQL_TYPE_DATE`] — will be converted to 'YYYY-MM-DD' string +/// * [`ColumnType::MYSQL_TYPE_TIME`] — will be converted to '[-][h]hh:mm::ss.µµµµµµ' string +/// * [`ColumnType::MYSQL_TYPE_DATETIME`] and [`ColumnType::MYSQL_TYPE_TIMESTAMP`] +/// — will be converted to 'YYYY-MM-DD hh:mm::ss.µµµµµµ' string +/// * other opaque values will be represented as strings in the form `base64:type:` +/// where: +/// - `` — [`ColumnType`] integer value +/// - `` — base64-encoded opaque data +#[derive(Debug, Clone, PartialEq)] +pub enum JsonDom { + Container(JsonContainer), + Scalar(JsonScalar), +} + +impl From for serde_json::Value { + fn from(value: JsonDom) -> Self { + match value { + JsonDom::Container(json_container) => json_container.into(), + JsonDom::Scalar(json_scalar) => json_scalar.into(), + } + } +} + +/// [`JsonDom`] container. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonContainer { + Array(Vec), + Object(BTreeMap), +} + +impl From for serde_json::Value { + fn from(value: JsonContainer) -> Self { + match value { + JsonContainer::Array(vec) => { + serde_json::Value::Array(Vec::from_iter(vec.into_iter().map(|x| x.into()))) + } + JsonContainer::Object(btree_map) => serde_json::Value::Object( + serde_json::Map::from_iter(btree_map.into_iter().map(|(k, v)| (k, v.into()))), + ), + } + } +} + +/// [`JsonDom`] scalar value. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonScalar { + Boolean(bool), + DateTime(MysqlTime), + Null, + Number(JsonNumber), + Opaque(JsonOpaque), + String(String), +} + +impl From for serde_json::Value { + fn from(value: JsonScalar) -> Self { + match value { + JsonScalar::Boolean(x) => serde_json::Value::Bool(x), + JsonScalar::DateTime(mysql_time) => { + serde_json::Value::String(format!("{:.6}", mysql_time)) + } + JsonScalar::Null => serde_json::Value::Null, + JsonScalar::Number(json_number) => json_number.into(), + JsonScalar::Opaque(json_opaque) => json_opaque.into(), + JsonScalar::String(x) => serde_json::Value::String(x), + } + } +} + +/// [`JsonDom`] number. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonNumber { + Decimal(Decimal), + Double(f64), + Int(i64), + Uint(u64), +} + +impl From for serde_json::Value { + fn from(value: JsonNumber) -> Self { + match value { + JsonNumber::Decimal(decimal) => serde_json::Value::String(decimal.to_string()), + JsonNumber::Double(x) => serde_json::Value::Number( + Number::from_f64(x) + // infinities an NaN are rendered as `0` + .unwrap_or_else(|| Number::from(0_u64)), + ), + JsonNumber::Int(x) => serde_json::Value::Number(Number::from(x)), + JsonNumber::Uint(x) => serde_json::Value::Number(Number::from(x)), + } + } +} + +/// [`JsonDom`] opaque value. +#[derive(Debug, Clone, PartialEq)] +pub struct JsonOpaque { + field_type: ColumnType, + value: Vec, +} + +impl fmt::Display for JsonOpaque { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "base64:type{}:{}", + self.field_type as u8, + BASE64_STANDARD.encode(&self.value) + ) + } +} + +impl From for serde_json::Value { + fn from(value: JsonOpaque) -> Self { + serde_json::Value::String(value.to_string()) + } +} + +/// Deserialized Jsonb value. +/// +/// You can [`Value::parse`] it to a structured [`JsonDom`] value. #[derive(Clone, PartialEq)] pub enum Value<'a> { Null, @@ -537,7 +679,9 @@ impl<'a> Value<'a> { matches!(self, Value::F64(_)) } - /// Returns the number of lements in array or buffer. + /// Returns the number of elements in array or object. + /// + /// Returns `None` on none-array/non-object values. pub fn element_count(&self) -> Option { match self { Value::SmallArray(x) => Some(x.element_count()), @@ -549,12 +693,139 @@ impl<'a> Value<'a> { } /// Returns the field type of an opaque value. + /// + /// Returns `None` on non-opaque values. pub fn field_type(&self) -> Option { match self { Value::Opaque(OpaqueValue { value_type, .. }) => Some(**value_type), _ => None, } } + + /// Parse this value to a structured representation. + pub fn parse(self) -> io::Result { + match self { + Value::Null => Ok(JsonDom::Scalar(JsonScalar::Null)), + Value::Bool(value) => Ok(JsonDom::Scalar(JsonScalar::Boolean(value))), + Value::I16(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Int( + x as i64, + )))), + Value::U16(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Uint( + x as u64, + )))), + Value::I32(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Int( + x as i64, + )))), + Value::U32(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Uint( + x as u64, + )))), + Value::I64(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Int(x)))), + Value::U64(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Uint(x)))), + Value::F64(x) => Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Double(x)))), + Value::String(jsonb_string) => { + let s = match jsonb_string.into_raw() { + Cow::Borrowed(x) => Cow::Borrowed( + std::str::from_utf8(x) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + ), + Cow::Owned(x) => Cow::Owned( + String::from_utf8(x) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, + ), + }; + Ok(JsonDom::Scalar(JsonScalar::String(s.into_owned()))) + } + Value::SmallArray(complex_value) => { + let mut elements = Vec::with_capacity(complex_value.element_count() as usize); + for i in 0.. { + if let Some(value) = complex_value.elem_at(i)? { + let y = value.parse()?; + elements.push(y); + } else { + break; + } + } + Ok(JsonDom::Container(JsonContainer::Array(elements))) + } + Value::LargeArray(complex_value) => { + let mut elements = Vec::with_capacity(complex_value.element_count() as usize); + for value in complex_value.iter() { + elements.push(value?.parse()?); + } + Ok(JsonDom::Container(JsonContainer::Array(elements))) + } + Value::SmallObject(complex_value) => { + let mut elements = BTreeMap::new(); + for value in complex_value.iter() { + let (key, value) = value?; + elements.insert(key.value().into_owned(), value.parse()?); + } + Ok(JsonDom::Container(JsonContainer::Object(elements))) + } + Value::LargeObject(complex_value) => { + let mut elements = BTreeMap::new(); + for value in complex_value.iter() { + let (key, value) = value?; + elements.insert(key.value().into_owned(), value.parse()?); + } + Ok(JsonDom::Container(JsonContainer::Object(elements))) + } + Value::Opaque(opaque_value) => match opaque_value.value_type() { + ColumnType::MYSQL_TYPE_NEWDECIMAL => { + let data = opaque_value.data_raw(); + + Ok(JsonDom::Scalar(JsonScalar::Number(JsonNumber::Decimal( + Decimal::read_packed(data, false)?, + )))) + } + ColumnType::MYSQL_TYPE_DATE => { + let packed_value = + opaque_value.data_raw().first_chunk::<8>().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "not enough data to decode MYSQL_TYPE_DATE", + ) + })?; + let packed_value = i64::from_le_bytes(*packed_value); + Ok(JsonDom::Scalar(JsonScalar::DateTime( + MysqlTime::from_int64_date_packed(packed_value), + ))) + } + ColumnType::MYSQL_TYPE_TIME => { + let packed_value = dbg!(opaque_value.data_raw()) + .first_chunk::<8>() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "not enough data to decode MYSQL_TYPE_TIME", + ) + })?; + let packed_value = dbg!(i64::from_le_bytes(*packed_value)); + Ok(JsonDom::Scalar(JsonScalar::DateTime( + MysqlTime::from_int64_time_packed(packed_value), + ))) + } + ColumnType::MYSQL_TYPE_DATETIME | ColumnType::MYSQL_TYPE_TIMESTAMP => { + let packed_value = + opaque_value.data_raw().first_chunk::<8>().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "not enough data to decode MYSQL_TYPE_DATETIME", + ) + })?; + let packed_value = i64::from_le_bytes(*packed_value); + Ok(JsonDom::Scalar(JsonScalar::DateTime( + MysqlTime::from_int64_datetime_packed(packed_value), + ))) + } + + field_type => Ok(JsonDom::Scalar(JsonScalar::Opaque(JsonOpaque { + field_type, + value: opaque_value.data.0.into_owned(), + }))), + }, + } + } } impl<'a> TryFrom> for serde_json::Value { diff --git a/src/binlog/mod.rs b/src/binlog/mod.rs index b1511a8..c270cf3 100644 --- a/src/binlog/mod.rs +++ b/src/binlog/mod.rs @@ -45,6 +45,7 @@ pub mod jsonb; pub mod jsondiff; pub mod misc; pub mod row; +pub mod time; pub mod value; pub struct BinlogCtx<'a> { @@ -854,6 +855,82 @@ mod tests { } } + if file_path.file_name().unwrap() == "json-opaque.binlog" { + let event_data = ev.read_data().unwrap(); + + /// Extracts first column of the binlog row after-image as a Jsonb::Value + /// then parses it into the structured representation and compares with + /// the expected value. + macro_rules! extract_cmp { + ($row:expr, $expected:tt) => { + let mut after = $row.1.unwrap().unwrap(); + let a = dbg!(after.pop().unwrap()); + let super::value::BinlogValue::Jsonb(a) = a else { + panic!("BinlogValue::Jsonb(_) expected"); + }; + assert_eq!( + serde_json::json!($expected), + serde_json::Value::from(a.parse().unwrap()) + ); + }; + } + + match event_data { + Some(EventData::RowsEvent(ev)) if i == 10 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"a": "base64:type15:VQ=="}); + } + Some(EventData::RowsEvent(ev)) if i == 12 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"b": "2012-03-18"}); + } + Some(EventData::RowsEvent(ev)) if i == 14 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"c": "2012-03-18 11:30:45.000000"}); + } + Some(EventData::RowsEvent(ev)) if i == 16 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"c": "87:31:46.654321"}); + } + Some(EventData::RowsEvent(ev)) if i == 18 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"d": "123.456"}); + } + Some(EventData::RowsEvent(ev)) if i == 20 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"e": "9.00"}); + } + Some(EventData::RowsEvent(ev)) if i == 22 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"e": [0, 1, true, false]}); + } + Some(EventData::RowsEvent(ev)) if i == 24 => { + let table_map_event = + binlog_file.reader().get_tme(ev.table_id()).unwrap(); + let mut rows = ev.rows(table_map_event); + extract_cmp!(rows.next().unwrap().unwrap(), {"e": null}); + } + Some(EventData::RowsEvent(ev)) => { + panic!("no more events expected i={}, {:?}", i, ev); + } + _ => (), + } + } + if file_path.file_name().unwrap() == "vector.binlog" { let event_data = ev.read_data().unwrap(); match event_data { diff --git a/src/binlog/time.rs b/src/binlog/time.rs new file mode 100644 index 0000000..a8c612d --- /dev/null +++ b/src/binlog/time.rs @@ -0,0 +1,208 @@ +use std::{ + cmp::min, + fmt::{self, Write}, +}; + +use super::misc::{my_packed_time_get_frac_part, my_packed_time_get_int_part}; + +/// Server-side mysql time representation. +#[derive(Debug, Clone, PartialEq)] +#[repr(C)] +pub struct MysqlTime { + pub year: u32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + pub second: u32, + /// microseconds + pub second_part: u32, + pub neg: bool, + pub time_type: MysqlTimestampType, + pub time_zone_displacement: i32, +} + +impl MysqlTime { + /// Convert time packed numeric representation to [`MysqlTime`]. + pub fn from_int64_time_packed(mut packed_value: i64) -> Self { + let neg = packed_value < 0; + if neg { + packed_value = -packed_value + } + + let hms: i64 = my_packed_time_get_int_part(packed_value); + + let hour = (hms >> 12) as u32 % (1 << 10); /* 10 bits starting at 12th */ + let minute = (hms >> 6) as u32 % (1 << 6); /* 6 bits starting at 6th */ + let second = hms as u32 % (1 << 6); /* 6 bits starting at 0th */ + let second_part = my_packed_time_get_frac_part(packed_value); + + Self { + year: 0, + month: 0, + day: 0, + hour, + minute, + second, + second_part: second_part as u32, + neg, + time_type: MysqlTimestampType::MYSQL_TIMESTAMP_TIME, + time_zone_displacement: 0, + } + } + + /// Convert packed numeric date representation to [`MysqlTime`]. + pub fn from_int64_date_packed(packed_value: i64) -> Self { + let mut this = Self::from_int64_datetime_packed(packed_value); + this.time_type = MysqlTimestampType::MYSQL_TIMESTAMP_DATE; + this + } + + /// Convert packed numeric datetime representation to [`MysqlTime`]. + pub fn from_int64_datetime_packed(mut packed_value: i64) -> Self { + let neg = packed_value < 0; + if neg { + packed_value = -packed_value + } + + let second_part = my_packed_time_get_frac_part(packed_value); + let ymdhms: i64 = my_packed_time_get_int_part(packed_value); + + let ymd: i64 = ymdhms >> 17; + let ym: i64 = ymd >> 5; + let hms: i64 = ymdhms % (1 << 17); + + let day = ymd % (1 << 5); + let month = ym % 13; + let year = (ym / 13) as _; + + let second = hms % (1 << 6); + let minute = (hms >> 6) % (1 << 6); + let hour = (hms >> 12) as _; + + Self { + year, + month: month as _, + day: day as _, + hour, + minute: minute as _, + second: second as _, + second_part: second_part as _, + neg, + time_type: MysqlTimestampType::MYSQL_TIMESTAMP_DATETIME, + time_zone_displacement: 0, + } + } +} + +impl fmt::Display for MysqlTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.time_type { + MysqlTimestampType::MYSQL_TIMESTAMP_DATETIME + | MysqlTimestampType::MYSQL_TIMESTAMP_DATETIME_TZ => format_datetime(self, f), + MysqlTimestampType::MYSQL_TIMESTAMP_DATE => format_date(self, f), + MysqlTimestampType::MYSQL_TIMESTAMP_TIME => format_time(self, f), + MysqlTimestampType::MYSQL_TIMESTAMP_NONE + | MysqlTimestampType::MYSQL_TIMESTAMP_ERROR => Ok(()), + } + } +} + +fn trim_two_digits(value: u32) -> u32 { + if value >= 100 { + 0 + } else { + value + } +} + +/// Formats a time value as `HH:MM:SS[.fraction]`. +fn format_time(time: &MysqlTime, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if time.neg { + f.write_char('-')?; + } + + write!( + f, + "{:02}:{:02}:{:02}", + time.hour, + trim_two_digits(time.minute), + trim_two_digits(time.second), + )?; + format_useconds(time.second_part, f)?; + Ok(()) +} + +/// Formats a datetime value with an optional fractional part (if formatter precision is given). +fn format_datetime(time: &MysqlTime, f: &mut fmt::Formatter<'_>) -> fmt::Result { + format_date_and_time(time, f)?; + format_useconds(time.second_part, f)?; + if time.time_type == MysqlTimestampType::MYSQL_TIMESTAMP_DATETIME_TZ { + format_tz(time.time_zone_displacement, f)?; + } + Ok(()) +} + +/// Formats date and time part as 'YYYY-MM-DD hh:mm:ss' +fn format_date_and_time(time: &MysqlTime, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:02}{:02}-{:02}-{:02} {:02}:{:02}:{:02}", + trim_two_digits(time.year / 100), + trim_two_digits(time.year % 100), + trim_two_digits(time.month), + trim_two_digits(time.day), + trim_two_digits(time.hour), + trim_two_digits(time.minute), + trim_two_digits(time.second), + ) +} + +/// Formats a date value as 'YYYY-MM-DD'. +fn format_date(time: &MysqlTime, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:02}{:02}-{:02}-{:02}", + trim_two_digits(time.year / 100), + trim_two_digits(time.year % 100), + trim_two_digits(time.month), + trim_two_digits(time.day), + ) +} + +/// Only formats useconds if formatter precision is given (will be truncated to 6) +fn format_useconds(mut useconds: u32, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Some(dec) = f.precision().map(|x| min(x, 6)) else { + return Ok(()); + }; + + if dec == 0 { + return Ok(()); + } + + useconds %= 1_000_000; + + for _ in 0..(6 - dec) { + useconds /= 10; + } + + write!(f, ".{:0width$}", useconds, width = dec) +} + +fn format_tz(tzd: i32, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "+{:02}:{:02}", tzd / 3600, tzd.abs() / 60 % 60) +} + +#[derive(Debug, Clone, PartialEq)] +#[repr(C)] +#[allow(non_camel_case_types)] +pub enum MysqlTimestampType { + /// Textual representation of this value is an empty string + MYSQL_TIMESTAMP_NONE = -2, + /// Textual representation of this value is an empty string + MYSQL_TIMESTAMP_ERROR = -1, + MYSQL_TIMESTAMP_DATE = 0, + MYSQL_TIMESTAMP_DATETIME = 1, + MYSQL_TIMESTAMP_TIME = 2, + MYSQL_TIMESTAMP_DATETIME_TZ = 3, +} diff --git a/src/lib.rs b/src/lib.rs index 88bb3a3..40c6bc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -333,7 +333,7 @@ // The `test` feature is required to compile tests. // It'll bind test binaries to an official C++ impl of MySql decimals (see build.rs) -// The idea is to test our rust impl agaist C++ impl. +// The idea is to test our rust impl against C++ impl. #[cfg(all(not(feature = "test"), test))] compile_error!("Please invoke `cargo test` with `--features test` flags"); diff --git a/test-data/binlogs/json-opaque.binlog b/test-data/binlogs/json-opaque.binlog new file mode 100644 index 0000000..ae00006 Binary files /dev/null and b/test-data/binlogs/json-opaque.binlog differ