tags:

views:

602

answers:

4

OK, I KNOW that variations on this have been asked and answered; I've been reading them all day, but I'm still stuck. So, here goes:

I need to create a summary list in HTML from some XML.

Given this XML:

<Root><!-- yes, I know I don't need a 'Root' element! Legacy code... -->
  <Plans>
    <Plan AreaID="1" UnitID="83">
      <Part ID="9122" Name="foo" />
      <Part ID="9126" Name="bar" />
    </Plan>
    <Plan AreaID="1" UnitID="86">
      <Part ID="8650" Name="baz" />
    </Plan>
    <Plan AreaID="2" UnitID="26">
      <Part ID="215" Name="quux" />
    </Plan>
    <Plan AreaID="1" UnitID="95">
      <Part ID="7350" Name="meh" />
    </Plan>
  </Plans>
</Root>

I need to emit:

<ol>
  <li>Area 1: 
    <ol><!-- units in Area 1 -->
      <li>Unit 83: 
        <ol>
          <li>Part 9122 (foo)</li>
          <li>Part 9126 (bar)</li>
        </ol>
      </li>
      <li>Unit 86: 
        <ol>
          <li>Part 8650 (baz)</li>
        </ol>
      <li>Unit 95: 
        <ol>
          <li>Part 7350 (meh)</li>
        </ol>
      </li>
    </ol><!-- /units in Area 1-->
  </li>
  <li>Area 2: 
    <ol><!-- units in Area 2 -->
      <li>Unit 26: 
        <ol>
          <li>Part 215 (quux)</li>
        </ol>
      </li>
    </ol><!-- /units in Area 2-->
  </li>
</ol>

I have the outer grouping working -- I get top-level list elements for Area 1 and 2. But I can't get the sequences of Units in the Areas -- I either get no output, or repeating the same value. I haven't even got down to the Part level :-(

I've been working on a stylesheet like this:

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 
<xsl:output method="html" indent="yes"/>

<xsl:key name="kAreaID" match="Plan" use="@AreaID" />
<xsl:key name="kUnitID" match="Plan" use="@UnitID" />

<xsl:template match="/Root/Plans">
<html><head><title>test grouping</title></head>
<body>
  <ol>
    <xsl:for-each select="./Plan[generate-id(.) = 
                      generate-id( key( 'kAreaID', @AreaID )[1] )]"
    >
      <xsl:sort order="ascending" select="./@AreaID" />
      <li>Area <xsl:value-of select="@AreaID"/>: 
        <ol>
          <xsl:for-each select="key( 'kUnitID', @UnitID )">
            <li>Unit <xsl:value-of select="@UnitID"/>: 
              <ol>
                <li>(Parts go here...)</li>
              </ol>
            </li>
          </xsl:for-each>
        </ol>
      </li>
    </xsl:for-each>
  </ol>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

Any help is greatly appreciated!

A: 

This does what you want but with recursion, not grouping. Sorry I am still learning how to use grouping too:

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"&gt;
  <xsl:output method="html" indent="yes"/>

  <xsl:key name="kAreaID" match="Plan" use="@AreaID" />
  <xsl:key name="kUnitID" match="Plan" use="@UnitID" />

  <xsl:template match="/Root/Plans">
    <html>
      <head>
        <title>test grouping</title>
      </head>
      <body>
        <ol>
          <xsl:for-each select="./Plan[generate-id(.) = 
                      generate-id( key( 'kAreaID', @AreaID )[1] )]"    >
            <xsl:sort order="ascending" select="./@AreaID" />
            <xsl:variable name="curArea" select="@AreaID"/>

            <li>
              Area <xsl:value-of select="$curArea"/>:
              <ol>
                <xsl:for-each select="ancestor::Root/Plans/Plan[@AreaID = $curArea]">
                  <xsl:variable name="curUnit" select="@UnitID"/>
                  <li>
                    Unit <xsl:value-of select="$curUnit"/>:
                    <ol>
                        <xsl:for-each select="ancestor::Root/Plans/Plan[@AreaID = $curArea and @UnitID = $curUnit]/Part">
                          <li>
                            Part <xsl:value-of select="concat(@ID, '  (', @Name, ')')"/>
                          </li>
                        </xsl:for-each>
                    </ol>
                  </li>
                </xsl:for-each>
              </ol>
            </li>
          </xsl:for-each>
        </ol>
      </body>
    </html>
  </xsl:template>
