File Coverage

blib/lib/XML/SemanticDiff.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             package XML::SemanticDiff;
2             our $AUTHORITY = 'cpan:SHLOMIF';
3              
4 16     16   786836 use strict;
  16         204  
  16         652  
5 16     16   109 use warnings;
  16         39  
  16         538  
6              
7 16     16   348 use 5.012;
  16         68  
8              
9             our $VERSION = '1.0006';
10              
11 16     16   13809 use XML::Parser;
  0            
  0            
12              
13              
14             sub new {
15             my ($proto, %args) = @_;
16             my $class = ref($proto) || $proto;
17             my $self = \%args;
18              
19             require XML::SemanticDiff::BasicHandler unless defined $args{diffhandler};
20              
21             bless ($self, $class);
22             return $self;
23             }
24              
25             sub _is_file
26             {
27             my ($self, $specifier) = @_;
28             return $specifier !~ /\n/g && -f $specifier;
29             }
30              
31             sub _get_pathfinder_obj {
32             my $self = shift;
33              
34             return XML::SemanticDiff::PathFinder::Obj->new();
35             }
36              
37             sub read_xml {
38             my $self = shift;
39              
40             my ($xml_specifier) = @_;
41              
42             if (ref($xml_specifier) eq 'HASH')
43             {
44             return $xml_specifier;
45             }
46             else
47             {
48             $self->{path_finder_obj} = $self->_get_pathfinder_obj();
49              
50             my $p = XML::Parser->new(
51             Style => 'Stream',
52             Pkg => 'XML::SemanticDiff::PathFinder',
53             'Non-Expat-Options' => $self,
54             Namespaces => 1
55             );
56              
57             my $ret =
58             $self->_is_file($xml_specifier)
59             ? $p->parsefile($xml_specifier)
60             : $p->parse($xml_specifier)
61             ;
62              
63             $self->{path_finder_obj} = undef;
64              
65             return $ret;
66             }
67             }
68              
69             sub _same_namespace
70             {
71             my ($self, $to, $from) = @_;
72              
73             my $t_e = exists($to->{NamespaceURI});
74             my $f_e = exists($from->{NamespaceURI});
75             if (!$t_e && !$f_e)
76             {
77             return 1;
78             }
79             elsif ($t_e && $f_e)
80             {
81             return ($to->{NamespaceURI} eq $from->{NamespaceURI});
82             }
83             else
84             {
85             return 0;
86             }
87             }
88              
89             sub _match_xpath {
90             my $self = shift;
91             my ($xpath, $flat_name) = @_;
92             my @x_way = split /\//, $xpath;
93             my @f_way = split /\//, $flat_name;
94             for my $i (0..$#x_way) {
95             $x_way[$i]=~s/.*?://g;
96             }
97             for my $i (0..$#f_way) {
98             $f_way[$i]=~s/\[.*?\]$//g;
99             }
100             return 0 if $#x_way > $#f_way;
101             for my $i (0..$#x_way) {
102             if ($x_way[$i] ne $f_way[$i]) {
103             return 0;
104             }
105             }
106             return 1;
107             }
108              
109             # Okay, it's pretty basic...
110             #
111             # We flatten each doc tree to a Perl hash where the keys are "fully qualified"
112             # XPath expressions (/root[1]/element[3]) that represent the unique location
113             # of each XML element, then compare the two hashes.
114             #
115             # Just loop over all the elements of the first hash- if the same key exists
116             # in the second, you compare the text and attributes and delete it. Any
117             # keys not found in the second hash are declared 'missing', and any keys leftover
118             # in the second hash after looping through the elements in the first are 'rogues'.
119              
120             sub compare {
121             my $self = shift;
122             my ($from_xml, $to_xml) = @_;
123              
124             my $from_doc = $self->read_xml($from_xml);
125             my $to_doc = $self->read_xml($to_xml);
126              
127             my @warnings = ();
128              
129             my $handler = $self->{diffhandler} || XML::SemanticDiff::BasicHandler->new(%$self);
130              
131             # drop away nodes matching xpaths to be ignored
132             if (defined $self->{ignorexpath}) {
133             my $ignore = $self->{ignorexpath};
134             for my $path (@$ignore) {
135             for my $ref ($from_doc, $to_doc) {
136             for my $key (keys %$ref) {
137             if ($self->_match_xpath($path, $key)) {
138             delete $ref->{$key};
139             }
140             }
141             }
142             }
143             }
144              
145             # fire the init handler
146             push (@warnings, $handler->init($self)) if $handler->can('init');
147              
148             # loop the elements
149             foreach my $element (sort keys (%$from_doc)) {
150              
151             # element existence check
152             if (defined $to_doc->{$element}) {
153              
154             # element value test
155             unless ($from_doc->{$element}->{TextChecksum} eq $to_doc->{$element}->{TextChecksum}) {
156             push (@warnings, $handler->element_value($element,
157             $to_doc->{$element},
158             $from_doc->{$element}))
159             if $handler->can('element_value');
160             }
161              
162             # namespace test
163             unless ($self->_same_namespace($from_doc->{$element},$to_doc->{$element})) {
164             push (@warnings, $handler->namespace_uri($element,
165             $to_doc->{$element},
166             $from_doc->{$element}))
167             if $handler->can('namespace_uri');
168             }
169              
170             # attribute tests
171             foreach my $attr (keys(%{$from_doc->{$element}->{Attributes}})) {
172              
173             # attr existence check
174             if (defined ($to_doc->{$element}->{Attributes}->{$attr})) {
175              
176             # attr value test
177             if ($to_doc->{$element}->{Attributes}->{$attr} ne $from_doc->{$element}->{Attributes}->{$attr}){
178             push (@warnings, $handler->attribute_value($attr,
179             $element,
180             $to_doc->{$element},
181             $from_doc->{$element}))
182             if $handler->can('attribute_value');
183             }
184             delete $to_doc->{$element}->{Attributes}->{$attr};
185             }
186             else {
187             push (@warnings, $handler->missing_attribute($attr,
188             $element,
189             $to_doc->{$element},
190             $from_doc->{$element}))
191             if $handler->can('missing_attribute');
192             }
193             }
194              
195             # rogue attrs
196             foreach my $leftover (keys(%{$to_doc->{$element}->{Attributes}})) {
197             push (@warnings, $handler->rogue_attribute($leftover,
198             $element,
199             $to_doc->{$element},
200             $from_doc->{$element}))
201             if $handler->can('rogue_attribute');
202             }
203              
204             delete $to_doc->{$element};
205             }
206             else {
207             push (@warnings, $handler->missing_element($element, $from_doc->{$element}))
208             if $handler->can('missing_element');
209             }
210             }
211              
212             # rogue elements
213             foreach my $leftover ( keys (%$to_doc) ) {
214             push (@warnings, $handler->rogue_element($leftover, $to_doc->{$leftover}))
215             if $handler->can('rogue_element');
216             }
217              
218             push (@warnings, $handler->final($self)) if $handler->can('final');
219              
220             return @warnings;
221             }
222              
223             1;
224              
225             package XML::SemanticDiff::PathFinder;
226             our $AUTHORITY = 'cpan:SHLOMIF';
227              
228             foreach my $func (qw(StartTag EndTag Text StartDocument EndDocument PI))
229             {
230             no strict 'refs';
231             *{__PACKAGE__.'::'.$func} = sub {
232             my $expat = shift;
233             return $expat->{'Non-Expat-Options'}->{path_finder_obj}->$func(
234             $expat, @_
235             );
236             };
237             }
238              
239             package XML::SemanticDiff::PathFinder::Obj;
240             our $AUTHORITY = 'cpan:SHLOMIF';
241              
242             use strict;
243              
244             use Digest::MD5 qw(md5_base64);
245              
246             use Encode qw(encode_utf8);
247              
248             foreach my $accessor (qw(descendents char_accumulator doc
249             opts xml_context PI_position_index))
250             {
251             no strict 'refs';
252             *{__PACKAGE__.'::'.$accessor} = sub {
253             my $self = shift;
254              
255             if (@_)
256             {
257             $self->{$accessor} = shift;
258             }
259             return $self->{$accessor};
260             };
261             }
262              
263             # PI_position_index is the position index for the PI's below - the processing
264             # instructions.
265              
266             sub new {
267             my $class = shift;
268              
269             my $self = {};
270             bless $self, $class;
271              
272             $self->_init(@_);
273              
274             return $self;
275             }
276              
277             sub _init {
278             return 0;
279             }
280              
281             sub StartTag {
282             my ($self, $expat, $element) = @_;
283              
284              
285             my %attrs = %_;
286              
287             my @context = $expat->context;
288             my $context_length = scalar (@context);
289             my $parent = $context[$context_length -1];
290             push (@{$self->descendents()->{$parent}}, $element) if $parent;
291              
292             my $last_ctx_elem = $self->xml_context()->[-1] || { position_index => {}};
293              
294             push @{$self->xml_context()},
295             {
296             element => "$element",
297             'index' => ++$last_ctx_elem->{position_index}->{"$element"},
298             position_index => {},
299             };
300              
301             my $test_context;
302              
303             # if (@context){
304             # $test_context = '/' . join ('/', map { $_ . '[' . $position_index->{$_} . ']' } @context);
305             # }
306              
307             # $test_context .= '/' . $element . '[' . $position_index->{$element} . ']';
308              
309             $test_context = $self->_calc_test_context();
310              
311             $self->doc()->{$test_context} =
312             {
313             NamespaceURI => ($expat->namespace($element) || ""),
314             Attributes => \%attrs,
315             ($self->opts()->{keeplinenums}
316             ? ( TagStart => $expat->current_line)
317             : ()
318             ),
319             };
320             }
321              
322             sub _calc_test_context
323             {
324             my $self = shift;
325              
326             return
327             join("",
328             map { "/". $_->{'element'} . "[" . $_->{'index'} . "]" }
329             @{$self->xml_context()}
330             );
331             }
332              
333             sub EndTag {
334             my ($self, $expat, $element) = @_;
335              
336             my @context = $expat->context;
337              
338             # if (@context){
339             # $test_context = '/' . join ('/', map { $_ . '[' . $position_index->{$_} . ']' } @context);
340             #}
341             # $test_context .= '/' . $element . '[' . $position_index->{$element} . ']';
342              
343             my $test_context = $self->_calc_test_context();
344              
345             my $text;
346             if ( defined( $self->char_accumulator()->{$element} )) {
347             $text = $self->char_accumulator()->{$element};
348             delete $self->char_accumulator()->{$element};
349             }
350             # This isn't the correct thing to do. If the before or after element
351             # had and 'o' and the other was undef, we would fail to find any differences
352             # Instead, when a value is undef we should be setting the the checksum
353             # to the value for an empty string since undef and empty string for a
354             # element are the same ( vs )
355             #$text ||= 'o';
356              
357             # warn "text is '$text' \n";
358             # my $ctx = Digest::MD5->new;
359             # $ctx->add("$text");
360             # $self->doc()->{"$test_context"}->{TextChecksum} = $ctx->b64digest;
361              
362             # In XML, a null(undef) value and an empty string should be treaded the same.
363             # Therefore, when the element is undef, we should set the TextChecksum to the same
364             # as an empty string.
365             $self->doc()->{"$test_context"}->{TextChecksum} =
366             md5_base64(
367             encode_utf8(
368             (defined $text) ? "$text" : ""
369             )
370             );
371              
372             if ($self->opts()->{keepdata}) {
373             $self->doc()->{"$test_context"}->{CData} = $text;
374             }
375              
376              
377             if (defined ( $self->descendents()->{$element})) {
378             my $seen = {};
379             foreach my $child (@{$self->descendents()->{$element}}) {
380             next if $seen->{$child};
381             $seen->{$child}++;
382             }
383             }
384              
385             $self->doc()->{"$test_context"}->{TagEnd} = $expat->current_line if $self->opts()->{keeplinenums};
386              
387             pop(@{$self->xml_context()});
388             }
389              
390             sub Text {
391             my $self = shift;
392             my $expat = shift;
393              
394             my $element = $expat->current_element;
395             my $char = $_;
396              
397             $char =~ s/^\s*//;
398             $char =~ s/\s*$//;
399             $char =~ s/\s+/ /g;
400             # We should add any character that isn't undef, so check
401             # for defined here instead of checking if the value is true
402             $self->char_accumulator()->{$element} .= $char if defined($char);
403              
404             }
405              
406             sub StartDocument {
407             my $self = shift;
408             my $expat = shift;
409             $self->doc({});
410             $self->descendents({});
411             $self->char_accumulator({});
412             $self->opts($expat->{'Non-Expat-Options'});
413             $self->xml_context([]);
414             $self->PI_position_index({});
415             }
416              
417             sub EndDocument {
418             my $self = shift;
419              
420             return $self->doc();
421             }
422              
423              
424             sub PI {
425             my ($self, $expat, $target, $data) = @_;
426             my $attrs = {};
427             $self->PI_position_index()->{$target}++;
428              
429             foreach my $pair (split /\s+/, $data) {
430             $attrs->{$1} = $2 if $pair =~ /^(.+?)=["'](.+?)["']$/;
431             }
432              
433             my $slug = '?' . $target . '[' . $self->PI_position_index()->{$target} . ']';
434              
435             $self->doc()->{$slug} =
436             {
437             Attributes => ($attrs || {}),
438             TextChecksum => "1",
439             NamespaceURI => "",
440             ( $self->opts()->{keeplinenums}
441             ? (
442             TagStart => $expat->current_line(),
443             TagEnd => $expat->current_line(),
444             )
445             : ()
446             ),
447             };
448             }
449              
450             1;
451              
452             __END__