1 package SL::Controller::Helper::ParseFilter;
5 use Exporter qw(import);
6 our @EXPORT = qw(parse_filter);
9 use SL::Helper::DateTime;
10 use List::MoreUtils qw(uniq);
11 use SL::MoreCommon qw(listify);
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] . '%' },
24 enable => sub { ;;;; },
25 eq_ignore_empty => sub { ($_[0] // '') eq '' ? () : +{ eq => $_[0] } },
27 # since $_ is an alias it can't be used in a closure. even "".$_ or "$_"
28 # does not work, we need a real copy.
30 $_ => sub { +{ $_copy => $_[0] } },
31 } qw(similar match imatch regex regexp like ilike rlike is is_not ne eq lt gt le ge),
35 my ($filter, %params) = @_;
37 my $objects = $params{with_objects} || [];
39 my ($flattened, $auto_objects) = flatten($filter, '', %params);
41 if (!$params{class}) {
42 _add_uniq($objects, $_) for @$auto_objects;
45 my $query = _parse_filter($flattened, $objects, %params);
47 _launder_keys($filter, $params{launder_to}) unless $params{no_launder};
50 ($query && @$query ? (query => $query) : ()),
51 ($objects && @$objects ? ( with_objects => [ uniq @$objects ]) : ());
55 my ($filter, $launder_to) = @_;
56 $launder_to ||= $filter;
57 return unless ref $filter eq 'HASH';
58 for my $key (keys %$filter) {
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} } };
66 $launder_to->{$key} ||= { };
67 _launder_keys($filter->{$key}, $launder_to->{$key});
73 my ($filter, $prefix, %params) = @_;
75 return (undef, []) unless 'HASH' eq ref $filter;
76 my $with_objects = [];
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, _prefix($prefix, $key));
84 push @result, @$query if $query;
85 _add_uniq($with_objects, $_) for _prefix($prefix, $key), @$more_objects;
87 push @result, _prefix($prefix, $key) => $value;
91 return \@result, $with_objects;
95 my ($flattened, $with_objects, %params) = @_;
97 return () unless 'ARRAY' eq ref $flattened;
99 $flattened = _collapse_indirect_filters($flattened);
102 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
103 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
104 my ($type, $op) = $key =~ m{:(.+)::(.+)};
106 if ($key =~ s/:multi//) {
109 for my $value (split / /, $value) {
110 ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } });
111 ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
112 ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class};
113 ($key, $value) = _apply_value_filters($key, $value, $type, $op);
114 push @multi, $key, $value;
117 ($key, $value) = (and => \@multi);
119 ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } });
120 ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
121 ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class};
122 ($key, $value) = _apply_value_filters($key, $value, $type, $op);
125 push @result, $key, $value if defined $key;
130 sub _apply_value_filters {
131 my ($key, $value, $type, $op) = @_;
133 return ($key, $value) unless $key && $value && $type && $op && (ref($value) eq 'HASH');
135 if (($type eq 'date') && ($op eq 'le')) {
136 my $date = delete $value->{le};
137 $value->{lt} = $date->add(days => 1);
140 return ($key, $value);
143 sub _dispatch_custom_filters {
144 my ($class, $with_objects, $key, $value) = @_;
146 # the key should by now have no filters left
147 # if it has, catch it here:
148 die 'unrecognized filters' if $key =~ /:/;
150 my @tokens = split /\./, $key;
151 my $last_token = pop @tokens;
152 my $curr_class = $class->object_class;
154 for my $token (@tokens) {
156 $curr_class = $curr_class->meta->relationship($token)->class;
160 Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
164 my $manager = $curr_class->meta->convention_manager->auto_manager_class_name;
165 my $obj_path = join '.', @tokens;
166 my $obj_prefix = join '.', @tokens, '';
168 if ($manager->can('filter')) {
169 ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix);
170 _add_uniq($with_objects, $obj) if $obj;
172 _add_uniq($with_objects, $obj_path) if $obj_path;
175 return ($key, $value);
179 my ($array, $what) = @_;
182 @$array = (uniq @$array, listify($what));
185 sub _collapse_indirect_filters {
186 my ($flattened) = @_;
188 die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2;
190 my (%keys_to_delete, %keys_to_move, @collapsed);
192 # search keys matching /::$/;
193 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
194 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
196 next unless $key =~ /^(.*\b)::$/;
198 $keys_to_delete{$key}++;
199 $keys_to_move{$1} = $1 . '::' . $value;
202 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
203 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
205 if ($keys_to_move{$key}) {
206 push @collapsed, $keys_to_move{$key}, $value;
209 if (!$keys_to_delete{$key}) {
210 push @collapsed, $key, $value;
218 join '.', grep $_, @_;
222 my ($value, $name, $filters) = @_;
223 return $value unless $name && $filters->{$name};
224 return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
225 return $filters->{$name}->($value);
229 my ($key, $value, $re, $subs) = @_;
231 while ($key =~ s/$re//) {
232 $value = _apply($value, $1, $subs);
244 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
248 use SL::Controller::Helper::ParseFilter;
249 SL::DB::Object->get_all(parse_filter($::form->{filter}));
252 SL::DB::Object->get_all(parse_filter($::form->{filter},
253 with_objects => [ qw(part customer) ]));
257 A search filter will usually search for things in relations of the actual
258 search target. A search for sales orders may be filtered by the name of the
259 customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
262 'customer.name' => 'John Doe',
263 'department.description' => [ ilike => '%Sales%' ],
264 'orddate' => [ lt => DateTime->today ],
267 Unfortunately, if you specify them in you form as these strings, the form
268 parser will convert them into nested structures like this:
278 And the substring match requires you to recognize the ilike, and modify the value.
280 C<parse_filter> tries to ease this by recognizing these structures and
281 providing suffixes for common search patterns.
287 =item C<parse_filter \%FILTER, [ %PARAMS ]>
289 First argument is the filter from form. It is highly recommended that you put
290 all filter attributes into a named container as to not confuse them with the
293 Nested structures will be parsed and interpreted as foreign references. For
294 example if you search for L<Order>s, this input will search for those with a
295 specific L<Salesman>:
297 [% L.select_tag('filter.salesman.id', ...) %]
299 Additionally you can add modifier to the name to set a certain method:
301 [% L.input_tag('filter.department.description:substr::ilike', ...) %]
303 This will add the "% .. %" wildcards for substr matching in SQL, and add an
304 C<< ilike => $value >> block around it to match case insensitively.
306 As a rule all value filters require a single colon and must be placed before
307 match method suffixes, which are appended with 2 colons. See below for a full
314 Unfortunately Template cannot parse the postfixes if you want to
315 rerender the filter. For this reason all colons filter keys are by
316 default laundered into underscores, so you can use them like this:
318 [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
320 Also Template has trouble when looking up the contents of arrays, so
321 these will get copied into a _ suffixed version as hashes:
323 [% L.checkbox_tag('filter.ids[]', value=15, checked=filter.ids_.15) %]
325 All of your original entries will stay intact. If you don't want this to
326 happen pass C<< no_launder => 1 >> as a parameter. Additionally you can pass a
327 different target for the laundered values with the C<launder_to> parameter. It
328 takes an hashref and will deep copy all values in your filter to the target. So
329 if you have a filter that looks like this:
332 'price:number::lt' => '2,30',
334 type => [ 'part', 'assembly' ],
339 parse_filter($filter, launder_to => $laundered_filter = { })
341 the original filter will be unchanged, and C<$laundered_filter> will end up
345 'price_number__lt' => '2,30',
347 'type_' => { part => 1, assembly => 1 },
350 =head1 INDIRECT FILTER METHODS
352 The reason for the method being last is that it is possible to specify the
353 method in another input. Suppose you want a date input and a separate
354 before/after/equal select, you can use the following:
356 [% L.date_tag('filter.appointed_date:date', ... ) %]
360 [% L.select_tag('filter.appointed_date:date::', ... ) %]
362 The special empty method will be used to set the method for the previous
365 =head1 CUSTOM FILTERS FROM OBJECTS
367 If the L<parse_filter> call contains a parameter C<class>, custom filters will
368 be honored. Suppose you have added a custom filter 'all' for parts which
369 expands to search both description and partnumber, the following
372 'part.all:substr::ilike' => 'A1',
379 part.description => { ilike => '%A1%' },
380 part.partnumber => { ilike => '%A1%' },
384 For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
386 =head1 FILTERS (leading with :)
388 The following filters are built in, and can be used.
394 Parses the input string with C<< DateTime->from_lxoffice >>
398 Pasres the input string with C<< Form->parse_amount >>
402 Parses the input string with C<< Form->parse_amount / 100 >>
406 Adds "%" at the end of the string.
410 Adds "%" at the end of the string.
414 Adds "% .. %" around the search string.
416 =item eq_ignore_empty
418 Ignores this item if it's empty. Otherwise compares it with the
419 standard SQL C<=> operator.
423 =head2 METHODS (leading with ::)
435 All these are recognized like the L<Rose::DB::Object> methods.
439 =head1 BUGS AND CAVEATS
441 This will not properly handle multiple versions of the same object in different
444 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
445 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
446 following will not work as you expect:
449 L.input_tag('customer.name:substr::ilike', ...)
450 L.input_tag('invoice.customer.name:substr::ilike', ...)
452 This will sarch for orders whose invoice has the _same_ customer, which matches
453 both inputs. This is because tables are aliased by their name and not by their
454 position in with_objects.
462 Additional filters shoud be pluggable.
468 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>