1 # vim: ts=8:sw=4:sts=4:et
2 package DBIx::Class::Ordered;
5 use base qw( DBIx::Class );
9 DBIx::Class::Ordered - Modify the position of objects in an ordered list.
13 Create a table for your ordered data.
16 item_id INTEGER PRIMARY KEY AUTOINCREMENT,
18 position INTEGER NOT NULL
21 Optionally, add one or more columns to specify groupings, allowing you
22 to maintain independent ordered lists within one table:
25 item_id INTEGER PRIMARY KEY AUTOINCREMENT,
27 position INTEGER NOT NULL,
28 group_id INTEGER NOT NULL
34 item_id INTEGER PRIMARY KEY AUTOINCREMENT,
36 position INTEGER NOT NULL,
37 group_id INTEGER NOT NULL,
38 other_group_id INTEGER NOT NULL
41 In your Schema or DB class add "Ordered" to the top
42 of the component list.
44 __PACKAGE__->load_components(qw( Ordered ... ));
46 Specify the column that stores the position number for
50 __PACKAGE__->position_column('position');
52 If you are using one grouping column, specify it as follows:
54 __PACKAGE__->grouping_column('group_id');
56 Or if you have multiple grouping columns:
58 __PACKAGE__->grouping_column(['group_id', 'other_group_id']);
60 That's it, now you can change the position of your objects.
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 });
69 my $rs = $item->siblings();
70 my @siblings = $item->siblings();
73 $sibling = $item->first_sibling();
74 $sibling = $item->last_sibling();
75 $sibling = $item->previous_sibling();
76 $sibling = $item->next_sibling();
78 $item->move_previous();
82 $item->move_to( $position );
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 );
90 This module provides a simple interface for modifying the ordered
91 position of DBIx::Class objects.
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.
101 =head2 position_column
103 __PACKAGE__->position_column('position');
105 Sets and retrieves the name of the column that stores the
106 positional value of each record. Defaults to "position".
110 __PACKAGE__->mk_classdata( 'position_column' => 'position' );
112 =head2 grouping_column
114 __PACKAGE__->grouping_column('group_id');
116 This method specifies a column to limit all queries in
117 this module by. This effectively allows you to have multiple
118 ordered lists within the same table.
122 __PACKAGE__->mk_classdata( 'grouping_column' );
126 my $rs = $item->siblings();
127 my @siblings = $item->siblings();
129 Returns either a resultset or an array of all other objects
130 excluding the one you called it on.
136 my $position_column = $self->position_column;
137 my $rs = $self->result_source->resultset->search(
139 $position_column => { '!=' => $self->get_column($position_column) },
140 $self->_grouping_clause(),
142 { order_by => $self->position_column },
144 return $rs->all() if (wantarray());
150 my $sibling = $item->first_sibling();
152 Returns the first sibling object, or 0 if the first sibling
159 return 0 if ($self->get_column($self->position_column())==1);
161 return ($self->result_source->resultset->search(
163 $self->position_column => 1,
164 $self->_grouping_clause(),
171 my $sibling = $item->last_sibling();
173 Returns the last sibling, or 0 if the last sibling is this
180 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
181 return 0 if ($self->get_column($self->position_column())==$count);
182 return ($self->result_source->resultset->search(
184 $self->position_column => $count,
185 $self->_grouping_clause(),
190 =head2 previous_sibling
192 my $sibling = $item->previous_sibling();
194 Returns the sibling that resides one position back. Returns undef
195 if the current object is the first one.
199 sub previous_sibling {
201 my $position_column = $self->position_column;
202 my $position = $self->get_column( $position_column );
203 return 0 if ($position==1);
204 return ($self->result_source->resultset->search(
206 $position_column => $position - 1,
207 $self->_grouping_clause(),
214 my $sibling = $item->next_sibling();
216 Returns the sibling that resides one position forward. Returns undef
217 if the current object is the last one.
223 my $position_column = $self->position_column;
224 my $position = $self->get_column( $position_column );
225 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
226 return 0 if ($position==$count);
227 return ($self->result_source->resultset->search(
229 $position_column => $position + 1,
230 $self->_grouping_clause(),
237 $item->move_previous();
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.
247 my $position = $self->get_column( $self->position_column() );
248 return $self->move_to( $position - 1 );
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.
263 my $position = $self->get_column( $self->position_column() );
264 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
265 return 0 if ($position==$count);
266 return $self->move_to( $position + 1 );
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.
280 return $self->move_to( 1 );
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.
294 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
295 return $self->move_to( $count );
300 $item->move_to( $position );
302 Moves the object to the specified position. Returns 1 on
303 success, and 0 if the object is already at the specified
309 my( $self, $to_position ) = @_;
310 my $position_column = $self->position_column;
311 my $from_position = $self->get_column( $position_column );
312 return 0 if ( $to_position < 1 );
313 return 0 if ( $from_position==$to_position );
315 ( $from_position < $to_position )
316 ? ( $from_position+1, $to_position )
317 : ( $to_position, $from_position-1 )
319 my $rs = $self->result_source->resultset->search({
320 $position_column => { -between => [ @between ] },
321 $self->_grouping_clause(),
323 my $op = ($from_position>$to_position) ? '+' : '-';
324 $rs->update({ $position_column => \"$position_column $op 1" }); #" Sorry, GEdit bug
325 $self->{_ORDERED_INTERNAL_UPDATE} = 1;
326 $self->update({ $position_column => $to_position });
334 $item->move_to_group( $group, $position );
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.
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.
348 my( $self, $to_group, $to_position ) = @_;
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};
355 my $position_column = $self->position_column;
356 #my @grouping_columns = $self->_grouping_columns;
358 return 0 if ( ! defined($to_group) );
359 return 0 if ( defined($to_position) and $to_position < 1 );
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)
366 # Move to end of current group and adjust siblings
369 $self->set_columns($to_group);
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 });
376 my @between = ($to_position, $new_group_count);
378 my $rs = $self->result_source->resultset->search({
379 $position_column => { -between => [ @between ] },
380 $self->_grouping_clause(),
382 $rs->update({ $position_column => \"$position_column + 1" }); #"
383 $self->{_ORDERED_INTERNAL_UPDATE} = 1;
384 $self->update({ $position_column => $to_position });
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.
400 my $position_column = $self->position_column;
401 $self->set_column( $position_column => $self->result_source->resultset->search( {$self->_grouping_clause()} )->count()+1 )
402 if (!$self->get_column($position_column));
403 return $self->next::method( @_ );
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.
419 if ($self->{_ORDERED_INTERNAL_UPDATE}) {
420 delete $self->{_ORDERED_INTERNAL_UPDATE};
421 return $self->next::method( @_ );
424 $self->set_columns($_[0]) if @_ > 0;
425 my %changes = $self->get_dirty_columns;
426 $self->discard_changes;
428 my $pos_col = $self->position_column;
430 # if any of our grouping columns have been changed
431 if (grep {$_} map {exists $changes{$_}} $self->_grouping_columns ) {
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
442 $self->move_to_group(
444 exists($changes{$pos_col}) ? delete($changes{$pos_col}) : $self->$pos_col
447 elsif (exists $changes{$pos_col}) {
448 $self->move_to(delete $changes{$pos_col});
450 return $self->next::method( \%changes );
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.
464 return $self->next::method( @_ );
467 =head1 PRIVATE METHODS
469 These methods are used internally. You should never have the
472 =head2 _grouping_clause
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
476 defined then this will return an empty list.
479 sub _grouping_clause {
481 return map { $_ => $self->get_column($_) } $self->_grouping_columns();
486 =head2 _get_grouping_columns
488 Returns a list of the column names used for grouping, regardless of whether
489 they were specified as an arrayref or a single string, and returns ()
490 if there is no grouping.
493 sub _grouping_columns {
495 my $col = $self->grouping_column();
496 if (ref $col eq 'ARRAY') {
507 =head2 _is_in_group($other)
509 $item->_is_in_group( {user => 'fred', list => 'work'} )
511 Returns true if the object is in the group represented by hashref $other
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};
530 =head2 Unique Constraints
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
539 =head2 Race Condition on Insert
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
543 count, and then inserts the record. The space of time between select
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
548 =head2 Multiple Moves
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.
555 There are times when you will want to move objects as groups, such
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.
564 Aran Deltac <bluefeet@cpan.org>
568 You may distribute this code under the same terms as Perl itself.