Search code examples
xsltxslt-1.0exslt

XSLT with two passes so the same input data can support calculations in multiple templates


I am transforming XML into XML using XSLT 1.0. I need to do at least two passes so that I can calculate subtotals on the first pass and use that to output totals in the XML - to appear before the subtotals in the XML structure.

I have the following input XML:

<?xml version="1.0" encoding="UTF-8"?>
<invoice>
    <orders>
        <order id="1">
            <lineitem id="1">
                <item quantity="2" price="100"/>
                <item quantity="3" price="50"/>
            </lineitem>
        </order>
        <order id="2">
            <lineitem id="1">
                <item quantity="5" price="20"/>
                <item quantity="1" price="100"/>
            </lineitem>
        </order>
    </orders>
</invoice>

Which I wish to modify by adding in subtotals into the existing element and add a new statistics element at the top:

<?xml version="1.0" encoding="UTF-8"?>
<invoice>
    <statistics>
        <totalPrice>550</totalPrice>
        <totalQuantity>11</totalQuantity>
    </statistics>
    <orders>
        <order id="1">
            <lineitem id="1">
                <item quantity="2" price="100" subtotal="200"/>
                <item quantity="3" price="50" subtotal="150"/>
            </lineitem>
        </order>
        <order id="2">
            <lineitem id="1">
                <item quantity="5" price="20" subtotal="100"/>
                <item quantity="1" price="100" subtotal="100"/>
            </lineitem>
        </order>
    </orders>
</invoice>

However, I have only got half the solution, where I can add the subtotals, but haven't figured out how to add the statistics element.

<?xml version="1.0" encoding="UTF-8"?><invoice>
    <orders totalPrice="550.00">
        <order id="1">
            <lineitem id="1">
                <item quantity="2" price="100" subtotal="200.00"/>
                <item quantity="3" price="50" subtotal="150.00"/>
            </lineitem>
        </order>
        <order id="2">
            <lineitem id="1">
                <item quantity="5" price="20" subtotal="100.00"/>
                <item quantity="1" price="100" subtotal="100.00"/>
            </lineitem>
        </order>
    </orders>
</invoice>

This is my XSLT so far:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"
    xmlns:exsl="http://exslt.org/common"
    extension-element-prefixes="exsl">

    <xsl:output method="xml" indent="yes"/>

    <!-- Handling of order items -->
    <xsl:template match="item">
        <xsl:variable name="subtotal" select="@price * @quantity" />
        <item price="{@price}" quantity="{@quantity}" subtotal="{format-number($subtotal,'0.00')}" />
    </xsl:template>
    
    <!-- Root template. Calculates total based on subtotals -->
    <xsl:template match="orders">
        <xsl:variable name="processedOrders">
            <xsl:apply-templates/>
        </xsl:variable>
        <orders totalPrice="{format-number(sum(exsl:node-set($processedOrders)//item/@subtotal),'0.00')}">
            <xsl:copy-of select="exsl:node-set($processedOrders)/*"/>
        </orders>
    </xsl:template>
    
    <!-- Identity template -->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
        </xsl:copy>
    </xsl:template>
    
</xsl:stylesheet>

My thinking is that I need to move processedOrders to make it a global variable that I can use in two places:

  1. The orders template (<xsl:template match="orders">) which uses exsl:node-set to sum the sub-totals and get totalPrice.
  2. The new statistics template which will do something similar to output its data.

However, it seems that the way I am making the variable is not working because the orders template is not seeing the data in the variable.

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"
    xmlns:exsl="http://exslt.org/common"
    extension-element-prefixes="exsl">

    <xsl:output method="xml" indent="yes"/>

    <!-- ++++++++++++++++++++++++++++++++++++++++++++++
    Trying to let this be referenced by other templates.
    -->
    <xsl:variable name="processedOrders" select="/invoice/orders"/>
    
    <!-- Handling of order items -->
    <xsl:template match="item">
        <xsl:variable name="subtotal" select="@price * @quantity" />
        <item price="{@price}" quantity="{@quantity}" subtotal="{format-number($subtotal,'0.00')}" />
    </xsl:template>
    
    <!-- Root template. Calculates total based on subtotals -->
    <xsl:template match="orders">
        <orders totalPrice="{format-number(sum(exsl:node-set($processedOrders)//item/@subtotal),'0.00')}">
            <xsl:copy-of select="exsl:node-set($processedOrders)/*"/>
        </orders>
    </xsl:template>
    
    <!-- Identity template -->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
        </xsl:copy>
    </xsl:template>
    
</xsl:stylesheet>

This outputs:

<?xml version=\"1.0\" encoding=\"UTF-8\"?><invoice>
    <orders totalPrice=\"0.00\">
        <order id=\"1\">
            <lineitem id=\"1\">
                <item quantity=\"2\" price=\"100\"/>
                <item quantity=\"3\" price=\"50\"/>
            </lineitem>
        </order>
        <order id=\"2\">
            <lineitem id=\"1\">
                <item quantity=\"5\" price=\"20\"/>
                <item quantity=\"1\" price=\"100\"/>
            </lineitem>
        </order>
    </orders>
