Friday 26 October 2018

Recursion in XSLT

Last week I helped someone on the Oracle community forums with transforming a comma separated string to a list of elements. He needed this to process each element in BPM Suite, but it is a use case that can come around in SOA Suite or even in Oracle Integration Cloud.

You would think that you could do something like a for-each and trimming the element from the variable.

Recursion

One typical thing with XSLT is that variables are immutable. That means that you can declare a variable and assign a value to it, but you cannot change it. So it is not possible to assign a new value to a variable based on a substring of that same variable.

To circumvent this, you should implement a template that conditionally calls itself until an end-condition is met. This is a typical algorithm called recursion. Recursion is a way of implementing a function that calls itself, for example to calculate the faculty of a number. Recursion can help circumventing the immutability of variables, because with every call to the function you can pass (a) calculated and thus different value(s) through the parameter(s).

I wrote about this earlier, but last week a co-worker asked a similar question, but just the other way around: transforming a list into a comma separated string.

So, apparently it's time to write an article about it.

Transforming CSV to a List

I refactored the xsd's from the question as follows. First the source xsd:
<?xml version="1.0" encoding="windows-1252" ?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.example.org/Approvals/Source"
            targetNamespace="http://www.example.org/Approvals/Source" elementFormDefault="qualified">
  <xsd:element name="ApprovalRoute" type="tns:approvalRouteByInvoiceNatureResponse"/>
  <xsd:complexType name="approvalRouteByInvoiceNatureResponse">
    <xsd:sequence>
      <xsd:element type="xsd:string" name="approvalRoute" minOccurs="0"/>
      <xsd:element type="xsd:boolean" name="autoApprove" minOccurs="0"/>
    </xsd:sequence>
  </xsd:complexType>
</xsd:schema>

And the target schema is:
<?xml version="1.0" encoding="windows-1252" ?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.example.org/Approvals/Target"
            targetNamespace="http://www.example.org/Approvals/Target" elementFormDefault="qualified">
  <xsd:element name="ApprovalRoute" type="tns:ApprovalRouteType"/>
  <xsd:complexType name="ApprovalRouteType">
    <xsd:sequence>
      <xsd:element name="Approver" type="xsd:string" minOccurs="0" maxOccurs="unbounded"/>
    </xsd:sequence>
  </xsd:complexType>
</xsd:schema>

To start with, we have an ApprovalRoute element based on a complex type with the approvalRoute sub-element being the comma-separated list of approvers. Then as a target we have an ApprovalRoute, based on a list of Approver elements.

I generated the following source xml to transform:
<?xml version="1.0" encoding="UTF-8" ?>
<ApprovalRoute xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:schemaLocation="http://www.example.org/Approvals/Source SOA/Schemas/Approvals-Source.xsd"
               xmlns="http://www.example.org/Approvals/Source">
  <approvalRoute>Approver1,Approver2,Approver3,Approver4,Approver5</approvalRoute>
  <autoApprove>true</autoApprove>
</ApprovalRoute>

Now, we need to split the approvalRoute value in a part before the first comma, and after the first comma. The value before the first comma can be put in an element. But the remainder has to be fed into the same template again. Then, at the end there is no comma in the remainder, so the part before the comma will be empty. There is no comma anymore, so we should not call the template with the remainder, but simply put the remainder in an element. Therefor, the non-existence of the comma can be the end-condition.

Remember, using recursion, you should always have a finalizing condition. To be honest, in my first piece of code in the answer of the question, I forgot about that. But, to my defence: I just put it together by heart and haven't been able to test.

The explanation above results in the following template:
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
                xmlns:xp20="http://www.oracle.com/XSL/Transform/java/oracle.tip.pc.services.functions.Xpath20"
                xmlns:tns="http://www.example.org/Approvals/Target"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xmlns:ns0="http://www.example.org/Approvals/Source" xmlns:xsl="
                http://www.w3.org/1999/XSL/Transform">
  <!-- https://community.oracle.com/thread/4178385 -->
  <xsl:template match="/">
    <tns:ApprovalRoute>
      <xsl:call-template name="parseDelimitedString">
        <xsl:with-param name="delimitedStr" select="/ns0:ApprovalRoute/ns0:approvalRoute"/>
      </xsl:call-template>
    </tns:ApprovalRoute>
  </xsl:template>
  <xsl:template name="parseDelimitedString">
    <xsl:param name="delimitedStr"/>
    <!-- https://www.w3schools.com/xml/xsl_functions.asp -->
    <xsl:variable name="firstItem" select="substring-before($delimitedStr, ',')"/>
    <xsl:variable name="restDelimitedStr" select="substring-after($delimitedStr, ',')"/>
    <tns:Approver>
      <xsl:value-of select="$firstItem"/>
    </tns:Approver>
    <xsl:choose>
      <xsl:when test="contains($restDelimitedStr, ',')">
        <xsl:call-template name="parseDelimitedString">
          <xsl:with-param name="delimitedStr" select="$restDelimitedStr"/>
        </xsl:call-template>
      </xsl:when>
      <xsl:otherwise>
        <tns:Approver>
          <xsl:value-of select="$restDelimitedStr"/>
        </tns:Approver>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>

