This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-11299-1b68589d69848552f9b32866f71633fe92114596 in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
commit aa36532f09f1eee3576c1612ca10472bfb472730 Author: Wilhelm Ă…gren <[email protected]> AuthorDate: Fri Oct 2 04:51:00 2026 +0000 fix(arrow-cast): allow whitespace around temporal values (#11225) (#11299) # Which issue does this PR close? Closes #11225. # Rationale for this change Integer and floating-point (numeric) parsers already accept surrounding ASCII whitespace but the current temporal parsers do not. Expected behaviour in a lot of other dbms's is to just parse the temporal values surrounded with ASCII whitespace normally. And for this crate to handle ASCII whitespace for some dtypes, but not for others, seems counter-intuitive. # What changes are included in this PR? Temporal parsers now retry parsing after trimming surrounding ASCII whitespace if parsing the original input failed. This preserves the existing fast path for **correct** (non-padded) values. # Are these changes tested? New tests added to arrow-cast/src/parse.rs, and regression test added arrow-cast/src/cast/mod.rs. All tests (and clippy) pass. `cargo test -p arrow-cast` # Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. If there are any breaking changes to public APIs, please call them out. --> Yes, string casts to temporal types now handle surrounding ASCII whitespace, no API changes but behavioural changes that might need changes to docs. Co-authored-by: Jeffrey Vo <[email protected]> --- arrow-cast/src/cast/mod.rs | 11 +++ arrow-cast/src/parse.rs | 178 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs index 3e562e508d..cb8a7ebb50 100644 --- a/arrow-cast/src/cast/mod.rs +++ b/arrow-cast/src/cast/mod.rs @@ -5627,6 +5627,17 @@ mod tests { assert_eq!(result.values(), &[247112596800]); } + #[test] + fn test_cast_string_to_date32_with_surrounding_whitespace() { + let array = StringArray::from(vec![" 2026-09-29 ", "2026-09-29"]); + + let result = cast(&array, &DataType::Date32).unwrap(); + let result = result.as_primitive::<Date32Type>(); + + assert!(!result.is_null(0)); + assert_eq!(result.value(0), result.value(1)); + } + #[test] fn test_cast_string_to_date32() { let a0 = Arc::new(StringViewArray::from(vec![ diff --git a/arrow-cast/src/parse.rs b/arrow-cast/src/parse.rs index ffc6738aa9..384eba2007 100644 --- a/arrow-cast/src/parse.rs +++ b/arrow-cast/src/parse.rs @@ -530,35 +530,65 @@ parser_primitive!(DurationSecondType); impl Parser for TimestampNanosecondType { fn parse(string: &str) -> Option<i64> { - string_to_timestamp_nanos(string).ok() + if let Ok(nanos) = string_to_timestamp_nanos(string) { + return Some(nanos); + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_timestamp_nanos(trimmed).ok() } } impl Parser for TimestampMicrosecondType { fn parse(string: &str) -> Option<i64> { - let nanos = string_to_timestamp_nanos(string).ok(); - nanos.map(|x| x / 1000) + if let Ok(nanos) = string_to_timestamp_nanos(string) { + return Some(nanos / 1_000); + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_timestamp_nanos(trimmed).ok().map(|x| x / 1_000) } } impl Parser for TimestampMillisecondType { fn parse(string: &str) -> Option<i64> { - let nanos = string_to_timestamp_nanos(string).ok(); - nanos.map(|x| x / 1_000_000) + if let Ok(nanos) = string_to_timestamp_nanos(string) { + return Some(nanos / 1_000_000); + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_timestamp_nanos(trimmed) + .ok() + .map(|x| x / 1_000_000) } } impl Parser for TimestampSecondType { fn parse(string: &str) -> Option<i64> { - let nanos = string_to_timestamp_nanos(string).ok(); - nanos.map(|x| x / 1_000_000_000) + if let Ok(nanos) = string_to_timestamp_nanos(string) { + return Some(nanos / 1_000_000_000); + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_timestamp_nanos(trimmed) + .ok() + .map(|x| x / 1_000_000_000) } } impl Parser for Time64NanosecondType { // Will truncate any fractions of a nanosecond fn parse(string: &str) -> Option<Self::Native> { - string_to_time_nanoseconds(string) + let value = string_to_time_nanoseconds(string) + .ok() + .or_else(|| string.parse::<Self::Native>().ok()); + + if value.is_some() { + return value; + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_time_nanoseconds(trimmed) .ok() .or_else(|| string.parse::<Self::Native>().ok()) } @@ -572,9 +602,19 @@ impl Parser for Time64NanosecondType { impl Parser for Time64MicrosecondType { // Will truncate any fractions of a microsecond fn parse(string: &str) -> Option<Self::Native> { - string_to_time_nanoseconds(string) + let value = string_to_time_nanoseconds(string) .ok() .map(|nanos| nanos / 1_000) + .or_else(|| string.parse::<Self::Native>().ok()); + + if value.is_some() { + return value; + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_time_nanoseconds(trimmed) + .ok() + .map(|x| x / 1_000) .or_else(|| string.parse::<Self::Native>().ok()) } @@ -587,9 +627,19 @@ impl Parser for Time64MicrosecondType { impl Parser for Time32MillisecondType { // Will truncate any fractions of a millisecond fn parse(string: &str) -> Option<Self::Native> { - string_to_time_nanoseconds(string) + let value = string_to_time_nanoseconds(string) .ok() .map(|nanos| (nanos / 1_000_000) as i32) + .or_else(|| string.parse::<Self::Native>().ok()); + + if value.is_some() { + return value; + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_time_nanoseconds(trimmed) + .ok() + .map(|x| (x / 1_000_000) as i32) .or_else(|| string.parse::<Self::Native>().ok()) } @@ -602,9 +652,19 @@ impl Parser for Time32MillisecondType { impl Parser for Time32SecondType { // Will truncate any fractions of a second fn parse(string: &str) -> Option<Self::Native> { - string_to_time_nanoseconds(string) + let value = string_to_time_nanoseconds(string) .ok() .map(|nanos| (nanos / 1_000_000_000) as i32) + .or_else(|| string.parse::<Self::Native>().ok()); + + if value.is_some() { + return value; + } + + let trimmed = trim_pre_and_post_whitespace(string); + string_to_time_nanoseconds(trimmed) + .ok() + .map(|x| (x / 1_000_000_000) as i32) .or_else(|| string.parse::<Self::Native>().ok()) } @@ -749,7 +809,12 @@ fn parse_date_to_days(string: &str) -> Option<i32> { impl Parser for Date32Type { fn parse(string: &str) -> Option<i32> { - parse_date_to_days(string) + if let Some(days) = parse_date_to_days(string) { + return Some(days); + } + + let trimmed = trim_pre_and_post_whitespace(string); + parse_date_to_days(trimmed) } fn parse_formatted(string: &str, format: &str) -> Option<i32> { @@ -3623,4 +3688,93 @@ mod tests { assert_eq!(Int32Type::parse("3j"), None); assert_eq!(Int32Type::parse("3"), Some(3)); } + + #[test] + fn test_parse_temporal_with_surrounding_whitespace() { + let date = Date32Type::parse("2024-01-05"); + assert_eq!(Date32Type::parse(" 2024-01-05 "), date); + assert_eq!(Date32Type::parse("\t2024-01-05\n"), date); + + let timestamp = "2024-01-05T10:00:00"; + let padded_timestamp = " 2024-01-05T10:00:00 "; + + assert_eq!( + TimestampNanosecondType::parse(padded_timestamp), + TimestampNanosecondType::parse(timestamp) + ); + assert_eq!( + TimestampMicrosecondType::parse(padded_timestamp), + TimestampMicrosecondType::parse(timestamp) + ); + assert_eq!( + TimestampMillisecondType::parse(padded_timestamp), + TimestampMillisecondType::parse(timestamp) + ); + assert_eq!( + TimestampSecondType::parse(padded_timestamp), + TimestampSecondType::parse(timestamp) + ); + assert_eq!( + TimestampNanosecondType::parse("\t2024-01-05T10:00:00\n"), + TimestampNanosecondType::parse(timestamp) + ); + + // leet :) + let time = "13:37:00"; + let padded_time = " 13:37:00 "; + + assert_eq!( + Time64NanosecondType::parse(padded_time), + Time64NanosecondType::parse(time) + ); + assert_eq!( + Time64MicrosecondType::parse(padded_time), + Time64MicrosecondType::parse(time) + ); + assert_eq!( + Time64MicrosecondType::parse("\t13:37:00\n"), + Time64MicrosecondType::parse(time) + ); + } + + #[test] + fn test_parse_time_with_surrounding_ascii_whitespace() { + let time = "10:00:00"; + let padded = " 10:00:00 "; + let ascii_whitespace = "\t10:00:00\n"; + + assert_eq!( + Time64NanosecondType::parse(padded), + Time64NanosecondType::parse(time) + ); + assert_eq!( + Time64MicrosecondType::parse(padded), + Time64MicrosecondType::parse(time) + ); + assert_eq!( + Time32MillisecondType::parse(padded), + Time32MillisecondType::parse(time) + ); + assert_eq!( + Time32SecondType::parse(padded), + Time32SecondType::parse(time) + ); + + assert_eq!( + Time64NanosecondType::parse(ascii_whitespace), + Time64NanosecondType::parse(time) + ); + assert_eq!( + Time64MicrosecondType::parse(ascii_whitespace), + Time64MicrosecondType::parse(time) + ); + assert_eq!( + Time32MillisecondType::parse(ascii_whitespace), + Time32MillisecondType::parse(time) + ); + assert_eq!( + Time32SecondType::parse(ascii_whitespace), + Time32SecondType::parse(time) + ); + } }
