Commit | Line | Data |
118e6b96 |
1 | # vim: ts=8:sw=4:sts=4:et |
a733c37f |
2 | package DBIx::Class::Ordered; |
118e6b96 |
3 | use strict; |
4 | use warnings; |
5 | use base qw( DBIx::Class ); |
6 | |
7 | =head1 NAME |
8 | |
a733c37f |
9 | DBIx::Class::Ordered - Modify the position of objects in an ordered list. |
118e6b96 |
10 | |
11 | =head1 SYNOPSIS |
12 | |
a733c37f |
13 | Create a table for your ordered data. |
118e6b96 |
14 | |
a733c37f |
15 | CREATE TABLE items ( |
16 | item_id INTEGER PRIMARY KEY AUTOINCREMENT, |
118e6b96 |
17 | name TEXT NOT NULL, |
18 | position INTEGER NOT NULL |
19 | ); |
1d941d67 |
20 | |
e9188247 |
21 | Optionally, add one or more columns to specify groupings, allowing you |
22 | to maintain independent ordered lists within one table: |
23 | |
24 | CREATE TABLE items ( |
25 | item_id INTEGER PRIMARY KEY AUTOINCREMENT, |
26 | name TEXT NOT NULL, |
27 | position INTEGER NOT NULL, |
28 | group_id INTEGER NOT NULL |
29 | ); |
30 | |
31 | Or even |
32 | |
33 | CREATE TABLE items ( |
34 | item_id INTEGER PRIMARY KEY AUTOINCREMENT, |
35 | name TEXT NOT NULL, |
36 | position INTEGER NOT NULL, |
37 | group_id INTEGER NOT NULL, |
38 | other_group_id INTEGER NOT NULL |
39 | ); |
40 | |
a8492531 |
41 | In your Schema or DB class add "Ordered" to the top |
118e6b96 |
42 | of the component list. |
43 | |
a733c37f |
44 | __PACKAGE__->load_components(qw( Ordered ... )); |
118e6b96 |
45 | |
46 | Specify the column that stores the position number for |
47 | each row. |
48 | |
a733c37f |
49 | package My::Item; |
118e6b96 |
50 | __PACKAGE__->position_column('position'); |
1d941d67 |
51 | |
e9188247 |
52 | If you are using one grouping column, specify it as follows: |
53 | |
54 | __PACKAGE__->grouping_column('group_id'); |
55 | |
56 | Or if you have multiple grouping columns: |
57 | |
58 | __PACKAGE__->grouping_column(['group_id', 'other_group_id']); |
59 | |
a8492531 |
60 | That's it, now you can change the position of your objects. |
118e6b96 |
61 | |
62 | #!/use/bin/perl |
a733c37f |
63 | use My::Item; |
118e6b96 |
64 | |
a733c37f |
65 | my $item = My::Item->create({ name=>'Matt S. Trout' }); |
66 | # If using grouping_column: |
67 | my $item = My::Item->create({ name=>'Matt S. Trout', group_id=>1 }); |
118e6b96 |
68 | |
a733c37f |
69 | my $rs = $item->siblings(); |
70 | my @siblings = $item->siblings(); |
118e6b96 |
71 | |
72 | my $sibling; |
a733c37f |
73 | $sibling = $item->first_sibling(); |
74 | $sibling = $item->last_sibling(); |
75 | $sibling = $item->previous_sibling(); |
76 | $sibling = $item->next_sibling(); |
118e6b96 |
77 | |
a733c37f |
78 | $item->move_previous(); |
79 | $item->move_next(); |
80 | $item->move_first(); |
81 | $item->move_last(); |
82 | $item->move_to( $position ); |
1d941d67 |
83 | $item->move_to_group( 'groupname' ); |
84 | $item->move_to_group( 'groupname', $position ); |
85 | $item->move_to_group( {group_id=>'groupname', 'other_group_id=>'othergroupname'} ); |
86 | $item->move_to_group( {group_id=>'groupname', 'other_group_id=>'othergroupname'}, $position ); |
118e6b96 |
87 | |
88 | =head1 DESCRIPTION |
89 | |
a733c37f |
90 | This module provides a simple interface for modifying the ordered |
91 | position of DBIx::Class objects. |
118e6b96 |
92 | |
133dd22a |
93 | =head1 AUTO UPDATE |
94 | |
95 | All of the move_* methods automatically update the rows involved in |
96 | the query. This is not configurable and is due to the fact that if you |
97 | move a record it always causes other records in the list to be updated. |
98 | |
118e6b96 |
99 | =head1 METHODS |
100 | |
101 | =head2 position_column |
102 | |
103 | __PACKAGE__->position_column('position'); |
104 | |
105 | Sets and retrieves the name of the column that stores the |
a8492531 |
106 | positional value of each record. Defaults to "position". |
118e6b96 |
107 | |
108 | =cut |
109 | |
110 | __PACKAGE__->mk_classdata( 'position_column' => 'position' ); |
111 | |
a733c37f |
112 | =head2 grouping_column |
133dd22a |
113 | |
a733c37f |
114 | __PACKAGE__->grouping_column('group_id'); |
133dd22a |
115 | |
a8492531 |
116 | This method specifies a column to limit all queries in |
133dd22a |
117 | this module by. This effectively allows you to have multiple |
a733c37f |
118 | ordered lists within the same table. |
133dd22a |
119 | |
120 | =cut |
121 | |
a733c37f |
122 | __PACKAGE__->mk_classdata( 'grouping_column' ); |
133dd22a |
123 | |
118e6b96 |
124 | =head2 siblings |
125 | |
a733c37f |
126 | my $rs = $item->siblings(); |
127 | my @siblings = $item->siblings(); |
118e6b96 |
128 | |
a8492531 |
129 | Returns either a resultset or an array of all other objects |
118e6b96 |
130 | excluding the one you called it on. |
131 | |
132 | =cut |
133 | |
134 | sub siblings { |
135 | my( $self ) = @_; |
136 | my $position_column = $self->position_column; |
a9cdbec2 |
137 | my $rs = $self->result_source->resultset->search( |
7a76f44c |
138 | { |
139 | $position_column => { '!=' => $self->get_column($position_column) }, |
a733c37f |
140 | $self->_grouping_clause(), |
7a76f44c |
141 | }, |
118e6b96 |
142 | { order_by => $self->position_column }, |
143 | ); |
7a76f44c |
144 | return $rs->all() if (wantarray()); |
145 | return $rs; |
118e6b96 |
146 | } |
147 | |
148 | =head2 first_sibling |
149 | |
a733c37f |
150 | my $sibling = $item->first_sibling(); |
118e6b96 |
151 | |
5faa95af |
152 | Returns the first sibling object, or 0 if the first sibling |
a8492531 |
153 | is this sibling. |
118e6b96 |
154 | |
155 | =cut |
156 | |
157 | sub first_sibling { |
158 | my( $self ) = @_; |
5faa95af |
159 | return 0 if ($self->get_column($self->position_column())==1); |
fa6b598f |
160 | |
a9cdbec2 |
161 | return ($self->result_source->resultset->search( |
162 | { |
163 | $self->position_column => 1, |
a733c37f |
164 | $self->_grouping_clause(), |
a9cdbec2 |
165 | }, |
118e6b96 |
166 | )->all())[0]; |
167 | } |
168 | |
169 | =head2 last_sibling |
170 | |
a733c37f |
171 | my $sibling = $item->last_sibling(); |
118e6b96 |
172 | |
a8492531 |
173 | Returns the last sibling, or 0 if the last sibling is this |
5faa95af |
174 | sibling. |
118e6b96 |
175 | |
176 | =cut |
177 | |
178 | sub last_sibling { |
179 | my( $self ) = @_; |
a733c37f |
180 | my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count(); |
5faa95af |
181 | return 0 if ($self->get_column($self->position_column())==$count); |
a9cdbec2 |
182 | return ($self->result_source->resultset->search( |
183 | { |
184 | $self->position_column => $count, |
a733c37f |
185 | $self->_grouping_clause(), |
a9cdbec2 |
186 | }, |
118e6b96 |
187 | )->all())[0]; |
188 | } |
189 | |
190 | =head2 previous_sibling |
191 | |
a733c37f |
192 | my $sibling = $item->previous_sibling(); |
118e6b96 |
193 | |
a8492531 |
194 | Returns the sibling that resides one position back. Returns undef |
195 | if the current object is the first one. |
118e6b96 |
196 | |
197 | =cut |
198 | |
199 | sub previous_sibling { |
200 | my( $self ) = @_; |
201 | my $position_column = $self->position_column; |
707cbb2d |
202 | my $position = $self->get_column( $position_column ); |
203 | return 0 if ($position==1); |
3ffca97b |
204 | return ($self->result_source->resultset->search( |
7a76f44c |
205 | { |
707cbb2d |
206 | $position_column => $position - 1, |
a733c37f |
207 | $self->_grouping_clause(), |
707cbb2d |
208 | } |
118e6b96 |
209 | )->all())[0]; |
210 | } |
211 | |
212 | =head2 next_sibling |
213 | |
a733c37f |
214 | my $sibling = $item->next_sibling(); |
118e6b96 |
215 | |
a8492531 |
216 | Returns the sibling that resides one position forward. Returns undef |
217 | if the current object is the last one. |
118e6b96 |
218 | |
219 | =cut |
220 | |
221 | sub next_sibling { |
222 | my( $self ) = @_; |
223 | my $position_column = $self->position_column; |
707cbb2d |
224 | my $position = $self->get_column( $position_column ); |
a733c37f |
225 | my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count(); |
707cbb2d |
226 | return 0 if ($position==$count); |
133dd22a |
227 | return ($self->result_source->resultset->search( |
7a76f44c |
228 | { |
707cbb2d |
229 | $position_column => $position + 1, |
a733c37f |
230 | $self->_grouping_clause(), |
7a76f44c |
231 | }, |
118e6b96 |
232 | )->all())[0]; |
233 | } |
234 | |
80010e2b |
235 | =head2 move_previous |
118e6b96 |
236 | |
a733c37f |
237 | $item->move_previous(); |
118e6b96 |
238 | |
a8492531 |
239 | Swaps position with the sibling in the position previous in |
240 | the list. Returns 1 on success, and 0 if the object is |
241 | already the first one. |
118e6b96 |
242 | |
243 | =cut |
244 | |
80010e2b |
245 | sub move_previous { |
118e6b96 |
246 | my( $self ) = @_; |
133dd22a |
247 | my $position = $self->get_column( $self->position_column() ); |
248 | return $self->move_to( $position - 1 ); |
118e6b96 |
249 | } |
250 | |
80010e2b |
251 | =head2 move_next |
118e6b96 |
252 | |
a733c37f |
253 | $item->move_next(); |
118e6b96 |
254 | |
a8492531 |
255 | Swaps position with the sibling in the next position in the |
256 | list. Returns 1 on success, and 0 if the object is already |
257 | the last in the list. |
118e6b96 |
258 | |
259 | =cut |
260 | |
80010e2b |
261 | sub move_next { |
118e6b96 |
262 | my( $self ) = @_; |
133dd22a |
263 | my $position = $self->get_column( $self->position_column() ); |
a733c37f |
264 | my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count(); |
133dd22a |
265 | return 0 if ($position==$count); |
266 | return $self->move_to( $position + 1 ); |
118e6b96 |
267 | } |
268 | |
269 | =head2 move_first |
270 | |
a733c37f |
271 | $item->move_first(); |
118e6b96 |
272 | |
a8492531 |
273 | Moves the object to the first position in the list. Returns 1 |
274 | on success, and 0 if the object is already the first. |
118e6b96 |
275 | |
276 | =cut |
277 | |
278 | sub move_first { |
279 | my( $self ) = @_; |
280 | return $self->move_to( 1 ); |
281 | } |
282 | |
283 | =head2 move_last |
284 | |
a733c37f |
285 | $item->move_last(); |
118e6b96 |
286 | |
a8492531 |
287 | Moves the object to the last position in the list. Returns 1 |
288 | on success, and 0 if the object is already the last one. |
118e6b96 |
289 | |
290 | =cut |
291 | |
292 | sub move_last { |
293 | my( $self ) = @_; |
a733c37f |
294 | my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count(); |
118e6b96 |
295 | return $self->move_to( $count ); |
296 | } |
297 | |
298 | =head2 move_to |
299 | |
a733c37f |
300 | $item->move_to( $position ); |
118e6b96 |
301 | |
a8492531 |
302 | Moves the object to the specified position. Returns 1 on |
303 | success, and 0 if the object is already at the specified |
304 | position. |
118e6b96 |
305 | |
306 | =cut |
307 | |
308 | sub move_to { |
309 | my( $self, $to_position ) = @_; |
310 | my $position_column = $self->position_column; |
311 | my $from_position = $self->get_column( $position_column ); |
133dd22a |
312 | return 0 if ( $to_position < 1 ); |
313 | return 0 if ( $from_position==$to_position ); |
dc66dea1 |
314 | my @between = ( |
315 | ( $from_position < $to_position ) |
316 | ? ( $from_position+1, $to_position ) |
317 | : ( $to_position, $from_position-1 ) |
318 | ); |
133dd22a |
319 | my $rs = $self->result_source->resultset->search({ |
dc66dea1 |
320 | $position_column => { -between => [ @between ] }, |
a733c37f |
321 | $self->_grouping_clause(), |
118e6b96 |
322 | }); |
323 | my $op = ($from_position>$to_position) ? '+' : '-'; |
fa6b598f |
324 | $rs->update({ $position_column => \"$position_column $op 1" }); #" Sorry, GEdit bug |
79dc353a |
325 | $self->{_ORDERED_INTERNAL_UPDATE} = 1; |
b1c66eea |
326 | $self->update({ $position_column => $to_position }); |
118e6b96 |
327 | return 1; |
328 | } |
329 | |
fa6b598f |
330 | |
331 | |
79dc353a |
332 | =head2 move_to_group |
333 | |
334 | $item->move_to_group( $group, $position ); |
335 | |
336 | Moves the object to the specified position of the specified |
337 | group, or to the end of the group if $position is undef. |
338 | 1 is returned on success, and 0 is returned if the object is |
339 | already at the specified position of the specified group. |
340 | |
1d941d67 |
341 | $group may be specified as a single scalar if only one |
342 | grouping column is in use, or as a hashref of column => value pairs |
343 | if multiple grouping columns are in use. |
fa6b598f |
344 | |
79dc353a |
345 | =cut |
346 | |
347 | sub move_to_group { |
348 | my( $self, $to_group, $to_position ) = @_; |
fa6b598f |
349 | |
350 | # if we're given a string, turn it into a hashref |
351 | unless (ref $to_group eq 'HASH') { |
352 | $to_group = {($self->_grouping_columns)[0] => $to_group}; |
353 | } |
354 | |
79dc353a |
355 | my $position_column = $self->position_column; |
fa6b598f |
356 | #my @grouping_columns = $self->_grouping_columns; |
79dc353a |
357 | |
358 | return 0 if ( ! defined($to_group) ); |
359 | return 0 if ( defined($to_position) and $to_position < 1 ); |
fa6b598f |
360 | return 0 if ( $self->_is_in_group($to_group) |
361 | and ((not defined($to_position)) |
362 | or (defined($to_position) and $self->$position_column==$to_position) |
363 | ) |
364 | ); |
79dc353a |
365 | |
366 | # Move to end of current group and adjust siblings |
367 | $self->move_last; |
368 | |
fa6b598f |
369 | $self->set_columns($to_group); |
79dc353a |
370 | my $new_group_count = $self->result_source->resultset->search({$self->_grouping_clause()})->count(); |
371 | if (!defined($to_position) or $to_position > $new_group_count) { |
372 | $self->{_ORDERED_INTERNAL_UPDATE} = 1; |
373 | $self->update({ $position_column => $new_group_count + 1 }); |
374 | } |
375 | else { |
376 | my @between = ($to_position, $new_group_count); |
377 | |
378 | my $rs = $self->result_source->resultset->search({ |
379 | $position_column => { -between => [ @between ] }, |
380 | $self->_grouping_clause(), |
381 | }); |
fa6b598f |
382 | $rs->update({ $position_column => \"$position_column + 1" }); #" |
79dc353a |
383 | $self->{_ORDERED_INTERNAL_UPDATE} = 1; |
384 | $self->update({ $position_column => $to_position }); |
385 | } |
386 | |
387 | return 1; |
388 | } |
389 | |
118e6b96 |
390 | =head2 insert |
391 | |
392 | Overrides the DBIC insert() method by providing a default |
393 | position number. The default will be the number of rows in |
394 | the table +1, thus positioning the new record at the last position. |
395 | |
396 | =cut |
397 | |
398 | sub insert { |
399 | my $self = shift; |
400 | my $position_column = $self->position_column; |
a733c37f |
401 | $self->set_column( $position_column => $self->result_source->resultset->search( {$self->_grouping_clause()} )->count()+1 ) |
118e6b96 |
402 | if (!$self->get_column($position_column)); |
0a298c73 |
403 | return $self->next::method( @_ ); |
118e6b96 |
404 | } |
405 | |
79dc353a |
406 | =head2 update |
407 | |
408 | Overrides the DBIC update() method by checking for a change |
409 | to the position and/or group columns. Movement within a |
410 | group or to another group is handled by repositioning |
411 | the appropriate siblings. Position defaults to the end |
412 | of a new group if it has been changed to undef. |
413 | |
414 | =cut |
415 | |
416 | sub update { |
417 | my $self = shift; |
418 | |
419 | if ($self->{_ORDERED_INTERNAL_UPDATE}) { |
420 | delete $self->{_ORDERED_INTERNAL_UPDATE}; |
421 | return $self->next::method( @_ ); |
422 | } |
423 | |
424 | $self->set_columns($_[0]) if @_ > 0; |
425 | my %changes = $self->get_dirty_columns; |
426 | $self->discard_changes; |
427 | |
428 | my $pos_col = $self->position_column; |
fa6b598f |
429 | |
fa6b598f |
430 | # if any of our grouping columns have been changed |
fa6b598f |
431 | if (grep {$_} map {exists $changes{$_}} $self->_grouping_columns ) { |
432 | |
433 | # create new_group by taking the current group and inserting changes |
434 | my $new_group = {$self->_grouping_clause}; |
435 | foreach my $col (keys %$new_group) { |
436 | if (exists $changes{$col}) { |
437 | $new_group->{$col} = $changes{$col}; |
438 | delete $changes{$col}; # don't want to pass this on to next::method |
439 | } |
440 | } |
441 | |
79dc353a |
442 | $self->move_to_group( |
fa6b598f |
443 | $new_group, |
79dc353a |
444 | exists($changes{$pos_col}) ? delete($changes{$pos_col}) : $self->$pos_col |
445 | ); |
446 | } |
447 | elsif (exists $changes{$pos_col}) { |
448 | $self->move_to(delete $changes{$pos_col}); |
449 | } |
450 | return $self->next::method( \%changes ); |
451 | } |
452 | |
118e6b96 |
453 | =head2 delete |
454 | |
455 | Overrides the DBIC delete() method by first moving the object |
456 | to the last position, then deleting it, thus ensuring the |
457 | integrity of the positions. |
458 | |
459 | =cut |
460 | |
461 | sub delete { |
462 | my $self = shift; |
463 | $self->move_last; |
0a298c73 |
464 | return $self->next::method( @_ ); |
118e6b96 |
465 | } |
466 | |
7a76f44c |
467 | =head1 PRIVATE METHODS |
468 | |
469 | These methods are used internally. You should never have the |
470 | need to use them. |
471 | |
a733c37f |
472 | =head2 _grouping_clause |
118e6b96 |
473 | |
e9188247 |
474 | This method returns one or more name=>value pairs for limiting a search |
475 | by the grouping column(s). If the grouping column is not |
133dd22a |
476 | defined then this will return an empty list. |
118e6b96 |
477 | |
7a76f44c |
478 | =cut |
a733c37f |
479 | sub _grouping_clause { |
169bb185 |
480 | my( $self ) = @_; |
fa6b598f |
481 | return map { $_ => $self->get_column($_) } $self->_grouping_columns(); |
482 | } |
483 | |
484 | |
485 | |
486 | =head2 _get_grouping_columns |
487 | |
488 | Returns a list of the column names used for grouping, regardless of whether |
1d941d67 |
489 | they were specified as an arrayref or a single string, and returns () |
490 | if there is no grouping. |
fa6b598f |
491 | |
492 | =cut |
493 | sub _grouping_columns { |
494 | my( $self ) = @_; |
a733c37f |
495 | my $col = $self->grouping_column(); |
fa6b598f |
496 | if (ref $col eq 'ARRAY') { |
497 | return @$col; |
498 | } elsif ($col) { |
499 | return ( $col ); |
500 | } else { |
501 | return (); |
133dd22a |
502 | } |
7a76f44c |
503 | } |
504 | |
fa6b598f |
505 | |
506 | |
507 | =head2 _is_in_group($other) |
508 | |
509 | $item->_is_in_group( {user => 'fred', list => 'work'} ) |
510 | |
511 | Returns true if the object is in the group represented by hashref $other |
512 | =cut |
513 | sub _is_in_group { |
514 | my ($self, $other) = @_; |
515 | my $current = {$self->_grouping_clause}; |
516 | return 0 unless (ref $other eq 'HASH') and (keys %$current == keys %$other); |
517 | for my $key (keys %$current) { |
518 | return 0 unless exists $other->{$key}; |
519 | return 0 if $current->{$key} ne $other->{$key}; |
520 | } |
521 | return 1; |
522 | } |
523 | |
524 | |
7a76f44c |
525 | 1; |
526 | __END__ |
118e6b96 |
527 | |
528 | =head1 BUGS |
529 | |
dc66dea1 |
530 | =head2 Unique Constraints |
531 | |
532 | Unique indexes and constraints on the position column are not |
533 | supported at this time. It would be make sense to support them, |
534 | but there are some unexpected database issues that make this |
535 | hard to do. The main problem from the author's view is that |
536 | SQLite (the DB engine that we use for testing) does not support |
537 | ORDER BY on updates. |
538 | |
133dd22a |
539 | =head2 Race Condition on Insert |
540 | |
118e6b96 |
541 | If a position is not specified for an insert than a position |
542 | will be chosen based on COUNT(*)+1. But, it first selects the |
a8492531 |
543 | count, and then inserts the record. The space of time between select |
118e6b96 |
544 | and insert introduces a race condition. To fix this we need the |
545 | ability to lock tables in DBIC. I've added an entry in the TODO |
546 | about this. |
547 | |
133dd22a |
548 | =head2 Multiple Moves |
549 | |
550 | Be careful when issueing move_* methods to multiple objects. If |
551 | you've pre-loaded the objects then when you move one of the objects |
552 | the position of the other object will not reflect their new value |
553 | until you reload them from the database. |
554 | |
dc66dea1 |
555 | There are times when you will want to move objects as groups, such |
133dd22a |
556 | as changeing the parent of several objects at once - this directly |
557 | conflicts with this problem. One solution is for us to write a |
558 | ResultSet class that supports a parent() method, for example. Another |
559 | solution is to somehow automagically modify the objects that exist |
560 | in the current object's result set to have the new position value. |
561 | |
118e6b96 |
562 | =head1 AUTHOR |
563 | |
564 | Aran Deltac <bluefeet@cpan.org> |
565 | |
566 | =head1 LICENSE |
567 | |
568 | You may distribute this code under the same terms as Perl itself. |
569 | |