f48e9967a834546eda7fd10821101bbba0afa7bf
[kivitendo-erp.git] / SL / DB / Helper / ActsAsList.pm
1 package SL::DB::Helper::ActsAsList;
2
3 use strict;
4
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);
8
9 use Carp;
10
11 my %list_spec;
12
13 sub import {
14   my ($class, @params)   = @_;
15   my $importing = caller();
16
17   configure_acts_as_list($importing, @params);
18
19   $importing->before_save(  sub { SL::DB::Helper::ActsAsList::set_position(@_)    });
20   $importing->before_delete(sub { SL::DB::Helper::ActsAsList::remove_position(@_) });
21
22   # Don't 'goto' to Exporters import, it would try to parse @params
23   __PACKAGE__->export_to_level(1, $class, @EXPORT);
24 }
25
26 #
27 # Exported functions
28 #
29
30 sub move_position_up {
31   my ($self) = @_;
32   do_move($self, 'up');
33 }
34
35 sub move_position_down {
36   my ($self) = @_;
37   do_move($self, 'down');
38 }
39
40 sub remove_from_list {
41   my ($self) = @_;
42
43   my $worker = sub {
44     remove_position($self);
45
46     # Set to -1 manually because $self->update_attributes() would
47     # trigger the before_save() hook from this very plugin assigning a
48     # number at the end of the list again.
49     my $table           = $self->meta->table;
50     my $column          = column_name($self);
51     my $primary_key_col = ($self->meta->primary_key)[0];
52     my $sql             = <<SQL;
53       UPDATE ${table}
54       SET ${column} = -1
55       WHERE ${primary_key_col} = ?
56 SQL
57     $self->db->dbh->do($sql, undef, $self->$primary_key_col);
58     $self->$column(undef);
59   };
60
61   return $self->db->in_transaction ? $worker->() : $self->db->do_transaction($worker);
62 }
63
64 sub add_to_list {
65   my ($self, %params) = @_;
66
67   croak "Invalid parameter 'position'" unless ($params{position} || '') =~ m/^ (?: before | after | first | last ) $/x;
68
69   my $column = column_name($self);
70
71   $self->remove_from_list if ($self->$column // -1) != -1;
72
73   if ($params{position} eq 'last') {
74     set_position($self);
75     $self->save;
76     return;
77   }
78
79   my $table               = $self->meta->table;
80   my $primary_key_col     = ($self->meta->primary_key)[0];
81   my ($group_by, @values) = get_group_by_where($self);
82   $group_by               = " AND ${group_by}" if $group_by;
83   my $new_position;
84
85   if ($params{position} eq 'first') {
86     $new_position = 1;
87
88   } else {
89     # Can only be 'before' or 'after' -- 'last' has been checked above
90     # already.
91
92     my $reference = $params{reference};
93     croak "Missing parameter 'reference'" if !$reference;
94
95     my $reference_pos;
96     if (ref $reference) {
97       $reference_pos = $reference->$column;
98     } else {
99       ($reference_pos) = $self->db->dbh->selectrow_array(qq|SELECT ${column} FROM ${table} WHERE ${primary_key_col} = ?|, undef, $reference);
100     }
101
102     $new_position = $params{position} eq 'before' ? $reference_pos : $reference_pos + 1;
103   }
104
105   my $query = <<SQL;
106     UPDATE ${table}
107     SET ${column} = ${column} + 1
108     WHERE (${column} > ?)
109       ${group_by}
110 SQL
111
112   my $worker = sub {
113     $self->db->dbh->do($query, undef, $new_position - 1, @values);
114     $self->update_attributes($column => $new_position);
115   };
116
117   return $self->db->in_transaction ? $worker->() : $self->db->do_transaction($worker);
118 }
119
120 sub get_next_in_list {
121   my ($self) = @_;
122   return get_previous_or_next($self, 'next');
123 }
124
125 sub get_previous_in_list {
126   my ($self) = @_;
127   return get_previous_or_next($self, 'previous');
128 }
129
130 sub get_full_list {
131   my ($self) = @_;
132
133   my $group_by = get_spec(ref $self, 'group_by') || [];
134   $group_by    = [ $group_by ] if $group_by && !ref $group_by;
135   my @where    = map { ($_ => $self->$_) } @{ $group_by };
136
137   return $self->_get_manager_class->get_all(where => \@where, sort_by => column_name($self) . ' ASC');
138 }
139
140 sub reorder_list {
141   my ($class_or_self, @ids) = @_;
142
143   return 1 unless @ids;
144
145   my $self   = ref($class_or_self) ? $class_or_self : $class_or_self->new;
146   my $column = column_name($self);
147   my $result = $self->db->do_transaction(sub {
148     my $query = qq|UPDATE | . $self->meta->table . qq| SET ${column} = ? WHERE id = ?|;
149     my $sth   = $self->db->dbh->prepare($query) || die $self->db->dbh->errstr;
150
151     foreach my $new_position (1 .. scalar(@ids)) {
152       $sth->execute($new_position, $ids[$new_position - 1]) || die $sth->errstr;
153     }
154
155     $sth->finish;
156   });
157
158   return $result;
159 }
160
161 sub configure_acts_as_list {
162   my ($class, %params) = @_;
163
164   $list_spec{$class} = {
165     group_by    => $params{group_by},
166     column_name => $params{column_name},
167   };
168 }
169
170 #
171 # Helper functions
172 #
173
174 sub get_group_by_where {
175   my ($self)   = @_;
176
177   my $group_by = get_spec(ref $self, 'group_by') || [];
178   $group_by    = [ $group_by ] if $group_by && !ref $group_by;
179
180   my (@where, @values);
181   foreach my $column (@{ $group_by }) {
182     my $value = $self->$column;
183     push @values, $value if defined $value;
184     push @where,  defined($value) ? "(${column} = ?)" : "(${column} IS NULL)";
185   }
186
187   return (join(' AND ', @where), @values);
188 }
189
190 sub set_position {
191   my ($self) = @_;
192   my $column = column_name($self);
193   my $value  = $self->$column;
194
195   return 1 if defined($value) && ($value != -1);
196
197   my $table               = $self->meta->table;
198   my ($group_by, @values) = get_group_by_where($self);
199   $group_by               = " AND ${group_by}" if $group_by;
200   my $sql                 = <<SQL;
201     SELECT COALESCE(MAX(${column}), 0)
202     FROM ${table}
203     WHERE (${column} <> -1)
204       ${group_by}
205 SQL
206
207   my $max_position = $self->db->dbh->selectrow_arrayref($sql, undef, @values)->[0];
208   $self->$column($max_position + 1);
209
210   return 1;
211 }
212
213 sub remove_position {
214   my ($self) = @_;
215   my $column = column_name($self);
216
217   $self->load;
218   my $value = $self->$column;
219   return 1 unless defined($value) && ($value != -1);
220
221   my $table               = $self->meta->table;
222   my ($group_by, @values) = get_group_by_where($self);
223   $group_by               = ' AND ' . $group_by if $group_by;
224   my $sql                 = <<SQL;
225     UPDATE ${table}
226     SET ${column} = ${column} - 1
227     WHERE (${column} > ?)
228      ${group_by}
229 SQL
230
231   $self->db->dbh->do($sql, undef, $value, @values);
232
233   return 1;
234 }
235
236 sub do_move {
237   my ($self, $direction) = @_;
238
239   croak "Object has not been saved yet" unless $self->id;
240
241   my $column       = column_name($self);
242   my $old_position = $self->$column;
243   croak "No position set yet" unless defined($old_position) && ($old_position != -1);
244
245   my $table                                        = $self->meta->table;
246   my ($comp_sel, $comp_upd, $min_max, $plus_minus) = $direction eq 'up' ? ('<', '>=', 'MAX', '+') : ('>', '<=', 'MIN', '-');
247   my ($group_by, @values)                          = get_group_by_where($self);
248   $group_by                                        = ' AND ' . $group_by if $group_by;
249   my $sql                                          = <<SQL;
250     SELECT ${min_max}(${column})
251     FROM ${table}
252     WHERE (${column} <>          -1)
253       AND (${column} ${comp_sel} ?)
254       ${group_by}
255 SQL
256
257   my $new_position = $self->db->dbh->selectrow_arrayref($sql, undef, $old_position, @values)->[0];
258
259   return undef unless defined $new_position;
260
261   $sql = <<SQL;
262     UPDATE ${table}
263     SET ${column} = ?
264     WHERE (${column} = ?)
265      ${group_by};
266 SQL
267
268   $self->db->dbh->do($sql, undef, $old_position, $new_position, @values);
269
270   $self->update_attributes($column => $new_position);
271 }
272
273 sub get_previous_or_next {
274   my ($self, $direction)  = @_;
275
276   my $asc_desc            = $direction eq 'next' ? 'ASC' : 'DESC';
277   my $comparator          = $direction eq 'next' ? '>'   : '<';
278   my $table               = $self->meta->table;
279   my $column              = column_name($self);
280   my $primary_key_col     = ($self->meta->primary_key)[0];
281   my ($group_by, @values) = get_group_by_where($self);
282   $group_by               = " AND ${group_by}" if $group_by;
283   my $sql                 = <<SQL;
284     SELECT ${primary_key_col}
285     FROM ${table}
286     WHERE (${column} ${comparator} ?)
287       ${group_by}
288     ORDER BY ${column} ${asc_desc}
289     LIMIT 1
290 SQL
291
292   my $id = ($self->db->dbh->selectrow_arrayref($sql, undef, $self->$column, @values) || [])->[0];
293
294   return $id ? $self->_get_manager_class->find_by(id => $id) : undef;
295 }
296
297 sub column_name {
298   my ($self) = @_;
299   my $column = get_spec(ref $self, 'column_name');
300   return $column if $column;
301   return $self->can('sortkey') ? 'sortkey' : 'position';
302 }
303
304 sub get_spec {
305   my ($class, $key) = @_;
306
307   return undef unless $list_spec{$class};
308   return $list_spec{$class}->{$key};
309 }
310
311 1;
312 __END__
313
314 =pod
315
316 =encoding utf8
317
318 =head1 NAME
319
320 SL::DB::Helper::ActsAsList - Mixin for managing ordered items by a
321 column
322
323 =head1 SYNOPSIS
324
325   package SL::DB::SomeObject;
326   use SL::DB::Helper::ActsAsList [ PARAMS ];
327
328   package SL::Controller::SomeController;
329   ...
330   # Assign a position automatically
331   $obj = SL::DB::SomeObject->new(description => 'bla');
332   $obj->save;
333
334   # Move items up and down
335   $obj = SL::DB::SomeOBject->new(id => 1)->load;
336   $obj->move_position_up;
337   $obj->move_position_down;
338
339   # Adjust all remaining positions automatically
340   $obj->delete
341
342 This mixin assumes that the mixing package's table contains a column
343 called C<position> or C<sortkey> (for legacy tables). This column is
344 set automatically upon saving the object if it hasn't been set
345 already. If it hasn't then it will be set to the maximum position used
346 in the table plus one.
347
348 When the object is deleted all positions greater than the object's old
349 position are decreased by one.
350
351 C<PARAMS> will be given to L<configure_acts_as_list> and can be used to
352 set the column name.
353
354 =head1 CLASS FUNCTIONS
355
356 =over 4
357
358 =item C<configure_acts_as_list %params>
359
360 Configures the mixin's behaviour. Will get called automatically with the
361 include parameters. C<%params> can contain the following values:
362
363 =over 2
364
365 =item C<column_name>
366
367 The name of the column containing the position. If not set explicitly
368 then the mixin will use C<sortkey> if the model contains such a column
369 (only for legacy tables) and C<position> otherwise.
370
371 =item C<group_by>
372
373 An optional column name (or array reference of column names) by which
374 to group. If a table contains items for several distinct sets and each
375 set has its own sorting then this can be used.
376
377 An example would be requirement spec text blocks. They have a column
378 called C<output_position> that selects where to output the text blocks
379 (either before or after the sections). Furthermore these text blocks
380 each belong to a single requirement spec document. So each combination
381 of C<requirement_spec_id> and C<output_position> should have its own
382 set of C<position> values, which can be achieved by configuring this
383 mixin with C<group_by = [qw(requirement_spec_id output_position)]>.
384
385 =back
386
387 =back
388
389 =head1 INSTANCE FUNCTIONS
390
391 =over 4
392
393 =item C<move_position_up>
394
395 Swaps the object with the object one step above the current one
396 regarding their sort order by exchanging their C<position> values.
397
398 =item C<move_position_down>
399
400 Swaps the object with the object one step below the current one
401 regarding their sort order by exchanging their C<position> values.
402
403 =item C<add_to_list %params>
404
405 Adds this item to the list. The parameter C<position> is required and
406 can be one of C<first>, C<last>, C<before> and C<after>. With C<first>
407 the item is inserted as the first item in the list and all other
408 item's positions are shifted up by one. For C<position = last> the
409 item is inserted at the end of the list.
410
411 For C<before> and C<after> an additional parameter C<reference> is
412 required. This is either a Rose model instance or the primary key of
413 one. The current item will then be inserted either before or after the
414 referenced item by shifting all the appropriate item positions up by
415 one.
416
417 If C<$self>'s positional column is already set when this function is
418 called then L</remove_from_list> will be called first before anything
419 else is done.
420
421 After this function C<$self>'s positional column has been set and
422 saved to the database.
423
424 =item C<remove_from_list>
425
426 Sets this items positional column to C<-1>, saves it and moves all
427 following items up by 1.
428
429 =item C<get_previous_in_list>
430
431 Fetches the previous item in the list. Returns C<undef> if C<$self> is
432 already the first one.
433
434 =item C<get_next_in_list>
435
436 Fetches the next item in the list. Returns C<undef> if C<$self> is
437 already the last one.
438
439 =item C<get_full_list>
440
441 Fetches all items in the same list as C<$self> and returns them as an
442 array reference.
443
444 =item C<reorder_list @ids>
445
446 Re-orders the objects given in C<@ids> by their position in C<@ids> by
447 updating all of their positional columns. Each element in
448 C<@positions> must be the ID of an object. The new position is the
449 ID's index inside C<@ids> plus one (meaning the first element's new
450 position will be 1 and not 0).
451
452 This works by executing SQL "UPDATE" statements directly.
453
454 Returns the result of the whole transaction (trueish in case of
455 success).
456
457 This method can be called both as a class method or an instance
458 method.
459
460 =back
461
462 =head1 BUGS
463
464 Nothing here yet.
465
466 =head1 AUTHOR
467
468 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
469
470 =cut