This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new cb5b51aa8bf [fix](be) Align CSV V2 enclosed field parsing (#65501)
cb5b51aa8bf is described below
commit cb5b51aa8bf0518862db3ff9bd1bf9d7201d65cc
Author: Gabriel <[email protected]>
AuthorDate: Mon Jul 13 09:28:01 2026 +0800
[fix](be) Align CSV V2 enclosed field parsing (#65501)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary: FileScanner V2 used an independent CSV field-splitting
state machine that diverged from the line reader for bare quotes,
escapes outside enclosed fields, and configurations where escape equals
enclose. It also removed double quotes before nullable string
conversion, causing quoted null markers to be treated as NULL. This
change reuses the line reader's separator positions, preserves
quoted-string provenance through null matching, and adjusts separator
positions when a UTF-8 BOM is removed.
### Release note
Fix CSV FileScanner V2 parsing for enclosed fields and quoted null
literals.
### Check List (For Author)
- Test: Unit Test and Regression test
- `CsvV2ReaderTest`: 30 tests passed
- `test_local_tvf_csv_enclose_consistency`: passed
- Behavior changed: Yes. CSV V2 now matches the established
enclosed-field parser and preserves quoted null literals as strings.
- Does this need documentation: No
---
.../file_reader/new_plain_text_line_reader.cpp | 28 +-
.../file_reader/new_plain_text_line_reader.h | 9 +-
be/src/format/hive_text_util.h | 33 +++
be/src/format/text/text_reader.cpp | 5 +-
be/src/format_v2/delimited_text/csv_reader.cpp | 115 +++-----
be/src/format_v2/delimited_text/csv_reader.h | 3 +
.../delimited_text/delimited_text_reader.cpp | 10 +-
.../delimited_text/delimited_text_reader.h | 2 +
be/src/format_v2/delimited_text/text_reader.cpp | 5 +-
.../format/text/hive_text_field_splitter_test.cpp | 4 +-
.../format_v2/delimited_text/csv_reader_test.cpp | 294 +++++++++++++++++++++
.../format_v2/delimited_text/text_reader_test.cpp | 29 ++
.../doris/datasource/hive/source/HiveScanNode.java | 6 +-
.../datasource/hive/source/HiveScanNodeTest.java | 11 +
.../external_table_p0/tvf/csv_enclose_state.csv | 3 +
.../tvf/csv_matching_escape_enclose.csv | 2 +
.../data/external_table_p0/tvf/csv_quoted_null.csv | 3 +
.../tvf/test_local_tvf_csv_enclose_consistency.out | 12 +
.../test_local_tvf_csv_enclose_consistency.groovy | 73 +++++
19 files changed, 562 insertions(+), 85 deletions(-)
diff --git a/be/src/format/file_reader/new_plain_text_line_reader.cpp
b/be/src/format/file_reader/new_plain_text_line_reader.cpp
index c0396a39e84..25a7a0a7ac2 100644
--- a/be/src/format/file_reader/new_plain_text_line_reader.cpp
+++ b/be/src/format/file_reader/new_plain_text_line_reader.cpp
@@ -46,11 +46,31 @@
namespace doris {
const uint8_t* EncloseCsvLineReaderCtx::read_line_impl(const uint8_t* start,
const size_t length) {
+ if (_skip_utf8_bom && !_first_record_prefix_checked && _idx == 0) {
+ constexpr uint8_t UTF8_BOM[] = {0xEF, 0xBB, 0xBF};
+ constexpr size_t UTF8_BOM_SIZE = sizeof(UTF8_BOM);
+ const size_t prefix_size = std::min(length, UTF8_BOM_SIZE);
+ if (std::memcmp(start, UTF8_BOM, prefix_size) != 0) {
+ _first_record_prefix_checked = true;
+ } else if (length < UTF8_BOM_SIZE) {
+ // The input buffer can end inside the BOM. Wait for the remaining
prefix bytes instead
+ // of feeding a partial BOM into START, which would permanently
select NORMAL state.
+ return nullptr;
+ } else {
+ // Keep offsets relative to the original buffer. CsvReader removes
these three bytes
+ // from the returned line and shifts separator positions by the
same amount.
+ _idx = UTF8_BOM_SIZE;
+ _first_record_prefix_checked = true;
+ }
+ }
// Avoid part bytes of the multi-char column separator have already been
parsed,
// causing parse column separator error.
if (_state.curr_state == ReaderState::NORMAL ||
_state.curr_state == ReaderState::MATCH_ENCLOSE) {
- _idx -= std::min(_column_sep_len - 1, _idx);
+ const size_t last_column_sep_end =
+ _column_sep_positions.empty() ? 0 :
_column_sep_positions.back() + _column_sep_len;
+ DORIS_CHECK_LE(last_column_sep_end, _idx);
+ _idx -= std::min(_column_sep_len - 1, _idx - last_column_sep_end);
}
_total_len = length;
size_t bound = update_reading_bound(start);
@@ -134,7 +154,8 @@ void EncloseCsvLineReaderCtx::_on_start(const uint8_t*
start, size_t& len) {
void EncloseCsvLineReaderCtx::_on_normal(const uint8_t* start, size_t& len) {
const uint8_t* curr_start = start + _idx;
- size_t curr_len = len - _idx;
+ const size_t field_search_bound = _result == nullptr ? len : _result -
start;
+ const size_t curr_len = field_search_bound - _idx;
const uint8_t* col_sep_pos =
find_col_sep_func(curr_start, curr_len, _column_sep.c_str(),
_column_sep_len);
@@ -190,7 +211,8 @@ void EncloseCsvLineReaderCtx::_on_pre_match_enclose(const
uint8_t* start, size_t
void EncloseCsvLineReaderCtx::_on_match_enclose(const uint8_t* start, size_t&
len) {
const uint8_t* curr_start = start + _idx;
- size_t curr_len = len - _idx;
+ const size_t field_search_bound = _result == nullptr ? len : _result -
start;
+ const size_t curr_len = field_search_bound - _idx;
const uint8_t* delim_pos =
find_col_sep_func(curr_start, curr_len, _column_sep.c_str(),
_column_sep_len);
diff --git a/be/src/format/file_reader/new_plain_text_line_reader.h
b/be/src/format/file_reader/new_plain_text_line_reader.h
index 7262b203e02..ed7f80493b0 100644
--- a/be/src/format/file_reader/new_plain_text_line_reader.h
+++ b/be/src/format/file_reader/new_plain_text_line_reader.h
@@ -156,10 +156,12 @@ public:
explicit EncloseCsvLineReaderCtx(const std::string& line_delimiter_,
const size_t line_delimiter_len_,
std::string column_sep_,
const size_t column_sep_len_, size_t
col_sep_num,
- const char enclose, const char escape,
const bool keep_cr_)
+ const char enclose, const char escape,
const bool keep_cr_,
+ const bool skip_utf8_bom_ = false)
: BaseTextLineReaderContext(line_delimiter_, line_delimiter_len_,
keep_cr_),
_enclose(enclose),
_escape(escape),
+ _skip_utf8_bom(skip_utf8_bom_),
_column_sep_len(column_sep_len_),
_column_sep(std::move(column_sep_)) {
if (column_sep_len_ == 1) {
@@ -210,6 +212,11 @@ private:
ReaderStateWrapper _state;
const char _enclose;
const char _escape;
+ // Only V2 readers starting at file offset zero enable this. The line
reader must recognize the
+ // BOM before deciding whether the first field is enclosed; the format
reader later removes the
+ // same bytes from the returned Slice and adjusts the recorded separator
positions.
+ const bool _skip_utf8_bom;
+ bool _first_record_prefix_checked = false;
const uint8_t* _result = nullptr;
size_t _total_len;
diff --git a/be/src/format/hive_text_util.h b/be/src/format/hive_text_util.h
new file mode 100644
index 00000000000..80dc19f7ec8
--- /dev/null
+++ b/be/src/format/hive_text_util.h
@@ -0,0 +1,33 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstddef>
+
+namespace doris {
+
+inline bool is_hive_text_separator_escaped(const char* data, size_t
separator_pos,
+ char escape_char) {
+ size_t escape_count = 0;
+ while (separator_pos > escape_count && data[separator_pos - escape_count -
1] == escape_char) {
+ ++escape_count;
+ }
+ return escape_count % 2 == 1;
+}
+
+} // namespace doris
diff --git a/be/src/format/text/text_reader.cpp
b/be/src/format/text/text_reader.cpp
index 23501f94cd6..693fde2d9a8 100644
--- a/be/src/format/text/text_reader.cpp
+++ b/be/src/format/text/text_reader.cpp
@@ -34,6 +34,7 @@
#include "exec/scan/scanner.h"
#include "format/csv/csv_reader.h"
#include "format/file_reader/new_plain_text_line_reader.h"
+#include "format/hive_text_util.h"
#include "format/line_reader.h"
#include "io/file_factory.h"
#include "io/fs/buffered_reader.h"
@@ -60,7 +61,7 @@ void HiveTextFieldSplitter::_split_field_single_char(const
Slice& line,
for (size_t i = 0; i < size; ++i) {
if (data[i] == _value_sep[0]) {
// hive will escape the field separator in string
- if (_escape_char != 0 && i > 0 && data[i - 1] == _escape_char) {
+ if (_escape_char != 0 && is_hive_text_separator_escaped(data, i,
_escape_char)) {
continue;
}
process_value_func(data, value_start, i - value_start,
_trimming_char, splitted_values);
@@ -98,7 +99,7 @@ void HiveTextFieldSplitter::_split_field_multi_char(const
Slice& line,
}
if (j == (int)_value_sep_len - 1) {
size_t curpos = i - _value_sep_len + 1;
- if (_escape_char != 0 && curpos > 0 && data[curpos - 1] ==
_escape_char) {
+ if (_escape_char != 0 && is_hive_text_separator_escaped(data,
curpos, _escape_char)) {
j = next[j];
continue;
}
diff --git a/be/src/format_v2/delimited_text/csv_reader.cpp
b/be/src/format_v2/delimited_text/csv_reader.cpp
index 711146a9880..bb6c55d3084 100644
--- a/be/src/format_v2/delimited_text/csv_reader.cpp
+++ b/be/src/format_v2/delimited_text/csv_reader.cpp
@@ -134,14 +134,13 @@ Status CsvReader::_create_line_reader() {
text_line_reader_ctx = std::make_shared<PlainTextLineReaderCtx>(
_line_delimiter, _line_delimiter.size(), _keep_cr);
} else {
- // The enclosed-line context finds logical records that may span
physical newlines.
- // Field slicing still happens in `_split_line()` because the v2
scan request may ask
- // for CSV ordinals in a different order from the physical file.
const size_t col_sep_num =
_source_file_slot_descs.size() > 1 ?
_source_file_slot_descs.size() - 1 : 0;
- text_line_reader_ctx = std::make_shared<EncloseCsvLineReaderCtx>(
+ _enclose_reader_ctx = std::make_shared<EncloseCsvLineReaderCtx>(
_line_delimiter, _line_delimiter.size(), _value_separator,
- _value_separator.size(), col_sep_num, _enclose, _escape,
_keep_cr);
+ _value_separator.size(), col_sep_num, _enclose, _escape,
_keep_cr,
+ _start_offset == 0);
+ text_line_reader_ctx = _enclose_reader_ctx;
}
_line_reader = NewPlainTextLineReader::create_unique(
_profile, _file_reader, _decompressor.get(),
std::move(text_line_reader_ctx), _size,
@@ -174,77 +173,40 @@ void CsvReader::_split_line(const Slice& line) {
return;
}
- // The text line reader is responsible for split boundaries and multi-line
quoted fields.
- // Field slicing still happens here because FileScannerV2 asks columns by
file-local id, so we
- // must be able to materialize only the requested CSV ordinals without
building a row object.
- // Example: for `1,"a,b",10` and column separator `,`, this loop returns
three slices:
- // `1`, `a,b`, and `10`; the comma inside quotes does not create an extra
field.
- bool in_quote = false;
- bool escaped = false;
- size_t start = 0;
- size_t i = 0;
- while (i < line.size) {
- const char ch = line.data[i];
- if (_enclose != 0) {
- if (escaped) {
- escaped = false;
- ++i;
- continue;
- }
- if (_escape != 0 && ch == _escape) {
- escaped = true;
- ++i;
- continue;
- }
- if (ch == _enclose) {
- if (in_quote && i + 1 < line.size && line.data[i + 1] ==
_enclose) {
- i += 2;
- continue;
- }
- in_quote = !in_quote;
- ++i;
- continue;
- }
+ const auto append_value = [&](size_t value_start, size_t value_len) {
+ while (_trim_tailing_spaces && value_len > 0 &&
+ line.data[value_start + value_len - 1] == ' ') {
+ --value_len;
}
- if (!in_quote && starts_with_at(line, i, _value_separator)) {
- size_t value_start = start;
- size_t value_len = i - start;
- while (_trim_tailing_spaces && value_len > 0 &&
- line.data[value_start + value_len - 1] == ' ') {
- --value_len;
- }
- if (_trim_double_quotes && value_len > 1 && line.data[value_start]
== '"' &&
- line.data[value_start + value_len - 1] == '"') {
- ++value_start;
- value_len -= 2;
- } else if (_enclose != 0 && value_len > 1 &&
line.data[value_start] == _enclose &&
- line.data[value_start + value_len - 1] == _enclose) {
- ++value_start;
- value_len -= 2;
- }
- _split_values.emplace_back(line.data + value_start, value_len);
- i += _value_separator.size();
- start = i;
- continue;
+ if (_enclose != 0 && value_len > 1 && line.data[value_start] ==
_enclose &&
+ line.data[value_start + value_len - 1] == _enclose) {
+ ++value_start;
+ value_len -= 2;
}
- ++i;
- }
+ _split_values.emplace_back(line.data + value_start, value_len);
+ };
- size_t value_start = start;
- size_t value_len = line.size - start;
- while (_trim_tailing_spaces && value_len > 0 && line.data[value_start +
value_len - 1] == ' ') {
- --value_len;
- }
- if (_trim_double_quotes && value_len > 1 && line.data[value_start] == '"'
&&
- line.data[value_start + value_len - 1] == '"') {
- ++value_start;
- value_len -= 2;
- } else if (_enclose != 0 && value_len > 1 && line.data[value_start] ==
_enclose &&
- line.data[value_start + value_len - 1] == _enclose) {
- ++value_start;
- value_len -= 2;
+ size_t value_start = 0;
+ if (_enclose_reader_ctx != nullptr) {
+ for (const size_t separator_position :
_enclose_reader_ctx->column_sep_positions()) {
+ DORIS_CHECK_LE(value_start, separator_position);
+ DORIS_CHECK_LE(separator_position, line.size);
+ append_value(value_start, separator_position - value_start);
+ value_start = separator_position + _value_separator.size();
+ }
+ } else {
+ for (size_t i = 0; i < line.size;) {
+ if (starts_with_at(line, i, _value_separator)) {
+ append_value(value_start, i - value_start);
+ i += _value_separator.size();
+ value_start = i;
+ } else {
+ ++i;
+ }
+ }
}
- _split_values.emplace_back(line.data + value_start, value_len);
+ DORIS_CHECK_LE(value_start, line.size);
+ append_value(value_start, line.size - value_start);
}
Status CsvReader::_deserialize_one_cell(const RequestedColumn& column,
IColumn* output,
@@ -261,7 +223,8 @@ Status CsvReader::_deserialize_one_cell(const
RequestedColumn& column, IColumn*
// CSV keeps empty-field handling separate from null_format matching.
An empty
// null_format must not turn every empty CSV field into NULL unless FE
explicitly sets
// empty_field_as_null; OpenCSV-compatible tables expect empty fields
to stay empty strings.
- if (_options.null_len > 0 && value.size == _options.null_len &&
+ const bool quoted = _options.converted_from_string &&
value.trim_double_quotes();
+ if (!quoted && _options.null_len > 0 && value.size ==
_options.null_len &&
std::memcmp(value.data, _options.null_format, value.size) == 0) {
null_column.insert_data(nullptr, 0);
return Status::OK();
@@ -292,4 +255,10 @@ bool CsvReader::_can_split() const {
_file_format_type == TFileFormatType::FORMAT_CSV_PLAIN);
}
+void CsvReader::_on_bom_removed(size_t bom_size) {
+ if (_enclose_reader_ctx != nullptr) {
+ _enclose_reader_ctx->adjust_column_sep_positions(bom_size);
+ }
+}
+
} // namespace doris::format::csv
diff --git a/be/src/format_v2/delimited_text/csv_reader.h
b/be/src/format_v2/delimited_text/csv_reader.h
index e5d1ce25a74..dd1d77d5a34 100644
--- a/be/src/format_v2/delimited_text/csv_reader.h
+++ b/be/src/format_v2/delimited_text/csv_reader.h
@@ -25,6 +25,7 @@
#include "util/slice.h"
namespace doris {
+class EncloseCsvLineReaderCtx;
class SlotDescriptor;
} // namespace doris
@@ -61,6 +62,7 @@ private:
Slice value) override;
Slice _normalize_value(Slice value) const override;
bool _can_split() const override;
+ void _on_bom_removed(size_t bom_size) override;
TFileFormatType::type _file_format_type =
TFileFormatType::FORMAT_CSV_PLAIN;
char _enclose = 0;
@@ -68,6 +70,7 @@ private:
bool _trim_tailing_spaces = false;
bool _empty_field_as_null = false;
bool _keep_cr = false;
+ std::shared_ptr<EncloseCsvLineReaderCtx> _enclose_reader_ctx;
};
} // namespace doris::format::csv
diff --git a/be/src/format_v2/delimited_text/delimited_text_reader.cpp
b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
index b26e0d44456..f19d12c7571 100644
--- a/be/src/format_v2/delimited_text/delimited_text_reader.cpp
+++ b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
@@ -624,6 +624,10 @@ bool DelimitedTextReader::_can_split() const {
return _file_compress_type == TFileCompressType::PLAIN;
}
+void DelimitedTextReader::_on_bom_removed(size_t bom_size) {
+ (void)bom_size;
+}
+
Status DelimitedTextReader::_append_null(IColumn* output) {
DORIS_CHECK(output != nullptr);
auto* nullable = assert_cast<ColumnNullable*>(output);
@@ -635,8 +639,10 @@ const uint8_t* DelimitedTextReader::_remove_bom(const
uint8_t* ptr, size_t* size
DORIS_CHECK(size != nullptr);
if (ptr != nullptr && *size >= 3 && static_cast<uint8_t>(ptr[0]) == 0xEF &&
static_cast<uint8_t>(ptr[1]) == 0xBB && static_cast<uint8_t>(ptr[2])
== 0xBF) {
- *size -= 3;
- return ptr + 3;
+ constexpr size_t BOM_SIZE = 3;
+ *size -= BOM_SIZE;
+ _on_bom_removed(BOM_SIZE);
+ return ptr + BOM_SIZE;
}
return ptr;
}
diff --git a/be/src/format_v2/delimited_text/delimited_text_reader.h
b/be/src/format_v2/delimited_text/delimited_text_reader.h
index 06cb93dd7f7..daea3bc9094 100644
--- a/be/src/format_v2/delimited_text/delimited_text_reader.h
+++ b/be/src/format_v2/delimited_text/delimited_text_reader.h
@@ -123,6 +123,8 @@ protected:
// Whether this file can start at a non-zero split offset. Compressed
delimited files cannot be
// split because the decompressor needs the stream from the beginning.
virtual bool _can_split() const;
+ // Let formats adjust parser metadata that was computed before the common
reader removed a BOM.
+ virtual void _on_bom_removed(size_t bom_size);
Status _append_null(IColumn* output);
// Match the generic nullable serde semantics exactly: a field is NULL
when its raw slice is
diff --git a/be/src/format_v2/delimited_text/text_reader.cpp
b/be/src/format_v2/delimited_text/text_reader.cpp
index 930052a14f1..6dea7a07a4c 100644
--- a/be/src/format_v2/delimited_text/text_reader.cpp
+++ b/be/src/format_v2/delimited_text/text_reader.cpp
@@ -25,6 +25,7 @@
#include "core/data_type/data_type_string.h"
#include "core/data_type_serde/data_type_string_serde.h"
#include "format/file_reader/new_plain_text_line_reader.h"
+#include "format/hive_text_util.h"
#include "runtime/descriptors.h"
#include "util/decompressor.h"
@@ -104,7 +105,7 @@ void TextReader::_split_line_single_char(const Slice& line)
{
if (line.data[i] == _value_separator[0]) {
// Hive text lets a string escape the field separator. The
backslash remains in the
// field slice so deserialize_one_cell_from_hive_text() can
unescape the final value.
- if (_escape != 0 && i > 0 && line.data[i - 1] == _escape) {
+ if (_escape != 0 && is_hive_text_separator_escaped(line.data, i,
_escape)) {
continue;
}
_split_values.emplace_back(line.data + value_start, i -
value_start);
@@ -119,7 +120,7 @@ void TextReader::_split_line_multi_char(const Slice& line) {
size_t i = 0;
while (i < line.size) {
if (starts_with_at(line, i, _value_separator)) {
- if (_escape != 0 && i > 0 && line.data[i - 1] == _escape) {
+ if (_escape != 0 && is_hive_text_separator_escaped(line.data, i,
_escape)) {
++i;
continue;
}
diff --git a/be/test/format/text/hive_text_field_splitter_test.cpp
b/be/test/format/text/hive_text_field_splitter_test.cpp
index 6887ff6d2db..d52078aead0 100644
--- a/be/test/format/text/hive_text_field_splitter_test.cpp
+++ b/be/test/format/text/hive_text_field_splitter_test.cpp
@@ -81,7 +81,9 @@ TEST_F(HiveTextFieldSplitterTest, OverlappingPatterns) {
// Test escape character functionality
TEST_F(HiveTextFieldSplitterTest, EscapeCharacter) {
verify_field_split("a\\,b,c", ",", {"a\\,b", "c"}, '\\');
+ verify_field_split(R"(a\\,b)", ",", {R"(a\\)", "b"}, '\\');
verify_field_split("a\\||b||c", "||", {"a\\||b", "c"}, '\\');
+ verify_field_split(R"(a\\||b)", "||", {R"(a\\)", "b"}, '\\');
verify_field_split("field1\\|+|field2|+|field3", "|+|",
{"field1\\|+|field2", "field3"}, '\\');
}
@@ -94,4 +96,4 @@ TEST_F(HiveTextFieldSplitterTest, RealWorldScenarios) {
verify_field_split("a|+||+|c", "|+|", {"a", "", "c"});
}
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/be/test/format_v2/delimited_text/csv_reader_test.cpp
b/be/test/format_v2/delimited_text/csv_reader_test.cpp
index d5eafec7142..0ce99630c7a 100644
--- a/be/test/format_v2/delimited_text/csv_reader_test.cpp
+++ b/be/test/format_v2/delimited_text/csv_reader_test.cpp
@@ -24,6 +24,7 @@
#include <fstream>
#include <memory>
+#include "common/config.h"
#include "common/consts.h"
#include "common/object_pool.h"
#include "core/assert_cast.h"
@@ -45,6 +46,8 @@
#include "runtime/runtime_profile.h"
#include "testutil/desc_tbl_builder.h"
#include "testutil/mock/mock_runtime_state.h"
+#include "util/debug_points.h"
+#include "util/defer_op.h"
namespace doris::format::csv {
namespace {
@@ -780,6 +783,187 @@ TEST_F(CsvV2ReaderTest,
EnclosedFieldKeepsSeparatorInsideStringValue) {
EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
"alice,team");
}
+// Bare quotes and escapes outside enclosed fields stay in NORMAL state in
+// EncloseCsvLineReaderCtx. Field slicing must reuse those exact separator
positions.
+TEST_F(CsvV2ReaderTest, EncloseFieldSplittingMatchesLineReaderStateMachine) {
+ const auto quoted_path = (_test_dir / "enclose_state.csv").string();
+ std::ofstream output(quoted_path, std::ios::binary);
+ output << "id,name,score\n";
+ output << "1,ab\"cd,20\n";
+ output << "2,C:\\dir\\,30,40\n";
+ output.close();
+
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
+ auto reader = create_reader(quoted_path, &_params, _slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(1)),
+
LocalColumnIndex::top_level(LocalColumnId(2))};
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(2), LocalIndex(1));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {1, 2});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 2);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
"ab\"cd");
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 0), 20);
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 1), 30);
+}
+
+// OpenCSV permits escape and enclose to use the same character. The shared
line-reader state
+// machine handles doubled quotes without treating every quote as a generic
escape.
+TEST_F(CsvV2ReaderTest, MatchingEscapeAndEncloseStillSplitQuotedFields) {
+ const auto quoted_path = (_test_dir /
"matching_escape_enclose.csv").string();
+ std::ofstream output(quoted_path, std::ios::binary);
+ output << "id,name,score\n";
+ output << "\"1\",\"alice\",\"10\"\n";
+ output.close();
+
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('"');
+ auto reader = create_reader(quoted_path, &_params, _slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0)),
+
LocalColumnIndex::top_level(LocalColumnId(1)),
+
LocalColumnIndex::top_level(LocalColumnId(2))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(1));
+ request->local_positions.emplace(LocalColumnId(2), LocalIndex(2));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0, 1, 2});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 0), 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(1).column, 0),
"alice");
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(2).column, 0), 10);
+}
+
+// OpenCSV custom quote characters only remove that configured enclosure.
Literal double quotes in
+// a field must remain when the configured enclosure is a single quote.
+TEST_F(CsvV2ReaderTest, CustomEnclosePreservesLiteralDoubleQuotes) {
+ const auto quoted_path = (_test_dir / "custom_enclose.csv").string();
+ std::ofstream output(quoted_path, std::ios::binary);
+ output << R"("Project Manager")" << '\n';
+ output.close();
+
+ auto value_slot = make_test_slot(&_pool, 10, 0,
+
make_nullable(std::make_shared<DataTypeString>()), "value");
+ std::vector<SlotDescriptor*> slots {value_slot};
+ _params.__set_column_idxs({0});
+ _params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_column_separator("\t");
+ _params.file_attributes.text_params.__set_enclose('\'');
+ _params.file_attributes.text_params.__set_escape('|');
+ auto reader = create_reader(quoted_path, &_params, slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
R"("Project Manager")");
+}
+
+// A column separator that overlaps the line delimiter must not be recorded
because CsvReader
+// splits a Slice that excludes the line delimiter bytes.
+TEST_F(CsvV2ReaderTest,
ColumnSeparatorOverlappingLineDelimiterStaysOutsideReturnedLine) {
+ const auto overlap_path = (_test_dir /
"overlapping_delimiters.csv").string();
+ std::ofstream output(overlap_path, std::ios::binary);
+ output << "value|\n";
+ output.close();
+
+ auto value_slot = make_test_slot(&_pool, 10, 0,
+
make_nullable(std::make_shared<DataTypeString>()), "value");
+ std::vector<SlotDescriptor*> slots {value_slot};
+ _params.__set_column_idxs({0});
+ _params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_column_separator("|\n");
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
+ auto reader = create_reader(overlap_path, &_params, slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
"value|");
+}
+
+// When a logical row is refilled, partial-separator backtracking must not
cross the end of a
+// separator that was already accepted from the previous buffer contents.
+TEST_F(CsvV2ReaderTest, MultiCharacterSeparatorAcrossOutputBufferRefill) {
+ const auto refill_path = (_test_dir / "separator_refill.csv").string();
+ std::ofstream output(refill_path, std::ios::binary);
+ output << std::string(28, 'a') << "|||||b\n";
+ output.close();
+
+ auto first_slot = make_test_slot(&_pool, 10, 0,
+
make_nullable(std::make_shared<DataTypeString>()), "first");
+ auto second_slot = make_test_slot(&_pool, 11, 1,
+
make_nullable(std::make_shared<DataTypeString>()), "second");
+ std::vector<SlotDescriptor*> slots {first_slot, second_slot};
+ _params.__set_column_idxs({0, 1});
+ _params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_column_separator("|||");
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
+
+ const bool enable_debug_points = config::enable_debug_points;
+ config::enable_debug_points = true;
+
DebugPoints::instance()->add_with_params("NewPlainTextLineReader.shrink_output_buf",
+ {{"output_buf_size", "32"}});
+ Defer restore_debug_point([enable_debug_points]() {
+
DebugPoints::instance()->remove("NewPlainTextLineReader.shrink_output_buf");
+ config::enable_debug_points = enable_debug_points;
+ });
+
+ auto reader = create_reader(refill_path, &_params, slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0)),
+
LocalColumnIndex::top_level(LocalColumnId(1))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(1));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0, 1});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
std::string(28, 'a'));
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(1).column, 0), "||b");
+}
+
// Scenario: when the CSV row has fewer fields than the FE-provided file slot
list, v2 fills the
// missing requested field with NULL instead of failing or shifting later
columns.
TEST_F(CsvV2ReaderTest, MissingRequestedFieldUsesNullFormat) {
@@ -845,6 +1029,8 @@ TEST_F(CsvV2ReaderTest,
BomIsRemovedFromFirstDataLineWithoutHeader) {
output.close();
_params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
auto reader = create_reader(bom_path, &_params, _slots, &_state,
&_profile);
std::vector<ColumnDefinition> schema;
ASSERT_TRUE(reader->get_schema(&schema).ok());
@@ -862,6 +1048,83 @@ TEST_F(CsvV2ReaderTest,
BomIsRemovedFromFirstDataLineWithoutHeader) {
EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 0), 5);
}
+// The enclosed line reader sees bytes before DelimitedTextReader removes the
BOM. It must skip the
+// BOM structurally so the quote immediately after it still starts an enclosed
first field and the
+// comma inside that field is not recorded as a column separator.
+TEST_F(CsvV2ReaderTest, BomBeforeQuotedFirstFieldWithComma) {
+ const auto bom_path = (_test_dir / "bom_quoted_comma.csv").string();
+ std::ofstream output(bom_path, std::ios::binary);
+ output.write("\xEF\xBB\xBF", 3);
+ output << "\"alice,team\",20\n";
+ output.close();
+
+ auto string_slot = make_test_slot(&_pool, 10, 0,
+
make_nullable(std::make_shared<DataTypeString>()), "name");
+ auto score_slot = make_test_slot(&_pool, 11, 1,
+
make_nullable(std::make_shared<DataTypeInt32>()), "score");
+ std::vector<SlotDescriptor*> slots {string_slot, score_slot};
+ _params.__set_column_idxs({0, 1});
+ _params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
+ auto reader = create_reader(bom_path, &_params, slots, &_state, &_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0)),
+
LocalColumnIndex::top_level(LocalColumnId(1))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(1));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0, 1});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
"alice,team");
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 0), 20);
+}
+
+// The same ordering matters before logical-row detection: a newline inside
the first enclosed
+// field belongs to the field, even when the opening quote follows a file BOM.
+TEST_F(CsvV2ReaderTest, BomBeforeQuotedFirstFieldWithNewline) {
+ const auto bom_path = (_test_dir / "bom_quoted_newline.csv").string();
+ std::ofstream output(bom_path, std::ios::binary);
+ output.write("\xEF\xBB\xBF", 3);
+ output << "\"alice\nteam\",20\n";
+ output.close();
+
+ auto string_slot = make_test_slot(&_pool, 10, 0,
+
make_nullable(std::make_shared<DataTypeString>()), "name");
+ auto score_slot = make_test_slot(&_pool, 11, 1,
+
make_nullable(std::make_shared<DataTypeInt32>()), "score");
+ std::vector<SlotDescriptor*> slots {string_slot, score_slot};
+ _params.__set_column_idxs({0, 1});
+ _params.file_attributes.__isset.header_type = false;
+ _params.file_attributes.text_params.__set_enclose('"');
+ _params.file_attributes.text_params.__set_escape('\\');
+ auto reader = create_reader(bom_path, &_params, slots, &_state, &_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(0)),
+
LocalColumnIndex::top_level(LocalColumnId(1))};
+ request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(1));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {0, 1});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
"alice\nteam");
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 0), 20);
+}
+
// Scenario: when FE does not set header_type, CSV v2 must honor skip_lines
exactly as the old
// reader does.
TEST_F(CsvV2ReaderTest, SkipLinesUsedWhenHeaderTypeUnset) {
@@ -951,6 +1214,37 @@ TEST_F(CsvV2ReaderTest,
NullFormatAndEmptyFieldAsNullProduceNullableValues) {
EXPECT_TRUE(is_null_at(*block.get_by_position(1).column, 0));
}
+// trim_double_quotes preserves quoted null literals as strings while an
unquoted marker remains
+// NULL, matching the V1 CSV reader's converted-from-string behavior.
+TEST_F(CsvV2ReaderTest, QuotedNullFormatIsAStringLiteral) {
+ const auto null_path = (_test_dir / "quoted_null_format.csv").string();
+ std::ofstream output(null_path, std::ios::binary);
+ output << "id,name,score\n";
+ output << "1,\"\\N\",10\n";
+ output << "2,\\N,20\n";
+ output.close();
+
+ _params.file_attributes.__set_trim_double_quotes(true);
+ _params.file_attributes.text_params.__set_null_format("\\N");
+ auto reader = create_reader(null_path, &_params, _slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(1))};
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(0));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {1});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 2);
+ EXPECT_FALSE(is_null_at(*block.get_by_position(0).column, 0));
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0), "\\N");
+ EXPECT_TRUE(is_null_at(*block.get_by_position(0).column, 1));
+}
+
// Scenario: OpenCSV keeps an empty field as an empty string when
empty_field_as_null is false,
// even if FE passes an empty null_format. This differs from Hive text serde,
where an empty
// serialization.null.format is a real NULL marker.
diff --git a/be/test/format_v2/delimited_text/text_reader_test.cpp
b/be/test/format_v2/delimited_text/text_reader_test.cpp
index b6402cab5d8..f5f7309e490 100644
--- a/be/test/format_v2/delimited_text/text_reader_test.cpp
+++ b/be/test/format_v2/delimited_text/text_reader_test.cpp
@@ -486,6 +486,35 @@ TEST_F(TextV2ReaderTest,
EscapedSeparatorStaysInsideStringField) {
EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 0), 10);
}
+// Hive LazySimpleSerDe treats an even-length escape run as escaped escape
characters, so the
+// following delimiter remains structural. V1 and V2 must both split the row
at that delimiter.
+TEST_F(TextV2ReaderTest, DoubleEscapeBeforeSeparatorStillSplitsField) {
+ const auto escaped_path = (_test_dir / "double_escaped.text").string();
+ std::ofstream output(escaped_path, std::ios::binary);
+ output << R"(1|alice\\|10)" << '\n';
+ output.close();
+
+ _params.file_attributes.text_params.__set_column_separator("|");
+ auto reader = create_reader(escaped_path, &_params, _slots, &_state,
&_profile);
+ std::vector<ColumnDefinition> schema;
+ ASSERT_TRUE(reader->get_schema(&schema).ok());
+
+ auto request = std::make_shared<FileScanRequest>();
+ request->non_predicate_columns =
{LocalColumnIndex::top_level(LocalColumnId(1)),
+
LocalColumnIndex::top_level(LocalColumnId(2))};
+ request->local_positions.emplace(LocalColumnId(1), LocalIndex(0));
+ request->local_positions.emplace(LocalColumnId(2), LocalIndex(1));
+ ASSERT_TRUE(reader->open(request).ok());
+
+ auto block = make_block(schema, {1, 2});
+ size_t rows = 0;
+ bool eof = false;
+ ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+ ASSERT_EQ(rows, 1);
+ EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0),
R"(alice\)");
+ EXPECT_EQ(nullable_int_at(*block.get_by_position(1).column, 0), 10);
+}
+
// Scenario: Hive text supports multi-character field separators. V2 must not
split on partial
// matches and must still honor FileScanRequest output positions.
TEST_F(TextV2ReaderTest, MultiCharacterSeparatorReadsRequestedColumns) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
index f936eccd59e..3ea39987d02 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
@@ -613,7 +613,7 @@ public class HiveScanNode extends FileQueryScanNode {
textParams.setNullFormat("");
fileAttributes.setTextParams(textParams);
fileAttributes.setHeaderType("");
- if (textParams.isSetEnclose()) {
+ if (shouldTrimDoubleQuotes(textParams)) {
fileAttributes.setTrimDoubleQuotes(true);
}
fileAttributes.setEnableTextValidateUtf8(
@@ -670,6 +670,10 @@ public class HiveScanNode extends FileQueryScanNode {
return fileAttributes;
}
+ static boolean shouldTrimDoubleQuotes(TFileTextScanRangeParams textParams)
{
+ return textParams.isSetEnclose() && textParams.getEnclose() == (byte)
'"';
+ }
+
@Override
protected TFileCompressType getFileCompressType(FileSplit fileSplit)
throws UserException {
TFileCompressType compressType = super.getFileCompressType(fileSplit);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
index e0c7b0464e0..db7a8abe024 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
@@ -28,6 +28,7 @@ import org.apache.doris.planner.PlanNodeId;
import org.apache.doris.planner.ScanContext;
import org.apache.doris.qe.SessionVariable;
import org.apache.doris.thrift.TFileScanRangeParams;
+import org.apache.doris.thrift.TFileTextScanRangeParams;
import com.google.common.collect.ImmutableMap;
import org.junit.Assert;
@@ -127,6 +128,16 @@ public class HiveScanNodeTest {
scanParams.getTableFormatParams().getTableFormatType());
}
+ @Test
+ public void testTrimDoubleQuotesOnlyForDoubleQuoteEnclose() {
+ TFileTextScanRangeParams textParams = new TFileTextScanRangeParams();
+ textParams.setEnclose((byte) '"');
+ Assert.assertTrue(HiveScanNode.shouldTrimDoubleQuotes(textParams));
+
+ textParams.setEnclose((byte) '\'');
+ Assert.assertFalse(HiveScanNode.shouldTrimDoubleQuotes(textParams));
+ }
+
private HiveScanNode createHiveScanNode() {
return createHiveScanNode(false);
}
diff --git a/regression-test/data/external_table_p0/tvf/csv_enclose_state.csv
b/regression-test/data/external_table_p0/tvf/csv_enclose_state.csv
new file mode 100644
index 00000000000..f6259106d54
--- /dev/null
+++ b/regression-test/data/external_table_p0/tvf/csv_enclose_state.csv
@@ -0,0 +1,3 @@
+id,name,score,extra
+1,ab"cd,20,x
+2,C:\dir\,30,40,y
diff --git
a/regression-test/data/external_table_p0/tvf/csv_matching_escape_enclose.csv
b/regression-test/data/external_table_p0/tvf/csv_matching_escape_enclose.csv
new file mode 100644
index 00000000000..adbafedf68e
--- /dev/null
+++ b/regression-test/data/external_table_p0/tvf/csv_matching_escape_enclose.csv
@@ -0,0 +1,2 @@
+id,name,score
+"1","alice","10"
diff --git a/regression-test/data/external_table_p0/tvf/csv_quoted_null.csv
b/regression-test/data/external_table_p0/tvf/csv_quoted_null.csv
new file mode 100644
index 00000000000..83c42bb7104
--- /dev/null
+++ b/regression-test/data/external_table_p0/tvf/csv_quoted_null.csv
@@ -0,0 +1,3 @@
+id,name,score
+1,"\N",10
+2,\N,20
diff --git
a/regression-test/data/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.out
b/regression-test/data/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.out
new file mode 100644
index 00000000000..b3b0f227aca
--- /dev/null
+++
b/regression-test/data/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.out
@@ -0,0 +1,12 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !enclose_state --
+1 20 x
+2 30 40
+
+-- !matching_escape_enclose --
+1 alice 10
+
+-- !quoted_null --
+1 5C4E false
+2 \N true
+
diff --git
a/regression-test/suites/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.groovy
b/regression-test/suites/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.groovy
new file mode 100644
index 00000000000..331faa8ddb3
--- /dev/null
+++
b/regression-test/suites/external_table_p0/tvf/test_local_tvf_csv_enclose_consistency.groovy
@@ -0,0 +1,73 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_local_tvf_csv_enclose_consistency", "p0,external") {
+ def backends = sql "show backends"
+ assertTrue(backends.size() > 0)
+ def beId = backends[0][0]
+ def dataPath = context.config.dataPath + "/external_table_p0/tvf"
+ def filePath = "/"
+
+ def files = [
+ "csv_enclose_state.csv",
+ "csv_matching_escape_enclose.csv",
+ "csv_quoted_null.csv"
+ ]
+ for (def backend : backends) {
+ for (def file : files) {
+ scpFiles("root", backend[1], "${dataPath}/${file}", filePath,
false)
+ }
+ }
+
+ sql "set enable_file_scanner_v2 = true"
+
+ order_qt_enclose_state """
+ select id, score, extra
+ from local(
+ "file_path" = "${filePath}/csv_enclose_state.csv",
+ "backend_id" = "${beId}",
+ "format" = "csv_with_names",
+ "column_separator" = ",",
+ "enclose" = "\\\"",
+ "escape" = "\\\\")
+ order by id
+ """
+
+ order_qt_matching_escape_enclose """
+ select id, name, score
+ from local(
+ "file_path" = "${filePath}/csv_matching_escape_enclose.csv",
+ "backend_id" = "${beId}",
+ "format" = "csv_with_names",
+ "column_separator" = ",",
+ "enclose" = "\\\"",
+ "escape" = "\\\"")
+ order by id
+ """
+
+ order_qt_quoted_null """
+ select id, hex(name), name is null
+ from local(
+ "file_path" = "${filePath}/csv_quoted_null.csv",
+ "backend_id" = "${beId}",
+ "format" = "csv_with_names",
+ "column_separator" = ",",
+ "trim_double_quotes" = "true",
+ "null_format" = "\\\\N")
+ order by id
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]