Ask Ben: Getting Dynamic References To XML Nodes And Setting Text Values In ColdFusion
Simon Horwith and I have some very different and very strong views on dynamic variable naming and setting conventions. He thinks one thing, I think another; but, at the end of the day, both of us get the job done with our own methodologies. In a conversation that we were having about ColdFusion's archaic SetVariable() method, Horwith stated that one of the places he uses it is to set XmlText values on user-selected, deeply nested XML nodes. He presented me with a challenge to see if I could come up with a way to do this without using SetVariable(). Here are the rules Horwith laid forth:
First: Create a fairly complex XML file - an XML file with different types of nodes nested within each other that each use attributes and/or XMLText - and make sure it has at least 3 or 4 levels of nesting.
Second: The challenge: create an html/CF interface (no AJAX or Flex) that let's you pick a top level node, then submit, then pick a node of the parent node that was submitted, and submit... etc., etc. - in the real world it should let the end user drill as deep as they need/want to - at any given level you should be able to type into a text-area and set the XML Text for the current tag. What's tricky about this is that the only way to deal with it is to pass a string from form to form that contains the string-path to where you're working. You might be thinking that you can just set a session scope variable on each form submission... but the problem is that if the XML is complex enough that won't work, as only XML Nodes and XML Node XML Attributes will create pointers. Go ahead... give it a try if you have the time :)
Really, it doesn't matter how deeply nested the XML nodes are or how complicated the XML tree is. If you can make this dynamic for one parent-child step, then it's pretty much the same thing for N-steps deep with whatever breadth you may choose. As such, here is the XML tree that I created and cached in my APPLICATION scope:
<root> <a1> <b1> <c>C Text Node</c> </b1> <b2> <c>C Text Node</c> </b2> </a1> <a2> <b1> <c1> <d1> <e>E Text Node</e> </d1> </c1> </b1> <b2> <c1>C Text Node</c1> </b2> </a2> <a3> <b1>B Text Node</b1> </a3> </root>
As you can see, I am keeping it fairly simple with my node naming. Some of them have an XML Text value, others do not (or rather, it is just white space). Before we get into the code, let's take a quick look at a demo video so you can get a better overview of how this works:
From the video, it is easy to tell that the text value I am setting is actually getting stored in the cached XML tree object, which is cached in our APPLICATION scope.
When I first started to think about this problem, I was thinking in the SetVariable() mindset, since that is how Simon Horwith's solution works. Aside from the fact that I don't use SetVariable(), the concept of a dynamic variable references in XML made me very uncomfortable. I didn't know why at first and then it dawned on me - I never handle XML that way; I have a very specific way of handling all of my XML problems and that is to first get a short-hand reference (pointer) to my XML node of choice and then use that reference to do anything that I wanted in terms of accessing or mutating information.
Once I had that mentality in place, it was just a matter of figuring out a way to keep track of my location in the XML document object. If you remember that XML nodes and node properties basically act like either structs or arrays, you might realize that keeping track of your current node can be done easily by simply keeping track of your ChildNodes array index values. Think about it this way, you might have an XML path that looks like this:
xml.XmlRoot.XmlChildren[ 2 ].XmlChildren[ 1 ].XmlChildren[ 5 ]
When you see the path this way, you can clearly see that the "directions" are the indexes of the XmlChildren arrays. The tricky part is that the XmlRoot object has an implicit "1" index. So really, the above path can be defined using the following comma-delimited list on index values:
Ok, but now that we have an easy way to store the path, how do we go about turning that path into an XML node pointer? It turns out, this is actually pretty easy - the sexy marriage of XPath and XmlSearch(). Really, what we want to do is turn the above, comma-delimited list into the following XPath:
/*[ 1 ]/*[ 2 ]/*[ 1 ]/*[ 5 ]
While this might seem like a complicated jump, it is merely a line of string concatenation and replacement. And, once we have our XPath in place, we can easily get a reference to the target node. And, once we have a pointer to the target node, we can easily get and set values of that node, such as the XmlText value laid out in the above challenge.
Now that you see where I am going with this, let's take a look at the code behind this demo:
<!--- Param XML path. This is a comma delimited list of child array indexes. If it is blank then no path has been yet to be selected. ---> <cfparam name="URL.path" type="regex" pattern="^\d+(,\d+)*$" default="1" /> <!--- Check to see if we need to create or reset the XML data element cached in our Application. ---> <cfif ( (NOT StructKeyExists( APPLICATION, "Data" )) OR StructKeyExists( URL, "reset" ) )> <!--- Create a nested XML example. ---> <cfxml variable="APPLICATION.Data"> <root> <a1> <b1> <c>C Text Node</c> </b1> <b2> <c>C Text Node</c> </b2> </a1> <a2> <b1> <c1> <d1> <e>E Text Node</e> </d1> </c1> </b1> <b2> <c1>C Text Node</c1> </b2> </a2> <a3> <b1>B Text Node</b1> </a3> </root> </cfxml> </cfif> <!--- Convert the comma delmitted list of child array indexes into an XPath string so that we can get the selected node. Example: 1,3,2 Becomes: /*/*/* ---> <cfset strXPath = ( "/*[" & Replace( URL.path, ",", "]/*[", "all" ) & "]" ) /> <!--- Get the selected noded based on the XPath. ---> <cfset arrNodes = XmlSearch( APPLICATION.Data, strXPath ) /> <!--- Since XmlSearch() returns an array, let's get a pointer directly the XML node in question. If our search went bad, this will throw an error. ---> <cfset xmlNode = arrNodes[ 1 ] /> <!--- Check to see if we have a URL field for the text. If so, then let's store that text into the node. Since this node is passed by reference, this update will automatically propogate to the cached APPLICATION scope data. ---> <cfif StructKeyExists( URL, "xml_text" )> <!--- Update xml text (automatically cached). ---> <cfset xmlNode.XmlText = XmlFormat( URL.xml_text ) /> </cfif> <cfoutput> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html> <head> <title>Dynamic XML Reference And Update Demo</title> </head> <body> <h1> Dynamic XML Reference And Update Demo </h1> <p> <!--- Check to see if we have more than one child node indexes. Since we are starting in the root node, then we need more than one to have a parent. ---> <cfif (ListLen( URL.path ) GT 1)> <a href="#CGI.script_name#?path=#ListDeleteAt( URL.path, ListLen( URL.path ) )#">Parent Node</a> <cfelse> <em>No Parent</em> </cfif> </p> <p> Current Node: <strong>#xmlNode.XmlName#</strong> </p> <ol> <!--- Loop over the child nodes of the current node. This will allow us to drill down into the children of the currently selected node. ---> <cfloop index="intIndex" from="1" to="#ArrayLen( xmlNode.XmlChildren )#" step="1"> <!--- For each child node, we need to add the current child array index to ongoing XML path. ---> <li> <a href="#CGI.script_name#?path=#ListAppend( URL.path, intIndex )#">#xmlNode.XmlChildren[ intIndex ].XmlName#</a> </li> </cfloop> </ol> <form action="#CGI.script_name#" method="get"> <!--- Pass back path to this node. ---> <input type="hidden" name="path" value="#URL.path#" /> <fieldset> <legend>Current Node Data</legend> <p> Node: #xmlNode.XmlName# </p> <p> Text: <input type="text" name="xml_text" value="#xmlNode.XmlText#" size="40" /> <input type="submit" value="Update" /> </p> </fieldset> </form> <br /> <!--- Output XML data for debugging. THis is so we can see how our data is actually being cached. ---> <cfdump var="#APPLICATION.Data#" /> </body> </html> </cfoutput>
So, while Simon Horwith and I have very different views on dynamic variable naming / setting, in the end, it turned out the best solution for this problem had nothing at all to do with dynamic variable naming / setting. I can't think of anything that's easier than passing around a comma delimited list of child node indexes.
Want to use code from this post? Check out the license.
Nice! +1 for Jing for your videos as they are not blocked at my work (the other videos were) :)
Glad you guys are liking the videos. I am really getting into them. I think it goes a long way to really demonstrate what the code is doing in a way that just looking at the code cannot.
This morning, I figured out to configure it to FTP the videos to my site rather than using the central server (which has a content size limit).
Thanks for the video! It helps me to get a more clear picture of what you guys (and gals) are all talking about. I'm a visual learner and sometimes actually seeing the code working in front of me gives me a more clear picture.
Glad to know that it is working. I will continue to make those when I feel they add value.
This article was fantastically helpful. I have an XML structure with complex sub-nodes and needed to put values into the nodes and was having difficulty addressing them correctly.
Although I did not actually end up using the code in this example, studying it helped me realise what I was doing wrong and fixed my problem.
Thanks for all your great CF know-how.