(I created this as an XSL Map, but removed the comments that were included by JDeveloper.
I tested this with the following input:
<?xml version="1.0" encoding="UTF-8" ?>
<ApprovalRoute xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:schemaLocation="http://www.example.org/Approvals/Source SOA/Schemas/Approvals-Source.xsd"
               xmlns="http://www.example.org/Approvals/Source">
  <approvalRoute>Approver1,Approver2,Approver3,Approver4,Approver5</approvalRoute>
  <autoApprove>true</autoApprove>
</ApprovalRoute>

And this resulted in the following output:
<?xml version = '1.0' encoding = 'UTF-8'?>
<tns:ApprovalRoute xmlns:tns="http://www.example.org/Approvals/Target" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.example.org/Approvals/Target file:/D:/Projects/2018-ODC/XSL-Demo/XSL-Demo/SOA/Schemas/Approvals-Target.xsd">
   <tns:Approver>Approver1</tns:Approver>
   <tns:Approver>Approver2</tns:Approver>
   <tns:Approver>Approver3</tns:Approver>
   <tns:Approver>Approver4</tns:Approver>
   <tns:Approver>Approver5</tns:Approver>
</tns:ApprovalRoute>

This I used for input for the following xslt.

The other way around: List to CSV

For didactional reasons I'll show the other way around too. Although, we'll see that this can be done easier.

In this case I mean to loop over a series of elements, starting with an index of 1, and adding the elements to a partial string. That means I have 3 parameters:
  • loopApprovers: the parent element, containing all the elements to loop over
  • index: the loop index, with a default of 1
  • partialApprovalRoute: the partial CSV list, defaulted to an empty string

The template loopApprovers can be called with only the approvalRoute. Then with an index of 1, the template is called recursively the first time, with a partialApprovalRoute assigned with the first Approver occurence and an index increased with 1.
For the other occurences where index > 1 and index <= count of elements, the template is called again recursively, but with an increased index and the indexed element added to the partialApprovalRoute separated with a comma.
Then the end situation is when the template is called where index exceeds the count of elements. Then just the partialApprovalRoute is 'returned'  (by the value-of instruction) where it is substringed to a 20000 characters:

<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
                xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                xmlns:xp20="http://www.oracle.com/XSL/Transform/java/oracle.tip.pc.services.functions.Xpath20"
                xmlns:ns0="http://www.example.org/Approvals/Target"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xmlns:tns="http://www.example.org/Approvals/Source" 
                xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="/">
    <tns:ApprovalRoute>
      <tns:approvalRoute>
        <xsl:call-template name="loopApprovers">
          <xsl:with-param name="approvalRoute" select="/ns0:ApprovalRoute"/>
        </xsl:call-template>
      </tns:approvalRoute>
    </tns:ApprovalRoute>
  </xsl:template>
  <xsl:template name="loopApprovers">
    <xsl:param name="approvalRoute"/>
    <xsl:param name="index" select="1"/>
    <xsl:param name="partialApprovalRoute" select="''"/>
    <xsl:choose>
      <xsl:when test="number($index)=1">
        <xsl:call-template name="loopApprovers">
          <xsl:with-param name="approvalRoute" select="$approvalRoute"/>
          <xsl:with-param name="index" select="$index+1"/>
          <xsl:with-param name="partialApprovalRoute" select="$approvalRoute/ns0:Approver[1]"/>
        </xsl:call-template>
      </xsl:when>
      <xsl:when test="number($index)> 1 and number($index)&lt;=count($approvalRoute/ns0:Approver)">
        <xsl:call-template name="loopApprovers">
          <xsl:with-param name="approvalRoute" select="$approvalRoute"/>
          <xsl:with-param name="index" select="$index+1"/>
          <xsl:with-param name="partialApprovalRoute"
                          select="concat($partialApprovalRoute,',',$approvalRoute/ns0:Approver[number($index)])"/>
        </xsl:call-template>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="substring($partialApprovalRoute,1,20000)"/>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>

Simpler transformation from list to csv

As can be found here for instance, a for-each does not necessarily need to return an element. It can return just a value. So, it can be a bit simpeler:
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
                xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                xmlns:xp20="http://www.oracle.com/XSL/Transform/java/oracle.tip.pc.services.functions.Xpath20"
                xmlns:ns0="http://www.example.org/Approvals/Target"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xmlns:tns="http://www.example.org/Approvals/Source" 
                xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <!-- http://p2p.wrox.com/xslt/72164-xslt-need-concatenate-strings-loop-hold-them-later-use.html -->
  <xsl:template match="/">
    <tns:ApprovalRoute>
      <tns:approvalRoute>
        <xsl:call-template name="loopApprovers">
          <xsl:with-param name="approvalRoute" select="/ns0:ApprovalRoute"/>
        </xsl:call-template>
      </tns:approvalRoute>
    </tns:ApprovalRoute>
  </xsl:template>
  <xsl:template name="loopApprovers">
    <xsl:param name="approvalRoute"/>
    <xsl:variable name="approvalRouteCsv">
      <xsl:for-each select="$approvalRoute/ns0:Approver">
        <xsl:value-of select="concat(substring(.,1,20000),',')"/>
      </xsl:for-each>
    </xsl:variable>
    <xsl:value-of select="substring($approvalRouteCsv,1,string-length($approvalRouteCsv)-1)"/>
  </xsl:template>
</xsl:stylesheet>

Conclusion

Understanding Recursion with XSLT will help you with solving much complexer problems in transformations. The last example of transforming a list to a comma separated list is of course structural easier. But the recursive variant allows for more calculations or conditional processing.

1 comment :

Jan Kettenis said...

I implemented something similar using a (JavaScript) library in OIC Integration. But then I'm not such an XSLT hero like you 😉