perlxml-twig

Issue when trying to insert an XML sub-tree as new element using XML::Twig


(This is the XML variant of JSONizing nested Perl objects)

Creating an XML presentation of my object, I deferred creating XML for the sub-objects to those. After an initial version was working, but created an unnecessarily deeply nested structure, I tried to flatten the structure somewhat, but ran into a problem I was able to solve via some ugly work-around only.

Let's view this code snippet (executed in a foreach loop):

my $child = XML::Twig::Elt->new('sample', { 'name' => $_ });
my $F = $val->{$_}->XML_string($child);
$F->print;
$child = $elt->insert_new_elt(last_child => 'dummy');
$child->replace_with($F);

$F (assigned to a temporary variable for debugging purposes) contains the XML sub-tree for the object $val->{$_} ($val is the current object slot being processed, and in this case it's a collection (hash) of sub-objects. So $val->{$_} is the sub-object to process). The sub-tree is added as children of the extra parameter ($child, i.e.: sample).

What I wanted to do was $elt->insert_new_elt(last_child => $F);, but that never worked. So I insert a dummy element at the right position, just to replace it afterwards.

Here is some debugging output for these lines:

### The parent node where the new 'sample' children should be added
  DB<4> p $elt->print
<perf_data/>
### The new child (it's still too complex...)
  DB<5> p $F->print
<sample name="max"><label>max</label><value>2.48584</value><unit/><warn><range part="end">0.5</range><range part="inverted">0</range><range part="start">0</range></warn><crit><range part="end">1</range><range part="inverted">0</range><range part="start">0</range></crit><min>0</min><max/></sample>
### the dummy child
  DB<6> p $child->print
<dummy/>
### The parent after adding the dummy child
  DB<7> p $elt->print
<perf_data><dummy/></perf_data>
### the parent after having replaced the dummy child with the real one
  DB<8> p $elt->print
<perf_data><sample name="max"><label>max</label><value>2.48584</value><unit/><warn><range part="end">0.5</range><range part="inverted">0</range><range part="start">0</range></warn><crit><range part="end">1</range><range part="inverted">0</range><range part="start">0</range></crit><min>0</min><max/></sample></perf_data>
### more 'sample' elements to follow...

How can I avoid the temporary dummy element? I tried to find pout from the docs, but failed. Maybe to add the children in-order, I should use something other than insert_new_elt (maybe an append_new_elt?), but most of the docs is about parsing existing XML, not constructing XML.

Simplified alternate version

As I was asked to provide a simplified version, here is one. However it's only a little bit similar to the original data structures. At least I tried to preserve the essence of the problem.

So here is the code:

#!/usr/bin/perl
use strict;
use warnings;
use 5.018;

package FOO;

use constant ATTRIBUTES => (
    ['a', 0],
    ['b', 1],
    ['c', 2],
    ['d', 3],
);

sub new($)
{
    my $class = shift;
    my $self = [];

    $#$self = 4;
    bless $self, $class;
    foreach (@$self) {
        $_ = int(rand(10))
    }
    return $self;
}

use XML::Twig;
sub XML_string($;$)
{
    my ($self, $root) = @_;

    $root //= XML::Twig::Elt->new(__PACKAGE__);
    foreach (ATTRIBUTES) {
        my ($name, $i) = @$_[0, 1];

        if (defined(my $val = $self->[$i])) {
            my $elt = $root->insert_new_elt(last_child => $name);

            if ($i == 1 || $i == 2) {
                my $range = $elt->insert_new_elt(last_child => 'range');

                $range->set_att('baz' => $val);
            }
        }       # else leave out
    }
    return $root;
}

package BAR;

use constant ATTRIBUTES => (
    ['e', 0],
    ['f', 1],
    ['g', 2],
    ['h', 3],
);

sub new($)
{
    my $class = shift;
    my $self = [];

    $#$self = 4;
    bless $self, $class;
    foreach (@$self) {
        $_ = int(rand(10))
    }
    return $self;
}

use XML::Twig;
sub XML_string($;$)
{
    my $self = shift;
    my $xml = XML::Twig->new()->set_xml_version('1.0')->set_encoding('utf-8');
    my $b = XML::Twig::Elt->new(__PACKAGE__, {'version' => '1.0'});

    $xml->set_root($b);
    foreach (ATTRIBUTES) {
        my ($name, $i) = @$_[0, 1];

        if (defined(my $val = $self->[$i])) {
            my $elt = $b->insert_new_elt(last_child => $name);

            if ($i == 1) {
                my $e = $elt->insert_new_elt(last_child => 'sample');
                my $F = $val->XML_string($e);

                #print $F->print,"\n";;
                $e = $elt->insert_new_elt(last_child => 'dummy');
                $e->replace_with($F);
            }
        }       # else leave out
    }
    if ($#_ >= 0 && $_[0]) {
        $xml->set_pretty_print('indented');
    }
    return $xml->sprint();
}

package main;
my $f1 = FOO->new();
my $f2 = FOO->new();
my $b = BAR->new();
$b->[1] = $f1;
$b->[2] = $f2;
print $b->XML_string(1), "\n";

And a sample output might look like this:

  DB<3> x $b
0  BAR=ARRAY(0x1b83860)
   0  1
   1  FOO=ARRAY(0xe2ad40)
      0  2
      1  4
      2  0
      3  4
      4  7
   2  FOO=ARRAY(0x1633788)
      0  3
      1  8
      2  0
      3  1
      4  0
   3  3
   4  1
  DB<4> n
<?xml version="1.0" encoding="utf-8"?>
<BAR version="1.0">
  <e/>
  <f>
    <sample>
      <a/>
      <b>
        <range baz="4"/>
      </b>
      <c>
        <range baz="0"/>
      </c>
      <d/>
    </sample>
  </f>
  <g/>
  <h/>
</BAR>

I hope it's similar enough to the original problem.


Solution

  • Don't use the constructor for the new element. Use the constructor parameters directly for insert_new_elt:

    my $child = $elt->insert_new_elt(last_child => 'sample', {name => $_});
    $val->{$_}->XML_string($child);
    

    or, use paste:

    my $child = XML::Twig::Elt->new('sample', {name => $_});
    $child->paste(last_child => $elt);