3
votes

I would like to sort elements based on a derived value based on child elements. The derived value cannot be calculated using XPath (sum, concat, etc), but can with XSL (xsl:choose, xsl:if, etc).

I would use the EXSLT function extension but it is not available. The environment is XSLT 1.0, Xalan-C++ version 1.10 with the EXSLT common and set extensions.

EDIT Changed the example to emphasize that the derived value I need to group by cannot be calculated with simple node/xpath functions in xsl:sort statements.

My goal is to list current medications before inactive ones, sorted by descending start date. The logic to determine if a medication is current depends on if it was canceled, hasn't expired, and some other business logic.

Given this XML:

<?xml version="1.0"?>
<medications>
  <medication>
    <name>med1</name>
    <status>canceled</status>
    <startTime>2012-02-01T00:00:00Z</startTime>
    <endTime>2012-12-31T00:00:00Z</endTime>
    <!-- other elements omitted -->
  </medication>
  <medication>
    <name>med2</name>
    <status />
    <startTime>2012-01-01T00:00:00Z</startTime>
    <endTime>2012-01-07T00:00:00Z</endTime>
    <!-- other elements omitted -->
  </medication>
  <medication>
    <name>med3</name>
    <status />
    <startTime>2012-01-01T00:00:00Z</startTime>
    <!-- other element omitted -->
  </medication>
</medications>

The stylesheet will produce a sorted list of medications including information omitted from the example data (ordering doctor, pharmacy location, etc) and data from a parent node (patient address, primary care physician, etc). For this example, I'll just produce a simple sorted list that shows the medication node can be traversed:

<?xml version="1.0" encoding="utf-8"?>
<medications>
  <medication isCurrent="1" name="med3" />
  <medication isCurrent="0" name="med1" />
  <medication isCurrent="0" name="med2" />
</medications>

The best I could come up with is to pre-calculate the derived value into an EXSLT node-set (along with other values needed for sort), and use a key to lookup the medication element by generate-id:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:exsl="http://exslt.org/common"
  exclude-result-prefixes="exsl">
  <xsl:output encoding="utf-8" method="xml" indent="yes" />

  <xsl:key name="medications-by-id" match="medication" use="generate-id()" />
  <xsl:variable name="medication-sorter">
    <xsl:for-each select="//medication">
      <item id="{generate-id(.)}">
        <xsl:attribute name="isCurrent">
          <xsl:apply-templates mode="isCurrentMedication" select="." />
        </xsl:attribute>
        <xsl:attribute name="startTime">
          <xsl:value-of select="startTime/text()" />
        </xsl:attribute>
      </item>
    </xsl:for-each>
  </xsl:variable>

  <xsl:template match="medications">
    <!-- hardcoded key lookup works -->
    <hardcoded><xsl:value-of select="key('medications-by-id',generate-id(medication[2]))/name/text()"/></hardcoded>

    <!-- but key lookup from the sort helper does not -->
    <medications>
      <xsl:for-each select="exsl:node-set($medication-sorter)/item">
        <xsl:sort select="@isCurrent" order="descending" />
        <xsl:sort select="@startTime" order="descending" />
        <medication>
          <xsl:attribute name="isCurrent">
            <xsl:value-of select="@isCurrent" />
          </xsl:attribute>
          <xsl:attribute name="name">
            <xsl:value-of select="key('medications-by-id',@id)/name/text()" />
          </xsl:attribute>
        </medication>
      </xsl:for-each>
    </medications>
  </xsl:template>

  <xsl:template mode="isCurrentMedication" match="medication">
    <xsl:choose>
      <xsl:when test="(status/text()='canceled') or (status/text()='discontinued') or (status/text()='inactive')">0</xsl:when>
      <xsl:otherwise>
        <!-- omitted date checking logic not relevent to this question, so just hardcoded -->
        <xsl:choose>
          <xsl:when test="name/text()='med2'">0</xsl:when>
          <xsl:when test="name/text()='med3'">1</xsl:when>
        </xsl:choose>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>

However, this didn't work as expected. When looking up the key with generate-id(medication[2]) the node is valid and the name is output, but not work when called using the @id from the node set, even though the values appear to be exactly the same:

<?xml version="1.0" encoding="utf-8"?>
<medications>
  <hardcoded>med2</hardcoded>
  <medication isCurrent="1" name="" />
  <medication isCurrent="0" name="" />
  <medication isCurrent="0" name="" />
</medications>

Also tested this with Xalan for Java 2.7.1 with the same result.

I can get around this by including a copy-of the medication element in the $medication-sorter node-set, but then the parent context is lost and there are times where my styleheet will need that.

Is there another way to approach sorting/grouping on a value that must be calculated using a xsl:template?

2

2 Answers

2
votes

The solution might be much simplier:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output encoding="utf-8" method="xml" indent="yes"/>
<xsl:template match="books">
    <xsl:copy>
        <xsl:for-each select="book">
            <xsl:sort select="status" order="descending"/>
            <xsl:sort select="number(count) &gt; 0" order="descending"/>
            <xsl:copy-of select="."/>
        </xsl:for-each>
    </xsl:copy>
</xsl:template>
</xsl:stylesheet>
0
votes

This is all looking far too complex. You don't need a key when just sorting and not grouping or ignoring duplicates.
Try the following, which sorts on status and count, both descending, so that Out of Print occurs after Published, and zero count occurs last within each status.

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>

    <xsl:template match="/">
        <output>
            <xsl:for-each select="books/book">
                <xsl:sort select="status" order="descending"/>
                <xsl:sort select="count" order="descending"/>
                <xsl:copy-of select="."/>
            </xsl:for-each>
        </output>
    </xsl:template>

</xsl:stylesheet>

The result is slightly different from what you specified; either you are happy with it thus or we need to do a little complexification.

<?xml version="1.0" encoding="UTF-8"?>
<output>
    <book>
        <title>Three</title>
        <price>30</price>
        <count>3</count>
        <status>Published</status>
    </book>
    <book>
        <title>One</title>
        <price>10</price>
        <count>1</count>
        <status>Published</status>
    </book>
    <book>
        <title>Zero</title>
        <price>5</price>
        <count>0</count>
        <status>Published</status>
    </book>
    <book>
        <title>Two</title>
        <price/>
        <count>0</count>
        <status>Out of Print</status>
    </book>
</output>