File Coverage

blib/lib/DBIx/Tree/MaterializedPath/PathMapper.pm
Criterion Covered Total %
statement 153 153 100.0
branch 31 32 96.8
condition 13 17 76.4
subroutine 31 31 100.0
pod 19 19 100.0
total 247 252 98.0


line stmt bran cond sub pod time code
1             package DBIx::Tree::MaterializedPath::PathMapper;
2              
3 19     19   29911 use warnings;
  19         41  
  19         719  
4 19     19   99 use strict;
  19         40  
  19         629  
5              
6 19     19   106 use Carp;
  19         37  
  19         1557  
7 19     19   1477 use SQL::Abstract;
  19         11072  
  19         498  
8              
9 19     19   1015 use Readonly;
  19         3247  
  19         2483  
10              
11             Readonly::Scalar my $EMPTY_STRING => q{};
12              
13             Readonly::Scalar my $DEFAULT_CHUNKSIZE => 5;
14             Readonly::Scalar my $MAX_CHUNKSIZE => 8;
15              
16             my $re_period = qr/[.]/msx;
17              
18             =head1 NAME
19              
20             DBIx::Tree::MaterializedPath::PathMapper - manipulates paths for "materialized path" trees
21              
22             =head1 VERSION
23              
24             Version 0.06
25              
26             =cut
27              
28 19     19   17502 use version 0.74; our $VERSION = qv('0.06');
  19         44727  
  19         128  
