This is an automated email from the ASF dual-hosted git repository.
zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 51afe395b1 [GLUTEN-12597][CORE] Migrate ReadRel text read options to
Substrait 0.98 (TextReadOptions -> DelimiterSeparatedTextReadOptions) (#12917)
51afe395b1 is described below
commit 51afe395b1e1120538b24c57553b368556c40c7c
Author: Niels Pardon <[email protected]>
AuthorDate: Tue Oct 6 17:25:58 2026 +0200
[GLUTEN-12597][CORE] Migrate ReadRel text read options to Substrait 0.98
(TextReadOptions -> DelimiterSeparatedTextReadOptions) (#12917)
---
.../SubstraitSource/ExcelTextFormatFile.cpp | 9 +-
cpp-ch/local-engine/tests/gtest_parser.cpp | 2 +-
docs/developers/SubstraitModifications.md | 15 ++
.../gluten/substrait/rel/LocalFilesNode.java | 29 ++--
.../substrait/proto/substrait/algebra.proto | 49 ++++--
...limiterSeparatedTextReadOptionsProtoSuite.scala | 175 +++++++++++++++++++++
6 files changed, 255 insertions(+), 24 deletions(-)
diff --git
a/cpp-ch/local-engine/Storages/SubstraitSource/ExcelTextFormatFile.cpp
b/cpp-ch/local-engine/Storages/SubstraitSource/ExcelTextFormatFile.cpp
index c87facdd9b..a1c1c1c8c6 100644
--- a/cpp-ch/local-engine/Storages/SubstraitSource/ExcelTextFormatFile.cpp
+++ b/cpp-ch/local-engine/Storages/SubstraitSource/ExcelTextFormatFile.cpp
@@ -94,13 +94,16 @@ DB::FormatSettings
ExcelTextFormatFile::createFormatSettings() const
format_settings.csv.delimiter = *delimiter.data();
if (file_info.start() == 0)
- format_settings.csv.skip_first_lines = file_info.text().header();
+ format_settings.csv.skip_first_lines =
file_info.text().header_lines_to_skip();
if (delimiter == "\t" || delimiter == " ")
format_settings.csv.allow_whitespace_or_tab_as_delimiter = true;
- if (!file_info.text().null_value().empty())
- format_settings.csv.null_representation =
file_info.text().null_value();
+ /// `value_treated_as_null` is an `optional` field, so presence -- not
emptiness -- says whether
+ /// the producer asked for a null representation at all. An explicitly set
empty string means
+ /// "an empty field is NULL", which is not the same as the field being
absent.
+ if (file_info.text().has_value_treated_as_null())
+ format_settings.csv.null_representation =
file_info.text().value_treated_as_null();
bool empty_as_null = true;
if (context->getSettingsRef().has(EXCEL_EMPTY_AS_NULL))
diff --git a/cpp-ch/local-engine/tests/gtest_parser.cpp
b/cpp-ch/local-engine/tests/gtest_parser.cpp
index 60ef231480..94b78ff7dd 100644
--- a/cpp-ch/local-engine/tests/gtest_parser.cpp
+++ b/cpp-ch/local-engine/tests/gtest_parser.cpp
@@ -38,7 +38,7 @@ INCBIN(_readcsv_plan, SOURCE_DIR
"/utils/extern-local-engine/tests/json/read_stu
TEST(LocalExecutor, ReadCSV)
{
constexpr std::string_view split_template
- =
R"({"items":[{"uriFile":"{replace_local_files}","length":"56","text":{"fieldDelimiter":",","maxBlockSize":"8192","header":"1"},"schema":{"names":["id","name","language"],"struct":{"types":[{"string":{"nullability":"NULLABILITY_NULLABLE"}},{"string":{"nullability":"NULLABILITY_NULLABLE"}},{"string":{"nullability":"NULLABILITY_NULLABLE"}}]}},"metadataColumns":[{}]}]})";
+ =
R"({"items":[{"uriFile":"{replace_local_files}","length":"56","text":{"fieldDelimiter":",","maxBlockSize":"8192","headerLinesToSkip":"1"},"schema":{"names":["id","name","language"],"struct":{"types":[{"string":{"nullability":"NULLABILITY_NULLABLE"}},{"string":{"nullability":"NULLABILITY_NULLABLE"}},{"string":{"nullability":"NULLABILITY_NULLABLE"}}]}},"metadataColumns":[{}]}]})";
const std::string split = replaceLocalFilesWildcards(
split_template,
GLUTEN_SOURCE_URI("/backends-velox/src/test/resources/datasource/csv/student_option_schema.csv"));
auto plan =
local_engine::JsonStringToMessage<substrait::Plan>(EMBEDDED_PLAN(_readcsv_plan));
diff --git a/docs/developers/SubstraitModifications.md
b/docs/developers/SubstraitModifications.md
index 3849df7c04..3299c5f198 100644
--- a/docs/developers/SubstraitModifications.md
+++ b/docs/developers/SubstraitModifications.md
@@ -24,6 +24,9 @@ because the older practice of grafting local fields onto the
next free low numbe
## Modifications to algebra.proto
* Added `JsonReadOptions` and `TextReadOptions` in
`FileOrFiles`([#1584](https://github.com/apache/gluten/pull/1584)).
+`TextReadOptions` has since been rebased onto upstream's
`DelimiterSeparatedTextReadOptions`; the two knobs
+this PR added that are still Gluten-local, `max_block_size` and
`empty_as_default`, live at 1000/1001. See the
+rebase entry below.
* Changed join type `JOIN_TYPE_SEMI` to `JOIN_TYPE_LEFT_SEMI` and
`JOIN_TYPE_RIGHT_SEMI`([#408](https://github.com/apache/gluten/pull/408)).
* Added `WindowRel`, added `column_name` and `window_type` in `WindowFunction`,
changed `Unbounded` in `WindowFunction` into `Unbounded_Preceding` and
`Unbounded_Following`, and added
WindowType([#485](https://github.com/apache/gluten/pull/485)).
@@ -43,6 +46,18 @@ separate change. Note that Gluten attaches its writer
configuration to `named_ta
to the new top-level `WriteRel.advanced_extension`, which no Gluten code
reads. `WriteRel.common` uses
Gluten's pre-0.98 `RelCommon` copy (missing `rel_anchor`, `Hint.alias`,
`Hint.output_names` and the
saved/loaded computation messages), so it cannot carry a full 0.98 `common`
payload.
+* Rebased the text read options in `FileOrFiles` onto upstream `v0.98.0`:
`TextReadOptions` is now
+`DelimiterSeparatedTextReadOptions`, adopting upstream's fields verbatim
(`field_delimiter` 1, `max_line_size`
+2, `quote` 3, `header_lines_to_skip` 4, `escape` 5, `value_treated_as_null`
6). `header_lines_to_skip` and
+`value_treated_as_null` are the old `header` (was 5) and `null_value` (was 7)
renamed, and the deprecated
+`schema` field is dropped. Gluten's two local knobs are relocated to the 1000
range: `max_block_size` to 1000
+(rows per output block -- distinct from upstream's byte-oriented
`max_line_size`, so the two are kept as
+separate fields) and `empty_as_default` to 1001. The `file_format` oneof tag
stays at 14, which is also
+upstream's number for it. Note that the reused tags kept their old wire types,
so a JAR and a native library
+built from opposite sides of this change mis-read each other silently rather
than failing -- in particular old
+tag 6 (`escape`, which Gluten's Hive text path always sets) now parses as
`value_treated_as_null` -- and the two must be
+rebuilt together. `JsonReadOptions` keeps its Gluten-local fork until upstream
adds equivalent JSON read
+options.
## Modifications to type.proto
diff --git
a/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/LocalFilesNode.java
b/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/LocalFilesNode.java
index 5070a88581..3179896985 100644
---
a/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/LocalFilesNode.java
+++
b/gluten-substrait/src/main/java/org/apache/gluten/substrait/rel/LocalFilesNode.java
@@ -297,17 +297,24 @@ public class LocalFilesNode implements SplitInfo {
String header = fileReadProperties.getOrDefault("header", "0");
String escape = fileReadProperties.getOrDefault("escape", "");
String nullValue = fileReadProperties.getOrDefault("nullValue", "");
- ReadRel.LocalFiles.FileOrFiles.TextReadOptions textReadOptions =
- ReadRel.LocalFiles.FileOrFiles.TextReadOptions.newBuilder()
- .setFieldDelimiter(field_delimiter)
- .setQuote(quote)
- .setHeader(Long.parseLong(header))
- .setEscape(escape)
- .setNullValue(nullValue)
- .setMaxBlockSize(GlutenConfig.get().textInputMaxBlockSize())
-
.setEmptyAsDefault(GlutenConfig.get().textIputEmptyAsDefault())
- .build();
- fileBuilder.setText(textReadOptions);
+ GlutenConfig textConfig = GlutenConfig.get();
+
ReadRel.LocalFiles.FileOrFiles.DelimiterSeparatedTextReadOptions.Builder
+ textReadOptionsBuilder =
+
ReadRel.LocalFiles.FileOrFiles.DelimiterSeparatedTextReadOptions.newBuilder()
+ .setFieldDelimiter(field_delimiter)
+ .setQuote(quote)
+ .setHeaderLinesToSkip(Long.parseLong(header))
+ .setEscape(escape)
+ .setMaxBlockSize(textConfig.textInputMaxBlockSize())
+ .setEmptyAsDefault(textConfig.textIputEmptyAsDefault());
+ // `value_treated_as_null` is an `optional` field: setting it marks
the value as present
+ // even when it is the empty string, which upstream defines as "the
empty string is NULL
+ // and the file is entirely nullable strings". Leave it unset when
no nullValue was given
+ // so that the default stays "disabled".
+ if (!nullValue.isEmpty()) {
+ textReadOptionsBuilder.setValueTreatedAsNull(nullValue);
+ }
+ fileBuilder.setText(textReadOptionsBuilder.build());
break;
case JsonReadFormat:
ReadRel.LocalFiles.FileOrFiles.JsonReadOptions jsonReadOptions =
diff --git
a/gluten-substrait/src/main/resources/substrait/proto/substrait/algebra.proto
b/gluten-substrait/src/main/resources/substrait/proto/substrait/algebra.proto
index d8cd02bbc5..a698b297b6 100644
---
a/gluten-substrait/src/main/resources/substrait/proto/substrait/algebra.proto
+++
b/gluten-substrait/src/main/resources/substrait/proto/substrait/algebra.proto
@@ -160,15 +160,46 @@ message ReadRel {
message ArrowReadOptions {}
message OrcReadOptions {}
message DwrfReadOptions {}
- message TextReadOptions {
+ message DelimiterSeparatedTextReadOptions {
+ // Delimiter separated files may be compressed. The reader should
+ // autodetect this and decompress as needed.
+
+ // The character(s) used to separate fields. Common values are comma,
+ // tab, and pipe. Multiple characters are allowed.
string field_delimiter = 1;
- uint64 max_block_size = 2;
- NamedStruct schema = 3 [deprecated=true];
- string quote = 4;
- uint64 header = 5;
- string escape = 6;
- string null_value = 7;
- bool empty_as_default = 8;
+ // The maximum number of bytes to read from a single line. If a line
+ // exceeds this limit the resulting behavior is undefined.
+ uint64 max_line_size = 2;
+ // The character(s) used to quote strings. Common values are single
+ // and double quotation marks.
+ string quote = 3;
+ // The number of lines to skip at the beginning of the file.
+ uint64 header_lines_to_skip = 4;
+ // The character used to escape characters in strings. Backslash is
+ // a common value. Note that a double quote mark can also be used as
an
+ // escape character but the external quotes should be removed first.
+ string escape = 5;
+ // If this value is encountered (including empty string), the resulting
+ // value is null instead. Leave unset to disable. If this value is
+ // provided, the effective schema of this file is comprised entirely of
+ // nullable strings. If not provided, the effective schema is instead
+ // made up of non-nullable strings.
+ optional string value_treated_as_null = 6;
+
+ // WARNING: this message is wire-incompatible with the older
`TextReadOptions` it
+ // replaces, and the reused tags kept compatible wire types, so the
mismatch is SILENT
+ // rather than an error. Most damaging: tag 6 is now
`value_treated_as_null` but was
+ // `escape`, which Gluten's Hive text path always sets -- a stale
producer read by a
+ // current consumer therefore turns every escape character into a null
representation.
+ // Build the JAR and the native library from the same revision.
+
+ // Gluten-local knobs, numbered from 1000 per the convention documented
+ // in docs/developers/SubstraitModifications.md.
+ // Rows per output block for the native reader -- a batching knob,
+ // distinct from upstream's byte-oriented max_line_size above.
+ uint64 max_block_size = 1000;
+ // Treat an empty unquoted field as the column default value.
+ bool empty_as_default = 1001;
}
message JsonReadOptions {
uint64 max_block_size = 1;
@@ -240,7 +271,7 @@ message ReadRel {
OrcReadOptions orc = 11;
google.protobuf.Any extension = 12;
DwrfReadOptions dwrf = 13;
- TextReadOptions text = 14;
+ DelimiterSeparatedTextReadOptions text = 14;
JsonReadOptions json = 15;
IcebergReadOptions iceberg = 16;
DeltaReadOptions delta = 22;
diff --git
a/gluten-substrait/src/test/scala/org/apache/gluten/substrait/rel/DelimiterSeparatedTextReadOptionsProtoSuite.scala
b/gluten-substrait/src/test/scala/org/apache/gluten/substrait/rel/DelimiterSeparatedTextReadOptionsProtoSuite.scala
new file mode 100644
index 0000000000..35c58f4d07
--- /dev/null
+++
b/gluten-substrait/src/test/scala/org/apache/gluten/substrait/rel/DelimiterSeparatedTextReadOptionsProtoSuite.scala
@@ -0,0 +1,175 @@
+/*
+ * 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.
+ */
+package org.apache.gluten.substrait.rel
+
+import org.apache.gluten.config.GlutenConfig
+
+import com.google.protobuf.Descriptors.{Descriptor, FieldDescriptor}
+import io.substrait.proto.ReadRel
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.util.{Collections, Map => JMap}
+
+/**
+ * Pins the wire tags of the vendored `DelimiterSeparatedTextReadOptions`.
Producer and consumer
+ * share one schema, so a renumber or a field rename round-trips cleanly
through the generated
+ * classes and cannot be caught by exercising them; these assert on the
descriptors instead.
+ *
+ * The critical guard is that Gluten's `max_block_size` (rows per output
block) and the standard
+ * `max_line_size` (bytes per line) stay two distinct fields: `max_block_size`
lives in the 1000
+ * graft range, well clear of `max_line_size` on tag 2. Wiring the row count
onto tag 2 would
+ * compile but silently change its meaning.
+ *
+ * The producer tests at the end cover what descriptors cannot:
`value_treated_as_null` is
+ * `optional`, so setting it -- even to the empty string -- declares "this
value is NULL" and makes
+ * the whole file nullable strings. `LocalFilesNode` must therefore leave it
unset when the reader
+ * supplied no `nullValue`.
+ */
+class DelimiterSeparatedTextReadOptionsProtoSuite extends AnyFunSuite {
+
+ private def fileOrFiles: Descriptor =
+ ReadRel.LocalFiles.FileOrFiles.getDescriptor
+
+ private def textOptions: Descriptor =
+
ReadRel.LocalFiles.FileOrFiles.DelimiterSeparatedTextReadOptions.getDescriptor
+
+ private def field(name: String, descriptor: Descriptor): FieldDescriptor = {
+ val f = descriptor.findFieldByName(name)
+ assert(f != null, s"${descriptor.getName} has no field named $name")
+ f
+ }
+
+ /** Builds a text split through the real producer so the emitted options can
be inspected. */
+ private def producedTextOptions(
+ properties: JMap[String, String]
+ ): ReadRel.LocalFiles.FileOrFiles.DelimiterSeparatedTextReadOptions = {
+ val node = LocalFilesBuilder.makeLocalFiles(
+ Integer.valueOf(0),
+ Collections.singletonList("file:///tmp/students.csv"),
+ Collections.singletonList(java.lang.Long.valueOf(0L)),
+ Collections.singletonList(java.lang.Long.valueOf(56L)),
+ Collections.emptyList[java.lang.Long](),
+ Collections.emptyList[java.lang.Long](),
+ Collections.singletonList[JMap[String,
String]](Collections.emptyMap[String, String]()),
+ Collections.emptyList[JMap[String, String]](),
+ LocalFilesNode.ReadFileFormat.TextReadFormat,
+ Collections.emptyList[String](),
+ properties,
+ Collections.emptyList[JMap[String, Object]]()
+ )
+ node.toProtobuf.getItems(0).getText
+ }
+
+ test("the message was renamed to DelimiterSeparatedTextReadOptions") {
+ assert(
+ fileOrFiles.findNestedTypeByName("DelimiterSeparatedTextReadOptions") !=
null,
+ "FileOrFiles must declare DelimiterSeparatedTextReadOptions")
+ assert(
+ fileOrFiles.findNestedTypeByName("TextReadOptions") === null,
+ "the old TextReadOptions message name must be gone")
+ }
+
+ test("the standard read-option fields carry their expected tags") {
+ assert(field("field_delimiter", textOptions).getNumber === 1)
+ assert(field("max_line_size", textOptions).getNumber === 2)
+ assert(field("quote", textOptions).getNumber === 3)
+ assert(field("header_lines_to_skip", textOptions).getNumber === 4)
+ assert(field("escape", textOptions).getNumber === 5)
+ assert(field("value_treated_as_null", textOptions).getNumber === 6)
+ // value_treated_as_null is `optional`, so it tracks presence.
+ assert(
+ field("value_treated_as_null", textOptions).hasPresence,
+ "value_treated_as_null must stay an optional (presence-tracking) field")
+ }
+
+ test("Gluten-local knobs live in the 1000 graft range, distinct from
max_line_size") {
+ // The trap: max_block_size (rows per block) must not sit on tag 2, which
belongs to
+ // max_line_size (bytes per line). Conflating them compiles but silently
changes meaning.
+ assert(
+ field("max_block_size", textOptions).getNumber === 1000,
+ "max_block_size (rows per block) must be grafted at 1000, NOT conflated
with max_line_size")
+ assert(field("empty_as_default", textOptions).getNumber === 1001)
+ }
+
+ test("the renamed and dropped fork field names are gone") {
+ assert(
+ textOptions.findFieldByName("header") === null,
+ "header was renamed to header_lines_to_skip")
+ assert(
+ textOptions.findFieldByName("null_value") === null,
+ "null_value was renamed to value_treated_as_null")
+ assert(
+ textOptions.findFieldByName("schema") === null,
+ "the deprecated schema field was dropped")
+ }
+
+ test("the file_format oneof still exposes text on tag 14 with the renamed
message type") {
+ val text = field("text", fileOrFiles)
+ assert(text.getNumber === 14, "FileOrFiles.text changed its number")
+ assert(
+ text.getContainingOneof != null && text.getContainingOneof.getName ===
"file_format",
+ "FileOrFiles.text must stay in the file_format oneof")
+ assert(
+ text.getMessageType.getName === "DelimiterSeparatedTextReadOptions",
+ "FileOrFiles.text must hold DelimiterSeparatedTextReadOptions")
+ }
+
+ test("LocalFilesNode maps the CSV read properties onto the renamed fields") {
+ val properties = new java.util.HashMap[String, String]()
+ properties.put("field_delimiter", ",")
+ properties.put("quote", "\"")
+ properties.put("header", "2")
+ properties.put("escape", "\\")
+ val opts = producedTextOptions(properties)
+
+ assert(opts.getFieldDelimiter === ",")
+ assert(opts.getQuote === "\"")
+ assert(opts.getHeaderLinesToSkip === 2L)
+ assert(opts.getEscape === "\\")
+ // Gluten always drives this from its own config, never from the read
properties. Compare
+ // against the config rather than a fixed value so the assertion cannot
depend on whatever
+ // SQLConf happens to be active when the suite runs.
+ assert(
+ opts.getMaxBlockSize === GlutenConfig.get.textInputMaxBlockSize,
+ "max_block_size must come from GlutenConfig")
+ assert(
+ !opts.getEmptyAsDefault,
+ "empty_as_default must come from GlutenConfig, whose default is false")
+ // The mirror image, and the regression this suite exists to prevent: the
row count must never
+ // be wired into the byte-oriented max_line_size on tag 2. Gluten produces
no value for it at
+ // all.
+ assert(opts.getMaxLineSize === 0L, "Gluten must leave max_line_size unset")
+ }
+
+ test("LocalFilesNode leaves value_treated_as_null unset when no nullValue
was supplied") {
+ val opts = producedTextOptions(new java.util.HashMap[String, String]())
+ // Setting the field -- even to "" -- means "this value is NULL and the
file is entirely
+ // nullable strings" (see the field comment in algebra.proto). Absent must
stay absent.
+ assert(
+ !opts.hasValueTreatedAsNull,
+ "value_treated_as_null must stay absent when the reader gave no
nullValue")
+ }
+
+ test("LocalFilesNode sets value_treated_as_null when a nullValue was
supplied") {
+ val properties = new java.util.HashMap[String, String]()
+ properties.put("nullValue", "NULL")
+ val opts = producedTextOptions(properties)
+
+ assert(opts.hasValueTreatedAsNull)
+ assert(opts.getValueTreatedAsNull === "NULL")
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]