</xsl:stylesheet>
Jay
A: 

I don't think you need to use the kUnitID key at all. Instead replace the following line...

<xsl:for-each select="key( 'kUnitID', @UnitID )">

..with this line instead, which should loop over all parts matching the current AreaID

<xsl:for-each select="key( 'kAreaID', @AreaID )">

And within this loop, for your (Parts go here...) code, you can then simply loop over the parts

<xsl:for-each select="Part">
   <li>Part (<xsl:value-of select="@ID" />)</li>
</xsl:for-each>
Tim C
Very nice! But, i get similar results to above -- the 2nd-level elements are repeating instead of grouping, so I getArea 1 Unit 83 Part 9122 Unit 83 Part 9126 Unit 86 Part 8650 instead of Area 1 Unit 83 Part 9122 Part 9126 Unit 86 Part 8650 I'll keep hacking at it. Thanks for the help!
Val
+1  A: 

Well, I gave up on the keys and Muenchian grouping for the time being. I barely understand it, and hacking away at it didn't yield the desired results. I understand recursion, tho, and so I went with this recursive approach that produces the desired output. I found it at http://www.biglist.com/lists/xsl-list/archives/200412/msg00865.html

The discussion thread warns that performance suffers on large input vs. the Muenchian approach, and the solution below is verbose and repetitive (I could prolly refactor it to make it smaller and harder to understand ;-) but 1) it actually works for me, and 2) for my present problem the input sets are pretty small, no more than a dozen or so bottom-level Part nodes.

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"&gt;
  <!-- recursive grouping http://www.biglist.com/lists/xsl-list/archives/200412/msg00865.html -->

  <xsl:template match="//Plans">
    <html>
      <head>
        <title>test grouping</title>
      </head>
      <body>
        <ol>
          <xsl:call-template name="PlanGrouping">
            <xsl:with-param name="list" select="Plan"/>
          </xsl:call-template>
        </ol>
      </body>
    </html>
  </xsl:template>

  <xsl:template name="PlanGrouping">
    <xsl:param name="list"/>
    <!-- Selecting the first Area ID as group identifier and the group itself-->
    <xsl:variable name="group-identifier" select="$list[1]/@AreaID"/>
    <xsl:variable name="group" select="$list[@AreaID = $group-identifier]"/>
    <!-- Do some work for the group -->
    <li>
      Area <xsl:value-of select="$group-identifier"/>:
      <ol>
        <xsl:call-template name="AreaGrouping">
          <xsl:with-param name="list" select="$list[(@AreaID = $group-identifier)]"/>
        </xsl:call-template>
      </ol>
    </li>
    <!-- If there are other groups left, calls itself -->
    <xsl:if test="count($list)>count($group)">
      <xsl:call-template name="PlanGrouping">
        <xsl:with-param name="list" select="$list[not(@AreaID = $group-identifier)]"/>
      </xsl:call-template>
    </xsl:if>
  </xsl:template>

  <xsl:template name="AreaGrouping">
    <xsl:param name="list"/>
    <!-- Selecting the first Unit ID as group identifier and the group itself-->
    <xsl:variable name="group-identifier" select="$list[1]/@UnitID"/>
    <xsl:variable name="group" select="$list[@UnitID = $group-identifier]"/>
    <!-- Do some work for the group -->
    <li>
      Unit <xsl:value-of select="$group-identifier"/>:
      <ol>
        <xsl:call-template name="Parts">
          <xsl:with-param name="list" select="$list[(@UnitID = $group-identifier)]"/>
        </xsl:call-template>
      </ol>
    </li>
    <!-- If there are other groups left, calls itself -->
    <xsl:if test="count($list)>count($group)">
      <xsl:call-template name="AreaGrouping">
        <xsl:with-param name="list" select="$list[not(@UnitID = $group-identifier)]"/>
      </xsl:call-template>
    </xsl:if>
  </xsl:template>

  <xsl:template name="Parts">
    <xsl:param name="list"/>
    <xsl:for-each select="$list/Part">
      <li>
        Part <xsl:value-of select="@ID"/> (<xsl:value-of select="@Name"/>)
      </li>
    </xsl:for-each>
  </xsl:template>

