diff --git a/docs/docs/concepts/spec/fileformat.md b/docs/docs/concepts/spec/fileformat.md
index 8367d86db2cc..ab36e5fbb2f1 100644
--- a/docs/docs/concepts/spec/fileformat.md
+++ b/docs/docs/concepts/spec/fileformat.md
@@ -151,8 +151,8 @@ Format Options:
| --- | --- | --- | --- |
| `csv.field-delimiter` | `,` | String | Field delimiter character (`','` by default), must be single character. You can use backslash to specify special characters, e.g. `'\t'` represents the tab character. |
| `csv.line-delimiter` | `\n` | String | The line delimiter for CSV format |
-| `csv.quote-character` | `"` | String | Quote character for enclosing field values (`"` by default). |
-| `csv.escape-character` | `\` | String | The escape character for CSV format. |
+| `csv.quote-character` | `"` | String | Quote character for enclosing field values (`"` by default), must be single character. |
+| `csv.escape-character` | `\` | String | The escape character for CSV format, must be single character. |
| `csv.include-header` | false | Boolean | Whether to include header in CSV files. |
| `csv.null-literal` | `""` | String | Null literal string that is interpreted as a null value (disabled by default). |
| `csv.mode` | `PERMISSIVE` | String | Allows a mode for dealing with corrupt records during reading. Currently supported values are `'PERMISSIVE'`, `'DROPMALFORMED'` and `'FAILFAST'`:
- Option `'PERMISSIVE'` sets malformed fields to null.
- Option `'DROPMALFORMED'` ignores the whole corrupted records.
- Option `'FAILFAST'` throws an exception when it meets corrupted records.
|
diff --git a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvOptions.java b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvOptions.java
index 4221282c31e7..e04f5cffc29a 100644
--- a/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvOptions.java
+++ b/paimon-format/src/main/java/org/apache/paimon/format/csv/CsvOptions.java
@@ -23,6 +23,7 @@
import org.apache.paimon.options.Options;
import org.apache.paimon.options.description.DescribedEnum;
import org.apache.paimon.options.description.InlineElement;
+import org.apache.paimon.utils.Preconditions;
import static org.apache.paimon.options.description.TextElement.text;
@@ -88,15 +89,31 @@ public class CsvOptions {
private final Mode mode;
public CsvOptions(Options options) {
- this.fieldDelimiter = options.get(FIELD_DELIMITER);
+ this.fieldDelimiter = singleCharacter(options, FIELD_DELIMITER);
this.lineDelimiter = options.get(LINE_DELIMITER);
this.nullLiteral = options.get(NULL_LITERAL);
this.includeHeader = options.get(INCLUDE_HEADER);
- this.quoteCharacter = options.get(QUOTE_CHARACTER);
- this.escapeCharacter = options.get(ESCAPE_CHARACTER);
+ this.quoteCharacter = singleCharacter(options, QUOTE_CHARACTER);
+ this.escapeCharacter = singleCharacter(options, ESCAPE_CHARACTER);
this.mode = options.get(MODE);
}
+ /**
+ * {@link CsvParser} keeps only the first character of these options while the writer emits the
+ * whole string, so a longer value is written and read differently. The line delimiter is
+ * deliberately not restricted here: {@code CustomLineReader} matches all of its bytes for the
+ * Paimon implementation.
+ */
+ private static String singleCharacter(Options options, ConfigOption option) {
+ String value = options.get(option);
+ Preconditions.checkArgument(
+ value.length() == 1,
+ "'%s' must be a single character, but was '%s'.",
+ option.key(),
+ value);
+ return value;
+ }
+
public String fieldDelimiter() {
return fieldDelimiter;
}
diff --git a/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
index a7033bfe2f0e..ba2ebccb98b2 100644
--- a/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
+++ b/paimon-format/src/test/java/org/apache/paimon/format/csv/CsvFileFormatTest.java
@@ -34,6 +34,7 @@
import org.apache.paimon.format.HadoopCompressionType;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.options.ConfigOption;
import org.apache.paimon.options.Options;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.types.DataTypes;
@@ -54,6 +55,7 @@
import static org.apache.paimon.data.BinaryString.fromString;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Test for {@link CsvFileFormat}. */
@@ -907,6 +909,30 @@ private List writeThenRead(
return read(format, fullRowType, rowType, testFile);
}
+ @Test
+ public void testSingleCharacterOptionsAreEnforced() {
+ // The writer emits the whole option string while CsvParser keeps only charAt(0), so a
+ // multi-character value silently wrote one delimiter and read back another.
+ for (ConfigOption option :
+ Arrays.asList(
+ CsvOptions.FIELD_DELIMITER,
+ CsvOptions.QUOTE_CHARACTER,
+ CsvOptions.ESCAPE_CHARACTER)) {
+ for (String bad : Arrays.asList("ab", "")) {
+ Options options = new Options();
+ options.set(option, bad);
+ assertThatThrownBy(() -> new CsvOptions(options))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(option.key());
+ }
+ }
+
+ // A multi-character line delimiter stays supported; CustomLineReader matches all of it.
+ Options multiCharLine = new Options();
+ multiCharLine.set(CsvOptions.LINE_DELIMITER, "|||");
+ assertThatCode(() -> new CsvOptions(multiCharLine)).doesNotThrowAnyException();
+ }
+
/** Writes the given data to a new CSV file and returns its path. */
private Path write(
FileFormat format, RowType rowType, List testData, String testPrefix)