29              
30             =head1 SYNOPSIS
31              
32             use DBIx::Tree::MaterializedPath::PathMapper;
33              
34             my $mapper = DBIx::Tree::MaterializedPath::PathMapper->new();
35              
36             # Path to the 2nd child of the 3rd child of the root node:
37             my $path_in_db = $mapper->map('1.3.2');
38              
39             my $path = $mapper->unmap($path_in_db); # "1.3.2"
40              
41             =head1 DESCRIPTION
42              
43             This module manipulates path representations for DBIx::Tree::MaterializedPath
44             "materialized path" trees.
45              
46             =head2 PATH REPRESENTATIONS
47              
48             The "human-readable" path is a sequence of integers separated by
49             periods that represents the path from the root node of the tree
50             to one of it's children.
51              
52             The first integer (representing the root node) is always a "1".
53             Subsequent integers represent the Nth child node of the parent,
54             e.g. "1.7" would be the 7th child of the root node, and "1.7.2"
55             would be the 2nd child of the 7th child of the root node.
56              
57             The "human-readable" path for each node is the tree is mapped
58             into a different format for storage in the database. The format
59             used for database storage must meet several criteria in order for
60             tree manipulation via SQL to work:
61              
62             =over 4
63              
64             =item *
65              
66             The human-readable path must map uniquely to the stored path,
67             and vice versa.
68              
69             =item *
70              
71             The stored path strings should sort such that the nodes they
72             represent would be traversed leftmost depth-first.
73              
74             =item *
75              
76             The length of each "chunk" of the stored path (where each chunk
77             represents one step deeper into the tree) should be a fixed value.
78             E.g. "1.108.15" could map to something like "001.108.015" if the
79             chunksize was 3. This ensures that the length of the path
80             representation is a function of the depth of the node in the tree.
81              
82             =item *
83              
84             The stored path may be prefixed with additional info, but there
85             should be no extraneous info at the end. Removing one "chunk"
86             from the end of a node's path should always result in the path
87             for that node's parent.
88              
89             =back
90              
91             =head2 LIMITATIONS
92              
93             This implementation uses a default chunksize of 5 when mapping
94             the digits in the human-readable path into a hex representation.
95             This means that the highest numbered child at any level is
96             0xfffff, or 1,048,575.
97              
98             In addition to limiting the maximum children a node may have,
99             the chunksize also affects the length of the path strings and
100             thus the amount of database storage required.
101              
102             The default chunksize may be overridden by passing a "chunksize"
103             option to C, with an integer value ranging from 1 to 8.
104              
105             =head1 METHODS
106              
107             =head2 new
108              
109             $mapper = DBIx::Tree::MaterializedPath::PathMapper->new()
110              
111             Returns a path mapping object.
112              
113             =cut
114              
115             sub new
116             {
117 54     54 1 3711 my ($class, @args) = @_;
118              
119 54 100       270 my $options = ref $args[0] eq 'HASH' ? $args[0] : {@args};
120              
121 54   66     455 my $self = bless {}, ref($class) || $class;
122 54         287 $self->_init($options);
123 54         342 return $self;
124             }
125              
126             sub _init
127             {
128 54     54   123 my ($self, $options) = @_;
129              
130 54         278 $self->{_version} = '1'; # must be single character
131              
132             # Size of storage format path chunks:
133 54   66     365 $self->{_chunksize} = $options->{chunksize} || $DEFAULT_CHUNKSIZE;
134 54 100       390 $self->{_chunksize} = $MAX_CHUNKSIZE
135             if $self->{_chunksize} > $MAX_CHUNKSIZE;
136              
137 54         610 $self->{_sqlmaker} = SQL::Abstract->new();
138              
139 54         5285 $self->{_cache} = {};
140              
141 54         311 return;
142             }
143              
144             #
145             # Be sure to keep _parse_path() consistent with map()...
146             #
147             sub _parse_path
148             {
149 552     552   1503 my ($self, $path) = @_;
150 552         1492 my $extra = substr($path, 0, 1);
151 552         992 my $chunksize = substr($path, 1, 1);
152 552         1480 my $pathpart = substr($path, 2);
153 552         3911 return ($chunksize, $pathpart, $extra);
154             }
155              
156             =head2 is_root
157              
158             $mapper->is_root( $path )
159              
160             Given a path, returns true if the path represents the root node.
161              
162             =cut
163              
164             # Be sure to keep is_root() consistent with map()...
165              
166             sub is_root
167             {
168 5     5 1 10 my ($self, $path) = @_;
169              
170 5         12 my ($chunksize, $pathpart) = $self->_parse_path($path);
171 5 100       13 return $self->_is_root($chunksize, $pathpart) ? 1 : 0;
172             }
173              
174             sub _is_root
175             {
176 242     242   471 my ($self, $chunksize, $pathpart) = @_;
177 242 50       580 return 0 unless $chunksize;
178 242 100       1480 return (length($pathpart) == $chunksize) ? 1 : 0;
179             }
180              
181             =head2 depth
182              
183             $mapper->depth( $path )
184              
185             Given a path, returns the depth of the node in the tree.
186             The root node is at zero depth.
187              
188             =cut
189              
190             sub depth
191             {
192 22     22 1 43 my ($self, $path) = @_;
193              
194 22         54 my ($chunksize, $pathpart) = $self->_parse_path($path);
195 22         203 return int(length($pathpart) / $chunksize) - 1;
196             }
197              
198             =head2 map
199              
200             $mapper->map( $human_readable_path )
201              
202             Maps a string representing the path from the root node of the tree
203             to a child node from human-readable format (e.g. "1.2.4.1") to the
204             format stored in the database.
205              
206             =cut
207              
208             sub map ## no critic (Subroutines::ProhibitBuiltinHomonyms)
209             {
210 124     124 1 10605 my ($self, $hrpath) = @_;
211              
212 124         459 my $chunksize = $self->{_chunksize};
213 124         339 my $format = '%0' . $chunksize . 'x';
214              
215 230         1735 my $pathpart = join $EMPTY_STRING,
216 124         833 map { sprintf($format, $_) } split($re_period, $hrpath);
217              
218 124         702 return $self->_map($chunksize, $pathpart);
219             }
220              
221             sub _map
222             {
223 139     139   292 my ($self, $chunksize, $pathpart) = @_;
224 139         360 my $path = $self->{_version} . $chunksize . $pathpart;
225 139         743 return $path;
226             }
227              
228             #
229             # Be sure to keep _map_chunk() consisent with map()...
230             #
231             sub _map_chunk
232             {
233 317     317   715 my ($self, $chunk, $chunksize) = @_;
234 317   33     779 $chunksize ||= $self->{_chunksize};
235 317         771 my $format = '%0' . $chunksize . 'x';
236 317         1900 return sprintf($format, $chunk);
237             }
238              
239             =head2 unmap
240              
241             $mapper->unmap( $path )
242              
243             Maps a string representing the path from the root node of the tree
244             to a child node from the format stored in the database to
245             human-readable format (e.g. "1.2.4.1").
246              
247             =cut
248              
249             sub unmap
250             {
251 65     65 1 464 my ($self, $path) = @_;
252 65         183 my ($chunksize, $pathpart) = $self->_parse_path($path);
253              
254             # This doesn't work in perl 5.6.1, the parentheses
255             # for grouping and repeating are not allowed:
256             #
257             #my $format = "(A$chunksize)*";
258              
259             # Build an explicit format that works in Perl 5.6.1:
260 65         196 my $num_chunks = int(length($pathpart) / $chunksize);
261 65         153 my $format = "A$chunksize" x $num_chunks;
262              
263 65         251 my $hrpath = join q{.}, map { hex $_ } unpack($format, $pathpart);
  167         406  
264 65         296 return $hrpath;
265             }
266              
267             #
268             # Be sure to keep _unmap_chunk() consistent with unmap()...
269             #
270             sub _unmap_chunk
271             {
272 219     219   381 my ($self, $chunk) = @_;
273 219         654 return hex($chunk);
274             }
275              
276             =head2 parent_path
277              
278             $mapper->parent_path( $path )
279              
280             Given a path to a node, return the path to its immediate parent.
281              
282             Returns an empty string if the input path represents the root node.
283              
284             =cut
285              
286             sub parent_path
287             {
288 17     17 1 28 my ($self, $path) = @_;
289              
290 17         45 my ($chunksize, $pathpart) = $self->_parse_path($path);
291              
292 17 100       49 return $EMPTY_STRING if $self->_is_root($chunksize, $pathpart);
293              
294 15         123 my $parentpathpart = substr($pathpart, 0, -$chunksize);
295              
296 15         41 return $self->_map($chunksize, $parentpathpart);
297             }
298              
299             =head2 first_child_path
300              
301             $mapper->first_child_path( $path )
302              
303             Given a path to a node, return a path to the first child
304             of that node.
305              
306             =cut
307              
308             sub first_child_path
309             {
310 98     98 1 224 my ($self, $path) = @_;
311              
312 98         426 my ($chunksize, $pathpart) = $self->_parse_path($path);
313              
314 98         515 return $path . $self->_map_chunk(1, $chunksize);
315             }
316              
317             =head2 next_child_path
318              
319             $mapper->next_child_path( $path, $optional_n )
320              
321             Given a path to a child node, return a path to the next child
322             for the same parent.
323              
324             If I<$n> is specified, return the path to the nth next child.
325             (I<$n> effectively defaults to 1.)
326              
327             Returns an empty string if the input path represents the root node.
328              
329             =cut
330              
331             sub next_child_path
332             {
333 220     220 1 525 my ($self, $path, $n) = @_;
334 220   100     983 $n ||= 1;
335              
336 220         735 my ($chunksize, $pathpart) = $self->_parse_path($path);
337              
338 220 100       791 return $EMPTY_STRING if $self->_is_root($chunksize, $pathpart);
339              
340 219         673 my $last_chunk = substr($path, -$chunksize);
341 219         646 $last_chunk = $self->_unmap_chunk($last_chunk);
342 219         465 $last_chunk += $n;
343 219         600 $last_chunk = $self->_map_chunk($last_chunk, $chunksize);
344 219         952 substr($path, -$chunksize) = $last_chunk;
345              
346 219         1206 return $path;
347             }
348              
349             =head2 where
350              
351             $mapper->where( $where )
352              
353             Given an L-styleSQL "where"
354             data structure, return an SQL "where" clause,
355             and the corresponding array of bind params.
356              
357             =cut
358              
359             sub where
360             {
361 1     1 1 1256 my ($self, $where) = @_;
362              
363 1         3 my $sqlmaker = $self->{_sqlmaker};
364 1         3 my ($sql, @bind_params) = $sqlmaker->where($where);
365 1         88 return ($sql, @bind_params);
366             }
367              
368             =head2 child_where
369              
370             $mapper->child_where( $path_column_name, $path )
371              
372             Given the name of the path column and a path to a node,
373             return an SQL "where" clause suitable for finding the node's
374             direct children, and the corresponding array of bind params.
375              
376             =cut
377              
378             sub child_where
379             {
380 100     100 1 495 my ($self, $path_col, $path) = @_;
381              
382 100         523 my ($chunksize, $pathpart) = $self->_parse_path($path);
383              
384 100         849 my $like = $path . ('_' x $chunksize);
385              
386 100         275 my $sqlmaker = $self->{_sqlmaker};
387 100         2177 my ($sql, @bind_params) = $sqlmaker->where({$path_col => {-like => $like}});
388 100         49969 return ($sql, @bind_params);
389             }
390              
391             =head2 sibling_where
392              
393             $mapper->sibling_where( $path_column_name, $path )
394              
395             Given the name of the path column and a path to a node,
396             return an SQL "where" clause suitable for finding the node's
397             siblings, and the corresponding array of bind params.
398              
399             =cut
400              
401             sub sibling_where
402             {
403 6     6 1 16 my ($self, $path_col, $path) = @_;
404              
405 6         22 my ($chunksize, $pathpart) = $self->_parse_path($path);
406              
407 6         27 my $like = $path;
408 6         29 substr($like, -$chunksize) = '_' x $chunksize;
409              
410 6         15 my $sqlmaker = $self->{_sqlmaker};
411 6         56 my ($sql, @bind_params) = $sqlmaker->where({$path_col => {-like => $like}});
412 6         2339 return ($sql, @bind_params);
413             }
414              
415             =head2 sibling_to_the_right_where
416              
417             $mapper->sibling_to_the_right_where( $path_column_name, $path )
418              
419             Given the name of the path column and a path to a node,
420             return an SQL "where" clause suitable for finding siblings
421             to the right of the node, and the corresponding array of bind
422             params.
423              
424             =cut
425              
426             sub sibling_to_the_right_where
427             {
428 14     14 1 27 my ($self, $path_col, $path) = @_;
429              
430 14         53 my ($chunksize, $pathpart) = $self->_parse_path($path);
431              
432 14         33 my $like = $path;
433 14         63 substr($like, -$chunksize) = '_' x $chunksize;
434              
435 14         29 my $sqlmaker = $self->{_sqlmaker};
436 14         190 my ($sql, @bind_params) = $sqlmaker->where(
437             {$path_col => [-and => {-like => $like}, {'>' => $path}]});
438 14         14814 return ($sql, @bind_params);
439             }
440              
441             =head2 sibling_to_the_left_where
442              
443             $mapper->sibling_to_the_left_where( $path_column_name, $path )
444              
445             Given the name of the path column and a path to a node,
446             return an SQL "where" clause suitable for finding siblings
447             to the left of the node, and the corresponding array of bind
448             params.
449              
450             =cut
451              
452             sub sibling_to_the_left_where
453             {
454 5     5 1 11 my ($self, $path_col, $path) = @_;
455              
456 5         16 my ($chunksize, $pathpart) = $self->_parse_path($path);
457              
458 5         8 my $like = $path;
459 5         19 substr($like, -$chunksize) = '_' x $chunksize;
460              
461 5         100 my $sqlmaker = $self->{_sqlmaker};
462 5         69 my ($sql, @bind_params) = $sqlmaker->where(
463             {$path_col => [-and => {-like => $like}, {'<' => $path}]});
464 5         4564 return ($sql, @bind_params);
465             }
466              
467             =head2 descendants_where_struct
468              
469             $mapper->descendants_where_struct( $path_column_name, $path )
470              
471             Given the name of the path column and a path to a node,
472             return an L-styleSQL "where"
473             data structure suitable for finding all of
474             the node's descendants.
475              
476             =cut
477              
478             sub descendants_where_struct
479             {
480 47     47 1 103 my ($self, $path_col, $path) = @_;
481              
482 47         109 my $cache = $self->{_cache};
483 47         90 my $key = 'descendants_where_struct';
484              
485 47 100       288 unless ($cache->{$key}->{$path_col}->{$path})
486             {
487 38         109 my $like = $path . q{%};
488              
489 38         427 $cache->{$key}->{$path_col}->{$path} =
490             {$path_col => [-and => {-like => $like}, {q{!=} => $path}]};
491             }
492              
493 47         372 return $cache->{$key}->{$path_col}->{$path};
494             }
495              
496             =head2 descendants_where
497              
498             $mapper->descendants_where( $path_column_name, $path )
499              
500             Given the name of the path column and a path to a node,
501             return an SQL "where" clause suitable for finding all of
502             the node's descendants, and the corresponding array of
503             bind params.
504              
505             =cut
506              
507             sub descendants_where
508             {
509 38     38 1 105 my ($self, $path_col, $path) = @_;
510              
511 38         116 my $cache = $self->{_cache};
512 38         134 my $key = 'descendants_where';
513              
514 38 100       325 unless ($cache->{$key}->{$path_col}->{$path})
515             {
516 36         164 my $where = $self->descendants_where_struct($path_col, $path);
517              
518 36         96 my $sqlmaker = $self->{_sqlmaker};
519 36         386 my ($sql, @bind_params) = $sqlmaker->where($where);
520 36         44292 $cache->{$key}->{$path_col}->{$path} = {
521             sql => $sql,
522             bind_params => \@bind_params,
523             };
524             }
525              
526 38         149 my $data = $cache->{$key}->{$path_col}->{$path};
527 38         151 return ($data->{sql}, @{$data->{bind_params}});
  38         230  
528             }
529              
530             =head2 descendants_and_self_where
531              
532             $mapper->descendants_and_self_where( $path_column_name, $path )
533              
534             Given the name of the path column and a path to a node,
535             return an SQL "where" clause suitable for finding a node and
536             all of its descendants, and the corresponding array of
537             bind params.
538              
539             =cut
540              
541             sub descendants_and_self_where
542             {
543 5     5 1 10 my ($self, $path_col, $path) = @_;
544              
545 5         11 my $like = $path . q{%};
546              
547 5         13 my $sqlmaker = $self->{_sqlmaker};
548 5         37 my ($sql, @bind_params) = $sqlmaker->where({$path_col => {-like => $like}});
549 5         1518 return ($sql, @bind_params);
550             }
551              
552             =head2 parent_where
553              
554             $mapper->parent_where( $path_column_name, $path )
555              
556             Given the name of the path column and a path to a node,
557             return an SQL "where" clause suitable for finding the node's
558             parent, and the corresponding array of bind params.
559              
560             =cut
561              
562             sub parent_where
563             {
564 14     14 1 2700 my ($self, $path_col, $path) = @_;
565              
566 14         31 my ($sql, @bind_params) = (undef, undef);
567              
568 14         39 my $parent_path = $self->parent_path($path);
569 14 100       35 if ($parent_path)
570             {
571 13         26 my $sqlmaker = $self->{_sqlmaker};
572 13         77 ($sql, @bind_params) = $sqlmaker->where({$path_col => $parent_path});
573             }
574 14         1785 return ($sql, @bind_params);
575             }
576              
577             =head2 is_ancestor_of
578              
579             $mapper->is_ancestor_of( $path1, $path2 )
580              
581             Return true if I represents an ancestor of I.
582              
583             Returns false if I and I represent the same node.
584              
585             =cut
586              
587             sub is_ancestor_of
588             {
589 27     27 1 638 my ($self, $path1, $path2) = @_;
590              
591 27 100 100     489 croak 'Missing path' unless $path1 && $path2;
592              
593 25 100       87 return 0 if $path1 eq $path2;
594 21 100       289 return (substr($path2, 0, length($path1)) eq $path1) ? 1 : 0;
595             }
596              
597             =head2 is_descendant_of
598              
599             $mapper->is_descendant_of( $path1, $path2 )
600              
601             Return true if I represents a descendant of I.
602              
603             Returns false if I and I represent the same node.
604              
605             =cut
606              
607             sub is_descendant_of
608             {
609 26     26 1 1187 my ($self, $path1, $path2) = @_;
610              
611 26 100 100     316 croak 'Missing path' unless $path1 && $path2;
612              
613 24 100       2800 return 0 if $path1 eq $path2;
614 20 100       249 return (substr($path1, 0, length($path2)) eq $path2) ? 1 : 0;
615             }
616              
617             1;
618              
619             __END__