This is an automated email from the ASF dual-hosted git repository. RocMarshal pushed a commit to branch release-2.4 in repository https://gitbox.apache.org/repos/asf/flink.git
commit c6b2757cf12fcfbe99d42e2866fefb91cfa6b827 Author: Ramin Gharib <[email protected]> AuthorDate: Tue Nov 3 11:44:38 2026 +0100 [FLINK-41305][table-runtime] Support the raw line delimiter only for a string column and split UTF-8 on its bytes With 'raw.line-delimiter', the raw format split a message of any column type. It decoded the whole message with 'raw.charset', split the text and encoded each part again before converting it. Decoding turned invalid UTF-8 into U+FFFD, so a STRING column lost bytes that it keeps without a delimiter, and a VARIANT column accepted bytes that PARSE_JSON rejects. Splitting the bytes of a number or a BYTES value is not meaningful either, because those bytes can contain the delimiter. 'raw.line-delimiter' is now supported only for a CHAR, VARCHAR or STRING column. Any other column type fails with a ValidationException when a query reads or writes the table. Newline-delimited JSON can be read through a STRING column and PARSE_JSON. If 'raw.charset' is UTF-8, the message is split on the UTF-8 bytes of the delimiter and never decoded. In UTF-8 a delimiter's bytes can only match at a character boundary, so valid UTF-8 splits into the same parts as before. Other charsets keep the decoded split, because their delimiter bytes can match inside a character, as '|' does in GBK. An empty delimiter keeps it too, because it splits between characters. Generated-by: Claude Code 2.1.316 (Claude Opus 5.5) (cherry picked from commit 33a8167b49a17c6f45e0e3f864c9de859aa36430) --- .../docs/connectors/table/formats/raw.md | 11 +- docs/content/docs/connectors/table/formats/raw.md | 12 +- .../raw/RawFormatDeserializationSchema.java | 128 ++++++++++++++++----- .../apache/flink/formats/raw/RawFormatFactory.java | 19 +++ .../table/formats/raw/RawFormatFactoryTest.java | 26 +++++ .../formats/raw/RawFormatLineDelimiterTest.java | 91 ++++++++------- 6 files changed, 204 insertions(+), 83 deletions(-) diff --git a/docs/content.zh/docs/connectors/table/formats/raw.md b/docs/content.zh/docs/connectors/table/formats/raw.md index 02c32567938..1d728c3fadb 100644 --- a/docs/content.zh/docs/connectors/table/formats/raw.md +++ b/docs/content.zh/docs/connectors/table/formats/raw.md @@ -110,8 +110,11 @@ Format 参数 <td>可选</td> <td style="word-wrap: break-word;">(无)</td> <td>String</td> - <td>指定行分隔符,用于在反序列化时将一条消息拆分为多行。设置后,每条消息将使用 - 'raw.charset' 解码,并按此分隔符切分,每段输出一条数据行。在序列化时,分隔符字节会被 + <td>指定行分隔符,用于在反序列化时将一条消息拆分为多行。Only supported for a <code>STRING</code>, + <code>VARCHAR</code> or <code>CHAR</code> column. When set, each incoming message is split by + this delimiter; one row is emitted per segment. If 'raw.charset' is UTF-8, the message is split on + the UTF-8 bytes of the delimiter without being decoded, so each segment keeps its original bytes. + Otherwise the message is decoded using 'raw.charset' before it is split. 在序列化时,分隔符字节会被 追加到每条序列化值的末尾。常用值为 '\n'(换行符)或 '||'。 <br><strong>注意:</strong>当序列化与反序列化使用相同的分隔符配置时,两者具有 round-trip 兼容性:序列化器追加的末尾分隔符会在反序列化时自动被去除。</td> @@ -191,6 +194,6 @@ Format 参数 </tbody> </table> -Note: combining `VARIANT` with `raw.line-delimiter` gives you newline-delimited JSON, where each line of a message -becomes one row. +Note: a `VARIANT` column holds one JSON document per message. If a message holds more, only the first is read. To read +newline-delimited JSON, declare the column as `STRING` with `raw.line-delimiter` and convert each row with `PARSE_JSON`. diff --git a/docs/content/docs/connectors/table/formats/raw.md b/docs/content/docs/connectors/table/formats/raw.md index d7f2dd256d8..2f183e53f44 100644 --- a/docs/content/docs/connectors/table/formats/raw.md +++ b/docs/content/docs/connectors/table/formats/raw.md @@ -111,8 +111,12 @@ Format Options <td style="word-wrap: break-word;">(none)</td> <td>String</td> <td>Specify the line delimiter for splitting incoming messages into multiple rows during - deserialization. When set, each incoming message is decoded using 'raw.charset' and then split - by this delimiter; one row is emitted per segment. During serialization, the delimiter bytes + deserialization. Only supported for a <code>STRING</code>, <code>VARCHAR</code> or <code>CHAR</code> + column. When set, each incoming message is split by this delimiter; one row is emitted + per segment. If 'raw.charset' is UTF-8, the message is split on the UTF-8 bytes of the delimiter + without being decoded, so each segment keeps its original bytes. Otherwise the message is decoded + using 'raw.charset' before it is split. + During serialization, the delimiter bytes are appended to each serialized value. Common values are '\n' (newline) or '||'. <br><strong>Note:</strong> When the same delimiter is configured for both serialization and deserialization, the two are round-trip compatible: a trailing delimiter appended by the @@ -193,6 +197,6 @@ The table below details the SQL types the format supports, including details of </tbody> </table> -Note: combining `VARIANT` with `raw.line-delimiter` gives you newline-delimited JSON, where each line of a message -becomes one row. +Note: a `VARIANT` column holds one JSON document per message. If a message holds more, only the first is read. To read +newline-delimited JSON, declare the column as `STRING` with `raw.line-delimiter` and convert each row with `PARSE_JSON`. diff --git a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatDeserializationSchema.java b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatDeserializationSchema.java index e6e894b5c61..b89881eee49 100644 --- a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatDeserializationSchema.java +++ b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatDeserializationSchema.java @@ -39,7 +39,9 @@ import java.io.IOException; import java.io.Serializable; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Objects; +import java.util.function.Consumer; import java.util.regex.Pattern; import static org.apache.flink.util.Preconditions.checkNotNull; @@ -63,12 +65,6 @@ public class RawFormatDeserializationSchema implements DeserializationSchema<Row @Nullable private final String lineDelimiter; - /** - * Pre-compiled pattern for splitting by {@link #lineDelimiter}, or {@code null} if no - * delimiter. Note: this field and {@link #lineDelimiter} are either both null or both non-null. - */ - @Nullable private final Pattern lineDelimiterPattern; - private final DeserializationRuntimeConverter converter; private final DataLengthValidator validator; @@ -89,13 +85,11 @@ public class RawFormatDeserializationSchema implements DeserializationSchema<Row @Nullable String lineDelimiter) { this.deserializedType = checkNotNull(deserializedType); this.producedTypeInfo = checkNotNull(producedTypeInfo); - this.converter = createConverter(deserializedType, charsetName, isBigEndian); + this.converter = createConverter(deserializedType, charsetName, isBigEndian, lineDelimiter); this.validator = createDataLengthValidator(deserializedType); this.charsetName = charsetName; this.isBigEndian = isBigEndian; this.lineDelimiter = lineDelimiter; - this.lineDelimiterPattern = - lineDelimiter != null ? Pattern.compile(Pattern.quote(lineDelimiter)) : null; } @Override @@ -133,24 +127,13 @@ public class RawFormatDeserializationSchema implements DeserializationSchema<Row return; } - Charset charset = Charset.forName(charsetName); - String decoded = new String(message, charset); - // Use pre-compiled pattern. Split with -1 to keep intentional empty middle segments, - // but strip the single trailing empty string produced when the message ends with the - // delimiter (e.g. a serializer that appends one delimiter per row). - String[] parts = lineDelimiterPattern.split(decoded, -1); - int count = parts.length; - if (count > 0 && parts[count - 1].isEmpty()) { - count--; - } - for (int i = 0; i < count; i++) { - byte[] partBytes = parts[i].getBytes(charset); - validator.validate(partBytes); - Object field = converter.convert(partBytes); - GenericRowData rowData = new GenericRowData(1); - rowData.setField(0, field); - out.collect(rowData); - } + converter.convertLines( + message, + field -> { + final GenericRowData rowData = new GenericRowData(1); + rowData.setField(0, field); + out.collect(rowData); + }); } @Override @@ -194,16 +177,29 @@ public class RawFormatDeserializationSchema implements DeserializationSchema<Row default void open() {} Object convert(byte[] data) throws IOException; + + /** + * Converts a message that holds one value per line. Only a string column takes a line + * delimiter, so only the string converter splits the message. + */ + default void convertLines(byte[] message, Consumer<Object> out) throws IOException { + out.accept(convert(message)); + } } /** Creates a runtime converter. */ private static DeserializationRuntimeConverter createConverter( - LogicalType type, String charsetName, boolean isBigEndian) { + LogicalType type, + String charsetName, + boolean isBigEndian, + @Nullable String lineDelimiter) { switch (type.getTypeRoot()) { case CHAR: case VARCHAR: - return createStringConverter(charsetName); + return lineDelimiter == null + ? createStringConverter(charsetName) + : createDelimitedStringConverter(charsetName, lineDelimiter); case VARBINARY: case BINARY: @@ -283,6 +279,80 @@ public class RawFormatDeserializationSchema implements DeserializationSchema<Row }; } + /** + * Creates a converter for a string column with a line delimiter, which turns a message into one + * string per line. In UTF-8 the bytes of a delimiter can only match at a character boundary, so + * the message is split without decoding it. In other charsets they can match inside a + * character, and an empty delimiter splits between characters, so both decode the message + * first. + */ + private static DeserializationRuntimeConverter createDelimitedStringConverter( + final String charsetName, final String lineDelimiter) { + final DeserializationRuntimeConverter stringConverter = createStringConverter(charsetName); + final boolean splitBytes = + !lineDelimiter.isEmpty() + && StandardCharsets.UTF_8.equals(Charset.forName(charsetName)); + final byte[] delimiter = lineDelimiter.getBytes(StandardCharsets.UTF_8); + final Pattern pattern = Pattern.compile(Pattern.quote(lineDelimiter)); + return new DeserializationRuntimeConverter() { + private static final long serialVersionUID = 1L; + private transient Charset charset; + + @Override + public void open() { + stringConverter.open(); + charset = Charset.forName(charsetName); + } + + @Override + public Object convert(byte[] data) throws IOException { + return stringConverter.convert(data); + } + + @Override + public void convertLines(byte[] message, Consumer<Object> out) throws IOException { + if (splitBytes) { + int start = 0; + int end; + while ((end = indexOf(message, start)) >= 0) { + out.accept(convert(Arrays.copyOfRange(message, start, end))); + start = end + delimiter.length; + } + // Like the decoded split below, drop the empty part after a trailing delimiter. + if (start < message.length) { + out.accept(convert(Arrays.copyOfRange(message, start, message.length))); + } + return; + } + // Split with -1 to keep intentional empty middle segments, but strip the single + // trailing empty string produced when the message ends with the delimiter (e.g. a + // serializer that appends one delimiter per row). + final String[] parts = pattern.split(new String(message, charset), -1); + int count = parts.length; + if (count > 0 && parts[count - 1].isEmpty()) { + count--; + } + for (int i = 0; i < count; i++) { + out.accept(convert(parts[i].getBytes(charset))); + } + } + + /** Returns the position of the next delimiter at or after {@code from}, or -1. */ + private int indexOf(byte[] message, int from) { + for (int i = from; i <= message.length - delimiter.length; i++) { + int j = 0; + while (j < delimiter.length && message[i + j] == delimiter[j]) { + j++; + } + if (j == delimiter.length) { + return i; + } + } + return -1; + } + }; + } + /** * Creates a converter that parses UTF-8 JSON bytes into a {@link Variant} like {@code * PARSE_JSON}, so invalid UTF-8 and duplicate object keys are rejected. diff --git a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatFactory.java b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatFactory.java index 59fd275ba6c..bf2899d64ec 100644 --- a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatFactory.java +++ b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/formats/raw/RawFormatFactory.java @@ -37,6 +37,7 @@ import org.apache.flink.table.factories.FactoryUtil; import org.apache.flink.table.factories.SerializationFormatFactory; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeFamily; import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.flink.table.types.logical.RowType; @@ -93,6 +94,7 @@ public class RawFormatFactory implements DeserializationFormatFactory, Serializa DynamicTableSource.Context context, DataType producedDataType) { final RowType physicalRowType = (RowType) producedDataType.getLogicalType(); final LogicalType fieldType = validateAndExtractSingleField(physicalRowType); + checkLineDelimiter(fieldType, lineDelimiter); checkVariantCharset(fieldType, charsetName); final TypeInformation<RowData> producedTypeInfo = context.createTypeInformation(producedDataType); @@ -126,6 +128,7 @@ public class RawFormatFactory implements DeserializationFormatFactory, Serializa DynamicTableSink.Context context, DataType consumedDataType) { final RowType physicalRowType = (RowType) consumedDataType.getLogicalType(); final LogicalType fieldType = validateAndExtractSingleField(physicalRowType); + checkLineDelimiter(fieldType, lineDelimiter); checkVariantCharset(fieldType, charsetName); return new RawFormatSerializationSchema( fieldType, charsetName, isBigEndian, lineDelimiter.orElse(null)); @@ -166,6 +169,22 @@ public class RawFormatFactory implements DeserializationFormatFactory, Serializa } } + /** + * Checks that a line delimiter is only set for a string column. The bytes of other types can + * contain the delimiter, so splitting them would cut a value apart. + */ + private static void checkLineDelimiter(LogicalType fieldType, Optional<String> lineDelimiter) { + if (lineDelimiter.isPresent() && !fieldType.is(LogicalTypeFamily.CHARACTER_STRING)) { + throw new ValidationException( + String.format( + "The 'raw' format supports '%s.%s' only for a string column, " + + "but the column type is '%s'.", + IDENTIFIER, + RawFormatOptions.LINE_DELIMITER.key(), + fieldType.asSummaryString())); + } + } + /** * Checks that a VARIANT column uses UTF-8, which RFC 8259 requires for JSON exchanged between * systems and which {@code PARSE_JSON} reads. diff --git a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatFactoryTest.java b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatFactoryTest.java index 11bf1b74d52..92e95efa688 100644 --- a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatFactoryTest.java +++ b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatFactoryTest.java @@ -33,13 +33,17 @@ import org.apache.flink.table.factories.TestDynamicTableFactory; import org.apache.flink.table.runtime.connector.sink.SinkRuntimeProviderContext; import org.apache.flink.table.runtime.connector.source.ScanRuntimeProviderContext; import org.apache.flink.table.runtime.typeutils.InternalTypeInfo; +import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.RowType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Stream; import static java.nio.charset.StandardCharsets.UTF_16; import static java.nio.charset.StandardCharsets.UTF_8; @@ -217,6 +221,28 @@ class RawFormatFactoryTest { .hasMessage(expectedError); } + @ParameterizedTest + @MethodSource("nonStringTypes") + void testLineDelimiterRequiresStringColumn(final DataType type) { + final ResolvedSchema schema = ResolvedSchema.of(Column.physical("field1", type)); + final Map<String, String> tableOptions = + getModifiedOptions(options -> options.put("raw.line-delimiter", "\n")); + final String expectedError = + String.format( + "The 'raw' format supports 'raw.line-delimiter' only for a string column, " + + "but the column type is '%s'.", + type.getLogicalType().asSummaryString()); + + assertThatThrownBy(() -> createDeserializationSchema(schema, tableOptions)) + .hasMessage(expectedError); + assertThatThrownBy(() -> createSerializationSchema(schema, tableOptions)) + .hasMessage(expectedError); + } + + static Stream<DataType> nonStringTypes() { + return Stream.of(DataTypes.VARIANT(), DataTypes.BYTES(), DataTypes.INT()); + } + @Test void testLineDelimiterOption() { final Map<String, String> tableOptions = diff --git a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatLineDelimiterTest.java b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatLineDelimiterTest.java index e12a28f958d..0c138ab33e1 100644 --- a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatLineDelimiterTest.java +++ b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/formats/raw/RawFormatLineDelimiterTest.java @@ -30,19 +30,17 @@ import org.apache.flink.table.data.StringData; import org.apache.flink.table.types.logical.VarCharType; import org.apache.flink.table.types.logical.VariantType; import org.apache.flink.types.DeserializationException; -import org.apache.flink.types.variant.BinaryVariantInternalBuilder; import org.apache.flink.util.Collector; +import org.apache.flink.util.InstantiationUtil; import org.apache.flink.util.UserCodeClassLoader; import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonParseException; import org.junit.jupiter.api.Test; -import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -80,13 +78,15 @@ class RawFormatLineDelimiterTest { @Test void testDeserializeWithNewlineDelimiter_multipleRows() throws Exception { + // Cloned as Flink ships the schema, which also checks that the UTF-8 splitter serializes. RawFormatDeserializationSchema schema = - new RawFormatDeserializationSchema( - STRING_TYPE, - TypeInformation.of(RowData.class), - StandardCharsets.UTF_8.name(), - true, - "\n"); + InstantiationUtil.clone( + new RawFormatDeserializationSchema( + STRING_TYPE, + TypeInformation.of(RowData.class), + StandardCharsets.UTF_8.name(), + true, + "\n")); openDeser(schema); byte[] message = "line1\nline2\nline3".getBytes(StandardCharsets.UTF_8); @@ -134,20 +134,41 @@ class RawFormatLineDelimiterTest { @Test void testDeserializeWithGbkCharset() throws Exception { Charset gbk = Charset.forName("GBK"); - String original = "你好\n世界"; + // The GBK encoding of 亅 is 0x81 0x7C, so its second byte equals the delimiter '|'. + String original = "你好亅|世界"; byte[] message = original.getBytes(gbk); + // Cloned as Flink ships the schema, which also checks that the decoding splitter + // serializes. RawFormatDeserializationSchema schema = - new RawFormatDeserializationSchema( - STRING_TYPE, TypeInformation.of(RowData.class), "GBK", true, "\n"); + InstantiationUtil.clone( + new RawFormatDeserializationSchema( + STRING_TYPE, TypeInformation.of(RowData.class), "GBK", true, "|")); openDeser(schema); List<RowData> rows = collectRows(schema, message); assertThat(rows).hasSize(2); - assertThat(rows.get(0).getString(0)).hasToString("你好"); + assertThat(rows.get(0).getString(0)).hasToString("你好亅"); assertThat(rows.get(1).getString(0)).hasToString("世界"); } + @Test + void testDeserializeWithDelimiterKeepsInvalidUtf8() throws Exception { + RawFormatDeserializationSchema schema = + new RawFormatDeserializationSchema( + STRING_TYPE, + TypeInformation.of(RowData.class), + StandardCharsets.UTF_8.name(), + true, + "\n"); + openDeser(schema); + + byte[] message = {'a', (byte) 0xFF, '\n', (byte) 0x80}; + assertThat(collectRows(schema, message)) + .extracting(row -> row.getString(0).toBytes()) + .containsExactly(new byte[] {'a', (byte) 0xFF}, new byte[] {(byte) 0x80}); + } + // ----------------------------------------------------------------------- // Serialization tests // ----------------------------------------------------------------------- @@ -250,32 +271,22 @@ class RawFormatLineDelimiterTest { } @Test - void testRoundTripNewlineDelimitedJsonAsVariant() throws Exception { - RawFormatSerializationSchema ser = - new RawFormatSerializationSchema( - VARIANT_TYPE, StandardCharsets.UTF_8.name(), true, "\n"); - openSer(ser); - - RawFormatDeserializationSchema deser = + void testDeserializeWithMultiByteDelimiter() throws Exception { + RawFormatDeserializationSchema schema = new RawFormatDeserializationSchema( - VARIANT_TYPE, + STRING_TYPE, TypeInformation.of(RowData.class), StandardCharsets.UTF_8.name(), true, - "\n"); - openDeser(deser); - - byte[] stream = - concat( - ser.serialize(buildVariantRow("{\"id\":1}")), - ser.serialize(buildVariantRow("{\"id\":2}"))); - assertThat(new String(stream, StandardCharsets.UTF_8)) - .isEqualTo("{\"id\":1}\n{\"id\":2}\n"); + "é"); + openDeser(schema); - List<RowData> rows = collectRows(deser, stream); - assertThat(rows).hasSize(2); - assertThat(rows.get(0).getVariant(0).getField("id").getByte()).isEqualTo((byte) 1); - assertThat(rows.get(1).getVariant(0).getField("id").getByte()).isEqualTo((byte) 2); + // The delimiter é is c3 a9 and the data holds ü, which is c3 bc, so a match on the first + // byte alone is not enough. + byte[] message = "aüébüé".getBytes(StandardCharsets.UTF_8); + assertThat(collectRows(schema, message)) + .extracting(row -> row.getString(0).toString()) + .containsExactly("aü", "bü"); } @Test @@ -307,12 +318,6 @@ class RawFormatLineDelimiterTest { // Helpers // ----------------------------------------------------------------------- - private static byte[] concat(byte[] first, byte[] second) { - byte[] result = Arrays.copyOf(first, first.length + second.length); - System.arraycopy(second, 0, result, first.length, second.length); - return result; - } - private void openDeser(RawFormatDeserializationSchema schema) throws Exception { schema.open( new DeserializationSchema.InitializationContext() { @@ -365,10 +370,4 @@ class RawFormatLineDelimiterTest { row.setField(0, StringData.fromString(value)); return row; } - - private RowData buildVariantRow(String json) throws IOException { - GenericRowData row = new GenericRowData(1); - row.setField(0, BinaryVariantInternalBuilder.parseJson(json, false)); - return row; - } }
