patch from soulchild (thanks!)
[dbsrgits/DBIx-Class.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 );
169bb185 20 # Optional: group_id INTEGER NOT NULL
118e6b96 21
a8492531 22In your Schema or DB class add "Ordered" to the top
118e6b96 23of the component list.
24
a733c37f 25 __PACKAGE__->load_components(qw( Ordered ... ));
118e6b96 26
27Specify the column that stores the position number for
28each row.
29
a733c37f 30 package My::Item;
118e6b96 31 __PACKAGE__->position_column('position');
a733c37f 32 __PACKAGE__->grouping_column('group_id'); # optional
118e6b96 33
a8492531 34That's it, now you can change the position of your objects.
118e6b96 35
36 #!/use/bin/perl
a733c37f 37 use My::Item;
118e6b96 38
a733c37f 39 my $item = My::Item->create({ name=>'Matt S. Trout' });
40 # If using grouping_column:
41 my $item = My::Item->create({ name=>'Matt S. Trout', group_id=>1 });
118e6b96 42
a733c37f 43 my $rs = $item->siblings();
44 my @siblings = $item->siblings();
118e6b96 45
46 my $sibling;
a733c37f 47 $sibling = $item->first_sibling();
48 $sibling = $item->last_sibling();
49 $sibling = $item->previous_sibling();
50 $sibling = $item->next_sibling();
118e6b96 51
a733c37f 52 $item->move_previous();
53 $item->move_next();
54 $item->move_first();
55 $item->move_last();
56 $item->move_to( $position );
118e6b96 57
58=head1 DESCRIPTION
59
a733c37f 60This module provides a simple interface for modifying the ordered
61position of DBIx::Class objects.
118e6b96 62
133dd22a 63=head1 AUTO UPDATE
64
65All of the move_* methods automatically update the rows involved in
66the query. This is not configurable and is due to the fact that if you
67move a record it always causes other records in the list to be updated.
68
118e6b96 69=head1 METHODS
70
71=head2 position_column
72
73 __PACKAGE__->position_column('position');
74
75Sets and retrieves the name of the column that stores the
a8492531 76positional value of each record. Defaults to "position".
118e6b96 77
78=cut
79
80__PACKAGE__->mk_classdata( 'position_column' => 'position' );
81
a733c37f 82=head2 grouping_column
133dd22a 83
a733c37f 84 __PACKAGE__->grouping_column('group_id');
133dd22a 85
a8492531 86This method specifies a column to limit all queries in
133dd22a 87this module by. This effectively allows you to have multiple
a733c37f 88ordered lists within the same table.
133dd22a 89
90=cut
91
a733c37f 92__PACKAGE__->mk_classdata( 'grouping_column' );
133dd22a 93
118e6b96 94=head2 siblings
95
a733c37f 96 my $rs = $item->siblings();
97 my @siblings = $item->siblings();
118e6b96 98
a8492531 99Returns either a resultset or an array of all other objects
118e6b96 100excluding the one you called it on.
101
102=cut
103
104sub siblings {
105 my( $self ) = @_;
106 my $position_column = $self->position_column;
a9cdbec2 107 my $rs = $self->result_source->resultset->search(
7a76f44c 108 {
109 $position_column => { '!=' => $self->get_column($position_column) },
a733c37f 110 $self->_grouping_clause(),
7a76f44c 111 },
118e6b96 112 { order_by => $self->position_column },
113 );
7a76f44c 114 return $rs->all() if (wantarray());
115 return $rs;
118e6b96 116}
117
118=head2 first_sibling
119
a733c37f 120 my $sibling = $item->first_sibling();
118e6b96 121
5faa95af 122Returns the first sibling object, or 0 if the first sibling
a8492531 123is this sibling.
118e6b96 124
125=cut
126
127sub first_sibling {
128 my( $self ) = @_;
5faa95af 129 return 0 if ($self->get_column($self->position_column())==1);
a9cdbec2 130 return ($self->result_source->resultset->search(
131 {
132 $self->position_column => 1,
a733c37f 133 $self->_grouping_clause(),
a9cdbec2 134 },
118e6b96 135 )->all())[0];
136}
137
138=head2 last_sibling
139
a733c37f 140 my $sibling = $item->last_sibling();
118e6b96 141
a8492531 142Returns the last sibling, or 0 if the last sibling is this
5faa95af 143sibling.
118e6b96 144
145=cut
146
147sub last_sibling {
148 my( $self ) = @_;
a733c37f 149 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
5faa95af 150 return 0 if ($self->get_column($self->position_column())==$count);
a9cdbec2 151 return ($self->result_source->resultset->search(
152 {
153 $self->position_column => $count,
a733c37f 154 $self->_grouping_clause(),
a9cdbec2 155 },
118e6b96 156 )->all())[0];
157}
158
159=head2 previous_sibling
160
a733c37f 161 my $sibling = $item->previous_sibling();
118e6b96 162
a8492531 163Returns the sibling that resides one position back. Returns undef
164if the current object is the first one.
118e6b96 165
166=cut
167
168sub previous_sibling {
169 my( $self ) = @_;
170 my $position_column = $self->position_column;
707cbb2d 171 my $position = $self->get_column( $position_column );
172 return 0 if ($position==1);
3ffca97b 173 return ($self->result_source->resultset->search(
7a76f44c 174 {
707cbb2d 175 $position_column => $position - 1,
a733c37f 176 $self->_grouping_clause(),
707cbb2d 177 }
118e6b96 178 )->all())[0];
179}
180
181=head2 next_sibling
182
a733c37f 183 my $sibling = $item->next_sibling();
118e6b96 184
a8492531 185Returns the sibling that resides one position forward. Returns undef
186if the current object is the last one.
118e6b96 187
188=cut
189
190sub next_sibling {
191 my( $self ) = @_;
192 my $position_column = $self->position_column;
707cbb2d 193 my $position = $self->get_column( $position_column );
a733c37f 194 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
707cbb2d 195 return 0 if ($position==$count);
133dd22a 196 return ($self->result_source->resultset->search(
7a76f44c 197 {
707cbb2d 198 $position_column => $position + 1,
a733c37f 199 $self->_grouping_clause(),
7a76f44c 200 },
118e6b96 201 )->all())[0];
202}
203
80010e2b 204=head2 move_previous
118e6b96 205
a733c37f 206 $item->move_previous();
118e6b96 207
a8492531 208Swaps position with the sibling in the position previous in
209the list. Returns 1 on success, and 0 if the object is
210already the first one.
118e6b96 211
212=cut
213
80010e2b 214sub move_previous {
118e6b96 215 my( $self ) = @_;
133dd22a 216 my $position = $self->get_column( $self->position_column() );
217 return $self->move_to( $position - 1 );
118e6b96 218}
219
80010e2b 220=head2 move_next
118e6b96 221
a733c37f 222 $item->move_next();
118e6b96 223
a8492531 224Swaps position with the sibling in the next position in the
225list. Returns 1 on success, and 0 if the object is already
226the last in the list.
118e6b96 227
228=cut
229
80010e2b 230sub move_next {
118e6b96 231 my( $self ) = @_;
133dd22a 232 my $position = $self->get_column( $self->position_column() );
a733c37f 233 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
133dd22a 234 return 0 if ($position==$count);
235 return $self->move_to( $position + 1 );
118e6b96 236}
237
238=head2 move_first
239
a733c37f 240 $item->move_first();
118e6b96 241
a8492531 242Moves the object to the first position in the list. Returns 1
243on success, and 0 if the object is already the first.
118e6b96 244
245=cut
246
247sub move_first {
248 my( $self ) = @_;
249 return $self->move_to( 1 );
250}
251
252=head2 move_last
253
a733c37f 254 $item->move_last();
118e6b96 255
a8492531 256Moves the object to the last position in the list. Returns 1
257on success, and 0 if the object is already the last one.
118e6b96 258
259=cut
260
261sub move_last {
262 my( $self ) = @_;
a733c37f 263 my $count = $self->result_source->resultset->search({$self->_grouping_clause()})->count();
118e6b96 264 return $self->move_to( $count );
265}
266
267=head2 move_to
268
a733c37f 269 $item->move_to( $position );
118e6b96 270
a8492531 271Moves the object to the specified position. Returns 1 on
272success, and 0 if the object is already at the specified
273position.
118e6b96 274
275=cut
276
277sub move_to {
278 my( $self, $to_position ) = @_;
279 my $position_column = $self->position_column;
280 my $from_position = $self->get_column( $position_column );
133dd22a 281 return 0 if ( $to_position < 1 );
282 return 0 if ( $from_position==$to_position );
dc66dea1 283 my @between = (
284 ( $from_position < $to_position )
285 ? ( $from_position+1, $to_position )
286 : ( $to_position, $from_position-1 )
287 );
133dd22a 288 my $rs = $self->result_source->resultset->search({
dc66dea1 289 $position_column => { -between => [ @between ] },
a733c37f 290 $self->_grouping_clause(),
118e6b96 291 });
292 my $op = ($from_position>$to_position) ? '+' : '-';
b1c66eea 293 $rs->update({ $position_column => \"$position_column $op 1" });
294 $self->update({ $position_column => $to_position });
118e6b96 295 return 1;
296}
297
298=head2 insert
299
300Overrides the DBIC insert() method by providing a default
301position number. The default will be the number of rows in
302the table +1, thus positioning the new record at the last position.
303
304=cut
305
306sub insert {
307 my $self = shift;
308 my $position_column = $self->position_column;
a733c37f 309 $self->set_column( $position_column => $self->result_source->resultset->search( {$self->_grouping_clause()} )->count()+1 )
118e6b96 310 if (!$self->get_column($position_column));
0a298c73 311 return $self->next::method( @_ );
118e6b96 312}
313
314=head2 delete
315
316Overrides the DBIC delete() method by first moving the object
317to the last position, then deleting it, thus ensuring the
318integrity of the positions.
319
320=cut
321
322sub delete {
323 my $self = shift;
324 $self->move_last;
0a298c73 325 return $self->next::method( @_ );
118e6b96 326}
327
7a76f44c 328=head1 PRIVATE METHODS
329
330These methods are used internally. You should never have the
331need to use them.
332
a733c37f 333=head2 _grouping_clause
118e6b96 334
a8492531 335This method returns a name=>value pair for limiting a search
133dd22a 336by the collection column. If the collection column is not
337defined then this will return an empty list.
118e6b96 338
7a76f44c 339=cut
340
a733c37f 341sub _grouping_clause {
169bb185 342 my( $self ) = @_;
a733c37f 343 my $col = $self->grouping_column();
344 if ($col) {
345 return ( $col => $self->get_column($col) );
133dd22a 346 }
7a76f44c 347 return ();
348}
349
3501;
351__END__
118e6b96 352
353=head1 BUGS
354
dc66dea1 355=head2 Unique Constraints
356
357Unique indexes and constraints on the position column are not
358supported at this time. It would be make sense to support them,
359but there are some unexpected database issues that make this
360hard to do. The main problem from the author's view is that
361SQLite (the DB engine that we use for testing) does not support
362ORDER BY on updates.
363
133dd22a 364=head2 Race Condition on Insert
365
118e6b96 366If a position is not specified for an insert than a position
367will be chosen based on COUNT(*)+1. But, it first selects the
a8492531 368count, and then inserts the record. The space of time between select
118e6b96 369and insert introduces a race condition. To fix this we need the
370ability to lock tables in DBIC. I've added an entry in the TODO
371about this.
372
133dd22a 373=head2 Multiple Moves
374
375Be careful when issueing move_* methods to multiple objects. If
376you've pre-loaded the objects then when you move one of the objects
377the position of the other object will not reflect their new value
378until you reload them from the database.
379
dc66dea1 380There are times when you will want to move objects as groups, such
133dd22a 381as changeing the parent of several objects at once - this directly
382conflicts with this problem. One solution is for us to write a
383ResultSet class that supports a parent() method, for example. Another
384solution is to somehow automagically modify the objects that exist
385in the current object's result set to have the new position value.
386
118e6b96 387=head1 AUTHOR
388
389Aran Deltac <bluefeet@cpan.org>
390
391=head1 LICENSE
392
393You may distribute this code under the same terms as Perl itself.
394