File Coverage

blib/lib/Data/NestedKey.pm
Criterion Covered Total %
statement 155 187 82.8
branch 61 94 64.8
condition 27 49 55.1
subroutine 19 20 95.0
pod 6 6 100.0
total 268 356 75.2


line stmt bran cond sub pod time code
1             package Data::NestedKey;
2              
3             # This module provides an object-oriented way to manipulate deeply
4             # nested hash structures using dot-separated keys, with flexible
5             # serialization options.
6              
7 4     4   235395 use strict;
  4         10  
  4         180  
8 4     4   38 use warnings;
  4         10  
  4         225  
9              
10 4     4   24 use Carp;
  4         16  
  4         314  
11 4     4   2088 use Data::Dumper;
  4         29668  
  4         371  
12 4     4   2804 use JSON;
  4         56208  
  4         25  
13 4     4   678 use List::Util qw(pairs);
  4         8  
  4         419  
14 4     4   27 use Scalar::Util qw(reftype);
  4         8  
  4         277  
15 4     4   2312 use Storable qw(nfreeze);
  4         15535  
  4         296  
16 4     4   1730 use YAML::XS ();
  4         13148  
  4         308  
17              
18             our $VERSION = '1.1.0';
19              
20             # Package variables for serialization options
21             our $JSON_PRETTY = 1; # Controls whether JSON output is pretty or compact
22             our $FORMAT = 'JSON'; # Default serialization format
23              
24 4     4   28 use overload '""' => \&as_string;
  4         7  
  4         2176  