</xsl:stylesheet>
Val
+1 Actually, this is not a bad solution. Not elegant, but no reason to take it out of production. ;) However, there seems to be a copy-and-paste-error on the line below the '<xsl:if test="count($list)>count($group)">'.
Tomalak
? not seeing the copy/paste error. There are two such sections -- in the template for each grouping level, the test checks if there are remaining items at that level, and if so calls itself with the list minus the current element. What am I missing?
Val
+6  A: 

Here is the Muenchian grouping solution you are looking for.

Going from the original XML you provided, I thought grouping by AreaID would be enough, but it turns out that a second grouping by UnitID is also needed.

Here is my modified XSLT 1.0 solution. It's not a lot more complex than the original solution:

<xsl:stylesheet
  version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
>

  <xsl:key name="kPlanByArea" match="Plan" 
           use="@AreaID" />
  <xsl:key name="kPlanByAreaAndUnit" match="Plan" 
           use="concat(@AreaID, ',', @UnitID)" />

  <xsl:template match="/">
    <xsl:apply-templates select="Root/Plans" />
  </xsl:template>

  <!-- main template -->
  <xsl:template match="Plans">
    <ol>
      <!-- group by '{@AreaID}' (note the template mode!) -->
      <xsl:apply-templates mode="area-group" select="
        Plan[
          generate-id()
          =
          generate-id(
            key('kPlanByArea', @AreaID)[1]
          )
        ]
      ">
        <xsl:sort select="@AreaID" data-type="number" />
      </xsl:apply-templates>
    </ol>
  </xsl:template>

  <!-- template to output each '{@AreaID}' group -->
  <xsl:template match="Plan" mode="area-group">
    <li>
      <xsl:value-of select="concat('Area ', @AreaID)" />
      <ol>
        <!-- group by '{@AreaID},{@UnitID}' -->
        <xsl:apply-templates mode="unit-group" select="
          key('kPlanByArea', @AreaID)[
            generate-id()
            =
            generate-id(
              key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))[1]
            )
          ]
        ">
          <xsl:sort select="@UnitID" data-type="number" />
        </xsl:apply-templates>
      </ol>
    </li>
  </xsl:template>

  <!-- template to output each '{@AreaID},{@UnitID}' group -->
  <xsl:template match="Plan" mode="unit-group">
    <li>
      <xsl:value-of select="concat('Unit ', @UnitID)" />
      <ol>
        <xsl:apply-templates select="
          key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))/Part
        ">
          <xsl:sort select="@UnitID" data-type="number" />
        </xsl:apply-templates>
      </ol>
    </li>
  </xsl:template>

  <!-- template to output Parts into a list -->
  <xsl:template match="Part">
    <li>
      <xsl:value-of select="concat('Part ', @ID, ' (', @Name ,')')" />
    </li>
  </xsl:template>

</xsl:stylesheet>

Since your XML is missing it, I added a UnitID to group on:

<Plan AreaID="1" UnitID="86">
  <Part ID="8651" Name="zzz" />
</Plan>

And here is the output:

<ol>
  <li>Area 1
    <ol>
      <li>Unit 83
        <ol>
          <li>Part 9122 (foo)</li>
          <li>Part 9126 (bar)</li>
        </ol>
      </li>
      <li>Unit 86
        <ol>
          <li>Part 8650 (baz)</li>
          <li>Part 8651 (zzz)</li>
        </ol>
      </li>
      <li>Unit 95
        <ol>
          <li>Part 7350 (meh)</li>
        </ol>
      </li>
    </ol>
  </li>
  <li>Area 2
    <ol>
      <li>Unit 26
        <ol>
          <li>Part 215 (quux)</li>
        </ol>
      </li>
    </ol>
  </li>
</ol>


Since you seem to have a hard time with the XSL key, here my attempt of an explanation:

An <xsl:key> is absolutely equivalent to the associative array (map, hash, whatever you call it) known to many programming languages. This:

<xsl:key name="kPlanByAreaAndUnit" match="Plan" 
         use="concat(@AreaID, ',', @UnitID)" />

generates a data structure that could be expressed in JavaScript like this:

