fix: handle zero dates in MySQL, emit as Option::None (treat as NULL)

This commit is contained in:
Ryan Leckey 2020-07-03 05:49:58 -07:00
parent 0bd556e0ee
commit 0824723765
5 changed files with 131 additions and 23 deletions

View File

@ -140,7 +140,7 @@ impl Connect for MySqlConnection {
// https://mathiasbynens.be/notes/mysql-utf8mb4
conn.execute(concat!(
r#"SET sql_mode=(SELECT CONCAT(@@sql_mode, ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE')),"#,
r#"SET sql_mode=(SELECT CONCAT(@@sql_mode, ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION')),"#,
r#"time_zone='+00:00',"#,
r#"NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;"#,
)).await?;

View File

@ -5,7 +5,7 @@ use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike,
use crate::decode::Decode;
use crate::encode::{Encode, IsNull};
use crate::error::BoxDynError;
use crate::error::{BoxDynError, UnexpectedNullError};
use crate::mysql::protocol::text::ColumnType;
use crate::mysql::type_info::MySqlTypeInfo;
use crate::mysql::{MySql, MySqlValueFormat, MySqlValueRef};
@ -127,7 +127,9 @@ impl Encode<'_, MySql> for NaiveDate {
impl<'r> Decode<'r, MySql> for NaiveDate {
fn decode(value: MySqlValueRef<'r>) -> Result<Self, BoxDynError> {
match value.format() {
MySqlValueFormat::Binary => Ok(decode_date(&value.as_bytes()?[1..])),
MySqlValueFormat::Binary => {
decode_date(&value.as_bytes()?[1..]).ok_or_else(|| UnexpectedNullError.into())
}
MySqlValueFormat::Text => {
let s = value.as_str()?;
@ -186,7 +188,7 @@ impl<'r> Decode<'r, MySql> for NaiveDateTime {
let buf = value.as_bytes()?;
let len = buf[0];
let date = decode_date(&buf[1..]);
let date = decode_date(&buf[1..]).ok_or(UnexpectedNullError)?;
let dt = if len > 4 {
date.and_time(decode_time(len - 4, &buf[5..]))
@ -215,9 +217,18 @@ fn encode_date(date: &NaiveDate, buf: &mut Vec<u8>) {
buf.push(date.day() as u8);
}
fn decode_date(mut buf: &[u8]) -> NaiveDate {
let year = buf.get_u16_le();
NaiveDate::from_ymd(year as i32, buf[0] as u32, buf[1] as u32)
fn decode_date(mut buf: &[u8]) -> Option<NaiveDate> {
if buf.len() == 0 {
// MySQL specifies that if there are no bytes, this is all zeros
None
} else {
let year = buf.get_u16_le();
Some(NaiveDate::from_ymd(
year as i32,
buf[0] as u32,
buf[1] as u32,
))
}
}
fn encode_time(time: &NaiveTime, include_micros: bool, buf: &mut Vec<u8>) {

View File

@ -7,7 +7,7 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
use crate::decode::Decode;
use crate::encode::{Encode, IsNull};
use crate::error::BoxDynError;
use crate::error::{BoxDynError, UnexpectedNullError};
use crate::mysql::protocol::text::ColumnType;
use crate::mysql::type_info::MySqlTypeInfo;
use crate::mysql::{MySql, MySqlValueFormat, MySqlValueRef};
@ -143,7 +143,9 @@ impl Encode<'_, MySql> for Date {
impl<'r> Decode<'r, MySql> for Date {
fn decode(value: MySqlValueRef<'r>) -> Result<Self, BoxDynError> {
match value.format() {
MySqlValueFormat::Binary => decode_date(&value.as_bytes()?[1..]),
MySqlValueFormat::Binary => {
Ok(decode_date(&value.as_bytes()?[1..])?.ok_or(UnexpectedNullError)?)
}
MySqlValueFormat::Text => {
let s = value.as_str()?;
Date::parse(s, "%Y-%m-%d").map_err(Into::into)
@ -195,7 +197,7 @@ impl<'r> Decode<'r, MySql> for PrimitiveDateTime {
MySqlValueFormat::Binary => {
let buf = value.as_bytes()?;
let len = buf[0];
let date = decode_date(&buf[1..])?;
let date = decode_date(&buf[1..])?.ok_or(UnexpectedNullError)?;
let dt = if len > 4 {
date.with_time(decode_time(len - 4, &buf[5..])?)
@ -239,13 +241,19 @@ fn encode_date(date: &Date, buf: &mut Vec<u8>) {
buf.push(date.day());
}
fn decode_date(buf: &[u8]) -> Result<Date, BoxDynError> {
fn decode_date(buf: &[u8]) -> Result<Option<Date>, BoxDynError> {
if buf.is_empty() {
// zero buffer means a zero date (null)
return Ok(None);
}
Date::try_from_ymd(
LittleEndian::read_u16(buf) as i32,
buf[2] as u8,
buf[3] as u8,
)
.map_err(Into::into)
.map(Some)
}
fn encode_time(time: &Time, include_micros: bool, buf: &mut Vec<u8>) {

View File

@ -4,6 +4,7 @@ use std::str::from_utf8;
use bytes::Bytes;
use crate::error::{BoxDynError, UnexpectedNullError};
use crate::mysql::protocol::text::ColumnType;
use crate::mysql::{MySql, MySqlTypeInfo};
use crate::value::{Value, ValueRef};
@ -65,7 +66,7 @@ impl Value for MySqlValue {
}
fn is_null(&self) -> bool {
self.value.is_none()
is_null(self.value.as_deref(), self.type_info.as_ref())
}
}
@ -92,7 +93,23 @@ impl<'r> ValueRef<'r> for MySqlValueRef<'r> {
self.type_info.as_ref().map(Cow::Borrowed)
}
#[inline]
fn is_null(&self) -> bool {
self.value.is_none()
is_null(self.value.as_deref(), self.type_info.as_ref())
}
}
fn is_null(value: Option<&[u8]>, ty: Option<&MySqlTypeInfo>) -> bool {
if let (Some(value), Some(ty)) = (value, ty) {
// zero dates and date times should be treated the same as NULL
if matches!(
ty.r#type,
ColumnType::Date | ColumnType::Timestamp | ColumnType::Datetime
) && value.get(0) == Some(&0)
{
return true;
}
}
value.is_none()
}

View File

@ -1,6 +1,7 @@
extern crate time_ as time;
use sqlx::mysql::MySql;
use sqlx::{Executor, Row};
use sqlx_test::test_type;
test_type!(bool(MySql, "false" == false, "true" == true));
@ -43,35 +44,68 @@ mod chrono {
use super::*;
use sqlx::types::chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
test_type!(chrono_date<NaiveDate>(
MySql,
test_type!(chrono_date<NaiveDate>(MySql,
"DATE '2001-01-05'" == NaiveDate::from_ymd(2001, 1, 5),
"DATE '2050-11-23'" == NaiveDate::from_ymd(2050, 11, 23)
));
test_type!(chrono_time_zero<NaiveTime>(
MySql,
test_type!(chrono_time_zero<NaiveTime>(MySql,
"TIME '00:00:00.000000'" == NaiveTime::from_hms_micro(0, 0, 0, 0)
));
test_type!(chrono_time<NaiveTime>(
MySql,
test_type!(chrono_time<NaiveTime>(MySql,
"TIME '05:10:20.115100'" == NaiveTime::from_hms_micro(5, 10, 20, 115100)
));
test_type!(chrono_date_time<NaiveDateTime>(
MySql,
test_type!(chrono_date_time<NaiveDateTime>(MySql,
"TIMESTAMP '2019-01-02 05:10:20'" == NaiveDate::from_ymd(2019, 1, 2).and_hms(5, 10, 20)
));
test_type!(chrono_timestamp<DateTime::<Utc>>(
MySql,
test_type!(chrono_timestamp<DateTime::<Utc>>(MySql,
"TIMESTAMP '2019-01-02 05:10:20.115100'"
== DateTime::<Utc>::from_utc(
NaiveDate::from_ymd(2019, 1, 2).and_hms_micro(5, 10, 20, 115100),
Utc,
)
));
#[sqlx_macros::test]
async fn test_type_chrono_zero_date() -> anyhow::Result<()> {
let mut conn = sqlx_test::new::<MySql>().await?;
// ensure that zero dates are turned on
// newer MySQL has these disabled by default
conn.execute("SET @@sql_mode := REPLACE(@@sql_mode, 'NO_ZERO_IN_DATE', '');")
.await?;
conn.execute("SET @@sql_mode := REPLACE(@@sql_mode, 'NO_ZERO_DATE', '');")
.await?;
// date
let row = sqlx::query("SELECT DATE '0000-00-00'")
.fetch_one(&mut conn)
.await?;
let val: Option<NaiveDate> = row.get(0);
assert_eq!(val, None);
assert!(row.try_get::<NaiveDate, _>(0).is_err());
// datetime
let row = sqlx::query("SELECT TIMESTAMP '0000-00-00 00:00:00'")
.fetch_one(&mut conn)
.await?;
let val: Option<NaiveDateTime> = row.get(0);
assert_eq!(val, None);
assert!(row.try_get::<NaiveDateTime, _>(0).is_err());
Ok(())
}
}
#[cfg(feature = "time")]
@ -110,6 +144,44 @@ mod time_tests {
.with_time(time!(5:10:20.115100))
.assume_utc()
));
#[sqlx_macros::test]
async fn test_type_time_zero_date() -> anyhow::Result<()> {
let mut conn = sqlx_test::new::<MySql>().await?;
// ensure that zero dates are turned on
// newer MySQL has these disabled by default
conn.execute("SET @@sql_mode := REPLACE(@@sql_mode, 'NO_ZERO_IN_DATE', '');")
.await?;
conn.execute("SET @@sql_mode := REPLACE(@@sql_mode, 'NO_ZERO_DATE', '');")
.await?;
// date
let row = sqlx::query("SELECT DATE '0000-00-00'")
.fetch_one(&mut conn)
.await?;
let val: Option<Date> = row.get(0);
assert_eq!(val, None);
assert!(row.try_get::<Date, _>(0).is_err());
// datetime
let row = sqlx::query("SELECT TIMESTAMP '0000-00-00 00:00:00'")
.fetch_one(&mut conn)
.await?;
let val: Option<PrimitiveDateTime> = row.get(0);
assert_eq!(val, None);
assert!(row.try_get::<PrimitiveDateTime, _>(0).is_err());
Ok(())
}
}
#[cfg(feature = "bigdecimal")]