1 package SL::DB::Helper::ActsAsList;
5 use parent qw(Exporter);
6 our @EXPORT = qw(move_position_up move_position_down add_to_list remove_from_list reorder_list configure_acts_as_list
7 get_previous_in_list get_next_in_list get_full_list);
15 my ($class, @params) = @_;
16 my $importing = caller();
18 configure_acts_as_list($importing, @params);
20 $importing->before_save( sub { SL::DB::Helper::ActsAsList::set_position(@_) });
21 $importing->before_delete(sub { SL::DB::Helper::ActsAsList::remove_position(@_) });
23 # Don't 'goto' to Exporters import, it would try to parse @params
24 __PACKAGE__->export_to_level(1, $class, @EXPORT);
31 sub move_position_up {
36 sub move_position_down {
38 do_move($self, 'down');
41 sub remove_from_list {
44 return $self->db->with_transaction(sub {
45 remove_position($self);
47 # Set to -1 manually because $self->update_attributes() would
48 # trigger the before_save() hook from this very plugin assigning a
49 # number at the end of the list again.
50 my $table = $self->meta->table;
51 my $column = column_name($self);
52 my $primary_key_col = ($self->meta->primary_key)[0];
56 WHERE ${primary_key_col} = ?
58 $self->db->dbh->do($sql, undef, $self->$primary_key_col);
59 $self->$column(undef);
64 my ($self, %params) = @_;
66 croak "Invalid parameter 'position'" unless ($params{position} || '') =~ m/^ (?: before | after | first | last ) $/x;
68 my $column = column_name($self);
70 $self->remove_from_list if ($self->$column // -1) != -1;
72 if ($params{position} eq 'last') {
78 my $table = $self->meta->table;
79 my $primary_key_col = ($self->meta->primary_key)[0];
80 my ($group_by, @values) = get_group_by_where($self);
81 $group_by = " AND ${group_by}" if $group_by;
84 if ($params{position} eq 'first') {
88 # Can only be 'before' or 'after' -- 'last' has been checked above
91 my $reference = $params{reference};
92 croak "Missing parameter 'reference'" if !$reference;
96 $reference_pos = $reference->$column;
98 ($reference_pos) = $self->db->dbh->selectrow_array(qq|SELECT ${column} FROM ${table} WHERE ${primary_key_col} = ?|, undef, $reference);
101 $new_position = $params{position} eq 'before' ? $reference_pos : $reference_pos + 1;
106 SET ${column} = ${column} + 1
107 WHERE (${column} > ?)
111 return $self->db->with_transaction(sub {
112 $self->db->dbh->do($query, undef, $new_position - 1, @values);
113 $self->update_attributes($column => $new_position);
117 sub get_next_in_list {
119 return get_previous_or_next($self, 'next');
122 sub get_previous_in_list {
124 return get_previous_or_next($self, 'previous');
130 my $group_by = get_spec(ref $self, 'group_by') || [];
131 $group_by = [ $group_by ] if $group_by && !ref $group_by;
132 my @where = map { ($_ => $self->$_) } @{ $group_by };
134 return $self->_get_manager_class->get_all(where => \@where, sort_by => column_name($self) . ' ASC');
138 my ($class_or_self, @ids) = @_;
140 return 1 unless @ids;
142 my $self = ref($class_or_self) ? $class_or_self : $class_or_self->new;
143 my $column = column_name($self);
144 my $result = $self->db->with_transaction(sub {
145 my $query = qq|UPDATE | . $self->meta->table . qq| SET ${column} = ? WHERE id = ?|;
146 my $sth = $self->db->dbh->prepare($query) || die $self->db->dbh->errstr;
148 foreach my $new_position (1 .. scalar(@ids)) {
149 $sth->execute($new_position, $ids[$new_position - 1]) || die SL::X::DBUtilsError->new(error => $sth->errstr);
160 sub configure_acts_as_list {
161 my ($class, %params) = @_;
163 $list_spec{$class} = {
164 group_by => $params{group_by},
165 column_name => $params{column_name},
173 sub get_group_by_where {
176 my $group_by = get_spec(ref $self, 'group_by') || [];
177 $group_by = [ $group_by ] if $group_by && !ref $group_by;
179 my (@where, @values);
180 foreach my $column (@{ $group_by }) {
181 my $value = $self->$column;
182 push @values, $value if defined $value;
183 push @where, defined($value) ? "(${column} = ?)" : "(${column} IS NULL)";
186 return (join(' AND ', @where), @values);
191 my $column = column_name($self);
192 my $value = $self->$column;
194 return 1 if defined($value) && ($value != -1);
196 my $table = $self->meta->table;
197 my ($group_by, @values) = get_group_by_where($self);
198 $group_by = " AND ${group_by}" if $group_by;
200 SELECT COALESCE(MAX(${column}), 0)
202 WHERE (${column} <> -1)
206 my $max_position = $self->db->dbh->selectrow_arrayref($sql, undef, @values)->[0];
207 $self->$column($max_position + 1);
212 sub remove_position {
214 my $column = column_name($self);
217 my $value = $self->$column;
218 return 1 unless defined($value) && ($value != -1);
220 my $table = $self->meta->table;
221 my ($group_by, @values) = get_group_by_where($self);
222 $group_by = ' AND ' . $group_by if $group_by;
225 SET ${column} = ${column} - 1
226 WHERE (${column} > ?)
230 $self->db->dbh->do($sql, undef, $value, @values);
236 my ($self, $direction) = @_;
238 croak "Object has not been saved yet" unless $self->id;
240 my $column = column_name($self);
241 my $old_position = $self->$column;
242 croak "No position set yet" unless defined($old_position) && ($old_position != -1);
244 my $table = $self->meta->table;
245 my ($comp_sel, $comp_upd, $min_max, $plus_minus) = $direction eq 'up' ? ('<', '>=', 'MAX', '+') : ('>', '<=', 'MIN', '-');
246 my ($group_by, @values) = get_group_by_where($self);
247 $group_by = ' AND ' . $group_by if $group_by;
249 SELECT ${min_max}(${column})
251 WHERE (${column} <> -1)
252 AND (${column} ${comp_sel} ?)
256 my $new_position = $self->db->dbh->selectrow_arrayref($sql, undef, $old_position, @values)->[0];
258 return undef unless defined $new_position;
263 WHERE (${column} = ?)
267 $self->db->dbh->do($sql, undef, $old_position, $new_position, @values);
269 $self->update_attributes($column => $new_position);
272 sub get_previous_or_next {
273 my ($self, $direction) = @_;
275 my $asc_desc = $direction eq 'next' ? 'ASC' : 'DESC';
276 my $comparator = $direction eq 'next' ? '>' : '<';
277 my $table = $self->meta->table;
278 my $column = column_name($self);
279 my $primary_key_col = ($self->meta->primary_key)[0];
280 my ($group_by, @values) = get_group_by_where($self);
281 $group_by = " AND ${group_by}" if $group_by;
283 SELECT ${primary_key_col}
285 WHERE (${column} ${comparator} ?)
287 ORDER BY ${column} ${asc_desc}
291 my $id = ($self->db->dbh->selectrow_arrayref($sql, undef, $self->$column, @values) || [])->[0];
293 return $id ? $self->_get_manager_class->find_by(id => $id) : undef;
298 my $column = get_spec(ref $self, 'column_name');
299 return $column if $column;
300 return $self->can('sortkey') ? 'sortkey' : 'position';
304 my ($class, $key) = @_;
306 return undef unless $list_spec{$class};
307 return $list_spec{$class}->{$key};
319 SL::DB::Helper::ActsAsList - Mixin for managing ordered items by a
324 package SL::DB::SomeObject;
325 use SL::DB::Helper::ActsAsList [ PARAMS ];
327 package SL::Controller::SomeController;
329 # Assign a position automatically
330 $obj = SL::DB::SomeObject->new(description => 'bla');
333 # Move items up and down
334 $obj = SL::DB::SomeOBject->new(id => 1)->load;
335 $obj->move_position_up;
336 $obj->move_position_down;
338 # Adjust all remaining positions automatically
341 This mixin assumes that the mixing package's table contains a column
342 called C<position> or C<sortkey> (for legacy tables). This column is
343 set automatically upon saving the object if it hasn't been set
344 already. If it hasn't then it will be set to the maximum position used
345 in the table plus one.
347 When the object is deleted all positions greater than the object's old
348 position are decreased by one.
350 C<PARAMS> will be given to L<configure_acts_as_list> and can be used to
353 =head1 CLASS FUNCTIONS
357 =item C<configure_acts_as_list %params>
359 Configures the mixin's behaviour. Will get called automatically with the
360 include parameters. C<%params> can contain the following values:
366 The name of the column containing the position. If not set explicitly
367 then the mixin will use C<sortkey> if the model contains such a column
368 (only for legacy tables) and C<position> otherwise.
372 An optional column name (or array reference of column names) by which
373 to group. If a table contains items for several distinct sets and each
374 set has its own sorting then this can be used.
376 An example would be requirement spec text blocks. They have a column
377 called C<output_position> that selects where to output the text blocks
378 (either before or after the sections). Furthermore these text blocks
379 each belong to a single requirement spec document. So each combination
380 of C<requirement_spec_id> and C<output_position> should have its own
381 set of C<position> values, which can be achieved by configuring this
382 mixin with C<group_by = [qw(requirement_spec_id output_position)]>.
388 =head1 INSTANCE FUNCTIONS
392 =item C<move_position_up>
394 Swaps the object with the object one step above the current one
395 regarding their sort order by exchanging their C<position> values.
397 =item C<move_position_down>
399 Swaps the object with the object one step below the current one
400 regarding their sort order by exchanging their C<position> values.
402 =item C<add_to_list %params>
404 Adds this item to the list. The parameter C<position> is required and
405 can be one of C<first>, C<last>, C<before> and C<after>. With C<first>
406 the item is inserted as the first item in the list and all other
407 item's positions are shifted up by one. For C<position = last> the
408 item is inserted at the end of the list.
410 For C<before> and C<after> an additional parameter C<reference> is
411 required. This is either a Rose model instance or the primary key of
412 one. The current item will then be inserted either before or after the
413 referenced item by shifting all the appropriate item positions up by
416 If C<$self>'s positional column is already set when this function is
417 called then L</remove_from_list> will be called first before anything
420 After this function C<$self>'s positional column has been set and
421 saved to the database.
423 =item C<remove_from_list>
425 Sets this items positional column to C<-1>, saves it and moves all
426 following items up by 1.
428 =item C<get_previous_in_list>
430 Fetches the previous item in the list. Returns C<undef> if C<$self> is
431 already the first one.
433 =item C<get_next_in_list>
435 Fetches the next item in the list. Returns C<undef> if C<$self> is
436 already the last one.
438 =item C<get_full_list>
440 Fetches all items in the same list as C<$self> and returns them as an
443 =item C<reorder_list @ids>
445 Re-orders the objects given in C<@ids> by their position in C<@ids> by
446 updating all of their positional columns. Each element in
447 C<@positions> must be the ID of an object. The new position is the
448 ID's index inside C<@ids> plus one (meaning the first element's new
449 position will be 1 and not 0).
451 This works by executing SQL "UPDATE" statements directly.
453 Returns the result of the whole transaction (trueish in case of
456 This method can be called both as a class method or an instance
467 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>