ParseFilter Mixin.
[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 Data::Dumper;
12
13 my %filters = (
14   date    => sub { DateTime->from_lxoffice($_[0]) },
15   number  => sub { $::form->parse_amount(\%::myconfig, $_[0]) },
16   percent => sub { $::form->parse_amount(\%::myconfig, $_[0]) / 100 },
17   head    => sub { $_[0] . '%' },
18   tail    => sub { '%' . $_[0] },
19   substr  => sub { '%' . $_[0] . '%' },
20 );
21
22 my %methods = (
23   lt     => sub { +{ lt    => $_[0] } },
24   gt     => sub { +{ gt    => $_[0] } },
25   ilike  => sub { +{ ilike => $_[0] } },
26   like   => sub { +{ like  => $_[0] } },
27   enable => sub { ;;;; },
28 );
29
30 sub parse_filter {
31   my ($filter, %params) = @_;
32
33   my $hint_objects = $params{with_objects} || [];
34
35   my ($flattened, $objects) = _pre_parse($filter, $hint_objects, '', %params);
36
37   my $query = _parse_filter($flattened, %params);
38
39   _launder_keys($filter) unless $params{no_launder};
40
41   return
42     query => $query,
43     @$objects ? ( with_objects => [ uniq @$objects ]) : ();
44 }
45
46 sub _launder_keys {
47   my ($filter) = @_;
48   return unless ref $filter eq 'HASH';
49   my @keys = keys %$filter;
50   for my $key (@keys) {
51     my $orig = $key;
52     $key =~ s/:/_/g;
53     $filter->{$key} = $filter->{$orig};
54     _launder_keys($filter->{$key});
55   };
56
57   return $filter;
58 }
59
60 sub _pre_parse {
61   my ($filter, $with_objects, $prefix, %params) = @_;
62
63   return () unless 'HASH'  eq ref $filter;
64   $with_objects = [];
65
66   my @result;
67
68   while (my ($key, $value) = each %$filter) {
69     next if !defined $value || $value eq ''; # 0 is fine
70     if ('HASH' eq ref $value) {
71       my ($query, $more_objects) = _pre_parse($value, $with_objects, _prefix($prefix, $key));
72       push @result,        @$query if $query;
73       push @$with_objects, $key, ($more_objects ? @$more_objects : ());
74     } else {
75       push @result, _prefix($prefix, $key) => $value;
76     }
77   }
78
79   return \@result, $with_objects;
80 }
81
82 sub _parse_filter {
83   my ($flattened, %params) = @_;
84
85   return () unless 'ARRAY' eq ref $flattened;
86
87   my %sorted = ( @$flattened );
88
89   my @keys = sort { length($b) <=> length($a) } keys %sorted;
90   for my $key (@keys) {
91     next unless $key =~ /^(.*\b)::$/;
92     $sorted{$1 . '::' . delete $sorted{$key} } = delete $sorted{$1} if $sorted{$1} && $sorted{$key};
93   }
94
95   my %result;
96   while (my ($key, $value) = each %sorted) {
97     ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/,  { %filters, %{ $params{filters} || {} } });
98     ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
99     $result{$key} = $value;
100   }
101   return [ %result ];
102 }
103
104 sub _prefix {
105   join '.', grep $_, @_;
106 }
107
108 sub _apply {
109   my ($value, $name, $filters) = @_;
110   return $value unless $name && $filters->{$name};
111   return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
112   return $filters->{$name}->($value);
113 }
114
115 sub _apply_all {
116   my ($key, $value, $re, $subs) = @_;
117
118   while ($key =~ s/$re//) {
119     $value = _apply($value, $1, $subs);
120   }
121
122   return $key, $value;
123 }
124
125 1;
126
127 __END__
128
129 =head1 NAME
130
131 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
132
133 =head1 SYNOPSIS
134
135   use SL::Controller::Helper::ParseFilter;
136   SL::DB::Object->get_all(parse_filter($::form->{filter}));
137
138   # or more complex
139   SL::DB::Object->get_all(parse_filter($::form->{filter},
140     with_objects => [ qw(part customer) ]));
141
142 =head1 DESCRIPTION
143
144 A search filter will usually search for things in relations of the actual
145 search target. A search for sales orders may be filtered by the name of the
146 customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
147
148   query => [
149     customer.name => 'John Doe',
150     department.description => [ ilike => '%Sales%' ],
151     orddate => [ lt => DateTime->today ],
152   ]
153
154 Unfortunately, if you specify them in you form as these strings, the form
155 parser will convert them into nested structures like this:
156
157   $::form = bless {
158     filter => {
159       customer => {
160         name => 'John Doe',
161       },
162     },
163   }, Form;
164
165 And the substring match requires you to recognize the ilike, and modify the value.
166
167 C<parse_filter> tries to ease this by recognizing these structures and
168 providing suffixes for common search patterns.
169
170 =head1 FUNCTIONS
171
172 =over 4
173
174 =item parse_amount \%FILTER, [ %PARAMS ]
175
176 First argument is the filter from form. It is highly recommended that you put
177 all filter attributes into a named container as to not confuse them with the
178 rest of your form.
179
180 Nested structures will be parsed and interpreted as foreign references. For
181 example if you search for L<Order>s, this input will search for those with a
182 specific L<Salesman>:
183
184   [% L.select_tag('filter.salesman.id', ...
185
186 Additionally you can add modifier to the name to set a certain method:
187
188   [% L.input_tag('filter.department.description:substr::ilike' ...
189
190 This will add the "% .. %" wildcards for substr matching in sql, and add an C<[
191 ilike => $value ]> block around it to match case insensitively.
192
193 As a rule all value filters require a single colon and must be placed before
194 match method suffixes, which are appended with 2 colons. See below for a full
195 list of modifiers.
196
197 The reason for the method being last is that it is possible to specify the
198 method in another input. Suppose you want a date input and a separate
199 before/after/equal select, you can use the following:
200
201   [% L.date_tag('filter.appointed_date:date', ... ) %]
202
203 and later
204
205   [% L.select_tag('filter.appointed_date::', ... ) %]
206
207 The special empty method will be used to set the method for the previous
208 method-less input.
209
210 =item Laundering filter
211
212 Unfortunately Template cannot parse the postfixes if you want to rerender the
213 filter. For this reason all colons filter keys are by default laundered into
214 underscores. If you don't want this to happen pass C<no_launder => 1> as a
215 parameter. A full select_tag then loks like this:
216
217   [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
218
219
220 =back
221
222 =head1 FILTERS (leading with :)
223
224 The following filters are built in, and can be used.
225
226 =over 4
227
228 =item date
229
230 Parses the input string with DateTime->from_lxoffice
231
232 =item number
233
234 Pasres the input string with Form->parse_amount
235
236 =item percent
237
238 Parses the input string with Form->parse_amount / 100
239
240 =item head
241
242 Adds "%" at the end of the string.
243
244 =item tail
245
246 Adds "%" at the end of the string.
247
248 =item substr
249
250 Adds "% .. %" around the search string.
251
252 =back
253
254 =head2 METHODS (leading with ::)
255
256 =over 4
257
258 =item lt
259
260 =item gt
261
262 =item ilike
263
264 =item like
265
266 All these are recognized like the L<Rose::DB::Object> methods.
267
268 =back
269
270 =head1 BUGS AND CAVEATS
271
272 This will not properly handle multiple versions of the same object in different
273 context.
274
275 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
276 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
277 following will not work as you expect:
278
279   # does not work!
280   L.input_tag('customer.name:substr::ilike', ...
281   L.input_tag('invoice.customer.name:substr::ilike', ...
282
283 This will sarch for orders whoe invoice has the _same_ customer, which matches
284 both inputs. This is because tables are aliased by their name and not by their
285 position in with_objects.
286
287 =head1 TODO
288
289 =over 4
290
291 =item *
292
293 Additional filters shoud be pluggable.
294
295 =back
296
297 =head1 AUTHOR
298
299 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
300
301 =cut