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