25              
26             ########################################################################
27             sub new {
28             ########################################################################
29 7     7 1 849924 my ( $class, @args ) = @_;
30              
31 7 100       37 my $init_data = ref $args[0] ? shift @args : {};
32 7         19 my @kv_list = @args;
33              
34             # If the first argument is a hash reference, use it; otherwise, start with an empty structure
35 7 50       29 my $self = bless { data => _is_hash($init_data) ? $init_data : {} }, $class;
36              
37             # If $init_data wasn't a hash ref, treat it as a key-value pair
38 7 50       26 if ( !_is_hash($init_data) ) {
39 0         0 @kv_list = ( $init_data, @kv_list );
40             }
41              
42             # Short-circuit if no key-value pairs are provided
43 7 100       31 return $self
44             if !@kv_list;
45              
46             # Ensure key-value pairs are valid
47 3 50 33     19 croak 'Must provide key-value pairs'
48             if @kv_list && @kv_list % 2 != 0;
49              
50             # Populate the structure using `set`
51 3         18 $self->set(@kv_list);
52              
53 3         13 return $self;
54             }
55              
56             ########################################################################
57 24   100 24   123 sub _is_array { return ref $_[0] && reftype( $_[0] ) eq 'ARRAY'; }
58             ########################################################################
59              
60             ########################################################################
61 107   100 107   672 sub _is_hash { return ref $_[0] && reftype( $_[0] ) eq 'HASH'; }
62             ########################################################################
63              
64             # Parse a dot-separated path into a list of segment hashrefs.
65             # Each segment has:
66             # key => the hash key name
67             # index => array index (integer, possibly negative), or undef if none
68             #
69             # Examples:
70             # 'foo.bar' -> [{key=>'foo'},{key=>'bar'}]
71             # 'repositories[0].uri' -> [{key=>'repositories',index=>0},{key=>'uri'}]
72             # 'items[-1]' -> [{key=>'items',index=>-1}]
73              
74             ########################################################################
75             sub _parse_path {
76             ########################################################################
77 38     38   74 my ($path) = @_;
78              
79 38         54 my @segments;
80              
81 38         118 for my $part ( split /[.]/, $path ) {
82 73 100       287 if ( $part =~ /\A (.+?) \[ (-?\d+) \] \z/xsm ) {
83 17         123 push @segments, { key => $1, index => $2 + 0 };
84             }
85             else {
86 56         215 push @segments, { key => $part, index => undef };
87             }
88             }
89              
90 38         111 return @segments;
91             }
92              
93             # Walk a parsed path down into $data, stopping one segment before the
94             # end. Returns ($node, $final_segment) where $node is the innermost
95             # container the final segment lives in, or (undef, undef) if any
96             # intermediate step is missing or of the wrong type.
97             #
98             # If $create is true, missing intermediate hash keys are auto-vivified
99             # (mirrors the old behaviour in set()). Array slots are never
100             # auto-vivified here — callers that need that must handle it themselves.
101              
102             ########################################################################
103             sub _walk {
104             ########################################################################
105 0     0   0 my ( $data, $segments, $create ) = @_;
106              
107 0         0 my $current = $data;
108              
109 0         0 for my $seg ( @{$segments}[ 0 .. $#{$segments} - 1 ] ) {
  0         0  
  0         0  
110 0         0 my ( $key, $idx ) = @{$seg}{qw(key index)};
  0         0  
111              
112             # Step into the hash key
113 0 0       0 if ( _is_hash($current) ) {
114 0 0 0     0 $current->{$key} //= {} if $create && !exists $current->{$key};
      0        
115              
116 0 0       0 return ( undef, undef ) if !exists $current->{$key};
117              
118 0         0 $current = $current->{$key};
119             }
120             else {
121 0         0 return ( undef, undef );
122             }
123              
124             # If this segment also carries an array subscript, step into it
125 0 0       0 if ( defined $idx ) {
126 0 0       0 return ( undef, undef ) if !_is_array($current);
127              
128 0         0 $current = $current->[$idx];
129              
130 0 0       0 return ( undef, undef ) if !defined $current;
131             }
132             }
133              
134 0         0 return ( $current, $segments->[-1] );
135             }
136              
137             ########################################################################
138             sub set {
139             ########################################################################
140 18     18 1 94 my ( $self, @kv_list ) = @_;
141              
142 18 50       64 croak 'Must provide key-value pairs'
143             if @kv_list % 2 != 0;
144              
145 18         151 for my $p ( pairs @kv_list ) {
146 20         28 my ( $key_path, $value ) = @{$p};
  20         57  
147 20 100       91 my $action = $key_path =~ s/\A([+-])//xsm ? $1 : q{};
148              
149             # set() does not support array subscripts in paths; use plain dot-keys only.
150 20 100       341 croak "Array subscripts (e.g. key[0]) are not supported in set() paths: '$key_path'"
151             if $key_path =~ /\[/;
152              
153 19         61 my @keys = split /[.]/, $key_path;
154 19         111 my $current = $self->{data};
155              
156 19         68 for my $key ( @keys[ 0 .. $#keys - 1 ] ) {
157 13   100     58 $current->{$key} //= {};
158 13         26 $current = $current->{$key};
159             }
160              
161 19         30 my $final_key = $keys[-1];
162              
163 19 100       46 if ( $action eq q{+} ) {
    100          
164 4 100 66     10 if ( _is_array( $current->{$final_key} ) ) {
    100          
    50          
    50          
165 1         1 push @{ $current->{$final_key} }, $value;
  1         3  
166             }
167             elsif ( _is_hash( $current->{$final_key} ) && _is_hash($value) ) {
168 1         2 %{ $current->{$final_key} } = ( %{ $current->{$final_key} }, %{$value} );
  1         5  
  1         2  
  1         2  
169             }
170             elsif ( _is_hash( $current->{$final_key} ) ) {
171 0         0 croak sprintf q{Error: Attempting to merge a non-hash into a hash at key '%s'.}, $final_key;
172             }
173             elsif ( exists $current->{$final_key} ) {
174 2         7 $current->{$final_key} = [ $current->{$final_key}, $value ];
175             }
176             else {
177 0         0 $current->{$final_key} = [$value];
178             }
179             }
180             elsif ( $action eq q{-} ) {
181 3 100       6 if ( _is_array( $current->{$final_key} ) ) {
    100          
182 1         1 @{ $current->{$final_key} } = grep { $_ ne $value } @{ $current->{$final_key} };
  1         4  
  4         6  
  1         3  
183             }
184             elsif ( _is_hash( $current->{$final_key} ) ) {
185 1         3 delete $current->{$final_key}{$value};
186             }
187             else {
188 1         3 delete $current->{$final_key};
189             }
190             }
191             else {
192             croak sprintf q{Error: Attempting to replace a hash reference at key '%s' with a scalar value.}, $final_key
193 12 50 33     41 if _is_hash( $current->{$final_key} ) && !_is_hash($value);
194              
195 12         46 $current->{$final_key} = $value;
196             }
197             }
198              
199 17         80 return $self;
200             }
201              
202             ########################################################################
203             sub get {
204             ########################################################################
205 25     25 1 92 my ( $self, @key_paths ) = @_;
206 25         46 my @results;
207              
208 25         56 for my $key_path (@key_paths) {
209 26         63 my @segments = _parse_path($key_path);
210 26         88 my $current = $self->{data};
211 26         38 my $ok = 1;
212              
213 26         47 for my $seg (@segments) {
214 47         69 my ( $key, $idx ) = @{$seg}{qw(key index)};
  47         108  
215              
216 47 100 100     90 if ( _is_hash($current) && exists $current->{$key} ) {
217 45         80 $current = $current->{$key};
218             }
219             else {
220 2         5 $current = undef;
221 2         5 $ok = 0;
222 2         5 last;
223             }
224              
225 45 100       115 if ( defined $idx ) {
226 11 50       30 if ( _is_array($current) ) {
227 11         29 $current = $current->[$idx];
228             }
229             else {
230 0         0 $current = undef;
231 0         0 $ok = 0;
232 0         0 last;
233             }
234             }
235             }
236              
237 26 100       126 push @results, $ok ? $current : undef;
238             }
239              
240 25 100       206 return wantarray ? @results : $results[0];
241             }
242              
243             ########################################################################
244             sub as_string {
245             ########################################################################
246 10     10 1 6070 my ($self) = @_;
247              
248 10 50 66     220 return JSON->new->pretty->encode( $self->{data} ) if $FORMAT eq 'JSON' && $JSON_PRETTY;
249 6 50       11 return JSON->new->encode( $self->{data} ) if $FORMAT eq 'JSON';
250 6 100       276 return YAML::XS::Dump( $self->{data} ) if $FORMAT eq 'YAML';
251 4 100       16 return Dumper( $self->{data} ) if $FORMAT eq 'Dumper';
252 2 50       11 return nfreeze( $self->{data} ) if $FORMAT eq 'Storable';
253              
254 0         0 croak "Unsupported format: $FORMAT";
255             }
256              
257             ########################################################################
258             sub delete { ## no critic
259             ########################################################################
260 4     4 1 17 my ( $self, @key_paths ) = @_;
261              
262 4         11 for my $key_path (@key_paths) {
263 4         23 my @segments = _parse_path($key_path);
264 4         6 my @parents; # Track parent containers for empty-hash cleanup
265 4         9 my $current = $self->{data};
266 4         8 my $abort = 0;
267              
268 4         17 for my $seg ( @segments[ 0 .. $#segments - 1 ] ) {
269 4         8 my ( $key, $idx ) = @{$seg}{qw(key index)};
  4         13  
270              
271 4 50 33     10 last if !_is_hash($current) || !exists $current->{$key};
272              
273 4         12 push @parents, [ $current, $key ];
274 4         8 $current = $current->{$key};
275              
276 4 50       14 if ( defined $idx ) {
277 0 0       0 if ( _is_array($current) ) {
278 0         0 $current = $current->[$idx];
279              
280 0 0       0 if ( !defined $current ) {
281 0         0 $abort = 1;
282 0         0 last;
283             }
284             # Array slots are not tracked for empty-cleanup
285 0         0 @parents = ();
286             }
287             else {
288 0         0 $abort = 1;
289 0         0 last;
290             }
291             }
292             }
293              
294 4 50       14 next if $abort;
295              
296 4         7 my $final = $segments[-1];
297 4         8 my ( $final_key, $final_idx ) = @{$final}{qw(key index)};
  4         13  
298              
299 4 100       29 if ( defined $final_idx ) {
300             # Deleting a specific array slot: splice it out
301 2 50 33     6 if ( _is_hash($current)
      33        
302             && exists $current->{$final_key}
303             && _is_array( $current->{$final_key} ) ) {
304 2         4 splice @{ $current->{$final_key} }, $final_idx, 1;
  2         8  
305             }
306             }
307             else {
308 2 50 33     12 if ( _is_hash($current) && exists $current->{$final_key} ) {
309 2         7 delete $current->{$final_key};
310             }
311             }
312              
313             # Cleanup empty parent hashes (only meaningful when no array was traversed)
314 4         19 while (@parents) {
315 3         6 my ( $parent, $key ) = @{ pop @parents };
  3         10  
316 3 100 66     14 last if !_is_hash( $parent->{$key} ) || %{ $parent->{$key} };
  3         40  
317 2         9 delete $parent->{$key};
318             }
319             }
320              
321 4         14 return $self;
322             }
323              
324             ########################################################################
325             sub exists_key {
326             ########################################################################
327 8     8 1 26 my ( $self, @key_paths ) = @_;
328 8         13 my @results;
329              
330 8         21 for my $key_path (@key_paths) {
331 8         26 my @segments = _parse_path($key_path);
332 8         20 my $current = $self->{data};
333 8         15 my $exists = 1;
334              
335 8         16 for my $seg (@segments) {
336 15         27 my ( $key, $idx ) = @{$seg}{qw(key index)};
  15         37  
337              
338 15 100 66     38 if ( _is_hash($current) && exists $current->{$key} ) {
339 11         25 $current = $current->{$key};
340             }
341             else {
342 4         10 $exists = 0;
343 4         9 last;
344             }
345              
346 11 100       58 if ( defined $idx ) {
347 4 100 66     10 if ( _is_array($current) && defined $current->[$idx] ) {
348 3         7 $current = $current->[$idx];
349             }
350             else {
351 1         3 $exists = 0;
352 1         2 last;
353             }
354             }
355             }
356              
357 8         36 push @results, $exists;
358             }
359              
360 8 50       60 return wantarray ? @results : $results[0];
361             }
362              
363             1;
364              
365             __END__