Repository navigation
GH-51275: [C++][GLib] Allow the CSV Reader to accept multi-character delimiters #51286
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?
Changes from all commits
b94b6fe
bd6eb40
dbe45a9
a7912ea
19961af
612b963
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 |
|---|---|---|
|
|
@@ -47,12 +47,19 @@ class Lexer { | |
| }; | ||
|
|
||
| explicit Lexer(const ParseOptions& options) | ||
| : options_(options), bulk_filter_(options_) { | ||
| : options_(options), | ||
| bulk_filter_(options_), | ||
| delimiter_matcher_(InitDelimiterMatcher(options_)) { | ||
| DCHECK_EQ(SpecializedOptions::quoting, options_.quoting); | ||
| DCHECK_EQ(SpecializedOptions::escaping, options_.escaping); | ||
| } | ||
|
|
||
| void Reset() { state_ = FIELD_START; } | ||
| void Reset() { | ||
| state_ = FIELD_START; | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| delimiter_matcher_.Reset(); | ||
| } | ||
| } | ||
|
|
||
| // Decide whether it's worth using a bulk filter over the given data area | ||
| bool ShouldUseBulkFilter(const char* data, const char* data_end) { | ||
|
|
@@ -126,13 +133,27 @@ class Lexer { | |
|
|
||
| InField: | ||
| // Inside a non-quoted part of a field | ||
| if (UseBulkFilter) { | ||
| const char* bulk_end = RunBulkFilter(data, data_end); | ||
| if (ARROW_PREDICT_FALSE(bulk_end == nullptr)) { | ||
| state_ = IN_FIELD; | ||
| goto AbortLine; | ||
| if constexpr (UseBulkFilter) { | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| if (!delimiter_matcher_.has_partial_match()) { | ||
| const char* bulk_end = RunBulkFilter(data, data_end); | ||
| if (ARROW_PREDICT_FALSE(bulk_end == nullptr)) { | ||
| state_ = IN_FIELD; | ||
| goto AbortLine; | ||
| } | ||
| data = bulk_end; | ||
| } else if (ARROW_PREDICT_FALSE(data == data_end)) { | ||
| state_ = IN_FIELD; | ||
| goto AbortLine; | ||
| } | ||
| } else { | ||
| const char* bulk_end = RunBulkFilter(data, data_end); | ||
| if (ARROW_PREDICT_FALSE(bulk_end == nullptr)) { | ||
| state_ = IN_FIELD; | ||
| goto AbortLine; | ||
| } | ||
| data = bulk_end; | ||
| } | ||
| data = bulk_end; | ||
| } else { | ||
| if (ARROW_PREDICT_FALSE(data == data_end)) { | ||
| state_ = IN_FIELD; | ||
|
|
@@ -141,6 +162,9 @@ class Lexer { | |
| } | ||
| c = *data++; | ||
| if (SpecializedOptions::escaping && ARROW_PREDICT_FALSE(c == options_.escape_char)) { | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| delimiter_matcher_.Reset(); | ||
| } | ||
|
Comment on lines
+165
to
+167
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. Can the |
||
| if (ARROW_PREDICT_FALSE(data == data_end)) { | ||
| state_ = AT_ESCAPE; | ||
| goto AbortLine; | ||
|
|
@@ -157,9 +181,14 @@ class Lexer { | |
| if (ARROW_PREDICT_FALSE(c == '\n')) { | ||
| goto LineEnd; | ||
| } | ||
| // treat delimiter as a normal token if quoting is disabled | ||
| if (ARROW_PREDICT_FALSE(SpecializedOptions::quoting && c == options_.delimiter)) { | ||
| goto FieldEnd; | ||
| if constexpr (SpecializedOptions::quoting) { | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| if (ARROW_PREDICT_FALSE(delimiter_matcher_.Consume(c))) { | ||
| goto FieldStart; | ||
| } | ||
| } else if (ARROW_PREDICT_FALSE(c == options_.delimiter)) { | ||
| goto FieldStart; | ||
| } | ||
| } | ||
| goto InField; | ||
|
|
||
|
|
@@ -223,12 +252,11 @@ class Lexer { | |
| goto InField; | ||
| } | ||
|
|
||
| FieldEnd: | ||
| // At the end of a field | ||
| goto FieldStart; | ||
|
|
||
| LineEnd: | ||
| state_ = FIELD_START; | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| delimiter_matcher_.Reset(); | ||
| } | ||
| return data; | ||
|
|
||
| AbortLine: | ||
|
|
@@ -262,6 +290,21 @@ class Lexer { | |
| const ParseOptions& options_; | ||
| const BulkFilterType bulk_filter_; | ||
| State state_ = FIELD_START; | ||
|
|
||
| private: | ||
|
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. The private is a bit gratuitous here, no? |
||
| struct Empty {}; | ||
|
|
||
| static auto InitDelimiterMatcher(const ParseOptions& options) { | ||
| if constexpr (SpecializedOptions::multi_delimiter) { | ||
| return internal::StreamingDelimiterMatcher(internal::GetDelimiter(options)); | ||
| } else { | ||
| return Empty{}; | ||
| } | ||
| } | ||
|
|
||
| [[no_unique_address]] | ||
| std::conditional_t<SpecializedOptions::multi_delimiter, | ||
| internal::StreamingDelimiterMatcher, Empty> delimiter_matcher_; | ||
|
Comment on lines
+305
to
+307
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. This is kludgy, can we just use
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. It's because |
||
| }; | ||
|
|
||
| // A BoundaryFinder implementation that assumes CSV cells can contain raw newlines, | ||
|
|
@@ -380,23 +423,17 @@ std::unique_ptr<Chunker> MakeChunker(const ParseOptions& options) { | |
| if (!options.newlines_in_values) { | ||
| delimiter = MakeNewlineBoundaryFinder(); | ||
| } else { | ||
| if (options.quoting) { | ||
| if (options.escaping) { | ||
| delimiter = std::make_shared< | ||
| LexingBoundaryFinder<internal::SpecializedOptions<true, true>>>(options); | ||
| } else { | ||
| delimiter = std::make_shared< | ||
| LexingBoundaryFinder<internal::SpecializedOptions<true, false>>>(options); | ||
| } | ||
| } else { | ||
| if (options.escaping) { | ||
| delimiter = std::make_shared< | ||
| LexingBoundaryFinder<internal::SpecializedOptions<false, true>>>(options); | ||
| } else { | ||
| delimiter = std::make_shared< | ||
| LexingBoundaryFinder<internal::SpecializedOptions<false, false>>>(options); | ||
| } | ||
| } | ||
| // Unlike the parser, the chunker only needs delimiters to identify field starts | ||
| // for quote recognition. Without quoting, delimiters cannot affect line boundaries. | ||
| const bool multi_delimiter = options.quoting && !options.delimiter_string.empty(); | ||
|
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. Should we actually check for |
||
| delimiter = internal::DispatchBool( | ||
| [&]<bool Quoting, bool Escaping, bool MultiDelimiter>() | ||
| -> std::shared_ptr<BoundaryFinder> { | ||
| using SpecializedOptions = | ||
| internal::SpecializedOptions<Quoting, Escaping, MultiDelimiter>; | ||
| return std::make_shared<LexingBoundaryFinder<SpecializedOptions>>(options); | ||
| }, | ||
| options.quoting, options.escaping, multi_delimiter); | ||
| } | ||
| return std::make_unique<Chunker>(std::move(delimiter)); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -193,6 +193,47 @@ TEST_P(BaseChunkerTest, QuotingNewline) { | |
| } | ||
| } | ||
|
|
||
| TEST_P(BaseChunkerTest, MultiDelimiter) { | ||
| if (!options_.newlines_in_values) { | ||
| return; | ||
|
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. Use |
||
| } | ||
| options_.delimiter_string = "||"; | ||
| MakeChunker(); | ||
|
|
||
| auto partial = std::make_shared<Buffer>("name|"); | ||
|
HuaHuaY marked this conversation as resolved.
|
||
| auto block = std::make_shared<Buffer>("|message||\"hello\nworld\"||42\nnext||row\n"); | ||
|
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. Can we add more single pipes to exercise the delimiter search logic? |
||
| std::shared_ptr<Buffer> completion; | ||
| std::shared_ptr<Buffer> rest; | ||
| ASSERT_OK(chunker_->ProcessWithPartial(partial, block, &completion, &rest)); | ||
| ASSERT_EQ(completion->ToString(), "|message||\"hello\nworld\"||42\n"); | ||
| ASSERT_EQ(rest->ToString(), "next||row\n"); | ||
|
|
||
| partial = std::make_shared<Buffer>("name|"); | ||
| block = std::make_shared<Buffer>("|message"); | ||
| ASSERT_RAISES(Invalid, | ||
| chunker_->ProcessWithPartial(partial, block, &completion, &rest)); | ||
|
|
||
| // Long ordinary fields make the bulk filter profitable. | ||
| for (size_t padding : {0, 256}) { | ||
| const std::string first_field = std::string(padding, 'a') + "a|b"; | ||
| const std::string row = first_field + "||\"c\n d\"||e\n"; | ||
| const std::string next_row = std::string(padding, 'n') + "next||row\n"; | ||
| MakeChunker(); | ||
| const auto lengths = {row.size(), next_row.size()}; | ||
| AssertChunking(*chunker_, row + next_row, lengths); | ||
|
|
||
| for (size_t split : | ||
| {first_field.find('|') + 1, first_field.size() + 1, first_field.size() + 2}) { | ||
| ARROW_SCOPED_TRACE("padding = ", padding, ", split = ", split); | ||
| partial = Buffer::FromString(row.substr(0, split)); | ||
| block = Buffer::FromString(row.substr(split) + next_row); | ||
| ASSERT_OK(chunker_->ProcessWithPartial(partial, block, &completion, &rest)); | ||
| ASSERT_EQ(completion->ToString(), row.substr(split)); | ||
| ASSERT_EQ(rest->ToString(), next_row); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| TEST_P(BaseChunkerTest, QuotingUnbalanced) { | ||
| // Quote introduces a quoted field that doesn't end | ||
| auto csv = MakeCSVData({"a,b\n", "1,\",3,,5\n", "c,d\n"}); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,22 +19,25 @@ | |
|
|
||
| #include <cstdint> | ||
| #include <cstring> | ||
| #include <string> | ||
| #include <string_view> | ||
| #include <type_traits> | ||
| #include <utility> | ||
|
|
||
| #include "arrow/csv/options.h" | ||
| #include "arrow/util/logging_internal.h" | ||
| #include "arrow/util/simd.h" | ||
|
|
||
| namespace arrow { | ||
| namespace csv { | ||
| namespace internal { | ||
|
|
||
| template <bool Quoting, bool Escaping> | ||
| template <bool Quoting, bool Escaping, bool MultiDelimiter> | ||
| class SpecializedOptions { | ||
| public: | ||
| static constexpr bool quoting = Quoting; | ||
| static constexpr bool escaping = Escaping; | ||
| static constexpr bool multi_delimiter = MultiDelimiter; | ||
| }; | ||
|
|
||
| /// Convert runtime boolean options into template arguments for a callable. | ||
|
|
@@ -61,6 +64,58 @@ decltype(auto) DispatchBool(Fn&& fn, Rest... rest) | |
| } | ||
| } | ||
|
|
||
| inline std::string_view GetDelimiter(const ParseOptions& options) { | ||
| if (!options.delimiter_string.empty()) { | ||
| return options.delimiter_string; | ||
| } | ||
| return {&options.delimiter, 1}; | ||
| } | ||
|
|
||
| inline char GetDelimiterFirstByte(const ParseOptions& options) { | ||
| if (!options.delimiter_string.empty()) { | ||
| return options.delimiter_string.front(); | ||
| } | ||
| return options.delimiter; | ||
| } | ||
|
|
||
| class StreamingDelimiterMatcher { | ||
| public: | ||
| explicit StreamingDelimiterMatcher(std::string_view delimiter) : delimiter_(delimiter) { | ||
| DCHECK(!delimiter_.empty()); | ||
| } | ||
|
|
||
| bool Consume(char c) { | ||
| if (c == delimiter_[matched_]) { | ||
| matched_++; | ||
| if (matched_ == delimiter_.size()) { | ||
| matched_ = 0; | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| const std::string_view delimiter(delimiter_); | ||
| for (size_t length = matched_; length > 0; --length) { | ||
| if (c == delimiter[length - 1] && | ||
| delimiter.substr(0, length - 1) == | ||
| delimiter.substr(matched_ - (length - 1), length - 1)) { | ||
|
Comment on lines
+100
to
+101
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. This is of course expensive and could easily be precomputed in a table, since it only depends on the |
||
| matched_ = length; | ||
| return false; | ||
| } | ||
| } | ||
| matched_ = 0; | ||
| return false; | ||
| } | ||
|
|
||
| void Reset() { matched_ = 0; } | ||
|
|
||
| bool has_partial_match() const { return matched_ != 0; } | ||
|
|
||
| private: | ||
| const std::string delimiter_; | ||
| size_t matched_ = 0; | ||
| }; | ||
|
|
||
| // | ||
| // Bulk filters for packed character matching. | ||
| // These filters allow checking multiple CSV bytes at once for specific | ||
|
|
@@ -87,7 +142,7 @@ class BaseBloomFilter { | |
| auto add_char = [&](char c) { filter |= CharFilter(c); }; | ||
| add_char('\n'); | ||
| add_char('\r'); | ||
| add_char(options.delimiter); | ||
| add_char(GetDelimiterFirstByte(options)); | ||
| if (options.escaping) { | ||
| add_char(options.escape_char); | ||
| } | ||
|
|
@@ -188,7 +243,7 @@ class SSE42Filter { | |
| // Make a SIMD word of the characters we want to match | ||
| const char cr = '\r'; | ||
| const char lf = '\n'; | ||
| const char delim = options.delimiter; | ||
| const char delim = GetDelimiterFirstByte(options); | ||
| const char quote = SpecializedOptions::quoting ? options.quote_char : cr; | ||
| const char escape = SpecializedOptions::escaping ? options.escape_char : cr; | ||
|
|
||
|
|
@@ -213,7 +268,7 @@ class NeonFilter { | |
| using WordType = uint8x8_t; | ||
|
|
||
| explicit NeonFilter(const ParseOptions& options) | ||
| : delim_(vdup_n_u8(options.delimiter)), | ||
| : delim_(vdup_n_u8(GetDelimiterFirstByte(options))), | ||
| quote_(vdup_n_u8(SpecializedOptions::quoting ? options.quote_char : '\n')), | ||
| escape_(vdup_n_u8(SpecializedOptions::escaping ? options.escape_char : '\n')) {} | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,8 +23,39 @@ namespace csv { | |
| ParseOptions ParseOptions::Defaults() { return ParseOptions(); } | ||
|
|
||
| Status ParseOptions::Validate() const { | ||
| if (ARROW_PREDICT_FALSE(delimiter == '\n' || delimiter == '\r')) { | ||
| return Status::Invalid("ParseOptions: delimiter cannot be \\r or \\n"); | ||
| // The chunker handles escapes before delimiter matching, so allowing the escape | ||
|
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. Is it cleaner to consolidate the two cases like below? std::string invalid_chars = "\r\n";
if (escaping) {
invalid_chars.push_back(escape_char);
}
const bool use_delimiter_string = !delimiter_string.empty();
const auto invalid_pos =
use_delimiter_string ? delimiter_string.find_first_of(invalid_chars)
: invalid_chars.find(delimiter);
if (invalid_pos != std::string::npos) {
return Status::Invalid("ParseOptions: delimiter contains an invalid character");
}
const char delimiter_first_byte =
use_delimiter_string ? delimiter_string.front() : delimiter;
if (quoting && delimiter_first_byte == quote_char) {
return Status::Invalid(
"ParseOptions: delimiter cannot start with the quote character");
}
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. However, by doing this, we lose the specific reason for the error. |
||
| // character in a delimiter could make it disagree with the parser. | ||
| if (escaping) { | ||
|
HuaHuaY marked this conversation as resolved.
|
||
| if (delimiter_string.empty()) { | ||
| if (delimiter == escape_char) { | ||
| return Status::Invalid( | ||
| "ParseOptions: delimiter cannot be the escape character when escaping is " | ||
| "enabled"); | ||
| } | ||
| } else if (delimiter_string.find(escape_char) != std::string::npos) { | ||
| return Status::Invalid( | ||
| "ParseOptions: delimiter_string cannot contain the escape character when " | ||
| "escaping is enabled"); | ||
| } | ||
| } | ||
| // Line endings delimit records and therefore cannot be part of a field delimiter. | ||
| if (delimiter_string.empty()) { | ||
| if (ARROW_PREDICT_FALSE(delimiter == '\n' || delimiter == '\r')) { | ||
| return Status::Invalid("ParseOptions: delimiter cannot be \\r or \\n"); | ||
| } | ||
| } else if (ARROW_PREDICT_FALSE(delimiter_string.find_first_of("\r\n") != | ||
| std::string::npos)) { | ||
| return Status::Invalid("ParseOptions: delimiter_string cannot contain \\r or \\n"); | ||
| } | ||
| // The chunker handles quotes before delimiter matching at field boundaries, so | ||
| // allowing the quote character at the start of a delimiter could make it disagree | ||
| // with the parser. | ||
|
Comment on lines
+50
to
+52
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. Why not be stricter and not allow the quote character at all? |
||
| const char delimiter_first_byte = | ||
| delimiter_string.empty() ? delimiter : delimiter_string.front(); | ||
| if (ARROW_PREDICT_FALSE(quoting && delimiter_first_byte == quote_char)) { | ||
| return Status::Invalid( | ||
| "ParseOptions: delimiter cannot start with the quote character when quoting is " | ||
| "enabled"); | ||
| } | ||
| if (ARROW_PREDICT_FALSE(quoting && (quote_char == '\n' || quote_char == '\r'))) { | ||
| return Status::Invalid("ParseOptions: quote_char cannot be \\r or \\n"); | ||
|
|
||
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.
This GLib part changes are correct but you can revert this in this PR. I can work on the GLib part including multi-character delimiters support as a follow-up task after this is merged.
Uh oh!
There was an error while loading. Please reload this page.
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.
I can revert this part of the code tomorrow. However, this PR adds a
std::stringfield inParseOptions, which will break two CI tests because the reverted initialization code is missing in GLib.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.
OK. If this part is required to keep our CI green, let's keep this part in this PR.