Repository: xlate/staedi (java)

Issue:
Prevent trailing empty elements
Do not write trailing empty components in a composite element or trailing empty elements/composites in a segment.

Pull request description:
Add option to suppress trailing empty elements
Fixes #46 

Patch:
diff --git a/src/main/java/io/xlate/edi/internal/stream/StaEDIOutputFactory.java b/src/main/java/io/xlate/edi/internal/stream/StaEDIOutputFactory.java
index 196ebfc..143edd9 100644
--- a/src/main/java/io/xlate/edi/internal/stream/StaEDIOutputFactory.java
+++ b/src/main/java/io/xlate/edi/internal/stream/StaEDIOutputFactory.java
@@ -36,6 +36,7 @@ public class StaEDIOutputFactory extends EDIOutputFactory {
         supportedProperties.add(EDIStreamConstants.Delimiters.DECIMAL);
         supportedProperties.add(EDIStreamConstants.Delimiters.RELEASE);
         supportedProperties.add(PRETTY_PRINT);
+        supportedProperties.add(TRUNCATE_EMPTY_ELEMENTS);
 
         properties.put(PRETTY_PRINT, Boolean.FALSE);
     }
diff --git a/src/main/java/io/xlate/edi/internal/stream/StaEDIStreamWriter.java b/src/main/java/io/xlate/edi/internal/stream/StaEDIStreamWriter.java
index 05c4671..99dc53b 100644
--- a/src/main/java/io/xlate/edi/internal/stream/StaEDIStreamWriter.java
+++ b/src/main/java/io/xlate/edi/internal/stream/StaEDIStreamWriter.java
@@ -30,8 +30,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.function.Consumer;
-import java.util.function.Function;
 import java.util.function.Supplier;
+import java.util.logging.Logger;
 
 import io.xlate.edi.internal.stream.tokenization.CharacterClass;
 import io.xlate.edi.internal.stream.tokenization.CharacterSet;
