File Coverage

blib/lib/DBIx/Tree/MaterializedPath.pm
Criterion Covered Total %
statement 148 149 99.3
branch 35 38 92.1
condition 11 15 73.3
subroutine 19 19 100.0
pod 2 2 100.0
total 215 223 96.4


line stmt bran cond sub pod time code
1             package DBIx::Tree::MaterializedPath;
2 18     18   471011 use base DBIx::Tree::MaterializedPath::Node;
  18         49  
  18         16070  
3              
4 18     18   294 use warnings;
  18         33  
  18         607  
5 18     18   93 use strict;
  18         43  
  18         551  
6              
7 18     18   101 use Carp;
  18         39  
  18         1512  
8              
9             =head1 NAME
10              
11             DBIx::Tree::MaterializedPath - fast DBI queries and updates on "materialized path" trees
12              
13             =head1 VERSION
14              
15             Version 0.06
16              
17             =cut
18              
19 18     18   100 use version 0.74; our $VERSION = qv('0.06');
  18         427  
  18         140  
20              
21             =head1 SYNOPSIS
22              
23             use DBIx::Tree::MaterializedPath;
24              
25             my $root = DBIx::Tree::MaterializedPath->new({
26             dbh => $dbh,
27             table_name => 'my_movies_tree',
28             });
29              
30             # Add children to a node (assumes there is a "name" column
31             # in the "my_movies_tree" table):
32             #
33             my @children = $root->add_children([
34             {name => 'Drama'},
35             {name => 'Sci-Fi'},
36             {name => 'Horror'},
37             ]);
38              
39             # Add a new child in front of any existing children,
40             # instead of at the end of the list:
41             #
42             my $child = $root->add_children_at_left([{name => 'Comedy'}]);
43              
44             # Locate a node (uses SQL::Abstract to query node metadata):
45             #
46             my $sci_fi_node = $root->find(where => {name => {-like => 'Sci%'}});
47              
48             $sci_fi_node->add_child({name => 'The Andromeda Strain'});
49              
50             # Get children of a node:
51             #
52             @children = $sci_fi_node->get_children();
53              
54             # Access arbitrary node metadata:
55             #
56             print $children[0]->data->{name}; # 'The Andromeda Strain'
57              
58             # Walk tree (or node) descendants and operate on each node:
59             #
60             my $descendants = $tree->get_descendants;
61             $descendants->traverse($coderef);
62              
63             =head1 DESCRIPTION
64              
65             This module implements database storage for a "materialized path"
66             parent/child tree.
67              
68             Most methods (other than C) can act on any node in the tree,
69             including the root node. For documentation on additional methods
70             see
71             L.
72              
73             =head2 BACKGROUND
74              
75             This distribution was inspired by Dan Collis-Puro's
76             L modules
77             but is implemented in a more object-oriented way.
78              
79             Nested set trees are fast for typical tree queries (e.g. getting
80             a node's parents, children or siblings), because those operations
81             can typically be done with a single SQL statement. However, nested
82             set trees are generally slow for modifications to the tree
83             structure (e.g. adding, moving or deleting a node), because those
84             operations typically require renumbering the hierarchy info for
85             many affected nodes (often every node) due to a single
86             modification. (This may not be an issue for you if you have data
87             that is read often and updated very infrequently.)
88              
89             This materialized path tree implementation does away with the
90             integer "left" and "right" values that are stored with each node by
91             nested-set trees, and instead uses a formatted representation of
92             the path to the node (the "materialized path"), which is stored as
93             a text string. It retains the speed for tree query operations,
94             which can still typically be done with a single SQL statement, but
95             generally requires far fewer updates to existing rows when
96             modifications are made to the tree. This makes it better suited to
97             situations where the tree is updated with any frequency.
98              
99             =head1 METHODS
100              
101             =head2 new
102              
103             my $root = DBIx::Tree::MaterializedPath->new( $options_hashref )
104              
105             C initializes and returns a node pointing to the root of your
106             tree.
107              
108             B C assumes that the database table containing the
109             tree data already exists, it does not create the table for you.
110             The table may be empty or may contain previously populated tree data.
111             In addition to the required columns described below, the table may
112             contain as many other columns as needed to store the metadata that
113             corresponds to each node.
114              
115             If the tree table does not contain a row with a path corresponding to
116             the root node, a row for the root node will be inserted into the
117             table. The new row will contain no metadata, so your application
118             would need to call
119             L
120             to add any required metadata.
121              
122             C accepts a hashref of arguments:
123              
124             =over 4
125              
126             =item B
127              
128             B
129              
130             An active DBI handle as returned by C.
131              
132             =item B
133              
134             Optional, defaults to "B".
135              
136             The name of the database table that contains the tree data.
137              
138             =item B
139              
140             Optional, defaults to "B".
141              
142             The name of the database column that contains the unique ID
143             for the node.
144              
145             The ID is used internally as a "handle" to the row in the
146             database corresponding to each node. (This column would typically
147             be created in the database as e.g. a "sequence" or "serial" or
148             "autoincrement" type.)
149              
150             =item B
151              
152             Optional, defaults to "B".
153              
154             The name of the database column that contains the representation
155             of the path from the root of the tree to the node.
156              
157             Note that the path values which get stored in this column are
158             generated by the code, and may not be particularly human-readable.
159              
160             =item B
161              
162             Optional, defaults to B.
163              
164             If true, and no existing row is found in the database which matches
165             the root node's path, will create a new row for the root node.
166              
167             If false, and no existing row is found in the database which matches
168             the root node's path, will croak.
169              
170             Note that if a new root node row is created, it will not contain any
171             values in any metadata fields. This means the database insert will
172             fail if any of the corresponding columns in the database are required
173             to be non-NULL.
174              
175             =back
176              
177             =cut
178              
179             sub new
180             {
181 47     47 1 15603464 my ($class, @args) = @_;
182              
183 47 100       364 my $options = ref $args[0] eq 'HASH' ? $args[0] : {@args};
184              
185 47   66     614 my $self = bless {}, ref($class) || $class;
186              
187 47         388 $self->{_root} = $self;
188 47         239 $self->{_is_root} = 1;
189              
190 47         637 $self->SUPER::_init($options);
191 47         615 $self->_init($options);
192              
193 38         294 return $self;
194             }
195              
196             sub _init
197             {
198 47     47   121 my ($self, $options) = @_;
199              
200 47         164 $self->{_dbh} = $options->{dbh};
201 47   100     1051 $self->{_table_name} = $options->{table_name} || 'my_tree';
202 47   100     489 $self->{_id_column_name} = $options->{id_column_name} || 'id';
203 47   100     396 $self->{_path_column_name} = $options->{path_column_name} || 'path';
204              
205 47   33     596 $self->{_pathmapper} = $options->{path_mapper}
206             || DBIx::Tree::MaterializedPath::PathMapper->new();
207              
208 47 100       251 $self->{_auto_create_root} =
209             exists $options->{auto_create_root} ? $options->{auto_create_root} : 1;
210              
211 47         191 $self->{_sqlmaker} = SQL::Abstract->new();
212 47         2525 $self->{_sth_cache} = {};
213              
214 47         115 my $dbh = $self->{_dbh};
215 47 100       409 croak 'Missing required parameter: dbh' unless $dbh;
216 46 100       449 croak 'Invalid dbh: not a "DBI::db"' unless ref($dbh) eq 'DBI::db';
217              
218 44         1311 local $dbh->{PrintError} = 0; ## no critic (Variables::ProhibitLocalVars)
219 44         850 local $dbh->{PrintWarn} = 0; ## no critic (Variables::ProhibitLocalVars)
220 44         667 local $dbh->{RaiseError} = 1; ## no critic (Variables::ProhibitLocalVars)
221              
222             # Make sure the tree table exists in the database:
223 44         181 my $table = $self->{_table_name};
224 44         450 eval { $dbh->do("select count(*) from $table limit 1"); 1; }
  41         18325  
225 44 100       108 or do { croak qq{Table "$table" does not exist}; };
  3         1055  
226              
227             # Make sure the column exists in the tree table:
228 41         144 my $id_col = $self->{_id_column_name};
229 41         337 eval { $dbh->do("select $id_col from $table limit 1"); 1; }
  40         10059  
230 41 100       92 or do { croak qq{Column "$id_col" does not exist}; };
  1         481  
231              
232             # Make sure the column exists in the tree table:
233 40         129 my $path_col = $self->{_path_column_name};
234 40         301 eval { $dbh->do("select $path_col from $table limit 1"); 1; }
  39         10578  
235 40 100       91 or do { croak qq{Column "$path_col" does not exist}; };
  1         325  
236              
237             # Check if DB is capable of transactions:
238             #
239             # If RaiseError is false, begin_work() will:
240             # return true if a new transaction was started
241             # return false if already in a transaction
242             # croak if transactions not supported
243             #
244 39         101 my $started_a_new_transaction = 0;
245             eval {
246             ## no critic (Variables::ProhibitLocalVars)
247 39         637 local $dbh->{RaiseError} = 0;
248             ## use critic
249 39         458 $started_a_new_transaction = $dbh->begin_work;
250 39         683 $self->{_can_do_transactions} = 1;
251 39         391 1;
252 39 50       367 } or do { $self->{_can_do_transactions} = 0; };
  0         0  
253              
254             ## no critic (ErrorHandling::RequireCheckingReturnValueOfEval)
255 39 100       186 eval { $dbh->rollback } if $started_a_new_transaction;
  38         257  
256             ## use critic
257              
258             # Load the root node:
259 39         455 my $root_node_path = $self->_map_path('1');
260 39         329 eval { $self->_load_from_db_using_path($root_node_path); 1; } or do
  4         12  
261 39 100       90 {
262 35 50       2763 croak $@ unless $@ =~ /No\s+row/msx;
263 35 100       282 croak $@ unless $self->{_auto_create_root};
264              
265             # If we got here, the root node was not found and
266             # auto_create_root is true, so create the node
267 34         438 $self->_insert_into_db_from_hashref({$path_col => $root_node_path});
268             };
269              
270 38         874 return;
271             }
272              
273             =head2 clone
274              
275             Create a clone of an existing tree object.
276              
277             =cut
278              
279 18     18   14838 use Clone ();
  18         40  
  18         5881  
280              
281             sub clone
282             {
283 1     1 1 12 my ($self) = @_;
284              
285 1         566 my $clone = Clone::clone($self);
286              
287             # Fix up database handles that Clone::clone() might have broken:
288 1         13 $clone->{_dbh} = $self->{_dbh};
289 1         24 $clone->{_sth_cache} = $self->{_sth_cache};
290              
291 1         10 return $clone;
292             }
293              
294             ###################################################################
295              
296             #
297             # Execute code (within a transaction if the database supports
298             # transactions).
299             #
300             sub _do_transaction
301             {
302 106     106   2037 my ($self, $code, @args) = @_;
303              
304 106 100       424 unless ($self->{_can_do_transactions})
305             {
306 1         7 $code->(@args);
307 1         4 return;
308             }
309              
310 105         239 my $dbh = $self->{_dbh};
311 105         2689 local $dbh->{PrintError} = 0; ## no critic (Variables::ProhibitLocalVars)
312 105         1213 local $dbh->{PrintWarn} = 0; ## no critic (Variables::ProhibitLocalVars)
313 105         1416 local $dbh->{RaiseError} = 1; ## no critic (Variables::ProhibitLocalVars)
314              
315             # If RaiseError is true, begin_work() will:
316             # return true if a new transaction was started
317             # croak if already in a transaction
318             # croak if transactions not supported
319             #
320 105         334 my $started_a_new_transaction = 0;
321 105 100       323 eval { $started_a_new_transaction = $dbh->begin_work } or do { };
  105         922  
322              
323             eval {
324 105         382 $code->(@args);
325 104 100       3726360 $dbh->commit if $started_a_new_transaction;
326 104         1444 1;
327             } or do
328 105 100       2702 {
329 1         12 my $msg = $@;
330             ## no critic (ErrorHandling::RequireCheckingReturnValueOfEval)
331 1 50       3 eval { $dbh->rollback } if $started_a_new_transaction;
  1         5  
332             ## use critic
333 1         197 croak $msg;
334             };
335              
336 104         5938 return;
337             }
338              
339             ###################################################################
340              
341             #
342             # Manage a cache of active statement handles:
343             #
344              
345             sub _cached_sth
346             {
347 866     866   1785 my ($self, $sql) = @_;
348              
349 866   66     6592 $self->{_sth_cache}->{$sql} ||= $self->_create_sth($sql);
350              
351 863         4012 return $self->{_sth_cache}->{$sql};
352             }
353              
354             # Setting DBI's "STH_CACHE_REPLACE => 3" will:
355             # 1) Suppress DBI warnings from prepare_cached() if SQL matching
356             # an existing active handle is supplied, and
357             # 2) Replace the existing handle in the DBI cache with the
358             # newly-generated one
359             #
360 18     18   115 use Readonly;
  18         31  
  18         12318  
361             Readonly::Scalar my $STH_CACHE_REPLACE => 3;
362              
363             sub _create_sth
364             {
365 276     276   750 my ($self, $sql) = @_;
366              
367 276         582 my $dbh = $self->{_dbh};
368              
369 276         7420 local $dbh->{PrintError} = 0; ## no critic (Variables::ProhibitLocalVars)
370 276         3272 local $dbh->{PrintWarn} = 0; ## no critic (Variables::ProhibitLocalVars)
371 276         3540 local $dbh->{RaiseError} = 1; ## no critic (Variables::ProhibitLocalVars)
372              
373 276         2308 my $sth = $dbh->prepare_cached($sql, undef, $STH_CACHE_REPLACE);
374              
375 273         36219 return $sth;
376             }
377              
378             ###################################################################
379              
380             #
381             # Manage a cache of generated SQL:
382             #
383              
384             sub _cached_sql
385             {
386 425     425   1052 my ($self, $sql_key, $args) = @_;
387              
388 425         1327 my $sql = $self->{_sql}->{$sql_key};
389 425 100       1699 unless ($sql)
390             {
391 120 100       1181 my $func =
392             ($sql_key =~ /^VALIDATE_/msx)
393             ? '_cached_sql_VALIDATE'
394             : "_cached_sql_$sql_key";
395 120         787 $sql = $self->$func($args);
396 120         593 $self->{_sql}->{$sql_key} = $sql;
397             }
398 425         1981 return $sql;
399             }
400              
401             sub _cached_sql_SELECT_STAR_FROM_TABLE_WHERE_ID_EQ_X_LIMIT_1
402             {
403 7     7   27 my $self = shift;
404 7         24 my $table = $self->{_table_name};
405 7         25 my $id_col = $self->{_id_column_name};
406 7         32 my $sql = "SELECT * FROM $table WHERE ( $id_col = ? ) LIMIT 1";
407 7         18 return $sql;
408             }
409              
410             sub _cached_sql_SELECT_STAR_FROM_TABLE_WHERE_PATH_EQ_X_LIMIT_1
411             {
412 39     39   84 my $self = shift;
413 39         109 my $table = $self->{_table_name};
414 39         90 my $path_col = $self->{_path_column_name};
415 39         156 my $sql = "SELECT * FROM $table WHERE ( $path_col = ? ) LIMIT 1";
416 39         106 return $sql;
417             }
418              
419             sub _cached_sql_SELECT_ID_FROM_TABLE_WHERE_PATH_EQ_X_LIMIT_1
420             {
421 34     34   118 my $self = shift;
422 34         147 my $table = $self->{_table_name};
423 34         263 my $id_col = $self->{_id_column_name};
424 34         129 my $path_col = $self->{_path_column_name};
425 34         220 my $sql = "SELECT $id_col FROM $table WHERE ( $path_col = ? ) LIMIT 1";
426 34         178 return $sql;
427             }
428              
429             sub _cached_sql_UPDATE_TABLE_SET_PATH_EQ_X_WHERE_ID_EQ_X
430             {
431 7     7   16 my $self = shift;
432 7         16 my $table = $self->{_table_name};
433 7         18 my $path_col = $self->{_path_column_name};
434 7         176 my $id_col = $self->{_id_column_name};
435 7         29 my $sql = "UPDATE $table SET $path_col = ? WHERE ( $id_col = ? )";
436 7         21 return $sql;
437             }
438              
439             sub _cached_sql_VALIDATE
440             {
441 33     33   82 my $self = shift;
442 33         71 my $columns = shift;
443 33         102 my $table = $self->{_table_name};
444 33         83 my $id_col = $self->{_id_column_name};
445 33         109 my $where = {$id_col => 0};
446 33         100 my $sqlmaker = $self->{_sqlmaker};
447 33         292 my $sql = $sqlmaker->select($table, $columns, $where);
448 33         11753 $sql .= ' LIMIT 1';
449 33         371 return $sql;
450             }
451              
452             ###################################################################
453              
454             1;
455              
456             __END__