This is an automated email from the ASF dual-hosted git repository.

SvenO3 pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new b6820fde4c fix(#4564): Ensure correct escaping and quoting of string 
values in csv export (#4616)
b6820fde4c is described below

commit b6820fde4cc94a50a164e08087d406d9b77fd825
Author: Sven Oehler <[email protected]>
AuthorDate: Tue Jun 23 14:50:19 2026 +0200

    fix(#4564): Ensure correct escaping and quoting of string values in csv 
export (#4616)
---
 .../export/ConfiguredCsvOutputWriter.java          |  4 +-
 .../dataexplorer/export/item/CsvItemGenerator.java | 25 +++++++++++-
 .../export/TestConfiguredCsvOutputWriter.java      | 24 +++++++++++
 .../dataexplorer/export/TestCsvItemGenerator.java  | 34 ++++++++++++++++
 .../dataexplorer/influx/PropertyHandler.java       |  8 +++-
 .../influx/TimeSeriesStorageInfluxTest.java        | 46 ++++++++++++++++++++++
 6 files changed, 138 insertions(+), 3 deletions(-)

diff --git 
a/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/ConfiguredCsvOutputWriter.java
 
b/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/ConfiguredCsvOutputWriter.java
index cbc7eaba95..fd0012d475 100644
--- 
a/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/ConfiguredCsvOutputWriter.java
+++ 
b/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/ConfiguredCsvOutputWriter.java
@@ -78,7 +78,9 @@ public class ConfiguredCsvOutputWriter extends 
ConfiguredOutputWriter {
 
   private String makeHeaderLine(List<String> columns) {
     StringJoiner joiner = new StringJoiner(this.delimiter);
-    columns.forEach(c -> joiner.add(getHeaderName(schema, c, 
headerColumnNameStrategy)));
+    columns.forEach(c -> joiner.add(
+        itemGenerator.encodeCsvValue(getHeaderName(schema, c, 
headerColumnNameStrategy))
+    ));
     return joiner + LINE_SEPARATOR;
   }
 }
diff --git 
a/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/item/CsvItemGenerator.java
 
b/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/item/CsvItemGenerator.java
index 824c05f97c..0ec0c5004d 100644
--- 
a/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/item/CsvItemGenerator.java
+++ 
b/streampipes-data-explorer-export/src/main/java/org/apache/streampipes/dataexplorer/export/item/CsvItemGenerator.java
@@ -23,17 +23,40 @@ import 
org.apache.streampipes.dataexplorer.export.ExportUtils;
 
 public class CsvItemGenerator extends ItemGenerator {
 
+  private static final String QUOTE = "\"";
+  private static final String ESCAPED_QUOTE = "\"\"";
+  private static final String CARRIAGE_RETURN = "\r";
+  private static final String LINE_FEED = "\n";
+
+  private final String delimiter;
+
   public CsvItemGenerator(String delimiter) {
     super(delimiter);
+    this.delimiter = delimiter;
   }
 
   @Override
   protected String makeItemString(String key, Object value) {
-    return value != null ? ExportUtils.formatValue(value) : "";
+    return value != null ? encodeCsvValue(ExportUtils.formatValue(value)) : "";
   }
 
   @Override
   protected String finalizeItem(String item) {
     return item;
   }
+
+  public String encodeCsvValue(String value) {
+    if (requiresQuoting(value)) {
+      return QUOTE + value.replace(QUOTE, ESCAPED_QUOTE) + QUOTE;
+    } else {
+      return value;
+    }
+  }
+
+  private boolean requiresQuoting(String value) {
+    return value.contains(delimiter)
+        || value.contains(QUOTE)
+        || value.contains(CARRIAGE_RETURN)
+        || value.contains(LINE_FEED);
+  }
 }
diff --git 
a/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestConfiguredCsvOutputWriter.java
 
b/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestConfiguredCsvOutputWriter.java
index 15a6b66e28..60c3b67388 100644
--- 
a/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestConfiguredCsvOutputWriter.java
+++ 
b/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestConfiguredCsvOutputWriter.java
@@ -25,8 +25,10 @@ import org.junit.jupiter.api.Test;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
 import java.util.HashMap;
 
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_CSV_DELIMITER;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class TestConfiguredCsvOutputWriter extends TestConfiguredOutputWriter {
@@ -49,4 +51,26 @@ public class TestConfiguredCsvOutputWriter extends 
TestConfiguredOutputWriter {
       assertEquals(Expected, outputStream.toString(StandardCharsets.UTF_8));
     }
   }
+
+  @Test
+  public void testCsvOutputWriterEscapesConfiguredDelimiter() throws 
IOException {
+    var writer = new ConfiguredCsvOutputWriter();
+    var params = new HashMap<String, String>();
+    params.put(QP_CSV_DELIMITER, "semicolon");
+    writer.configure(null, new ProvidedRestQueryParams(null, params), true);
+    var columns = Arrays.asList("time", "string;header");
+    var row = Arrays.<Object>asList(1781515964488L, "abc=;,");
+
+    try (var outputStream = new ByteArrayOutputStream()) {
+      writer.beforeFirstItem(outputStream);
+
+      writer.writeItem(outputStream, row, columns, true);
+
+      writer.afterLastItem(outputStream);
+      assertEquals(
+          "time;\"string;header\"\n1781515964488;\"abc=;,\"\n",
+          outputStream.toString(StandardCharsets.UTF_8)
+      );
+    }
+  }
 }
diff --git 
a/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestCsvItemGenerator.java
 
b/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestCsvItemGenerator.java
index d1b088bcd3..2efa579c41 100644
--- 
a/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestCsvItemGenerator.java
+++ 
b/streampipes-data-explorer-export/src/test/java/org/apache/streampipes/dataexplorer/export/TestCsvItemGenerator.java
@@ -22,6 +22,8 @@ import 
org.apache.streampipes.dataexplorer.export.item.CsvItemGenerator;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.Arrays;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class TestCsvItemGenerator extends TestItemGenerator {
@@ -46,4 +48,36 @@ public class TestCsvItemGenerator extends TestItemGenerator {
 
     assertEquals(ExpectedSemicolon, result);
   }
+
+  @Test
+  public void testCsvItemWriterEscapesSpecialCharactersCommaSeparated() {
+    var writer = new CsvItemGenerator(",");
+    var row = Arrays.<Object>asList(
+        1668578077051L,
+        "abc=;,",
+        "quoted \"value\"",
+        "line\nbreak"
+    );
+    var columns = Arrays.asList("time", "string", "quoted", "multiline");
+
+    String result = writer.createItem(row, columns);
+
+    assertEquals("1668578077051,\"abc=;,\",\"quoted 
\"\"value\"\"\",\"line\nbreak\"", result);
+  }
+
+  @Test
+  public void testCsvItemWriterEscapesSpecialCharactersSemicolonSeparated() {
+    var writer = new CsvItemGenerator(";");
+    var row = Arrays.<Object>asList(
+        1668578077051L,
+        "abc=;,",
+        "quoted \"value\"",
+        "line\nbreak"
+    );
+    var columns = Arrays.asList("time", "string", "quoted", "multiline");
+
+    String result = writer.createItem(row, columns);
+
+    assertEquals("1668578077051;\"abc=;,\";\"quoted 
\"\"value\"\"\";\"line\nbreak\"", result);
+  }
 }
diff --git 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/PropertyHandler.java
 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/PropertyHandler.java
index 388f9cedee..d64a106797 100644
--- 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/PropertyHandler.java
+++ 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/PropertyHandler.java
@@ -100,7 +100,13 @@ public class PropertyHandler {
       PrimitiveField primitiveField,
       String sanitizedRuntimeName
   ) {
-    point.tag(sanitizedRuntimeName, primitiveField.getAsString());
+    point.tag(sanitizedRuntimeName, 
escapeSpecialCharacters(primitiveField.getAsString()));
+  }
+
+  private String escapeSpecialCharacters(String value) {
+    return value
+        .replace("\r", "\\r")
+        .replace("\n", "\\n");
   }
 
   /**
diff --git 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/TimeSeriesStorageInfluxTest.java
 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/TimeSeriesStorageInfluxTest.java
index 9874f081c8..cd35280034 100644
--- 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/TimeSeriesStorageInfluxTest.java
+++ 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/TimeSeriesStorageInfluxTest.java
@@ -52,7 +52,9 @@ import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TimeSeriesStorageInfluxTest {
 
@@ -188,6 +190,21 @@ public class TimeSeriesStorageInfluxTest {
     assertEquals(expected, actualPoint);
   }
 
+  @Test
+  public void onEventWithStringSpecialCharacters() {
+    var value = "abc=;,\nquoted \"value\"\r\n";
+    var expected = getPointBuilderWithTimestamp()
+        .addField(FIELD_NAME, value)
+        .build();
+
+    var actualPoint = testEventWithOneField(
+        XSD.STRING,
+        value
+    );
+
+    assertEquals(expected, actualPoint);
+  }
+
   @Test
   public void onEventReservedKeyWord() {
     // name is a reserved keyword
@@ -249,6 +266,35 @@ public class TimeSeriesStorageInfluxTest {
     assertEquals(expected, actualPoint);
   }
 
+  @Test
+  public void onEventWithTagSpecialCharacters() {
+    var eventSchema = getEventSchemaBuilderWithTimestamp()
+        .withEventProperty(
+            EventPropertyPrimitiveTestBuilder
+                .create()
+                .withRuntimeName(FIELD_NAME)
+                .withRuntimeType(XSD.STRING)
+                .build())
+        .withEventProperty(
+            EventPropertyPrimitiveTestBuilder
+                .create()
+                .withRuntimeName("id")
+                .withRuntimeType(XSD.STRING)
+                .withPropertyScope(PropertyScope.DIMENSION_PROPERTY)
+                .build())
+        .build();
+
+    var event = getEvent(eventSchema, Map.of(FIELD_NAME, "value", "id", 
"line\nbreak"));
+
+    var influxStore = getInfluxStore(eventSchema);
+
+    var actualPoint = executeOnEvent(influxStore, event);
+    var lineProtocol = actualPoint.lineProtocol(TimeUnit.MILLISECONDS);
+
+    assertFalse(lineProtocol.contains("\n"));
+    assertTrue(lineProtocol.contains(",id=line\\nbreak "));
+  }
+
 
   @Test
   public void onEventWithNestedProperty() {

Reply via email to