</invoice>

Update: Friday 14 June 2024, 01:36:37 PM

Using @Martin Honnen's answer I was able to get the rest of the way. Martin showed me that I do not need to store the results of the first pass in a global variable, but instead send off the first pass results to the second pass within the same template, and that the second pass can be sent to a root level template - making the second pass go over the whole document again.

The missing piece was how to add the statistics element and not have it duplicated by creating one in the first pass and another in the second pass. I use modes to handle this. Below is my new XSLT stuffed with comments that help me understand what is going on.

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"
                xmlns:exsl="http://exslt.org/common"
                extension-element-prefixes="exsl">

    <xsl:output method="xml" indent="yes"/>

    <!-- Two passes.
    The template matching / is the starting point for the transformation.
     
    It's doing two passes over your input by placing the result of the first 
    pass inside a variable and then reprocessing that result in a second pass.
    
    IMPORTANT: we are using modes to discriminate between logic we want to run 
    on the second pass only. 
    - The first pass (first use of <xsl:apply-templates/>
      within this template) has no mode. It will run all other matching templates
      that have no mode.
    - The second pass (second use of <xsl:apply-templates/>) runs in a mode 
      called "add-statistics". This will run all matching templates without a 
      mode plus any templates matching this mode. This is done to ensure that 
      the statistics element is only added once - in the second pass only, and 
      not duplicated within the first pass.
    -->
    <xsl:template match="/">
        <xsl:variable name="first-transformation">
            <xsl:apply-templates/>
        </xsl:variable>
        <xsl:apply-templates select="exsl:node-set($first-transformation)/node()" mode="add-statistics"/>
    </xsl:template>

    <!-- Write out the statistics element.
     Will write out the statistics element with the total price and total 
     quantity, but *only* when invoked by a calling template whose mode is
     "add-statistics". This template will be skipped by the first pass, because
     it has no mode. The second pass has the "add-statistics" mode so the 
     element will be added then, ensuring we do not get a duplicate.  
    -->
    <xsl:template match="invoice" mode="add-statistics">
        <invoice>
            <statistics>
                <totalPrice>
                    <xsl:value-of select="format-number(sum(//item/@subtotal),'0.00')"/>
                </totalPrice>
                <totalQuantity>
                    <xsl:value-of select="sum(//item/@quantity)"/>
                </totalQuantity>
            </statistics>
            <xsl:apply-templates/>
        </invoice>
    </xsl:template>

    <!-- Handling of order items.
    The template matching item multiplies the price and quantity attributes of 
    each item element to calculate a subtotal, then produces a new item element 
    with the price, quantity and calculated subtotal as attributes.
    -->
    <xsl:template match="item">
        <xsl:variable name="subtotal" select="@price * @quantity"/>
        <item price="{@price}" quantity="{@quantity}" subtotal="{format-number($subtotal,'0.00')}"/>
    </xsl:template>

    <!-- Root template. Calculates total based on subtotals
     The template matching orders calculates the total price by summing all the 
     subtotals, then produces a new orders element with that total as one attribute.-->
    <xsl:template match="orders">
        <orders totalPrice="{format-number(sum(//item/@subtotal),'0.00')}">
            <xsl:apply-templates/>
        </orders>
    </xsl:template>

    <!-- Identity template.
     The identity template is a design pattern that is used in XSLT where one
     wants to transform a source tree into a result tree, making changes to some
     part of it but keeping the rest of the tree the same. This basically copies
     over anything that doesn't match another template rule
     -->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
        </xsl:copy>
    </xsl:template>

</xsl:stylesheet>

Solution

  • You need to run to passes on the complete document and in the second pass you can compute the totals; for simplicity I have not used two modes but just a variable and eliminate the wrong/empty totals from the first pass computation by not processing the attribute on orders:

    <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0"
        xmlns:exsl="http://exslt.org/common"
        extension-element-prefixes="exsl">
    
        <xsl:output method="xml" indent="yes"/>
        
        <xsl:template match="/">
          <xsl:variable name="first-transformation">
            <xsl:apply-templates/>
          </xsl:variable>
          <xsl:apply-templates select="exsl:node-set($first-transformation)/node()"/>
        </xsl:template>
        
        <!-- Handling of order items -->
        <xsl:template match="item">
            <xsl:variable name="subtotal" select="@price * @quantity" />
            <item price="{@price}" quantity="{@quantity}" subtotal="{format-number($subtotal,'0.00')}" />
        </xsl:template>
        
        <!-- Root template. Calculates total based on subtotals -->
        <xsl:template match="orders">
            <orders totalPrice="{format-number(sum(//item/@subtotal),'0.00')}">
                <xsl:apply-templates/>
            </orders>
        </xsl:template>
        
        <!-- Identity template -->
        <xsl:template match="@*|node()">
            <xsl:copy>
                <xsl:apply-templates select="@*|node()"/>
            </xsl:copy>
        </xsl:template>
        
    </xsl:stylesheet>