@@ -57,6 +57,8 @@ import io.xlate.edi.stream.Location;
 
 public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler, ValidationEventHandler {
 
+    static final Logger LOGGER = Logger.getLogger(StaEDIStreamWriter.class.getName());
+
     private static final int LEVEL_INITIAL = 0;
     private static final int LEVEL_INTERCHANGE = 1;
     private static final int LEVEL_SEGMENT = 2;
@@ -93,14 +95,24 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
     private char decimalMark;
     private char releaseIndicator;
 
+    final boolean emptyElementTruncation;
     private final boolean prettyPrint;
     private final String lineSeparator;
 
+    private long elementLength = 0;
+
+    private int emptyElements = 0;
+    private boolean unterminatedElement = false;
+
+    private int emptyComponents = 0;
+    private boolean unterminatedComponent = false;
+
     public StaEDIStreamWriter(OutputStream stream, Charset charset, Map<String, Object> properties) {
         this.stream = stream;
         this.writer = new OutputStreamWriter(stream, charset);
         this.properties = new HashMap<>(properties);
-        this.prettyPrint = property(EDIOutputFactory.PRETTY_PRINT, Boolean::valueOf);
+        this.emptyElementTruncation = booleanValue(properties.get(EDIOutputFactory.TRUNCATE_EMPTY_ELEMENTS));
+        this.prettyPrint = booleanValue(properties.get(EDIOutputFactory.PRETTY_PRINT));
 
         if (prettyPrint) {
             lineSeparator = System.getProperty("line.separator");
@@ -110,13 +122,18 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         this.location = new StaEDIStreamLocation();
     }
 
-    @SuppressWarnings("unchecked")
-    private <T> T property(String key, Function<String, T> converter) {
-        Object prop = properties.get(key);
-        if (prop instanceof String) {
-            return converter.apply((String) prop);
+    boolean booleanValue(Object value) {
+        if (value instanceof Boolean) {
+            return (Boolean) value;
         }
-        return (T) prop;
+        if (value instanceof String) {
+            return Boolean.valueOf(value.toString());
+        }
+        if (value == null) {
+            return false;
+        }
+        LOGGER.warning(() -> "Value [" + value + "] could not be converted to boolean");
+        return false;
     }
 
     private void setupDelimiters() {
@@ -375,6 +392,9 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         }
 
         level = LEVEL_SEGMENT;
+        emptyElements = 0;
+        // Treat the segment tag as an unterminated element that must be closed when element data is encountered
+        unterminatedElement = true;
 
         return this;
     }
@@ -436,10 +456,17 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
     @Override
     public EDIStreamWriter writeStartElement() throws EDIStreamException {
         ensureLevel(LEVEL_SEGMENT);
-        write(this.dataElementSeparator);
         level = LEVEL_ELEMENT;
         location.incrementElementPosition();
         elementBuffer.clear();
+        elementLength = 0;
+        emptyComponents = 0;
+        unterminatedComponent = false;
+
+        if (!emptyElementTruncation) {
+            write(this.dataElementSeparator);
+        }
+
         return this;
     }
 
@@ -454,8 +481,13 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
     public EDIStreamWriter writeRepeatElement() throws EDIStreamException {
         ensureLevelAtLeast(LEVEL_SEGMENT);
         write(this.repetitionSeparator);
+        // The repetition separator was used instead of the data element separator
+        unterminatedElement = false;
         level = LEVEL_ELEMENT;
         location.incrementElementOccurrence();
+        elementLength = 0;
+        emptyComponents = 0;
+        unterminatedComponent = false;
         return this;
     }
 
@@ -474,6 +506,12 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         location.clearComponentPosition();
         level = LEVEL_SEGMENT;
 
+        if (elementLength > 0) {
+            unterminatedElement = true;
+        } else {
+            emptyElements++;
+        }
+
         if (state == State.ELEMENT_DATA_BINARY) {
             state = State.ELEMENT_END_BINARY;
         }
@@ -489,22 +527,31 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
             throw new IllegalStateException();
         }
 
-        if (LEVEL_COMPOSITE == level) {
+        if (LEVEL_COMPOSITE == level && !emptyElementTruncation) {
             write(this.componentElementSeparator);
         }
 
         level = LEVEL_COMPONENT;
         location.incrementComponentPosition();
         elementBuffer.clear();
+        elementLength = 0;
         return this;
     }
 
     @Override
     public EDIStreamWriter endComponent() throws EDIStreamException {
         ensureLevel(LEVEL_COMPONENT);
+
         if (!atomicElementWrite) {
             validateElement(this.elementBuffer::flip, this.elementBuffer);
         }
+
+        if (elementLength > 0) {
+            unterminatedComponent = true;
+        } else {
+            emptyComponents++;
+        }
+
         level = LEVEL_COMPOSITE;
         return this;
     }
@@ -573,9 +620,37 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         return this;
     }
 
+    void writeRequiredSeparators(int dataLength) throws EDIStreamException {
+        if (dataLength < 1 || !emptyElementTruncation) {
+            return;
+        }
+
+        writeRequiredSeparator(emptyElements, unterminatedElement, this.dataElementSeparator);
+        emptyElements = 0;
+        unterminatedElement = false;
+
+        if (level == LEVEL_COMPONENT) {
+            writeRequiredSeparator(emptyComponents, unterminatedComponent, this.componentElementSeparator);
+            emptyComponents = 0;
+            unterminatedComponent = false;
+        }
+    }
+
+    void writeRequiredSeparator(int emptyCount, boolean unterminated, char separator) throws EDIStreamException {
+        for (int i = 0; i < emptyCount; i++) {
+            write(separator);
+        }
+
+        if (unterminated) {
+            write(separator);
+        }
+    }
+
     @Override
     public EDIStreamWriter writeElementData(CharSequence text) throws EDIStreamException {
         ensureLevelAtLeast(LEVEL_ELEMENT);
+        writeRequiredSeparators(text.length());
+
         for (int i = 0, m = text.length(); i < m; i++) {
             char curr = text.charAt(i);
 
@@ -589,6 +664,7 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
 
             write(curr);
             elementBuffer.put(curr);
+            elementLength++;
         }
         return this;
     }
@@ -597,6 +673,7 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
     public EDIStreamWriter writeElementData(char[] text, int start, int end) throws EDIStreamException {
         ensureLevelAtLeast(LEVEL_ELEMENT);
         ensureArgs(text.length, start, end);
+        writeRequiredSeparators(end - start);
 
         for (int i = start, m = end; i < m; i++) {
             char curr = text[i];
@@ -605,6 +682,7 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
             }
             write(curr);
             elementBuffer.put(curr);
+            elementLength++;
         }
 
         return this;
@@ -617,11 +695,14 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         int output;
 
         try {
+            writeRequiredSeparators(binaryStream.available());
+
             flush(); // Write `Writer` buffers to stream before writing binary
 
             while ((output = binaryStream.read()) != -1) {
                 location.incrementOffset(output);
                 stream.write(output);
+                elementLength++;
             }
         } catch (IOException e) {
             throw new EDIStreamException("Exception writing binary element data", location, e);
@@ -635,6 +716,7 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
         ensureLevel(LEVEL_ELEMENT);
         ensureState(State.ELEMENT_DATA_BINARY);
         ensureArgs(binary.length, start, end);
+        writeRequiredSeparators(end - start);
 
         try {
             flush(); // Write `Writer` buffers to stream before writing binary
@@ -642,6 +724,7 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
             for (int i = start; i < end; i++) {
                 location.incrementOffset(binary[i]);
                 stream.write(binary[i]);
+                elementLength++;
             }
         } catch (IOException e) {
             throw new EDIStreamException("Exception writing binary element data", location, e);
@@ -654,9 +737,11 @@ public class StaEDIStreamWriter implements EDIStreamWriter, ElementDataHandler,
     public EDIStreamWriter writeBinaryData(ByteBuffer binary) throws EDIStreamException {
         ensureLevel(LEVEL_ELEMENT);
         ensureState(State.ELEMENT_DATA_BINARY);
+        writeRequiredSeparators(binary.remaining());
 
         while (binary.hasRemaining()) {
             write(binary.get());
+            elementLength++;
         }
 
         return this;
diff --git a/src/main/java/io/xlate/edi/stream/EDIOutputFactory.java b/src/main/java/io/xlate/edi/stream/EDIOutputFactory.java
index 21ce1ac..4103988 100644
--- a/src/main/java/io/xlate/edi/stream/EDIOutputFactory.java
+++ b/src/main/java/io/xlate/edi/stream/EDIOutputFactory.java
@@ -21,8 +21,23 @@ import javax.xml.stream.XMLStreamWriter;
 
 public abstract class EDIOutputFactory extends PropertySupport {
 
+    /**
+     * <p>When set to true, the EDI output will have a platform specific line
+     * separator written after each segment terminator.
+     *
+     * <p>Default value is false.
+     */
     public static final String PRETTY_PRINT = "io.xlate.edi.stream.PRETTY_PRINT";
 
+    /**
+     * <p>When set to true, empty trailing elements in a segment and empty trailing
+     * components in a composite element will be truncated. I.e, they will not
+     * be written to the output.
+     *
+     * <p>Default value is false.
+     */
+    public static final String TRUNCATE_EMPTY_ELEMENTS = "io.xlate.edi.stream.TRUNCATE_EMPTY_ELEMENTS";
+
     /**
      * Create a new instance of the factory. This static method creates a new
      * factory instance.


Test patch:
diff --git a/src/test/java/io/xlate/edi/internal/stream/StaEDIStreamWriterTest.java b/src/test/java/io/xlate/edi/internal/stream/StaEDIStreamWriterTest.java
index 8826f3b..3b19df4 100644
--- a/src/test/java/io/xlate/edi/internal/stream/StaEDIStreamWriterTest.java
+++ b/src/test/java/io/xlate/edi/internal/stream/StaEDIStreamWriterTest.java
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.BufferedInputStream;
@@ -44,6 +45,7 @@ import io.xlate.edi.schema.SchemaFactory;
 import io.xlate.edi.stream.EDIInputFactory;
 import io.xlate.edi.stream.EDIOutputFactory;
 import io.xlate.edi.stream.EDIStreamConstants;
+import io.xlate.edi.stream.EDIStreamConstants.Delimiters;
 import io.xlate.edi.stream.EDIStreamEvent;
 import io.xlate.edi.stream.EDIStreamException;
 import io.xlate.edi.stream.EDIStreamReader;
@@ -51,7 +53,6 @@ import io.xlate.edi.stream.EDIStreamValidationError;
 import io.xlate.edi.stream.EDIStreamWriter;
 import io.xlate.edi.stream.EDIValidationException;
 import io.xlate.edi.stream.Location;
-import io.xlate.edi.stream.EDIStreamConstants.Delimiters;
 
 @SuppressWarnings("resource")
 class StaEDIStreamWriterTest {
@@ -92,6 +93,17 @@ class StaEDIStreamWriterTest {
         assertThrows(IllegalArgumentException.class, () -> writer.getProperty(null));
     }
 
+    @Test
+    void testInvalidBooleanPropertyIsFalse() {
+        EDIOutputFactory factory = EDIOutputFactory.newFactory();
+        factory.setProperty(EDIOutputFactory.TRUNCATE_EMPTY_ELEMENTS, new Object());
+        OutputStream stream = new ByteArrayOutputStream(1);
+        EDIStreamWriter writer = factory.createEDIStreamWriter(stream);
+
+        assertTrue(writer instanceof StaEDIStreamWriter);
+        assertEquals(false, ((StaEDIStreamWriter) writer).emptyElementTruncation);
+    }
+
     @Test
     void testStartInterchange() {
         try {
@@ -185,7 +197,7 @@ class StaEDIStreamWriterTest {
         writer.writeElement("0");
         writer.writeElement("P");
         EDIStreamException thrown = assertThrows(EDIStreamException.class, () -> writer.writeElement(":"));
-        assertEquals("Unexpected header character: 0x002A [*] in segment ISA at position 1, element 15", thrown.getMessage());
+        assertEquals("Unexpected header character: 0x002A [*] in segment ISA at position 1, element 16", thrown.getMessage());
     }
 
     @Test
@@ -343,6 +355,26 @@ class StaEDIStreamWriterTest {
         assertEquals("ISA*:~", stream.toString());
     }
 
+    @Test
+    void testComponent_EmptyTruncated() throws IllegalStateException, EDIStreamException {
+        EDIOutputFactory factory = EDIOutputFactory.newFactory();
+        factory.setProperty(EDIOutputFactory.TRUNCATE_EMPTY_ELEMENTS, true);
+        OutputStream stream = new ByteArrayOutputStream(4096);
+        EDIStreamWriter writer = factory.createEDIStreamWriter(stream);
+        writer.startInterchange();
+        writer.writeStartSegment("ISA");
+        writer.writeStartElement()
+              .startComponent()
+              .endComponent()
+              .startComponent()
+              .endComponent()
+              .endElement()
+              .writeEmptyElement()
+              .writeEndSegment();
+        writer.flush();
+        assertEquals("ISA~", stream.toString());
+    }
+
     @Test
     void testComponentIllegal() throws IllegalStateException, EDIStreamException {
         EDIOutputFactory factory = EDIOutputFactory.newFactory();
@@ -407,6 +439,85 @@ class StaEDIStreamWriterTest {
         assertEquals("ISA*:::~", stream.toString());
     }
 
+    @Test
+    void testWriteEmptyElements_EmptyTruncated() throws IllegalStateException, EDIStreamException {
+        EDIOutputFactory factory = EDIOutputFactory.newFactory();
+        factory.setProperty(EDIOutputFactory.TRUNCATE_EMPTY_ELEMENTS, true);
+        OutputStream stream = new ByteArrayOutputStream(4096);
+        EDIStreamWriter writer = factory.createEDIStreamWriter(stream);
+        writer.startInterchange();
+        writer.writeStartSegment("ISA");
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.endElement();
+        writer.writeEndSegment();
+        writer.flush();
+        assertEquals("ISA~", stream.toString());
+    }
+
+    @Test
+    void testWriteTruncatedElements() throws IllegalStateException, EDIStreamException {
+        EDIOutputFactory factory = EDIOutputFactory.newFactory();
+        factory.setProperty(EDIOutputFactory.TRUNCATE_EMPTY_ELEMENTS, true);
+        OutputStream stream = new ByteArrayOutputStream(4096);
+        EDIStreamWriter writer = factory.createEDIStreamWriter(stream);
+        writer.startInterchange();
+        writer.writeStartSegment("ISA");
+
+        // All components truncated
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.endElement();
+
+        // Components after "LAST" truncated
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeComponent("LAST");
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.endElement();
+
+        // All present
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.writeComponent("LAST");
+        writer.endElement();
+
+        // All present
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeComponent("SECOND");
+        writer.writeEmptyComponent();
+        writer.writeComponent("LAST");
+        writer.endElement();
+
+        writer.writeEmptyElement();
+        writer.writeEmptyElement();
+
+        writer.writeElement("LAST");
+
+        // All three elements truncated
+        writer.writeEmptyElement();
+        writer.writeStartElement();
+        writer.writeEmptyComponent();
+        writer.writeEmptyComponent();
+        writer.endElement();
+        writer.writeEmptyElement();
+
+        writer.writeEndSegment();
+        writer.flush();
+
+        assertEquals("ISA**:LAST*:::LAST*:SECOND::LAST***LAST~", stream.toString());
+    }
+
     @Test
     void testWriteElementDataCharSequence() throws EDIStreamException {
         EDIOutputFactory factory = EDIOutputFactory.newFactory();