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) || SL::X::DBUtilsError->throw(msg => 'reorder_list error', db_error => $self->db->dbh->errstr);
 
 148     foreach my $new_position (1 .. scalar(@ids)) {
 
 149       $sth->execute($new_position, $ids[$new_position - 1]) || SL::X::DBUtilsError->throw(msg => 'reorder_list error', db_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>