Converting a SimpleXML object to an array

This functionality can now be found within the Titon Utility library.

If you have been following my Twitter, you would of heard me complaining about converting a SimpleXML object into an array. I am still having that problem, so if you can get it working correctly (my test so far below), I would be greatly appreciative. If you have never used the SimpleXML object, it can be quite awesome when actually reading an XML document - but once it comes to converting it to something else, it comes straight from the darkest depths of hell. Every property of the object, is also a SimpleXML object, so on and so forth. Each property/object has a method children(), which returns more properties, or attributes() which returns attributes; weirdly enough, children() also return attributes. Furthermore, you can't just echo the object out to get a value, you have to turn it into a string. You can see where this can get quite difficult and confusing, as it always spits out data your not expecting.

After countless hours, I was able to get it to properly convert to an array... about 95% of the time... while keeping attributes and parent/children hierarchy. The only scenario where it doesn't convert properly, is when you have nodes within a node that has attributes (which is kind of rare in my opinion). Here's a small little example:

// Works just fine
<root>
	<node foo="bar">I'm a node!</node>
</root>
// Does not work
<root>
	<node foo="bar">
		<childNode>I'm here to make your life miserable!</childNode>
		<childNode>Me too!</childNode>
	</node>
</root>

Besides that little instance, I am able to properly turn an XML document with attributes, and multiple nodes with the same name, all into a perfectly replicated array. Here is the code I wrote to achieve such an amazing task (sarcasm).

/**
 * Convert a SimpleXML object into an array (last resort).
 *
 * @access public
 * @param object $xml
 * @param boolean $root - Should we append the root node into the array
 * @return array
 */
public function xmlToArray($xml, $root = true) {
	if (!$xml->children()) {
		return (string)$xml;
	}
	$array = array();
	foreach ($xml->children() as $element => $node) {
		$totalElement = count($xml->{$element});
		if (!isset($array[$element])) {
			$array[$element] = "";
		}
		// Has attributes
		if ($attributes = $node->attributes()) {
			$data = array(
				'attributes' => array(),
				'value' => (count($node) > 0) ? xmlToArray($node, false) : (string)$node
				// 'value' => (string)$node (old code)
			);
			foreach ($attributes as $attr => $value) {
				$data['attributes'][$attr] = (string)$value;
			}
			if ($totalElement > 1) {
				$array[$element][] = $data;
			} else {
				$array[$element] = $data;
			}
		// Just a value
		} else {
			if ($totalElement > 1) {
				$array[$element][] = xmlToArray($node, false);
			} else {
				$array[$element] = xmlToArray($node, false);
			}
		}
	}
	if ($root) {
		return array($xml->getName() => $array);
	} else {
		return $array;
	}
}

I know exactly where the problem resides also. Its the value index of the $data array. (The little bastard below).

$data = array('attributes' => array(), 'value' => (string)$node);
// Should be
$data = array('attributes' => array(), 'value' => xmlToArray($node, false));

A simple fix right? Nope! When you do that, it totally breaks... for some reason. The first line of the function (!$xml->children()) gets passed since the element passed does have children, since it has attributes; now I can never understand why attributes count as children when you have attributes(). I tried many different conditionals to get it working, I tried unsetting the attributes (but can't determine the property), and all these other routes... but to no avail. But I digress, seeing as how it works 95% of the time, and the case it doesn't work isn't used that much. However, if you can figure it out, I will be in your debt forever.

Fixing a Models result array, when doing subqueries

This approach should no longer be used in the later versions of CakePHP. I highly suggest using the ContainableBehavior.

In some cases, you want to grab extra data in the find() method by calling an SQL statement like COUNT() AS, or SELECT(). When you do this, your extra data is not nested in the Model index of your resulted array. In the example below, we are doing a test find() and taking a look at the returned array.

// Find() query
$this->User->find('all', array(
	'fields' => array(
    	'User.*', 
        'COUNT(User.id) AS totalUsers'
   	)
)); 
/* Resulting array
[User] => Array (
    [id] => 1
    [username] => milesj
)
[0] => Array (
    [totalUsers] => 100
)*/

Now there are two ways to fix this problem, one is doing it in the afterFind() of your model, and the other is editing the core CakePHP DBO files. The first method can be found at the link below, and was written by a fellow baker, Teknoid. This technique would only apply to the model it was put in, the next technique applies it globally.

http://teknoid.wordpress.com/2008/09/29/dealing-with-calculated-fields-in-cakephps-find/

The second method is editing the resultSet() method of your datasource (does not apply to all datasources), which was brought to my attention by grigri. In my example, this technique will work for both MySQL and MySQLi, but I will be using MySQLi. Ee need to open the MySQLi datasource found at cake/libs/model/datasources/dbo/dbo_mysqli.php, copy the whole code and save our own version at app/model/datasources/dbo/dbo_mysqli.php. Once we have created our own file, we will navigate our way down to the method resultSet(). All we need to do is add another if statement in the while loop that looks for a result similar to Model__fieldName. Below you can see the before and after edits (only a part of the method):

// Old code block
while ($j < $numFields) {
    $column = mysqli_fetch_field_direct($results, $j);
    if (!empty($column->table)) {
        $this->map[$index++] = array($column->table, $column->name);
    } else {
        $this->map[$index++] = array(0, $column->name);
    }
    $j++;
}
// New code block
while ($j < $numFields) {
    $column = mysqli_fetch_field_direct($results,$j);
    if (!empty($column->table)) {
        $this->map[$index++] = array($column->table, $column->name);
    } else {
        if (strpos($column->name, '__')) {
            $parts = explode('__', $column->name);
            $this->map[$index++] = array($parts[0], $parts[1]);
        } else {
            $this->map[$index++] = array(0, $column->name);
        }
    }
    $j++;
}

This technique is probably the easiest to do, and will apply to all models. Once we have altered our datasource, we can change our find() method and our result should be working correctly now.

// Find() query
$this->User->find('all', array(
	'fields' => array(
    	'User.*', 
        'COUNT(User.id) AS User__totalUsers'
   	)
)); 
/* Resulting array
[User] => Array (
    [id] => 1
    [username] => milesj
    [totalUsers] => 100
)*/