var kPlanByAreaAndUnit = {
  "1,83": ['array of all <Plan> nodes with @AreaID="1" and @UnitID="83"'],
  "1,86": ['array of all <Plan> nodes with @AreaID="1" and @UnitID="86"'],
  /* ... */
  "1,95": ['array of all <Plan> nodes with @AreaID="1" and @UnitID="95"']
};

The function to access the data structure is called key(). So, this XPath expression:

key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))

is the logical equivalent of (in JavaScript, again):

kPlanByAreaAndUnit[this.AreaID + ',' + this.UnitID];

returning an array (a node-set, more correctly) of all nodes matching the given key string (the key is always a string). This node-set can be used like any other node-set in XSLT, i.e. like the ones you retrieve through "traditional" XPath. This means you can apply conditions (predicates) to it:

<!-- first node only... -->
key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))[1]

<!-- nodes that have <Part> children only... -->
key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))[Part]

or use it as a base for XPath navigation:

<!-- the actual <Part> children of matched nodes... -->
key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))/Part

and so on. This also means we can use it as a "select" expression for <xsl:apply-templates>, and we can use it as a base for grouping. Which leads us to the core of the above stylesheet (if you have wrapped your head around this one, you've understood the rest of the solution as well):

key('kPlanByArea', @AreaID)[
  generate-id()
  =
  generate-id(
    key('kPlanByAreaAndUnit', concat(@AreaID, ',', @UnitID))[1]
  )
]

In JavaScript again, this could be expressed as:

// the result will be a node-set, so we prepare an array
var selectedNodes = [];

// "key('kPlanByArea', @AreaID)"
var nodeSet = kPlanByArea[this.AreaID];

// "[...]" - the [] actually triggers a loop that applies 
// the predicate expression to all nodes in the set, so we do:
for (var i = 0; i < nodeSet.length; i++) {
   // use the current node for any calculations
   var c = nodeSet[i];
   if (
     // if the current node === the *first* node in kPlanByAreaAndUnit...
     generateId(c)
     ==
     generateId(kPlanByAreaAndUnit[c.AreaID + ',' + c.UnitID][0])
   ) {
     // ...include it in the resulting selection
     selectedNodes.push(c)
   }
}

After the expression is done, only those nodes are selected that are the respective first ones with a given "AreaID, UnitID" combination - effectively we have grouped them on their "AreaID, UnitID" combination.

Applying a template to this node-set causes every combination to appear only once. My <xsl:template match="Plan" mode="unit-group"> then retrieves the full list again to achieve complete output for each group.

I hope the use of JavaScript to explain the concept was a helpful idea.

Tomalak
Hi, I was hoping you'd show up! (I read a lot of your stuff while researching this.) Example wasn't clear; it's a 3-level *target*, but only groups on 2 levels. I didn't show that the same UnitID can also appear on separate Plan elements, same as AreaID does. So I need to gather together those UnitIDs into single group, just as AreaIDs. Also, your construct that does the heavy lifting, <xsl:apply-templates select="key('kPlanByArea', @AreaID)">, still makes my head hurt. My recursive solution works, so I'm inclined to leave well-enough alone for now. Thanks, tho! (still hating XSL!)
Val
I've modified the solution to do the two-way grouping, and I've added an explanation that may make your headache go away. Since I think that code transports meaning clearer than words, I tried to model the explanation with JavaScript rather than writing it up in English. I know that the <xsl:key> concept needs some time to sink in, but once it did you may even wonder why it took so long. ;-)
Tomalak
Oh, BTW: The above solution fails if there is more than one <Plans> element in your document. If that can happen, we need to introduce one more check, just tell me if this is necessary.
Tomalak
This is a really nice explanation of key, +1 for that alone (I'll just assume the code works :P )
annakata
Agreed, best explanation of xsl:key I've seen in days of research! all else is either hand-waving ('just write this, then magic happens!'), or overly-verbose spec-speak written by academics for spec committees. Your JavaScript analogy is GREAT, and I can finally read the key() statements and make sense of them. I'd up-vote it, but don't have enuf rep yet - but I WILL mark it as Answer, even tho I'm sticking with what I wrote (passed UAT, in Production already :-). Thanks for the help!
Val
As for Plans, no, there's only one Plans element, which is why I don't need the Root element left by the previous corrupt regime. I'll find a use for this someday; in the meantime I'm on to the next problem.
Val
+1 for key explanation. well done.
E Rolnicki