Repository navigation
GH-41488: [C++][Python] Apply timestamp_parsers as fallback when parsing CSV date and time columns #50146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
GH-41488: [C++][Python] Apply timestamp_parsers as fallback when parsing CSV date and time columns #50146
Changes from all commits
09ac291
d19c0b3
4a0fb79
5239526
4e14bcb
90ca767
05df5b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ | |
| #include <limits> | ||
| #include <sstream> | ||
| #include <string> | ||
| #include <string_view> | ||
| #include <type_traits> | ||
| #include <vector> | ||
|
|
||
|
|
@@ -36,6 +37,7 @@ | |
| #include "arrow/type_traits.h" | ||
| #include "arrow/util/checked_cast.h" | ||
| #include "arrow/util/decimal.h" | ||
| #include "arrow/util/int_util_overflow.h" | ||
| #include "arrow/util/trie_internal.h" | ||
| #include "arrow/util/utf8_internal.h" | ||
| #include "arrow/util/value_parsing.h" // IWYU pragma: keep | ||
|
|
@@ -440,6 +442,14 @@ struct SingleParserTimestampValueDecoder : public ValueDecoder { | |
| const TimestampParser& parser_; | ||
| }; | ||
|
|
||
| std::vector<const TimestampParser*> GetTimestampParsers(const ConvertOptions& options) { | ||
| std::vector<const TimestampParser*> parsers(options.timestamp_parsers.size()); | ||
| for (size_t i = 0; i < options.timestamp_parsers.size(); ++i) { | ||
| parsers[i] = options.timestamp_parsers[i].get(); | ||
| } | ||
| return parsers; | ||
| } | ||
|
|
||
| struct MultipleParsersTimestampValueDecoder : public ValueDecoder { | ||
| using value_type = int64_t; | ||
|
|
||
|
|
@@ -449,7 +459,7 @@ struct MultipleParsersTimestampValueDecoder : public ValueDecoder { | |
| : ValueDecoder(type, options, trie_cache), | ||
| unit_(checked_cast<const TimestampType&>(*type_).unit()), | ||
| expect_timezone_(!checked_cast<const TimestampType&>(*type_).timezone().empty()), | ||
| parsers_(GetParsers(options_)) {} | ||
| parsers_(GetTimestampParsers(options_)) {} | ||
|
|
||
| Status Decode(const uint8_t* data, uint32_t size, bool quoted, value_type* out) { | ||
| bool zone_offset_present = false; | ||
|
|
@@ -464,18 +474,156 @@ struct MultipleParsersTimestampValueDecoder : public ValueDecoder { | |
| } | ||
|
|
||
| protected: | ||
| using ParserVector = std::vector<const TimestampParser*>; | ||
| TimeUnit::type unit_; | ||
| bool expect_timezone_; | ||
| std::vector<const TimestampParser*> parsers_; | ||
| }; | ||
|
|
||
| // | ||
| // Value decoder for dates and times, with fallback to user-defined | ||
| // timestamp parsers | ||
| // | ||
|
|
||
| // Splits the fractional seconds (up to 9 digits) off an ISO-8601 timestamp | ||
| // "YYYY-MM-DD[ T]hh:mm:ss.s{1,9}" with an optional zone offset. Returns `s` | ||
| // if there are no fractional seconds, a copy without them in `buffer`, or | ||
| // nullptr if they are malformed. The split-off digits are returned in | ||
| // `subseconds`. | ||
| const char* SplitISO8601Subseconds(const char* s, uint32_t* size, char (&buffer)[64], | ||
| std::string_view* subseconds) { | ||
| if (*size <= 20 || s[19] != '.') { | ||
| return s; | ||
| } | ||
| uint32_t fraction_length = 0; | ||
| while (20 + fraction_length < *size && s[20 + fraction_length] >= '0' && | ||
| s[20 + fraction_length] <= '9') { | ||
| ++fraction_length; | ||
| } | ||
| const uint32_t rest_length = *size - 20 - fraction_length; | ||
| if (fraction_length == 0 || fraction_length > 9 || 19 + rest_length > sizeof(buffer)) { | ||
| return nullptr; | ||
| } | ||
| std::memcpy(buffer, s, 19); | ||
| std::memcpy(buffer + 19, s + 20 + fraction_length, rest_length); | ||
| *subseconds = std::string_view(s + 20, fraction_length); | ||
| *size = 19 + rest_length; | ||
| return buffer; | ||
| } | ||
|
|
||
| // Tries the ISO-8601 format first, then the user-defined timestamp parsers. | ||
| // A timestamp produced by a user-defined parser is floored to the day | ||
| // boundary for dates, and reduced to the time of day for times (consistent | ||
| // with casting a timestamp to date32/date64/time32/time64). | ||
| template <typename T> | ||
| struct DateTimeWithParsersValueDecoder : public ValueDecoder { | ||
| using value_type = typename T::c_type; | ||
|
|
||
| DateTimeWithParsersValueDecoder(const std::shared_ptr<DataType>& type, | ||
| const ConvertOptions& options, | ||
| const TrieCache* trie_cache) | ||
| : ValueDecoder(type, options, trie_cache), | ||
| concrete_type_(checked_cast<const T&>(*type)), | ||
| parse_unit_(GetParseUnit(concrete_type_)), | ||
| ticks_per_day_(TicksPerDay(parse_unit_)), | ||
| parsers_(GetTimestampParsers(options_)) {} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because
The same happens with This matches casting a timestamp to a date or time. But it means a shifted column or a data-entry error turns into plausible-looking data instead of a conversion error. Could we skip parsers that can't produce the column's type? For example, the If you'd rather keep the current behavior, could the docs at least say that the parsers apply to all date and time columns, so that a value matching any of them is accepted with the missing part defaulted (1900-01-01 for dates, midnight for times)? I ran all four examples against the sanitizer build with both parsers set together, and they produce exactly the results quoted above. |
||
|
|
||
| Status Decode(const uint8_t* data, uint32_t size, bool quoted, value_type* out) { | ||
| TrimWhiteSpace(&data, &size); | ||
| if (ARROW_PREDICT_TRUE(string_converter_.Convert( | ||
| concrete_type_, reinterpret_cast<const char*>(data), size, out))) { | ||
| return Status::OK(); | ||
| } | ||
| for (const auto& parser : parsers_) { | ||
| int64_t timestamp = 0; | ||
| bool zone_offset_present = false; | ||
| const char* s = reinterpret_cast<const char*>(data); | ||
| uint32_t s_size = size; | ||
| char buffer[64]; | ||
| std::string_view subseconds; | ||
| if (std::strcmp(parser->kind(), "iso8601") == 0) { | ||
| // Set the fractional seconds aside, so that any number of digits is | ||
| // accepted: dates discard them, times add them back below | ||
| s = SplitISO8601Subseconds(s, &s_size, buffer, &subseconds); | ||
| if (s == nullptr) { | ||
| continue; | ||
| } | ||
| } | ||
| TimeUnit::type unit = parse_unit_; | ||
| bool parsed = parser->operator()(s, s_size, unit, ×tamp, &zone_offset_present); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Values with a seconds field of
With Without parsers, The decoder only sees the already-normalized timestamp, so I think the check has to go in Could we add that check, along with regression tests rejecting the examples above? I checked every input and output in the comment against the sanitizer build. I left out glibc because I only tested on macOS. |
||
| if (!parsed && unit != TimeUnit::SECOND) { | ||
| // The date may be out of the range of the time unit while the time of | ||
| // day is representable: retry in seconds, scaled below | ||
| unit = TimeUnit::SECOND; | ||
| parsed = parser->operator()(s, s_size, unit, ×tamp, &zone_offset_present); | ||
| } | ||
| if (parsed && !zone_offset_present) { | ||
| const int64_t ticks_per_day = TicksPerDay(unit); | ||
| // Floor division, to handle values before the epoch | ||
| int64_t days = timestamp / ticks_per_day; | ||
| days -= (timestamp % ticks_per_day) < 0; | ||
| if constexpr (std::is_same_v<T, Date32Type>) { | ||
| if (days < std::numeric_limits<value_type>::min() || | ||
| days > std::numeric_limits<value_type>::max()) { | ||
| continue; // out of the range of date32 | ||
| } | ||
| *out = static_cast<value_type>(days); | ||
| } else if constexpr (std::is_same_v<T, Date64Type>) { | ||
| if (arrow::internal::MultiplyWithOverflow(days, kMillisPerDay, out)) { | ||
| continue; // out of the range of date64 | ||
| } | ||
| } else { | ||
| static_assert(is_time_type<T>::value); | ||
| // Normalized remainder, as days * ticks_per_day can overflow | ||
| int64_t time_of_day = timestamp % ticks_per_day; | ||
| if (time_of_day < 0) { | ||
| time_of_day += ticks_per_day; | ||
| } | ||
| uint32_t subseconds_ticks = 0; | ||
| if (!subseconds.empty() && | ||
| !arrow::internal::detail::ParseSubSeconds( | ||
| subseconds.data(), subseconds.size(), parse_unit_, &subseconds_ticks)) { | ||
| continue; // the time unit cannot represent the fractional seconds | ||
| } | ||
| *out = static_cast<value_type>(time_of_day * (ticks_per_day_ / ticks_per_day) + | ||
| subseconds_ticks); | ||
| } | ||
| return Status::OK(); | ||
| } | ||
| } | ||
| return GenericConversionError(type_, data, size); | ||
| } | ||
|
|
||
| protected: | ||
| static constexpr int64_t kMillisPerDay = 86400000; | ||
|
|
||
| static ParserVector GetParsers(const ConvertOptions& options) { | ||
| ParserVector parsers(options.timestamp_parsers.size()); | ||
| for (size_t i = 0; i < options.timestamp_parsers.size(); ++i) { | ||
| parsers[i] = options.timestamp_parsers[i].get(); | ||
| static TimeUnit::type GetParseUnit(const T& type) { | ||
| if constexpr (is_time_type<T>::value) { | ||
| // Parse in the time type's own unit, so that the time of day can be | ||
| // extracted without further conversion | ||
| return type.unit(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the time type’s unit for the intermediate timestamp unnecessarily restricts For example, with input
I reproduced this with both Could we extract the time at a usable intermediate precision before scaling to the output unit, while preserving fractional precision? Regression tests with dates outside the nanosecond timestamp range would cover this. This failure occurs during parsing, so replacing the later multiplication with a normalized remainder won’t resolve it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, fixed in 4e14bcb. The value is still parsed in the time unit first; when that fails, it is parsed again in seconds and only the time of day is scaled to the unit. For the ISO-8601 parser the fractional digits are split off and parsed separately in the time unit, so precision is kept for any date ( |
||
| } else { | ||
| return TimeUnit::SECOND; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using import pyarrow as pa
import pyarrow.csv as csv
csv.read_csv(
pa.BufferReader(b"a\n2020-03-15 14:30:00.123\n"),
convert_options=csv.ConvertOptions(
column_types={"a": pa.date32()},
timestamp_parsers=[csv.ISO8601],
),
)This fails with a conversion error, whereas removing Could we accept the fractional component during parsing and discard it when computing the date? Regression tests for both date types, including a pre-epoch value, would help cover this. The fix should also preserve the supported date range; unconditionally switching to nanosecond parsing would narrow it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, fixed in 4a0fb79. Accepting the fraction inside the parser would mean either parsing in a finer unit, which narrows the date range as you note, or changing |
||
| } | ||
| return parsers; | ||
| } | ||
|
|
||
| TimeUnit::type unit_; | ||
| bool expect_timezone_; | ||
| static int64_t TicksPerDay(TimeUnit::type unit) { | ||
| switch (unit) { | ||
| case TimeUnit::SECOND: | ||
| return 86400LL; | ||
| case TimeUnit::MILLI: | ||
| return 86400000LL; | ||
| case TimeUnit::MICRO: | ||
| return 86400000000LL; | ||
| case TimeUnit::NANO: | ||
| return 86400000000000LL; | ||
| } | ||
| return -1; // unreachable | ||
| } | ||
|
|
||
| const T& concrete_type_; | ||
| arrow::internal::StringConverter<T> string_converter_; | ||
| const TimeUnit::type parse_unit_; | ||
| const int64_t ticks_per_day_; | ||
| std::vector<const TimestampParser*> parsers_; | ||
| }; | ||
|
|
||
|
|
@@ -675,6 +823,27 @@ std::shared_ptr<Converter> MakeTimestampConverter(const std::shared_ptr<DataType | |
| } | ||
| } | ||
|
|
||
| // | ||
| // Concrete Converter factory for dates and times | ||
| // | ||
|
|
||
| template <template <typename, typename> class ConverterType, typename T> | ||
| std::shared_ptr<Converter> MakeDateTimeConverter(const std::shared_ptr<DataType>& type, | ||
| const ConvertOptions& options, | ||
| MemoryPool* pool, | ||
| bool is_type_inference) { | ||
| if (is_type_inference || options.timestamp_parsers.empty()) { | ||
| // Default to ISO-8601. Type inference must not use the user-defined | ||
| // timestamp parsers, otherwise a value with a time-of-day (resp. date) part | ||
| // could be inferred as a date (resp. time) and be silently truncated. | ||
| return std::make_shared<ConverterType<T, NumericValueDecoder<T>>>(type, options, | ||
| pool); | ||
| } | ||
| // Try ISO-8601 first, then the user-defined timestamp parsers | ||
| return std::make_shared<ConverterType<T, DateTimeWithParsersValueDecoder<T>>>( | ||
| type, options, pool); | ||
| } | ||
|
|
||
| // | ||
| // Concrete Converter factory for reals | ||
| // | ||
|
|
@@ -715,7 +884,8 @@ DictionaryConverter::DictionaryConverter(const std::shared_ptr<DataType>& value_ | |
|
|
||
| Result<std::shared_ptr<Converter>> Converter::Make(const std::shared_ptr<DataType>& type, | ||
| const ConvertOptions& options, | ||
| MemoryPool* pool) { | ||
| MemoryPool* pool, | ||
| bool is_type_inference) { | ||
| std::shared_ptr<Converter> ptr; | ||
|
|
||
| switch (type->id()) { | ||
|
|
@@ -746,10 +916,6 @@ Result<std::shared_ptr<Converter>> Converter::Make(const std::shared_ptr<DataTyp | |
| NUMERIC_CONVERTER_CASE(Type::FLOAT, FloatType) | ||
| NUMERIC_CONVERTER_CASE(Type::DOUBLE, DoubleType) | ||
| REAL_CONVERTER_CASE(Type::DECIMAL, Decimal128Type, DecimalValueDecoder) | ||
| NUMERIC_CONVERTER_CASE(Type::DATE32, Date32Type) | ||
| NUMERIC_CONVERTER_CASE(Type::DATE64, Date64Type) | ||
| NUMERIC_CONVERTER_CASE(Type::TIME32, Time32Type) | ||
| NUMERIC_CONVERTER_CASE(Type::TIME64, Time64Type) | ||
| NUMERIC_CONVERTER_CASE(Type::DURATION, DurationType) | ||
| CONVERTER_CASE(Type::BOOL, (PrimitiveConverter<BooleanType, BooleanValueDecoder>)) | ||
| CONVERTER_CASE(Type::BINARY, | ||
|
|
@@ -763,6 +929,26 @@ Result<std::shared_ptr<Converter>> Converter::Make(const std::shared_ptr<DataTyp | |
| ptr = MakeTimestampConverter<PrimitiveConverter>(type, options, pool); | ||
| break; | ||
|
|
||
| case Type::DATE32: | ||
| ptr = MakeDateTimeConverter<PrimitiveConverter, Date32Type>(type, options, pool, | ||
| is_type_inference); | ||
| break; | ||
|
|
||
| case Type::DATE64: | ||
| ptr = MakeDateTimeConverter<PrimitiveConverter, Date64Type>(type, options, pool, | ||
| is_type_inference); | ||
| break; | ||
|
|
||
| case Type::TIME32: | ||
| ptr = MakeDateTimeConverter<PrimitiveConverter, Time32Type>(type, options, pool, | ||
| is_type_inference); | ||
| break; | ||
|
|
||
| case Type::TIME64: | ||
| ptr = MakeDateTimeConverter<PrimitiveConverter, Time64Type>(type, options, pool, | ||
| is_type_inference); | ||
| break; | ||
|
|
||
| case Type::STRING: | ||
| if (options.check_utf8) { | ||
| ptr = std::make_shared<PrimitiveConverter<StringType, BinaryValueDecoder<true>>>( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we validate the suffix after the fractional digits before copying it into
buffer? The new splitting logic accepts timestamps with two fractional components.For an explicit
time32[ms]column withtimestamp_parsers=[ISO8601]:2020-03-15 14:30:00.5.5incorrectly succeeds as14:30:01.000.2020-03-15 23:59:59.9.9produces86400800milliseconds, outside the valid daily range, so the resulting table failsValidateFull().The splitter removes the first fraction, leaving the second for the ISO parser to consume, and then the decoder adds the first fraction back. This also affects
time64[us]andtime64[ns].Please ensure the suffix is empty or a valid zone offset before reconstructing the string, and add regression tests rejecting both examples.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A possibly simpler way to fix this: date and time columns reject zone offsets anyway, so the only valid thing after the fractional digits is the end of the value. If
SplitISO8601Subsecondsrequires the fraction to run to the end, it can just shortensizeto 19 instead of copying the rest into a buffer. That rejects a second fraction outright and also removes the 64-byte stack buffer and bothmemcpycalls:with the call site becoming:
The existing tests that reject zone offsets after a fraction (
.5Z,.5+05,.5+0530,.5+05:30) should still pass, since those values now fail at the split instead of in the parser. The regression tests for2020-03-15 14:30:00.5.5and2020-03-15 23:59:59.9.9would still be good to add.