Repository: xlate/staedi (java)

Issue:
Allow gaps in XML elements passed to writer
```xml
<s:SEG>
  <!-- SEG01 missing, treat as empty EDI element in output -->
  <e:SEG02>value</e:SEG02>
  <!-- SEG03 missing, treat as empty EDI element in output -->
  <c:SEG04>
    <e:C002-01>value</e:C002-01>
    <!-- C002-02 missing, treat as empty EDI component in output -->
    <e:C002-03>value</e:C002-03>
  </c:SEG04>
</s:SEG>
```

Pull request description:
Allow element gaps - require proper element and composite naming
Fixes #112 

Patch:
diff --git a/src/main/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriter.java b/src/main/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriter.java
index c0ef8f2..927177e 100644
--- a/src/main/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriter.java
+++ b/src/main/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriter.java
@@ -20,10 +20,17 @@ final class StaEDIXMLStreamWriter implements XMLStreamWriter {
 
     private static final QName INTERCHANGE = new QName(EDINamespaces.LOOPS, "INTERCHANGE");
 
+    static final String MSG_INVALID_COMPONENT_NAME = "Invalid component element name or position not given: %s";
+    static final String MSG_INVALID_COMPONENT_POSITION = "Invalid/non-numeric component element position: %s";
+    static final String MSG_INVALID_ELEMENT_NAME = "Invalid element name or position not numeric: %s";
+    static final String MSG_ILLEGAL_NONWHITESPACE = "Illegal non-whitespace characters";
+
     private final EDIStreamWriter ediWriter;
 
     private final Deque<QName> elementStack = new ArrayDeque<>();
     private QName previousElement;
+    int lastElementPosition;
+    int lastComponentPosition;
 
     private NamespaceContext namespaceContext;
     private final Deque<Map<String, String>> namespaceStack = new ArrayDeque<>();
@@ -58,35 +65,55 @@ final class StaEDIXMLStreamWriter implements XMLStreamWriter {
         return name.getLocalPart().equals(previousElement.getLocalPart());
     }
 
+    int getPosition(QName name, boolean component) throws XMLStreamException {
+        int position;
+        String localPart = name.getLocalPart();
+
+        if (component) {
+            int componentIdx = localPart.indexOf('-');
+
+            if (componentIdx < 2) {
+                throw new XMLStreamException(String.format(MSG_INVALID_COMPONENT_NAME, name));
+            }
+
+            try {
+                position = Integer.parseInt(localPart.substring(componentIdx + 1));
+            } catch (NumberFormatException e) {
+                throw new XMLStreamException(String.format(MSG_INVALID_COMPONENT_POSITION, name));
+            }
+        } else {
+            try {
+                position = Integer.parseInt(localPart.substring(localPart.length() - 2));
+            } catch (NumberFormatException e) {
+                throw new XMLStreamException(String.format(MSG_INVALID_ELEMENT_NAME, name));
+            }
+        }
+
+        return position;
+    }
+
     void writeStart(QName name) throws XMLStreamException {
         namespaceStack.push(new HashMap<>());
 
         switch (name.getNamespaceURI()) {
         case EDINamespaces.COMPOSITES:
-            if (repeatedElement(name, previousElement)) {
-                execute(ediWriter::writeRepeatElement);
-            } else {
-                execute(ediWriter::writeStartElement);
-            }
-            elementStack.push(name);
+            lastComponentPosition = 0;
+            writeElementStart(name);
             break;
         case EDINamespaces.ELEMENTS:
             if (EDINamespaces.COMPOSITES.equals(elementStack.element().getNamespaceURI())) {
-                execute(ediWriter::startComponent);
+                writeComponentStart(name);
             } else {
-                if (repeatedElement(name, previousElement)) {
-                    execute(ediWriter::writeRepeatElement);
-                } else {
-                    execute(ediWriter::writeStartElement);
-                }
+                writeElementStart(name);
             }
-            elementStack.push(name);
             break;
         case EDINamespaces.LOOPS:
             // Loops are implicit when writing
             elementStack.push(name);
             break;
         case EDINamespaces.SEGMENTS:
+            lastElementPosition = 0;
+            lastComponentPosition = 0;
             execute(() -> ediWriter.writeStartSegment(name.getLocalPart()));
             elementStack.push(name);
             break;
@@ -95,6 +122,37 @@ final class StaEDIXMLStreamWriter implements XMLStreamWriter {
         }
     }
 
+    void writeComponentStart(QName name) throws XMLStreamException {
+        int position = getPosition(name, true);
+
+        while (position > lastComponentPosition + 1) {
+            lastComponentPosition++;
+            execute(ediWriter::writeEmptyComponent);
+        }
+
+        execute(ediWriter::startComponent);
+        lastComponentPosition++;
+        elementStack.push(name);
+    }
+
+    void writeElementStart(QName name) throws XMLStreamException {
+        int position = getPosition(name, false);
+
+        while (position > lastElementPosition + 1) {
+            lastElementPosition++;
+            execute(ediWriter::writeEmptyElement);
+        }
+
+        if (repeatedElement(name, previousElement)) {
+            execute(ediWriter::writeRepeatElement);
+        } else {
+            execute(ediWriter::writeStartElement);
+        }
+
+        lastElementPosition++;
+        elementStack.push(name);
+    }
+
     void writeEnd() throws XMLStreamException {
         QName name = elementStack.remove();
         namespaceStack.remove();
@@ -280,7 +338,7 @@ final class StaEDIXMLStreamWriter implements XMLStreamWriter {
         } else {
             for (int i = 0, m = text.length(); i < m; i++) {
                 if (!Character.isWhitespace(text.charAt(i))) {
-                    throw new XMLStreamException("Illegal non-whitespace characters");
+                    throw new XMLStreamException(MSG_ILLEGAL_NONWHITESPACE);
                 }
             }
         }
@@ -294,7 +352,7 @@ final class StaEDIXMLStreamWriter implements XMLStreamWriter {
         } else {
             for (int i = start, m = start + len; i < m; i++) {
                 if (!Character.isWhitespace(text[i])) {
-                    throw new XMLStreamException("Illegal non-whitespace characters");
+                    throw new XMLStreamException(MSG_ILLEGAL_NONWHITESPACE);
                 }
             }
         }


Test patch:
diff --git a/src/test/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriterTest.java b/src/test/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriterTest.java
index 9e5dc37..aada52e 100644
--- a/src/test/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriterTest.java
+++ b/src/test/java/io/xlate/edi/internal/stream/StaEDIXMLStreamWriterTest.java
@@ -6,11 +6,17 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 
 import javax.xml.namespace.NamespaceContext;
 import javax.xml.namespace.QName;
 import javax.xml.stream.XMLStreamException;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.stax.StAXResult;
+import javax.xml.transform.stream.StreamSource;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -284,7 +290,7 @@ class StaEDIXMLStreamWriterTest {
     void testWriteCharactersString_JunkIllegal() throws XMLStreamException {
         it.writeStartDocument();
         it.writeStartElement("l", "INTERCHANGE", EDINamespaces.LOOPS);
-        XMLStreamException thrown = assertThrows(XMLStreamException.class, ()-> it.writeCharacters(" illegal non-whitespace characters "));
+        XMLStreamException thrown = assertThrows(XMLStreamException.class, () -> it.writeCharacters(" illegal non-whitespace characters "));
         assertEquals("Illegal non-whitespace characters", thrown.getMessage());
     }
 
@@ -302,7 +308,8 @@ class StaEDIXMLStreamWriterTest {
     void testWriteCharactersCharArrayIntInt_JunkIllegal() throws XMLStreamException {
         it.writeStartDocument();
         it.writeStartElement("l", "INTERCHANGE", EDINamespaces.LOOPS);
-        XMLStreamException thrown = assertThrows(XMLStreamException.class, ()-> it.writeCharacters(" \t \n \r  OUT OF INDEX BOUNDS".toCharArray(), 2, 7)); // Test
+        XMLStreamException thrown = assertThrows(XMLStreamException.class,
+                                                 () -> it.writeCharacters(" \t \n \r  OUT OF INDEX BOUNDS".toCharArray(), 2, 7)); // Test
         assertEquals("Illegal non-whitespace characters", thrown.getMessage());
     }
 
@@ -359,4 +366,206 @@ class StaEDIXMLStreamWriterTest {
     void testGetProperty() {
         assertThrows(IllegalArgumentException.class, () -> it.getProperty("anything"));
     }
+
+    @Test
+    void testSkippedElementsValid() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:ISA>\n" +
+                "    <e:ISA01>00</e:ISA01>\n" +
+                "    <e:ISA02>          </e:ISA02>\n" +
+                "    <e:ISA03>00</e:ISA03>\n" +
+                "    <e:ISA04>          </e:ISA04>\n" +
+                "    <e:ISA05>ZZ</e:ISA05>\n" +
+                "    <e:ISA06>ReceiverID     </e:ISA06>\n" +
+                "    <e:ISA07>ZZ</e:ISA07>\n" +
+                "    <e:ISA08>Sender         </e:ISA08>\n" +
+                "    <e:ISA09>050812</e:ISA09>\n" +
+                "    <e:ISA10>1953</e:ISA10>\n" +
+                "    <e:ISA11>^</e:ISA11>\n" +
+                "    <e:ISA12>00501</e:ISA12>\n" +
+                "    <e:ISA13>000000001</e:ISA13>\n" +
+                "    <e:ISA14>0</e:ISA14>\n" +
+                "    <e:ISA15>P</e:ISA15>\n" +
+                "    <e:ISA16>:</e:ISA16>\n" +
+                "  </s:ISA>"
+                + "<l:GROUP>\n" +
+                "    <s:GS>\n" +
+                // GS01 skipped
+                "      <e:GS02>Receiver</e:GS02>\n" +
+                "      <e:GS03>Sender</e:GS03>\n" +
+                "      <e:GS04>20050812</e:GS04>\n" +
+                "      <e:GS05>195335</e:GS05>\n" +
+                "      <e:GS06>1</e:GS06>\n" +
+                "      <e:GS07>X</e:GS07>\n" +
+                "      <e:GS08>005010X230</e:GS08>\n" +
+                "    </s:GS>"
+                + "  <s:GE>\n" +
+                "      <e:GE01>1</e:GE01>\n" +
+                "      <e:GE02>1</e:GE02>\n" +
+                "    </s:GE>\n" +
+                "  </l:GROUP>"
+                + "<s:IEA>\n" +
+                "    <e:IEA01>1</e:IEA01>\n" +
+                "    <e:IEA02>000000001</e:IEA02>\n" +
+                "  </s:IEA>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        TransformerFactory.newInstance().newTransformer().transform(source, result);
+        it.close();
+        assertEquals("" +
+                "ISA*00*          *00*          *ZZ*ReceiverID     *ZZ*Sender         *050812*1953*^*00501*000000001*0*P*:~\n" +
+                "GS**Receiver*Sender*20050812*195335*1*X*005010X230~\n" +
+                "GE*1*1~\n" +
+                "IEA*1*000000001~\n" +
+                "", new String(stream.toByteArray()));
+    }
+
+    @Test
+    void testSkippedCompositesValid() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:UNB>\n" +
+                "    <c:UNB01>"
+                + "    <e:UNB01-01>UNOA</e:UNB01-01>"
+                + "    <e:UNB01-02>3</e:UNB01-02>"
+                + "  </c:UNB01>\n" +
+                "    <c:UNB04>"
+                + "    <e:UNB04-01>200914</e:UNB04-01>"
+                + "    <e:UNB04-02>1945</e:UNB04-02>"
+                + "  </c:UNB04>"
+                + "  <e:UNB05>1</e:UNB05>" +
+                "  </s:UNB>"
+                + "<s:UNZ>\n" +
+                "    <e:UNZ01>0</e:UNZ01>\n" +
+                "    <e:UNZ02>1</e:UNZ02>\n" +
+                "  </s:UNZ>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        TransformerFactory.newInstance().newTransformer().transform(source, result);
+        it.close();
+        assertEquals("" +
+                "UNB+UNOA:3+++200914:1945+1'\n" +
+                "UNZ+0+1'\n", new String(stream.toByteArray()));
+    }
+
+    @Test
+    void testSkippedComponentValid() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:UNB>\n" +
+                "    <c:UNB01>"
+                + "    <e:UNB01-01>UNOA</e:UNB01-01>"
+                + "    <e:UNB01-02>3</e:UNB01-02>"
+                + "  </c:UNB01>\n" +
+                "    <c:UNB04>"
+                + "    <e:UNB04-02>1945</e:UNB04-02>"
+                + "  </c:UNB04>"
+                + "  <e:UNB05>1</e:UNB05>" +
+                "  </s:UNB>"
+                + "<s:UNZ>\n" +
+                "    <e:UNZ01>0</e:UNZ01>\n" +
+                "    <e:UNZ02>1</e:UNZ02>\n" +
+                "  </s:UNZ>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        TransformerFactory.newInstance().newTransformer().transform(source, result);
+        it.close();
+        assertEquals("" +
+                "UNB+UNOA:3+++:1945+1'\n" +
+                "UNZ+0+1'\n", new String(stream.toByteArray()));
+    }
+
+    @Test
+    void testInvalidCompositeNameThrowsException() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:UNB>\n" +
+                "    <c:UNB01>"
+                + "    <e:B-01>UNOA</e:B-01>"
+                + "    <e:UNB01-02>3</e:UNB01-02>"
+                + "  </c:UNB01>\n" +
+                "  </s:UNB>"
+                + "<s:UNZ>\n" +
+                "    <e:UNZ01>0</e:UNZ01>\n" +
+                "    <e:UNZ02>1</e:UNZ02>\n" +
+                "  </s:UNZ>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        Transformer tx = TransformerFactory.newInstance().newTransformer();
+        Exception thrown;
+        thrown = assertThrows(TransformerException.class, () -> tx.transform(source, result));
+        Throwable cause = thrown;
+        while (cause.getCause() != null) {
+            cause = cause.getCause();
+        }
+        assertEquals(String.format(StaEDIXMLStreamWriter.MSG_INVALID_COMPONENT_NAME, "{urn:xlate.io:staedi:names:elements}B-01"),
+                     cause.getMessage());
+    }
+
+    @Test
+    void testInvalidCompositePositionThrowsException() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:UNB>\n" +
+                "    <c:UNB01>"
+                + "    <e:UNB01-AB>UNOA</e:UNB01-AB>"
+                + "    <e:UNB01-02>3</e:UNB01-02>"
+                + "  </c:UNB01>\n" +
+                "  </s:UNB>"
+                + "<s:UNZ>\n" +
+                "    <e:UNZ01>0</e:UNZ01>\n" +
+                "    <e:UNZ02>1</e:UNZ02>\n" +
+                "  </s:UNZ>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        Transformer tx = TransformerFactory.newInstance().newTransformer();
+        Exception thrown;
+        thrown = assertThrows(TransformerException.class, () -> tx.transform(source, result));
+        Throwable cause = thrown;
+        while (cause.getCause() != null) {
+            cause = cause.getCause();
+        }
+        assertEquals(String.format(StaEDIXMLStreamWriter.MSG_INVALID_COMPONENT_POSITION, "{urn:xlate.io:staedi:names:elements}UNB01-AB"),
+                     cause.getMessage());
+    }
+
+    @Test
+    void testInvalidElementPositionThrowsException() throws Exception {
+        String input = "" +
+                "<l:INTERCHANGE xmlns:l=\"urn:xlate.io:staedi:names:loops\" xmlns:s=\"urn:xlate.io:staedi:names:segments\" xmlns:c=\"urn:xlate.io:staedi:names:composites\" xmlns:e=\"urn:xlate.io:staedi:names:elements\">\n" +
+                "  <s:UNB>\n" +
+                "    <c:UNBAA>"
+                + "    <e:UNB01-01>UNOA</e:UNB01-01>"
+                + "    <e:UNB01-02>3</e:UNB01-02>"
+                + "  </c:UNBAA>\n" +
+                "  </s:UNB>"
+                + "<s:UNZ>\n" +
+                "    <e:UNZ01>0</e:UNZ01>\n" +
+                "    <e:UNZ02>1</e:UNZ02>\n" +
+                "  </s:UNZ>\n" +
+                "</l:INTERCHANGE>";
+
+        StreamSource source = new StreamSource(new ByteArrayInputStream(input.getBytes()));
+        StAXResult result = new StAXResult(it);
+        Transformer tx = TransformerFactory.newInstance().newTransformer();
+        Exception thrown;
+        thrown = assertThrows(TransformerException.class, () -> tx.transform(source, result));
+        Throwable cause = thrown;
+        while (cause.getCause() != null) {
+            cause = cause.getCause();
+        }
+        assertEquals(String.format(StaEDIXMLStreamWriter.MSG_INVALID_ELEMENT_NAME, "{urn:xlate.io:staedi:names:composites}UNBAA"),
+                     cause.getMessage());
+    }
 }