ee5a740d1c95f47a3cf6b0c11a34ea5d42fde3e9
[kivitendo-erp.git] / SL / Controller / Helper / ParseFilter.pm
1 package SL::Controller::Helper::ParseFilter;
2
3 use strict;
4
5 use Exporter qw(import);
6 our @EXPORT = qw(parse_filter);
7
8 use DateTime;
9 use SL::Helper::DateTime;
10 use List::MoreUtils qw(uniq);
11 use SL::MoreCommon qw(listify);
12 use Data::Dumper;
13
14 my %filters = (
15   date    => sub { DateTime->from_lxoffice($_[0]) },
16   number  => sub { $::form->parse_amount(\%::myconfig, $_[0]) },
17   percent => sub { $::form->parse_amount(\%::myconfig, $_[0]) / 100 },
18   head    => sub { $_[0] . '%' },
19   tail    => sub { '%' . $_[0] },
20   substr  => sub { '%' . $_[0] . '%' },
21 );
22
23 my %methods = (
24   enable => sub { ;;;; },
25   map {
26     # since $_ is an alias it can't be used in a closure. even "".$_ or "$_"
27     # does not work, we need a real copy.
28     my $_copy = "$_";
29     $_   => sub { +{ $_copy    => $_[0] } },
30   } qw(similar match imatch regex regexp like ilike rlike is is_not ne eq lt gt le ge),
31 );
32
33 sub parse_filter {
34   my ($filter, %params) = @_;
35
36   my $hint_objects = $params{with_objects} || [];
37   my $auto_objects = [];
38
39   my ($flattened, $objects) = flatten($filter, $auto_objects, '', %params);
40
41   if ($params{class}) {
42     $objects = $hint_objects;
43   }
44
45   my $query = _parse_filter($flattened, $objects, %params);
46
47   _launder_keys($filter, $params{launder_to}) unless $params{no_launder};
48
49   return
50     ($query   && @$query   ? (query => $query) : ()),
51     ($objects && @$objects ? ( with_objects => [ uniq @$objects ]) : ());
52 }
53
54 sub _launder_keys {
55   my ($filter, $launder_to) = @_;
56   $launder_to ||= $filter;
57   return unless ref $filter eq 'HASH';
58   for my $key (keys %$filter) {
59     my $orig = $key;
60     $key =~ s/:/_/g;
61     if ('' eq ref $filter->{$orig}) {
62       $launder_to->{$key} = $filter->{$orig};
63     } elsif ('ARRAY' eq ref $filter->{$orig}) {
64       $launder_to->{"${key}_"} = { map { $_ => 1 } @{ $filter->{$orig} } };
65     } else {
66       $launder_to->{$key} ||= { };
67       _launder_keys($filter->{$key}, $launder_to->{$key});
68     }
69   };
70 }
71
72 sub flatten {
73   my ($filter, $with_objects, $prefix, %params) = @_;
74
75   return (undef, $with_objects) unless 'HASH'  eq ref $filter;
76   $with_objects ||= [];
77
78   my @result;
79
80   while (my ($key, $value) = each %$filter) {
81     next if !defined $value || $value eq ''; # 0 is fine
82     if ('HASH' eq ref $value) {
83       my ($query, $more_objects) = flatten($value, $with_objects, _prefix($prefix, $key));
84       push @result,        @$query if $query;
85       push @$with_objects, _prefix($prefix, $key), ($more_objects ? @$more_objects : ());
86     } else {
87       push @result, _prefix($prefix, $key) => $value;
88     }
89   }
90
91   return \@result, $with_objects;
92 }
93
94 sub _parse_filter {
95   my ($flattened, $with_objects, %params) = @_;
96
97   return () unless 'ARRAY' eq ref $flattened;
98
99   $flattened = _collapse_indirect_filters($flattened);
100
101   my @result;
102   for (my $i = 0; $i < scalar @$flattened; $i += 2) {
103     my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
104
105     ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/,  { %filters, %{ $params{filters} || {} } });
106     ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
107     ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class};
108
109     push @result, $key, $value;
110   }
111   return \@result;
112 }
113
114 sub _dispatch_custom_filters {
115   my ($class, $with_objects, $key, $value) = @_;
116
117   # the key should by now have no filters left
118   # if it has, catch it here:
119   die 'unrecognized filters' if $key =~ /:/;
120
121   my @tokens     = split /\./, $key;
122   my $last_token = pop @tokens;
123   my $curr_class = $class->object_class;
124
125   for my $token (@tokens) {
126     eval {
127       $curr_class = $curr_class->meta->relationship($token)->class;
128       1;
129     } or do {
130       require Carp;
131       Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
132     }
133   }
134
135   my $manager    = $curr_class->meta->convention_manager->auto_manager_class_name;
136   my $obj_path   = join '.', @tokens;
137   my $obj_prefix = join '.', @tokens, '';
138
139   if ($manager->can('filter')) {
140     ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix);
141     _add_uniq($with_objects, $obj);
142   } else {
143     _add_uniq($with_objects, $obj_path);
144   }
145
146   return ($key, $value);
147 }
148
149 sub _add_uniq {
150    my ($array, $what) = @_;
151
152    $array //= [];
153    $array = [ uniq @$array, listify($what) ];
154 }
155
156 sub _collapse_indirect_filters {
157   my ($flattened) = @_;
158
159   die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2;
160
161   my (%keys_to_delete, %keys_to_move, @collapsed);
162
163   # search keys matching /::$/;
164   for (my $i = 0; $i < scalar @$flattened; $i += 2) {
165     my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
166
167     next unless $key =~ /^(.*\b)::$/;
168
169     $keys_to_delete{$key}++;
170     $keys_to_move{$1} = $1 . '::' . $value;
171   }
172
173   for (my $i = 0; $i < scalar @$flattened; $i += 2) {
174     my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
175
176     if ($keys_to_move{$key}) {
177       push @collapsed, $keys_to_move{$key}, $value;
178       next;
179     }
180     if (!$keys_to_delete{$key}) {
181       push @collapsed, $key, $value;
182     }
183   }
184
185   return \@collapsed;
186 }
187
188 sub _prefix {
189   join '.', grep $_, @_;
190 }
191
192 sub _apply {
193   my ($value, $name, $filters) = @_;
194   return $value unless $name && $filters->{$name};
195   return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
196   return $filters->{$name}->($value);
197 }
198
199 sub _apply_all {
200   my ($key, $value, $re, $subs) = @_;
201
202   while ($key =~ s/$re//) {
203     $value = _apply($value, $1, $subs);
204   }
205
206   return $key, $value;
207 }
208
209 1;
210
211 __END__
212
213 =head1 NAME
214
215 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
216
217 =head1 SYNOPSIS
218
219   use SL::Controller::Helper::ParseFilter;
220   SL::DB::Object->get_all(parse_filter($::form->{filter}));
221
222   # or more complex
223   SL::DB::Object->get_all(parse_filter($::form->{filter},
224     with_objects => [ qw(part customer) ]));
225
226 =head1 DESCRIPTION
227
228 A search filter will usually search for things in relations of the actual
229 search target. A search for sales orders may be filtered by the name of the
230 customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
231
232   query => [
233     'customer.name'          => 'John Doe',
234     'department.description' => [ ilike => '%Sales%' ],
235     'orddate'                => [ lt    => DateTime->today ],
236   ]
237
238 Unfortunately, if you specify them in you form as these strings, the form
239 parser will convert them into nested structures like this:
240
241   $::form = bless {
242     filter => {
243       customer => {
244         name => 'John Doe',
245       },
246     },
247   }, Form;
248
249 And the substring match requires you to recognize the ilike, and modify the value.
250
251 C<parse_filter> tries to ease this by recognizing these structures and
252 providing suffixes for common search patterns.
253
254 =head1 FUNCTIONS
255
256 =over 4
257
258 =item C<parse_filter \%FILTER, [ %PARAMS ]>
259
260 First argument is the filter from form. It is highly recommended that you put
261 all filter attributes into a named container as to not confuse them with the
262 rest of your form.
263
264 Nested structures will be parsed and interpreted as foreign references. For
265 example if you search for L<Order>s, this input will search for those with a
266 specific L<Salesman>:
267
268   [% L.select_tag('filter.salesman.id', ...) %]
269
270 Additionally you can add modifier to the name to set a certain method:
271
272   [% L.input_tag('filter.department.description:substr::ilike', ...) %]
273
274 This will add the "% .. %" wildcards for substr matching in SQL, and add an
275 C<< ilike => $value >> block around it to match case insensitively.
276
277 As a rule all value filters require a single colon and must be placed before
278 match method suffixes, which are appended with 2 colons. See below for a full
279 list of modifiers.
280
281 =back
282
283 =head1 LAUNDERING
284
285 Unfortunately Template cannot parse the postfixes if you want to
286 rerender the filter. For this reason all colons filter keys are by
287 default laundered into underscores, so you can use them like this:
288
289   [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
290
291 Also Template has trouble when looking up the contents of arrays, so
292 these will get copied into a _ suffixed version as hashes:
293
294   [% L.checkbox_tag('filter.ids[]', value=15, checked=filter.ids_.15) %]
295
296 All of your original entries will stay intact. If you don't want this to
297 happen pass C<< no_launder => 1 >> as a parameter.  Additionally you can pass a
298 different target for the laundered values with the C<launder_to>  parameter. It
299 takes an hashref and will deep copy all values in your filter to the target. So
300 if you have a filter that looks like this:
301
302   $filter = {
303     'price:number::lt' => '2,30',
304     closed             => '1',
305     type               => [ 'part', 'assembly' ],
306   }
307
308 and parse it with
309
310   parse_filter($filter, launder_to => $laundered_filter = { })
311
312 the original filter will be unchanged, and C<$laundered_filter> will end up
313 like this:
314
315   $filter = {
316     'price_number__lt' => '2,30',
317      closed            => '1',
318     'type_'            => { part => 1, assembly => 1 },
319   }
320
321 =head1 INDIRECT FILTER METHODS
322
323 The reason for the method being last is that it is possible to specify the
324 method in another input. Suppose you want a date input and a separate
325 before/after/equal select, you can use the following:
326
327   [% L.date_tag('filter.appointed_date:date', ... ) %]
328
329 and later
330
331   [% L.select_tag('filter.appointed_date:date::', ... ) %]
332
333 The special empty method will be used to set the method for the previous
334 method-less input.
335
336 =head1 CUSTOM FILTERS FROM OBJECTS
337
338 If the L<parse_filter> call contains a parameter C<class>, custom filters will
339 be honored. Suppose you have added a custom filter 'all' for parts which
340 expands to search both description and partnumber, the following
341
342   $filter = {
343     'part.all:substr::ilike' => 'A1',
344   }
345
346 will expand to:
347
348   query => [
349     or => [
350       part.description => { ilike => '%A1%' },
351       part.partnumber  => { ilike => '%A1%' },
352     ]
353   ]
354
355 For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
356
357 =head1 FILTERS (leading with :)
358
359 The following filters are built in, and can be used.
360
361 =over 4
362
363 =item date
364
365 Parses the input string with C<< DateTime->from_lxoffice >>
366
367 =item number
368
369 Pasres the input string with C<< Form->parse_amount >>
370
371 =item percent
372
373 Parses the input string with C<< Form->parse_amount / 100 >>
374
375 =item head
376
377 Adds "%" at the end of the string.
378
379 =item tail
380
381 Adds "%" at the end of the string.
382
383 =item substr
384
385 Adds "% .. %" around the search string.
386
387 =back
388
389 =head2 METHODS (leading with ::)
390
391 =over 4
392
393 =item lt
394
395 =item gt
396
397 =item ilike
398
399 =item like
400
401 All these are recognized like the L<Rose::DB::Object> methods.
402
403 =back
404
405 =head1 BUGS AND CAVEATS
406
407 This will not properly handle multiple versions of the same object in different
408 context.
409
410 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
411 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
412 following will not work as you expect:
413
414   # does not work!
415   L.input_tag('customer.name:substr::ilike', ...)
416   L.input_tag('invoice.customer.name:substr::ilike', ...)
417
418 This will sarch for orders whose invoice has the _same_ customer, which matches
419 both inputs. This is because tables are aliased by their name and not by their
420 position in with_objects.
421
422 =head1 TODO
423
424 =over 4
425
426 =item *
427
428 Additional filters shoud be pluggable.
429
430 =back
431
432 =head1 AUTHOR
433
434 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
435
436 =cut