FInally rewrote Ordered properly - a number of FIXME's still remain (grep source)
[dbsrgits/DBIx-Class.git] / lib / DBIx / Class / Ordered.pm
1 package DBIx::Class::Ordered;
2 use strict;
3 use warnings;
4 use base qw( DBIx::Class );
5
6 =head1 NAME
7
8 DBIx::Class::Ordered - Modify the position of objects in an ordered list.
9
10 =head1 SYNOPSIS
11
12 Create a table for your ordered data.
13
14   CREATE TABLE items (
15     item_id INTEGER PRIMARY KEY AUTOINCREMENT,
16     name TEXT NOT NULL,
17     position INTEGER NOT NULL
18   );
19
20 Optionally, add one or more columns to specify groupings, allowing you 
21 to maintain independent ordered lists within one table:
22
23   CREATE TABLE items (
24     item_id INTEGER PRIMARY KEY AUTOINCREMENT,
25     name TEXT NOT NULL,
26     position INTEGER NOT NULL,
27     group_id INTEGER NOT NULL
28   );
29
30 Or even
31
32   CREATE TABLE items (
33     item_id INTEGER PRIMARY KEY AUTOINCREMENT,
34     name TEXT NOT NULL,
35     position INTEGER NOT NULL,
36     group_id INTEGER NOT NULL,
37     other_group_id INTEGER NOT NULL
38   );
39
40 In your Schema or DB class add "Ordered" to the top 
41 of the component list.
42
43   __PACKAGE__->load_components(qw( Ordered ... ));
44
45 Specify the column that stores the position number for 
46 each row.
47
48   package My::Item;
49   __PACKAGE__->position_column('position');
50
51 If you are using one grouping column, specify it as follows:
52
53   __PACKAGE__->grouping_column('group_id');
54
55 Or if you have multiple grouping columns:
56
57   __PACKAGE__->grouping_column(['group_id', 'other_group_id']);
58
59 That's it, now you can change the position of your objects.
60
61   #!/use/bin/perl
62   use My::Item;
63   
64   my $item = My::Item->create({ name=>'Matt S. Trout' });
65   # If using grouping_column:
66   my $item = My::Item->create({ name=>'Matt S. Trout', group_id=>1 });
67   
68   my $rs = $item->siblings();
69   my @siblings = $item->siblings();
70   
71   my $sibling;
72   $sibling = $item->first_sibling();
73   $sibling = $item->last_sibling();
74   $sibling = $item->previous_sibling();
75   $sibling = $item->next_sibling();
76   
77   $item->move_previous();
78   $item->move_next();
79   $item->move_first();
80   $item->move_last();
81   $item->move_to( $position );
82   $item->move_to_group( 'groupname' );
83   $item->move_to_group( 'groupname', $position );
84   $item->move_to_group( {group_id=>'groupname', 'other_group_id=>'othergroupname'} );
85   $item->move_to_group( {group_id=>'groupname', 'other_group_id=>'othergroupname'}, $position );
86
87 =head1 DESCRIPTION
88
89 This module provides a simple interface for modifying the ordered 
90 position of DBIx::Class objects.
91
92 =head1 AUTO UPDATE
93
94 All of the move_* methods automatically update the rows involved in 
95 the query.  This is not configurable and is due to the fact that if you 
96 move a record it always causes other records in the list to be updated.
97
98 =head1 METHODS
99
100 =head2 position_column
101
102   __PACKAGE__->position_column('position');
103
104 Sets and retrieves the name of the column that stores the 
105 positional value of each record.  Defaults to "position".
106
107 =cut
108
109 __PACKAGE__->mk_classdata( 'position_column' => 'position' );
110
111 =head2 grouping_column
112
113   __PACKAGE__->grouping_column('group_id');
114
115 This method specifies a column to limit all queries in 
116 this module by.  This effectively allows you to have multiple 
117 ordered lists within the same table.
118
119 =cut
120
121 __PACKAGE__->mk_classdata( 'grouping_column' );
122
123 =head2 null_position_value
124
125   __PACKAGE__->null_position_value(undef);
126
127 This method specifies a value of L</position_column> which B<would
128 never be assigned to a row> during normal operation. When
129 a row is moved, its position is set to this value temporarily, so
130 that any unique constrainst can not be violated. This value defaults
131 to 0, which should work for all cases except when your positions do
132 indeed start from 0.
133
134 =cut
135
136 __PACKAGE__->mk_classdata( 'null_position_value' => 0 );
137
138 =head2 siblings
139
140   my $rs = $item->siblings();
141   my @siblings = $item->siblings();
142
143 Returns an B<ordered> resultset of all other objects in the same
144 group excluding the one you called it on.
145
146 The ordering is a backwards-compatibility artifact - if you need
147 a resultset with no ordering applied use L</_siblings>
148
149 =cut
150 sub siblings {
151     my $self = shift;
152     return $self->_siblings->search ({}, { order_by => $self->position_column } );
153 }
154
155 =head2 previous_siblings
156
157   my $prev_rs = $item->previous_siblings();
158   my @prev_siblings = $item->previous_siblings();
159
160 Returns a resultset of all objects in the same group
161 positioned before the object on which this method was called.
162
163 =cut
164 sub previous_siblings {
165     my $self = shift;
166     my $position_column = $self->position_column;
167     my $position = $self->get_column ($position_column);
168     return ( defined $position
169         ? $self->_siblings->search ({ $position_column => { '<', $position } })
170         : $self->_siblings
171     );
172 }
173
174 =head2 next_siblings
175
176   my $next_rs = $item->next_siblings();
177   my @next_siblings = $item->next_siblings();
178
179 Returns a resultset of all objects in the same group
180 positioned after the object on which this method was called.
181
182 =cut
183 sub next_siblings {
184     my $self = shift;
185     my $position_column = $self->position_column;
186     my $position = $self->get_column ($position_column);
187     return ( defined $position
188         ? $self->_siblings->search ({ $position_column => { '>', $position } })
189         : $self->_siblings
190     );
191 }
192
193 =head2 previous_sibling
194
195   my $sibling = $item->previous_sibling();
196
197 Returns the sibling that resides one position back.  Returns 0
198 if the current object is the first one.
199
200 =cut
201
202 sub previous_sibling {
203     my $self = shift;
204     my $position_column = $self->position_column;
205
206     my $psib = $self->previous_siblings->search(
207         {},
208         { rows => 1, order_by => { '-desc' => $position_column } },
209     )->single;
210
211     return defined $psib ? $psib : 0;
212 }
213
214 =head2 first_sibling
215
216   my $sibling = $item->first_sibling();
217
218 Returns the first sibling object, or 0 if the first sibling 
219 is this sibling.
220
221 =cut
222
223 sub first_sibling {
224     my $self = shift;
225     my $position_column = $self->position_column;
226
227     my $fsib = $self->previous_siblings->search(
228         {},
229         { rows => 1, order_by => { '-asc' => $position_column } },
230     )->single;
231
232     return defined $fsib ? $fsib : 0;
233 }
234
235 =head2 next_sibling
236
237   my $sibling = $item->next_sibling();
238
239 Returns the sibling that resides one position forward. Returns 0
240 if the current object is the last one.
241
242 =cut
243
244 sub next_sibling {
245     my $self = shift;
246     my $position_column = $self->position_column;
247     my $nsib = $self->next_siblings->search(
248         {},
249         { rows => 1, order_by => { '-asc' => $position_column } },
250     )->single;
251
252     return defined $nsib ? $nsib : 0;
253 }
254
255 =head2 last_sibling
256
257   my $sibling = $item->last_sibling();
258
259 Returns the last sibling, or 0 if the last sibling is this 
260 sibling.
261
262 =cut
263
264 sub last_sibling {
265     my $self = shift;
266     my $position_column = $self->position_column;
267     my $lsib = $self->next_siblings->search(
268         {},
269         { rows => 1, order_by => { '-desc' => $position_column } },
270     )->single;
271
272     return defined $lsib ? $lsib : 0;
273 }
274
275 =head2 move_previous
276
277   $item->move_previous();
278
279 Swaps position with the sibling in the position previous in
280 the list.  Returns 1 on success, and 0 if the object is
281 already the first one.
282
283 =cut
284
285 sub move_previous {
286     my $self = shift;
287     return $self->move_to ($self->_position - 1);
288 }
289
290 =head2 move_next
291
292   $item->move_next();
293
294 Swaps position with the sibling in the next position in the
295 list.  Returns 1 on success, and 0 if the object is already
296 the last in the list.
297
298 =cut
299
300 sub move_next {
301     my $self = shift;
302     return 0 unless $self->next_siblings->count;
303     return $self->move_to ($self->_position + 1);
304 }
305
306 =head2 move_first
307
308   $item->move_first();
309
310 Moves the object to the first position in the list.  Returns 1
311 on success, and 0 if the object is already the first.
312
313 =cut
314
315 sub move_first {
316     return shift->move_to( 1 );
317 }
318
319 =head2 move_last
320
321   $item->move_last();
322
323 Moves the object to the last position in the list.  Returns 1
324 on success, and 0 if the object is already the last one.
325
326 =cut
327
328 sub move_last {
329     my $self = shift;
330     return $self->move_to( $self->_group_rs->count );
331 }
332
333 =head2 move_to
334
335   $item->move_to( $position );
336
337 Moves the object to the specified position.  Returns 1 on
338 success, and 0 if the object is already at the specified
339 position.
340
341 =cut
342
343 sub move_to {
344     my( $self, $to_position ) = @_;
345     return 0 if ( $to_position < 1 );
346
347     my $from_position = $self->_position;
348     return 0 if ( $from_position == $to_position );
349
350     my $position_column = $self->position_column;
351
352     # FIXME this needs to be wrapped in a transaction
353     {
354         my ($direction, @between);
355         if ( $from_position < $to_position ) {
356             $direction = -1;
357             @between = map { $self->_position_value ($_) } ( $from_position + 1, $to_position );
358         }
359         else {
360             $direction = 1;
361             @between = map { $self->_position_value ($_) } ( $to_position, $from_position - 1 );
362         }
363
364         my $new_pos_val = $self->_position_value ($to_position);                              # record this before the shift
365         $self->_ordered_internal_update({ $position_column => $self->null_position_value });  # take the row out of the picture for a bit
366         $self->_shift_siblings ($direction, @between);
367         $self->_ordered_internal_update({ $position_column => $new_pos_val });
368
369         return 1;
370     }
371 }
372
373 =head2 move_to_group
374
375   $item->move_to_group( $group, $position );
376
377 Moves the object to the specified position of the specified
378 group, or to the end of the group if $position is undef.
379 1 is returned on success, and 0 is returned if the object is
380 already at the specified position of the specified group.
381
382 $group may be specified as a single scalar if only one 
383 grouping column is in use, or as a hashref of column => value pairs
384 if multiple grouping columns are in use.
385
386 =cut
387
388 sub move_to_group {
389     my( $self, $to_group, $to_position ) = @_;
390
391     $self->throw_exception ('move_to_group() expects a group specification')
392         unless defined $to_group;
393
394     # if we're given a string, turn it into a hashref
395     unless (ref $to_group eq 'HASH') {
396         my @gcols = $self->_grouping_columns;
397
398         $self->throw_exception ('Single group supplied for a multi-column group identifier') if @gcols > 1;
399         $to_group = {$gcols[0] => $to_group};
400     }
401
402     my $position_column = $self->position_column;
403
404     return 0 if ( defined($to_position) and $to_position < 1 );
405     if ($self->_is_in_group ($to_group) ) {
406         return 0 if not defined $to_position;
407         return $self->move_to ($to_position);
408     }
409
410     # FIXME this needs to be wrapped in a transaction
411     {
412         # Move to end of current group to adjust siblings
413         $self->move_last;
414
415         $self->set_inflated_columns({ %$to_group, $position_column => undef });
416         my $new_group_count = $self->_group_rs->count;
417
418         if ( not defined($to_position) or $to_position > $new_group_count) {
419             $self->set_column(
420                 $position_column => $new_group_count
421                     ? $self->_next_position_value ( $self->last_sibling->get_column ($position_column) )    # FIXME - no need to inflate last_sibling
422                     : $self->_initial_position_value
423             );
424         }
425         else {
426             my $bumped_pos_val = $self->_position_value ($to_position);
427             my @between = ($to_position, $new_group_count);
428             $self->_shift_siblings (1, @between);   #shift right
429             $self->set_column( $position_column => $bumped_pos_val );
430         }
431
432         $self->_ordered_internal_update;
433
434         return 1;
435     }
436 }
437
438 =head2 insert
439
440 Overrides the DBIC insert() method by providing a default 
441 position number.  The default will be the number of rows in 
442 the table +1, thus positioning the new record at the last position.
443
444 =cut
445
446 sub insert {
447     my $self = shift;
448     my $position_column = $self->position_column;
449
450     unless ($self->get_column($position_column)) {
451         my $lsib = $self->last_sibling;     # FIXME - no need to inflate last_sibling
452         $self->set_column(
453             $position_column => ($lsib
454                 ? $self->_next_position_value ( $lsib->get_column ($position_column) )
455                 : $self->_initial_position_value
456             )
457         );
458     }
459
460     return $self->next::method( @_ );
461 }
462
463 =head2 update
464
465 Overrides the DBIC update() method by checking for a change
466 to the position and/or group columns.  Movement within a
467 group or to another group is handled by repositioning
468 the appropriate siblings.  Position defaults to the end
469 of a new group if it has been changed to undef.
470
471 =cut
472
473 sub update {
474     my $self = shift;
475
476     # this is set by _ordered_internal_update()
477     return $self->next::method(@_) if $self->{_ORDERED_INTERNAL_UPDATE};
478
479     my $upd = shift;
480     $self->set_inflated_columns($upd) if $upd;
481     my %changes = $self->get_dirty_columns;
482     $self->discard_changes;
483
484     my $position_column = $self->position_column;
485
486     # if nothing group/position related changed - short circuit
487     if (not grep { exists $changes{$_} } ($self->_grouping_columns, $position_column) ) {
488         return $self->next::method( \%changes, @_ );
489     }
490
491     # FIXME this needs to be wrapped in a transaction
492     {
493         # if any of our grouping columns have been changed
494         if (grep { exists $changes{$_} } ($self->_grouping_columns) ) {
495
496             # create new_group by taking the current group and inserting changes
497             my $new_group = {$self->_grouping_clause};
498             foreach my $col (keys %$new_group) {
499                 if (exists $changes{$col}) {
500                     $new_group->{$col} = delete $changes{$col}; # don't want to pass this on to next::method
501                 }
502             }
503
504             $self->move_to_group(
505                 $new_group,
506                 (exists $changes{$position_column}
507                     # The FIXME bit contradicts the documentation: when changing groups without supplying explicit
508                     # positions in move_to_group(), we push the item to the end of the group.
509                     # However when I was rewriting this, the position from the old group was clearly passed to the new one
510                     # Probably needs to go away (by ribasushi)
511                     ? delete $changes{$position_column}     # means there was a position change supplied with the update too
512                     : $self->_position                      # FIXME!
513                 ),
514             );
515         }
516         elsif (exists $changes{$position_column}) {
517             $self->move_to(delete $changes{$position_column});
518         }
519
520         return $self->next::method( \%changes, @_ );
521     }
522 }
523
524 =head2 delete
525
526 Overrides the DBIC delete() method by first moving the object 
527 to the last position, then deleting it, thus ensuring the
528 integrity of the positions.
529
530 =cut
531
532 sub delete {
533     my $self = shift;
534     # FIXME this needs to be wrapped in a transaction
535     {
536         $self->move_last;
537         return $self->next::method( @_ );
538     }
539 }
540
541 =head1 Methods for extending Ordered
542
543 You would want to override the methods below if you use sparse
544 (non-linear) or non-numeric position values. This can be useful
545 if you are working with preexisting non-normalised position data,
546 or if you need to work with materialized path columns.
547
548 =head2 _position
549
550   my $num_pos = $item->_position;
551
552 Returns the absolute numeric position of the current object, with the
553 first object being at position 1, its sibling at position 2 and so on.
554 By default simply returns the value of L</position_column>.
555
556 =cut
557 sub _position {
558     my $self = shift;
559
560 #    #the right way to do this
561 #    return $self->previous_siblings->count + 1;
562
563     return $self->get_column ($self->position_column);
564 }
565
566 =head2 _position_value
567
568   my $pos_value = $item->_position_value ( $pos )
569
570 Returns the value of L</position_column> of the object at numeric
571 position C<$pos>. By default simply returns C<$pos>.
572
573 =cut
574 sub _position_value {
575     my ($self, $pos) = @_;
576
577 #    #the right way to do this (not optimized)
578 #    my $position_column = $self->position_column;
579 #    return $self -> _group_rs
580 #                 -> search({}, { order_by => $position_column })
581 #                 -> slice ( $pos - 1)
582 #                 -> single
583 #                 -> get_column ($position_column);
584
585     return $pos;
586 }
587
588 =head2 _initial_position_value
589
590   __PACKAGE__->_initial_position_value(0);
591
592 This method specifies a value of L</position_column> which is assigned
593 to the first inserted element of a group, if no value was supplied at
594 insertion time. All subsequent values are derived from this one by
595 L</_next_position_value> below. Defaults to 1.
596
597 =cut
598
599 __PACKAGE__->mk_classdata( '_initial_position_value' => 1 );
600
601 =head2 _next_position_value
602
603   my $new_value = $item->_next_position_value ( $position_value )
604
605 Returns a position value that would be considered C<next> with
606 regards to C<$position_value>. Can be pretty much anything, given
607 that C<< $position_value < $new_value >> where C<< < >> is the
608 SQL comparison operator (usually works fine on strings). The
609 default method expects C<$position_value> to be numeric, and
610 returns C<$position_value + 1>
611
612 =cut
613 sub _next_position_value {
614     return $_[1] + 1;
615 }
616
617 =head2 _shift_siblings
618
619   $item->_shift_siblings ($direction, @between)
620
621 Shifts all siblings with position in the range @between (inclusive)
622 by one position as specified by $direction (left if < 0, right if > 0).
623 By default simply increments/decrements each L<position_column> value
624 by 1.
625
626 =cut
627 sub _shift_siblings {
628     my ($self, $direction, @between) = @_;
629     return 0 unless $direction;
630
631     my $position_column = $self->position_column;
632
633     my ($op, $ord);
634     if ($direction < 0) {
635         $op = '-';
636         $ord = 'asc';
637     }
638     else {
639         $op = '+';
640         $ord = 'desc';
641     }
642
643     my $shift_rs = $self->_group_rs-> search ({ $position_column => { -between => \@between } });
644
645     # some databases (sqlite) are dumb and can not do a blanket
646     # increment/decrement. So what we do here is check if the
647     # position column is part of a unique constraint, and do a
648     # one-by-one update if this is the case
649
650     my %uc = $self->result_source->unique_constraints;
651     if (grep { $_ eq $position_column } ( map { @$_ } (values %uc) ) ) {
652
653         my $rs = $shift_rs->search ({}, { order_by => { "-$ord", $position_column } } );
654         # FIXME - no need to inflate each row
655         while (my $r = $rs->next) {
656             $r->_ordered_internal_update ({ $position_column => \ "$position_column $op 1" } );
657         }
658     }
659     else {
660         $shift_rs->update ({ $position_column => \ "$position_column $op 1" } );
661     }
662 }
663
664 =head1 PRIVATE METHODS
665
666 These methods are used internally.  You should never have the 
667 need to use them.
668
669 =head2 _group_rs
670
671 This method returns a resultset containing all memebers of the row
672 group (including the row itself).
673
674 =cut
675 sub _group_rs {
676     my $self = shift;
677     return $self->result_source->resultset->search({$self->_grouping_clause()});
678 }
679
680 =head2 _siblings
681
682 Returns an unordered resultset of all objects in the same group
683 excluding the object you called this method on.
684
685 =cut
686 sub _siblings {
687     my $self = shift;
688     my $position_column = $self->position_column;
689     return $self->_group_rs->search(
690         { $position_column => { '!=' => $self->get_column($position_column) } },
691     );
692 }
693
694 =head2 _grouping_clause
695
696 This method returns one or more name=>value pairs for limiting a search
697 by the grouping column(s).  If the grouping column is not
698 defined then this will return an empty list.
699
700 =cut
701 sub _grouping_clause {
702     my( $self ) = @_;
703     return map {  $_ => $self->get_column($_)  } $self->_grouping_columns();
704 }
705
706 =head2 _get_grouping_columns
707
708 Returns a list of the column names used for grouping, regardless of whether
709 they were specified as an arrayref or a single string, and returns ()
710 if there is no grouping.
711
712 =cut
713 sub _grouping_columns {
714     my( $self ) = @_;
715     my $col = $self->grouping_column();
716     if (ref $col eq 'ARRAY') {
717         return @$col;
718     } elsif ($col) {
719         return ( $col );
720     } else {
721         return ();
722     }
723 }
724
725 =head2 _is_in_group
726
727     $item->_is_in_group( {user => 'fred', list => 'work'} )
728
729 Returns true if the object is in the group represented by hashref $other
730
731 =cut
732 sub _is_in_group {
733     my ($self, $other) = @_;
734     my $current = {$self->_grouping_clause};
735
736     no warnings qw/uninitialized/;
737
738     return 0 if (
739         join ("\x00", sort keys %$current)
740             ne
741         join ("\x00", sort keys %$other)
742     );
743     for my $key (keys %$current) {
744         return 0 if $current->{$key} ne $other->{$key};
745     }
746     return 1;
747 }
748
749 sub _ordered_internal_update {
750     my $self = shift;
751     local $self->{_ORDERED_INTERNAL_UPDATE} = 1;
752     return $self->update (@_);
753 }
754
755 1;
756
757 __END__
758
759 =head1 CAVEATS
760
761 =head2 Race Condition on Insert
762
763 If a position is not specified for an insert than a position 
764 will be chosen based either on L</_initial_position_value> or
765 L</_next_position_value>, depending if there are already some
766 items in the current group. The space of time between the
767 necessary selects and insert introduces a race condition.
768 Having unique constraints on your position/group columns,
769 and using transactions (see L<DBIx::Class::Storage/txn_do>)
770 will prevent such race conditions going undetected.
771
772 =head2 Multiple Moves
773
774 Be careful when issueing move_* methods to multiple objects.  If 
775 you've pre-loaded the objects then when you move one of the objects 
776 the position of the other object will not reflect their new value 
777 until you reload them from the database - see
778 L<DBIx::Class::Row/discard_changes>.
779
780 There are times when you will want to move objects as groups, such 
781 as changeing the parent of several objects at once - this directly 
782 conflicts with this problem.  One solution is for us to write a 
783 ResultSet class that supports a parent() method, for example.  Another 
784 solution is to somehow automagically modify the objects that exist 
785 in the current object's result set to have the new position value.
786
787 =head1 AUTHOR
788
789 Aran Deltac <bluefeet@cpan.org>
790
791 =head1 LICENSE
792
793 You may distribute this code under the same terms as Perl itself.
794