File Coverage

blib/lib/Data/Hash/Patch/Smart/Engine.pm
Criterion Covered Total %
statement 125 177 70.6
branch 66 140 47.1
condition 23 57 40.3
subroutine 15 16 93.7
pod 0 1 0.0
total 229 391 58.5


line stmt bran cond sub pod time code
1             package Data::Hash::Patch::Smart::Engine;
2              
3 15     15   121 use strict;
  15         42  
  15         605  
4 15     15   81 use warnings;
  15         29  
  15         841  
5              
6 15     15   110 use Scalar::Util qw(refaddr);
  15         83  
  15         1020  
7 15     15   10008 use Storable qw(dclone);
  15         70852  
  15         48990  
8              
9             sub patch {
10 27     27 0 81 my ($data, $changes, %opts) = @_;
11              
12 27         2442 my $copy = dclone($data);
13              
14 27         126 for my $c (@$changes) {
15 46         160 _apply_change($copy, $c, \%opts);
16             }
17              
18 23         104 return $copy;
19             }
20              
21             sub _apply_change {
22 46     46   112 my ($root, $c, $opts) = @_;
23              
24 46 50       196 my $op = $c->{op} or die 'change missing op';
25 46 50       133 my $path = $c->{path} or die 'change missing path';
26              
27             # Split path into segments like ('items', '0') or ('items', '*')
28 46         159 my @parts = _split_path($path);
29              
30             # Leaf is the last segment; parent is everything before it
31 46         132 my $leaf = pop @parts;
32              
33             # Structural wildcard (in parent path)
34 46 100       103 if (grep { $_ eq '*' } @parts) {
  66         246  
35 12         59 return _apply_structural_wildcard($root, \@parts, $leaf, $c, $opts);
36             }
37              
38             # Walk down to the parent container (hash or array)
39 34         101 my $parent = _walk_to_parent($root, \@parts, $leaf, $opts);
40              
41             # Unordered array semantics: leaf is '*'
42 34 100       92 if ($leaf eq '*') {
43 6 100       19 if ($op eq 'add') {
    50          
44 2         4 _add_unordered($parent, $c->{value}, $opts);
45             } elsif ($op eq 'remove') {
46 4         18 _remove_unordered($parent, $c->{from}, $opts);
47             } else {
48 0         0 die "Unsupported op '$op' for unordered path '$path'";
49             }
50 5         17 return;
51             }
52              
53             # Normal index/hash semantics
54 28 100       91 if ($op eq 'change') {
    50          
    0          
55 23         83 _set_value($parent, $leaf, $c->{to}, $opts);
56             } elsif ($op eq 'add') {
57 5         21 _add_value($parent, $leaf, $c->{value}, $opts);
58             } elsif ($op eq 'remove') {
59 0         0 _remove_value($parent, $leaf, $opts);
60             } else {
61 0         0 die "Unsupported op: $op";
62             }
63             }
64              
65             sub _split_path {
66 46     46   88 my $path = $_[0];
67              
68 46 50 33     240 return () if !defined $path || $path eq '';
69 46         211 my @parts = grep { length $_ } split m{/}, $path;
  158         385  
70 46         226 return @parts;
71             }
72              
73             # Walk down the structure following the given path segments,
74             # stopping at the parent of the leaf. In strict mode, we die on
75             # invalid paths. With create_missing => 1, we auto-create
76             # intermediate hashes/arrays as needed.
77             sub _walk_to_parent {
78 34     34   110 my ($cur, $parts, $leaf, $opts) = @_;
79              
80             # Walk all segments that lead to the parent of $leaf
81 34         131 for (my $i = 0; $i < @$parts; $i++) {
82 41         92 my $p = $parts->[$i];
83 41         96 my $is_last = ($i == $#$parts);
84              
85             # For container creation, "next" is either the next part,
86             # or, if we're at the last part, the leaf segment.
87 41 100       113 my $next = $is_last ? $leaf : $parts->[$i + 1];
88              
89             # -----------------------------
90             # HASH navigation
91             # -----------------------------
92 41 100       132 if (ref($cur) eq 'HASH') {
93              
94             # Missing key
95 39 100       109 if (!exists $cur->{$p}) {
96 5 50       16 if ($opts->{create_missing}) {
    0          
97             # Decide container type based on what comes after
98 5 100 66     56 if (defined $next && $next =~ /^\d+$/) {
99 3         8 $cur->{$p} = [];
100             } else {
101 2         9 $cur->{$p} = {};
102             }
103             }
104             elsif ($opts->{strict}) {
105 0         0 die "Invalid path: missing hash key '$p'";
106             }
107             else {
108 0         0 return undef;
109             }
110             }
111              
112 39         75 $cur = $cur->{$p};
113 39         118 next;
114             }
115              
116             # -----------------------------
117             # ARRAY navigation
118             # -----------------------------
119 2 50       9 if (ref($cur) eq 'ARRAY') {
120              
121             # Index must be numeric
122 2 50       12 if ($p !~ /^\d+$/) {
123             die "Invalid path: non-numeric array index '$p'"
124 0 0       0 if $opts->{strict};
125 0         0 return undef;
126             }
127              
128             # Out of bounds
129 2 100       8 if ($p > $#$cur) {
130 1 50       3 if ($opts->{create_missing}) {
    0          
131             # Extend array
132 1         3 $#$cur = $p;
133              
134             # Decide container type for this new slot
135 1 50 33     7 if (defined $next && $next =~ /^\d+$/) {
136 0         0 $cur->[$p] = [];
137             } else {
138 1         2 $cur->[$p] = {};
139             }
140             }
141             elsif ($opts->{strict}) {
142 0         0 die "Invalid path: array index '$p' out of bounds";
143             }
144             else {
145 0         0 return undef;
146             }
147             }
148              
149 2         5 $cur = $cur->[$p];
150 2         5 next;
151             }
152              
153             # -----------------------------
154             # Undef or non-container
155             # -----------------------------
156 0 0       0 if (!defined $cur) {
157             die "Invalid path: encountered undef while walking"
158 0 0       0 if $opts->{strict};
159 0         0 return undef;
160             }
161              
162             die "Invalid path: cannot descend into non-container"
163 0 0       0 if $opts->{strict};
164              
165 0         0 return undef;
166             }
167              
168 34         79 return $cur;
169             }
170              
171              
172             sub _set_value {
173 36     36   99 my ($parent, $leaf, $value, $opts) = @_;
174              
175 36 100       103 if (ref($parent) eq 'HASH') {
176 23 100 100     93 if (!exists $parent->{$leaf} && $opts->{strict}) {
177 1         16 die "Strict mode: cannot change missing hash key '$leaf'";
178             }
179 22         46 $parent->{$leaf} = $value;
180 22         80 return;
181             }
182              
183 13 50       66 if (ref($parent) eq 'ARRAY') {
184 13 100 66     140 if ($leaf !~ /^\d+$/ || $leaf > $#$parent) {
185             die "Strict mode: array index '$leaf' out of bounds"
186 1 50       3 if $opts->{strict};
187             }
188 13         35 $parent->[$leaf] = $value;
189 13         49 return;
190             }
191              
192 0 0       0 die 'Strict mode: cannot set value on non-container' if $opts->{strict};
193             }
194              
195             sub _add_value {
196 7     7   17 my ($parent, $leaf, $value, $opts) = @_;
197              
198 7 100       22 if (ref($parent) eq 'HASH') {
199 2 0 33     4 if (exists $parent->{$leaf} && $opts->{strict}) {
200 0         0 die "Strict mode: cannot add existing hash key '$leaf'";
201             }
202 2         3 $parent->{$leaf} = $value;
203 2         6 return;
204             }
205              
206 5 50       17 if (ref($parent) eq 'ARRAY') {
207             # Leaf must be numeric
208 5 50       25 if ($leaf !~ /^\d+$/) {
209             die "Strict mode: invalid array index '$leaf'"
210 0 0       0 if $opts->{strict};
211 0         0 return;
212             }
213              
214             # Extend array if needed
215 5 50       19 if ($leaf > $#$parent) {
216 5         19 $#$parent = $leaf;
217             }
218              
219             # Insert value at exact index
220 5         13 $parent->[$leaf] = $value;
221 5         18 return;
222             }
223              
224 0 0       0 die 'Strict mode: cannot add value to non-container' if $opts->{strict};
225             }
226              
227             sub _remove_value {
228 2     2   5 my ($parent, $leaf, $opts) = @_;
229              
230 2 50       8 if (ref($parent) eq 'HASH') {
231 2 0 33     9 if (!exists $parent->{$leaf} && $opts->{strict}) {
232 0         0 die "Strict mode: cannot remove missing hash key '$leaf'";
233             }
234 2         5 delete $parent->{$leaf};
235 2         8 return;
236             }
237              
238 0 0       0 if (ref($parent) eq 'ARRAY') {
239 0 0 0     0 if ($leaf !~ /^\d+$/ || $leaf > $#$parent) {
240 0         0 die "Strict mode: array index '$leaf' out of bounds";
241             }
242 0         0 splice @$parent, $leaf, 1;
243 0         0 return;
244             }
245              
246 0 0       0 die 'Strict mode: cannot remove value from non-container' if $opts->{strict};
247             }
248              
249             # Add a value to an unordered array.
250             # We treat the parent as an arrayref and simply push the new value.
251             sub _add_unordered {
252 2     2   3 my ($parent, $value) = @_;
253              
254 2 50       5 die 'Unordered add requires an array parent' unless ref($parent) eq 'ARRAY';
255              
256 2         3 push @$parent, $value;
257             }
258              
259             # Remove a single matching value from an unordered array.
260             # We scan linearly and delete the first element that compares equal.
261             sub _remove_unordered {
262 4     4   10 my ($parent, $value, $opts) = @_;
263              
264 4 50       15 die "Unordered remove requires an array parent"
265             unless ref($parent) eq 'ARRAY';
266              
267 4         15 for (my $i = 0; $i < @$parent; $i++) {
268 7 0 33     20 if (!defined $parent->[$i] && !defined $value) {
269 0         0 splice @$parent, $i, 1;
270 0         0 return;
271             }
272 7 100 33     50 if (defined $parent->[$i] && defined $value && $parent->[$i] eq $value) {
      66        
273 3         6 splice @$parent, $i, 1;
274 3         7 return;
275             }
276             }
277              
278 1 50       17 die "Unordered remove: value '$value' not found" if $opts->{strict};
279              
280             # Non-strict: silently ignore
281             }
282              
283             # Apply a change to all paths matching a wildcard pattern.
284             # Example pattern: ['users', '*', 'password']
285             #
286             # We recursively walk the data structure, matching literal segments
287             # and branching on '*' segments.
288             sub _apply_wildcard {
289 0     0   0 my ($cur, $parts, $change, $opts, $depth) = @_;
290              
291 0   0     0 $depth //= 0;
292              
293             # If we've consumed all parts, we are at the leaf.
294 0 0       0 if ($depth == @$parts) {
295             # Apply the operation to this exact location.
296             # We treat this as a non-wildcard leaf.
297 0         0 my $op = $change->{op};
298              
299 0 0       0 if ($op eq 'change') {
    0          
    0          
300             # Replace the entire subtree
301 0         0 return $change->{to};
302             } elsif ($op eq 'add') {
303             # For wildcard add, we push into arrays or set hash keys
304             # but since wildcard leafs are ambiguous, we do nothing here.
305             # Wildcard adds are only meaningful when the leaf is '*'
306 0         0 return $cur;
307             } elsif ($op eq 'remove') {
308             # Remove the entire subtree
309 0         0 return undef;
310             } else {
311 0         0 die "Unsupported wildcard op: $op";
312             }
313             }
314              
315 0         0 my $seg = $parts->[$depth];
316              
317             # Literal segment: descend into matching child
318 0 0       0 if ($seg ne '*') {
319 0 0 0     0 if (ref($cur) eq 'HASH' && exists $cur->{$seg}) {
    0 0        
      0        
320 0         0 $cur->{$seg} = _apply_wildcard($cur->{$seg}, $parts, $change, $opts, $depth+1);
321             } elsif (ref($cur) eq 'ARRAY' && $seg =~ /^\d+$/ && $seg <= $#$cur) {
322 0         0 $cur->[$seg] = _apply_wildcard($cur->[$seg], $parts, $change, $opts, $depth+1);
323             }
324 0         0 return;
325             }
326              
327             # Wildcard segment: match all children at this level
328 0 0       0 if (ref($cur) eq 'HASH') {
    0          
329 0         0 for my $k (sort keys %$cur) {
330 0         0 $cur->{$k} = _apply_wildcard($cur->{$k}, $parts, $change, $opts, $depth+1);
331             }
332             }
333             elsif (ref($cur) eq 'ARRAY') {
334 0         0 for my $i (0 .. $#$cur) {
335 0         0 $cur->[$i] = _apply_wildcard($cur->[$i], $parts, $change, $opts, $depth+1);
336             }
337             }
338             }
339              
340             sub _apply_structural_wildcard {
341 41     41   124 my ($cur, $parts, $leaf, $change, $opts, $depth, $seen) = @_;
342              
343 41   100     145 $depth //= 0;
344 41   100     113 $seen ||= {};
345              
346             # Detect cycles
347 41 50       107 if (ref($cur)) {
348 41         72 my $addr = refaddr($cur);
349 41 100       225 if ($seen->{$addr}++) {
350             die "Cycle detected during wildcard patch"
351 2 50       89 if $opts->{strict};
352 0         0 return;
353             }
354             }
355              
356             # If we've matched all wildcard segments, apply leaf op
357 39 100       99 if ($depth == @$parts) {
358 17         38 return _apply_leaf_op($cur, $leaf, $change, $opts);
359             }
360              
361 22         43 my $seg = $parts->[$depth];
362              
363             # Literal segment
364 22 100       55 if ($seg ne '*') {
365 13 100 66     78 if (ref($cur) eq 'HASH' && exists $cur->{$seg}) {
    50 33        
      33        
366 12         57 _apply_structural_wildcard($cur->{$seg}, $parts, $leaf, $change, $opts, $depth+1, $seen);
367             } elsif (ref($cur) eq 'ARRAY' && $seg =~ /^\d+$/ && $seg <= $#$cur) {
368 0         0 _apply_structural_wildcard($cur->[$seg], $parts, $leaf, $change, $opts, $depth+1, $seen);
369             }
370 11         61 return;
371             }
372              
373             # Wildcard segment
374 9 100       35 if (ref($cur) eq 'HASH') {
    50          
375 8         30 for my $k (keys %$cur) {
376 14         42 _apply_structural_wildcard($cur->{$k}, $parts, $leaf, $change, $opts, $depth+1, $seen);
377             }
378             } elsif (ref($cur) eq 'ARRAY') {
379 1         5 for my $i (0 .. $#$cur) {
380 3         28 _apply_structural_wildcard($cur->[$i], $parts, $leaf, $change, $opts, $depth+1, $seen);
381             }
382             }
383             }
384              
385              
386             sub _apply_leaf_op {
387 17     17   40 my ($parent, $leaf, $change, $opts) = @_;
388              
389 17         31 my $op = $change->{op};
390              
391 17 100       44 if ($op eq 'change') {
    100          
    50          
392 13         38 return _set_value($parent, $leaf, $change->{to}, $opts);
393             } elsif ($op eq 'add') {
394 2         6 return _add_value($parent, $leaf, $change->{value}, $opts);
395             } elsif ($op eq 'remove') {
396 2         7 return _remove_value($parent, $leaf, $opts);
397             }
398              
399 0           die "Unsupported op '$op' in wildcard patch";
400             }
401              
402             1;