File Coverage

blib/lib/XML/Compare.pm
Criterion Covered Total %
statement 9 11 81.8
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 13 15 86.6


line stmt bran cond sub pod time code
1             ## ----------------------------------------------------------------------------
2             # Copyright (C) 2009 NZ Registry Services
3             ## ----------------------------------------------------------------------------
4             package XML::Compare;
5             $XML::Compare::VERSION = '0.05';
6 6     6   176246 use 5.006;
  6         24  
7 6     6   13649 use Moo 2;
  6         95532  
  6         43  
8 6     6   15425 use MooX::Types::MooseLike::Base qw(Bool Str ArrayRef HashRef Undef);
  6         46162  
  6         624  
9              
10 6     6   7896 use XML::LibXML 1.58;
  0            
  0            
11              
12             our $VERBOSE = $ENV{XML_COMPARE_VERBOSE} || 0;
13              
14             my $PARSER = XML::LibXML->new();
15              
16             my $has = {
17             localname => {
18             # not Comment, CDATASection
19             'XML::LibXML::Attr' => 1,
20             'XML::LibXML::Element' => 1,
21             },
22             namespaceURI => {
23             # not Comment, Text, CDATASection
24             'XML::LibXML::Attr' => 1,
25             'XML::LibXML::Element' => 1,
26             },
27             attributes => {
28             # not Attr, Comment, CDATASection
29             'XML::LibXML::Element' => 1,
30             },
31             value => {
32             # not Element, Comment, CDATASection
33             'XML::LibXML::Attr' => 1,
34             'XML::LibXML::Comment' => 1,
35             },
36             data => {
37             # not Element, Attr
38             'XML::LibXML::CDATASection' => 1,
39             'XML::LibXML::Comment' => 1,
40             'XML::LibXML::Text' => 1,
41             },
42             };
43              
44             has 'namespace_strict' =>
45             is => "rw",
46             isa => Bool,
47             default => 0,
48             ;
49              
50             has 'error' =>
51             is => "rw",
52             isa => Str,
53             clearer => "_clear_error",
54             ;
55              
56             sub _self {
57             my $args = shift;
58             if ( @$args == 3 ) {
59             shift @$args;
60             }
61             else {
62             __PACKAGE__->new();
63             }
64             }
65              
66             # acts almost like an assertion (either returns true or throws an exception)
67             sub same {
68             my $self = _self(\@_);
69             my ($xml1, $xml2) = @_;
70             # either throws an exception, or returns true
71             return $self->_compare($xml1, $xml2);;
72             }
73              
74             sub is_same {
75             my $self = _self(\@_);
76             my ($xml1, $xml2) = @_;
77             # catch the exception and return true or false
78             $self->_clear_error;
79             eval { $self->same($xml1, $xml2); };
80             if ( $@ ) {
81             $self->error($@);
82             return 0;
83             }
84             return 1;
85             }
86              
87             sub is_different {
88             my $self = _self(\@_);
89             my ($xml1, $xml2) = @_;
90             return !$self->is_same($xml1, $xml2);
91             }
92              
93             # private functions
94             sub _xpath {
95             my $l = shift;
96             return "/".join("/",@$l);
97             }
98              
99             sub _die {
100             my ($l, $fmt, @args) = @_;
101             my $msg;
102             if ( @args ) {
103             $msg = sprintf $fmt, @args;
104             }
105             else {
106             $msg = $fmt;
107             }
108             die("[at "._xpath($l)."]: ".$msg);
109             }
110              
111             sub _compare {
112             my $self = shift;
113             my ($xml1, $xml2) = (@_);
114             if ( $VERBOSE ) {
115             print '-' x 79, "\n";
116             print $xml1 . ($xml1 =~ /\n\Z/ ? "" : "\n");
117             print '-' x 79, "\n";
118             print $xml2 . ($xml2 =~ /\n\Z/ ? "" : "\n");
119             print '-' x 79, "\n";
120             }
121              
122             my $parser = XML::LibXML->new();
123             my $doc1 = $parser->parse_string( $xml1 );
124             my $doc2 = $parser->parse_string( $xml2 );
125             return $self->_are_docs_same($doc1, $doc2);
126             }
127              
128             sub _are_docs_same {
129             my $self = shift;
130             my ($doc1, $doc2) = @_;
131             my $ignore = $self->ignore;
132             if ( $ignore and @$ignore ) {
133             my $in = {};
134             for my $doc ( map { $_->documentElement } $doc1, $doc2 ) {
135             my $xpc;
136             if ( my $ix = $self->ignore_xmlns ) {
137             $xpc = XML::LibXML::XPathContext->new($doc);
138             $xpc->registerNs($_ => $ix->{$_})
139             for keys %$ix;
140             }
141             else {
142             $xpc = $doc;
143             }
144             for my $ignore_xpath ( @$ignore ) {
145             $in->{$_->nodePath}=undef
146             for $xpc->findnodes( $ignore_xpath );
147             }
148             }
149             $self->_ignore_nodes($in);
150             }
151             else {
152             $self->_ignore_nothing;
153             }
154             return $self->_are_nodes_same(
155             [ $doc1->documentElement->nodeName ],
156             $doc1->documentElement,
157             $doc2->documentElement,
158             );
159             }
160              
161             has 'ignore' =>
162             is => "rw",
163             isa => ArrayRef[Str],
164             ;
165              
166             has 'ignore_xmlns' =>
167             is => "rw",
168             isa => HashRef[Str],
169             ;
170              
171             has '_ignore_nodes' =>
172             is => "rw",
173             isa => HashRef[Undef],
174             clearer => "_ignore_nothing",
175             ;
176              
177             sub _are_nodes_same {
178             my $self = shift;
179             my ($l, $node1, $node2) = @_;
180             _msg($l, "\\ got (" . ref($node1) . ", " . ref($node2) . ")");
181              
182             # firstly, check that the node types are the same
183             my $nt1 = $node1->nodeType();
184             my $nt2 = $node2->nodeType();
185             if ( $nt1 eq $nt2 ) {
186             _same($l, "nodeType=$nt1");
187             }
188             else {
189             _outit($l, 'node types are different', $nt1, $nt2);
190             _die $l, 'node types are different (%s, %s)', $nt1, $nt2;
191             }
192              
193             # if these nodes are Text, compare the contents
194             if ( $has->{data}{ref $node1} ) {
195             my $data1 = $node1->data();
196             my $data2 = $node2->data();
197             # _msg($l, ": data ($data1, $data2)");
198             if ( $data1 eq $data2 ) {
199             _same($l, "data");
200             }
201             else {
202             _outit($l, 'data differs', $data1, $data2);
203             _die $l, 'data differs: (%s, %s)', $data1, $data2;
204             }
205             }
206              
207             # if these nodes are Attr, compare the contents
208             if ( $has->{value}{ref $node1} ) {
209             my $val1 = $node1->getValue();
210             my $val2 = $node2->getValue();
211             # _msg($l, ": val ($val1, $val2)");
212             if ( $val1 eq $val2 ) {
213             _same($l, "value");
214             }
215             else {
216             _outit($l, 'attr node values differs', $val1, $val2);
217             _die $l, "attr node values differs (%s, %s)", $val1, $val2
218             }
219             }
220              
221             # check that the nodes are the same name (localname())
222             if ( $has->{localname}{ref $node1} ) {
223             my $ln1 = $node1->localname();
224             my $ln2 = $node2->localname();
225             if ( $ln1 eq $ln2 ) {
226             _same($l, 'localname');
227             }
228             else {
229             _outit($l, 'node names are different', $ln1, $ln2);
230             _die $l, 'node names are different: ', $ln1, $ln2;
231             }
232             }
233              
234             # check that the nodes are the same namespace
235             if ( $has->{namespaceURI}{ref $node1} ) {
236             my $ns1 = $node1->namespaceURI();
237             my $ns2 = $node2->namespaceURI();
238             # _msg($l, ": namespaceURI ($ns1, $ns2)");
239             if ( defined $ns1 and defined $ns2 ) {
240             if ( $ns1 eq $ns2 ) {
241             _same($l, 'namespaceURI');
242             }
243             else {
244             _outit($l, 'namespaceURIs are different', $node1->namespaceURI(), $node2->namespaceURI());
245             _die $l, 'namespaceURIs are different: (%s, %s)', $ns1, $ns2;
246             }
247             }
248             elsif ( (!defined $ns1) and (!defined $ns2) ) {
249             _same($l, 'namespaceURI (not defined for either node)');
250             }
251             else {
252             if ( $self->namespace_strict or defined $ns1 ) {
253             _outit($l, 'namespaceURIs are defined/not defined', $ns1, $ns2);
254             _die $l, 'namespaceURIs are defined/not defined: (%s, %s)', ($ns1 || '[undef]'), ($ns2 || '[undef]');
255             }
256             }
257             }
258              
259             # check the attribute list is the same length
260             if ( $has->{attributes}{ref $node1} ) {
261              
262             my $in = $self->_ignore_nodes;
263             # get just the Attrs and sort them by namespaceURI:localname
264             my @attr1 = sort { _fullname($a) cmp _fullname($b) }
265             grep { (!$in) or (!exists $in->{$_->nodePath}) }
266             grep { defined and $_->isa('XML::LibXML::Attr') }
267             $node1->attributes();
268              
269             my @attr2 = sort { _fullname($a) cmp _fullname($b) }
270             grep { (!$in) or (!exists $in->{$_->nodePath}) }
271             grep { defined and $_->isa('XML::LibXML::Attr') }
272             $node2->attributes();
273              
274             if ( scalar @attr1 == scalar @attr2 ) {
275             _same($l, 'attribute length (' . (scalar @attr1) . ')');
276             }
277             else {
278             _die $l, 'attribute list lengths differ: (%d, %d)', scalar @attr1, scalar @attr2;
279             }
280              
281             # for each attribute, check they are all the same
282             my $total_attrs = scalar @attr1;
283             for (my $i = 0; $i < scalar @attr1; $i++ ) {
284             # recurse down (either an exception will be thrown, or all are correct
285             $self->_are_nodes_same( [@$l,'@'.$attr1[$i]->name], $attr1[$i], $attr2[$i] );
286             }
287             }
288              
289             my $in = $self->_ignore_nodes;
290              
291             # don't need to compare or care about Comments
292             my @nodes1 = grep { (!$in) or (!exists $in->{$_->nodePath}) }
293             grep { (not $_->isa('XML::LibXML::Comment')) and
294             not ( $_->isa("XML::LibXML::Text") && ($_->data =~ /\A\s*\Z/) )
295             }
296             $node1->childNodes();
297              
298             my @nodes2 = grep { (!$in) or (!exists $in->{$_->nodePath}) }
299             grep { (not $_->isa('XML::LibXML::Comment')) and
300             not ( $_->isa("XML::LibXML::Text") && ($_->data =~ /\A\s*\Z/) )
301             } $node2->childNodes();
302              
303             # firstly, convert all CData nodes to Text Nodes
304             @nodes1 = _convert_cdata_to_text( @nodes1 );
305             @nodes2 = _convert_cdata_to_text( @nodes2 );
306              
307             # append all the consecutive Text nodes
308             @nodes1 = _squash_text_nodes( @nodes1 );
309             @nodes2 = _squash_text_nodes( @nodes2 );
310              
311             # check that the nodes contain the same number of children
312             if ( @nodes1 != @nodes2 ) {
313             _die $l, 'different number of child nodes: (%d, %d)', scalar @nodes1, scalar @nodes2;
314             }
315              
316             # foreach of it's children, compare them
317             my $total_nodes = scalar @nodes1;
318             for (my $i = 0; $i < $total_nodes; $i++ ) {
319             # recurse down (either an exception will be thrown, or all are correct
320             my $nn = $nodes1[$i]->nodeName;
321             if ( grep { $_->nodeName eq $nn }
322             @nodes1[0..$i-1, $i+1..$#nodes1] ) {
323             $nn .= "[position()=".($i+1)."]";
324             }
325             $nn =~ s{#text}{text()};
326             $self->_are_nodes_same( [@$l,$nn], $nodes1[$i], $nodes2[$i] );
327             }
328              
329             _msg($l, '/');
330             return 1;
331             }
332              
333             # takes an array of nodes and converts all the CDATASection nodes into Text nodes
334             sub _convert_cdata_to_text {
335             my @nodes = @_;
336             my @new;
337             foreach my $n ( @nodes ) {
338             if ( ref $n eq 'XML::LibXML::CDATASection' ) {
339             $n = XML::LibXML::Text->new( $n->data() );
340             }
341             push @new, $n;
342             }
343             return @new;
344             }
345              
346             # takes an array of nodes and concatenates all the Text nodes together
347             sub _squash_text_nodes {
348             my @nodes = @_;
349             my @new;
350             my $last_type = '';
351             foreach my $n ( @nodes ) {
352             if ( $last_type eq 'XML::LibXML::Text' and ref $n eq 'XML::LibXML::Text' ) {
353             $n = XML::LibXML::Text->new( $new[-1]->data() . $n->data() );
354             $new[-1] = $n;
355             }
356             else {
357             push @new, $n;
358             }
359             $last_type = ref $n;
360             }
361             return @new;
362             }
363              
364             sub _fullname {
365             my ($node) = @_;
366             my $name = '';
367             $name .= $node->namespaceURI() . ':' if $node->namespaceURI();
368             $name .= $node->localname();
369             # print "name=$name\n";
370             return $name;
371             }
372              
373             sub _same {
374             my ($l, $msg) = @_;
375             return unless $VERBOSE;
376             print '' . (' ' x (@$l+1)) . "= $msg\n";
377             }
378              
379             sub _msg {
380             my ($l, $msg) = @_;
381             return unless $VERBOSE;
382             print ' ' . (' ' x (@$l)) ._xpath($l). " $msg\n";
383             }
384              
385             sub _outit {
386             my ($l, $msg, $v1, $v2) = @_;
387             return unless $VERBOSE;
388             print '' . (' ' x @$l) . "! " ._xpath($l)." $msg:\n";
389             print '' . (' ' x @$l) . '. ' . ($v1 || '[undef]') . "\n";
390             print '' . (' ' x @$l) . '. ' . ($v2 || '[undef]') . "\n";
391             }
392              
393             1;
394             __END__