Ben, I know that you have done a fair bit of work with xml files in ColdFusion and I am hoping you can help me with a problem I am trying to solve. I am interested in sorting an xml file by a given attribute. For Example:
I would like to sort the xmlitem nodes by the name attribute. Do you know of a simple way to do this? Can xslt be used to solve this? I know that xslt can be used for sorting, but I am not familiar with it.
This was a really interesting question. I have to say, it's not one that I have come up against before, even in my own programming, and it took me a little while to figure out. The solution I have is for this very specific problem; however, after this, I'd love to come up with a way to abstract this out into something more generic. But, that's a whole other blog post altogether.
Ok, so let's just take a look at the code, and then we can walk through it a bit more:
<!--- Define the XML data. ---> <cfxml variable="xmlData"> <xmlitems> <xmlitem name="b">something</xmlitem> <xmlitem name="a">somethingelse</xmlitem> </xmlitems> </cfxml> <!--- Define the XSL Transofrm data. ---> <cfxml variable="xmlTransform"> <!--- Document type declaration. ---> <?xml version="1.0" encoding="ISO-8859-1"?> <xsl:transform version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <!--- Match the root node (xmlitems). ---> <xsl:template match="xmlitems"> <!--- Copy the current node's top-level values (the tag and it's attributes, but not it's descendents). ---> <xsl:copy> <!--- Loop over the xmlitem nodes. ---> <xsl:for-each select="xmlitem"> <!--- As we are looping over these xmlitem nodes, sort them ascendingly according to their NAME attribute. ---> <xsl:sort select="@name" data-type="text" order="ascending" /> <!--- Copy the entire node (include its descendantas). ---> <xsl:copy-of select="." /> </xsl:for-each> </xsl:copy> </xsl:template> </xsl:transform> </cfxml> <cfoutput> <!--- Output the transformation. ---> #HTMLEditFormat( XmlTransform( xmlData, xmlTransform ) )# </cfoutput>
Running the above code, we get the following output (based on our resultant XML document):
<?xml version="1.0" encoding="UTF-8"?> <xmlitems> <xmlitem name="a">somethingelse</xmlitem> <xmlitem name="b">something</xmlitem> </xmlitems>
As you can see, the XML document is copied over with the XmlItem nodes sorting ascendingly using the NAME attribute.
Ok, so let's walk through the code a bit. The first thing we are doing is matching a template on the root element, XmlItems. Once we have that matched node, we are copying the that node using the xsl:copy command. The xsl:copy command copies the current node without its child nodes. Within this copy, we then loop over the child nodes, XmlItem, and copy those using the xsl:copy-of command. The difference between xsl:copy and the xsl:copy-of command is that the xsl:copy-of copies over the current tag attribute AND the child nodes. The key to this solution is that inside the node loop, xsl:for-each, we are using an xsl:sort command to define the order in which the child-nodes are being iterated. In our case, the sort direction depends on the value of the NAME attribute as defined by the select="@name" sort.
I know that this solution is very specific to your sample data; but, maybe it will point you in the right direction. I'd like to now take some time to figure out how to do this in a more generic way.
Want to use code from this post? Check out the license.
The XSL here doesn't need to be in a cfxml tag, you can just save it to a string with cfsavecontent, which is how I generally save xsl. I could easily see a function with an xsl template created at run-time that accepts just 2 arguments in addition to the XML to modify and return the updated XML. The first argument would be an xpath to target the nodes to sort and the 2nd argument would be an xpath to target the attributes (or other nodes) to use for sorting. So you'd say myXML = xslSort(myXML,"/root/parent/sortednode","@sortby") -- and you could make that 2nd xpath argument optional by making it default to "./text()" or possibly ".". I guess you would want a couple extra arguments though to allow reverse order.
The down side is that xmlTransform returns a string instead of modifying the existing XML packet in memory, so you have to then parse the string and then return it to the same variable name if you want the packet to stay in the same place. Would be nice imo if it worked like ArrayAppend() where the target XML packet is modified directly instead of making a string rep. of a copy of the XML.
It's funny you mention CFSaveContent - as I was typing the actual CFXML tag, it occurred to me, "I don't have to compile this as a CF XML document." But then, I just said, "Ah" and went ahead with the CFXML anyway.
The function is a bit more complicated that I would have hoped. I was tinkering with it earlier. The problem is that the the above example uses the *parent* of the target nodes. Ideally, I think you'd want to, as you say, give XPath the target nodes. Here is what I had:
XmlSort( Xml, TargetXPath, SortXpath [, DataType [, Direction]] )
But, once I have the target XPath, I am not sure how to really move up one place (to parent) and then run the sort from there. Cause, once you are *in* a target node template, you lose the sort context.... I think. My knowledge of XPath is not quite this good.
Still tinkering :)
Yeah, true. There would be nothing fast about this anyway - XPath is very slow in ColdFusion. So, re-parsing the string into XML is probably just a drop in the pond.
I'm pretty sure you can get the parent node with .. in an xpath statement, the same way . represents the current node -- much like file path conventions... so ../blah means go up to the parent node and find me the blah from there ... I think there's also a parent-node:: or somesuch, but I haven't really used it.
You are correct. I am messing around with it. Fingers crossed.
Thanks for this. I appreciate your xslt knowledge. I can definitely use this, and I look forward to seeing what you come up with for a more generic solution.
Here's my best attempt so far:
I am not totally happy with it cause I feel the XPath requirements are somewhat awkward. I'd like to come up with something better, but because XML can be so mixed, maybe this makes the most sense???