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 ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } });
107 ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
108 ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class};
109 ($key, $value) = _apply_value_filters($key, $value, $type, $op);
111 push @result, $key, $value if defined $key;
116 sub _apply_value_filters {
117 my ($key, $value, $type, $op) = @_;
119 return ($key, $value) unless $key && $value && $type && $op && (ref($value) eq 'HASH');
121 if (($type eq 'date') && ($op eq 'le')) {
122 my $date = delete $value->{le};
123 $value->{lt} = $date->add(days => 1);
126 return ($key, $value);
129 sub _dispatch_custom_filters {
130 my ($class, $with_objects, $key, $value) = @_;
132 # the key should by now have no filters left
133 # if it has, catch it here:
134 die 'unrecognized filters' if $key =~ /:/;
136 my @tokens = split /\./, $key;
137 my $last_token = pop @tokens;
138 my $curr_class = $class->object_class;
140 for my $token (@tokens) {
142 $curr_class = $curr_class->meta->relationship($token)->class;
146 Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
150 my $manager = $curr_class->meta->convention_manager->auto_manager_class_name;
151 my $obj_path = join '.', @tokens;
152 my $obj_prefix = join '.', @tokens, '';
154 if ($manager->can('filter')) {
155 ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix);
156 _add_uniq($with_objects, $obj) if $obj;
158 _add_uniq($with_objects, $obj_path) if $obj_path;
161 return ($key, $value);
165 my ($array, $what) = @_;
168 @$array = (uniq @$array, listify($what));
171 sub _collapse_indirect_filters {
172 my ($flattened) = @_;
174 die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2;
176 my (%keys_to_delete, %keys_to_move, @collapsed);
178 # search keys matching /::$/;
179 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
180 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
182 next unless $key =~ /^(.*\b)::$/;
184 $keys_to_delete{$key}++;
185 $keys_to_move{$1} = $1 . '::' . $value;
188 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
189 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
191 if ($keys_to_move{$key}) {
192 push @collapsed, $keys_to_move{$key}, $value;
195 if (!$keys_to_delete{$key}) {
196 push @collapsed, $key, $value;
204 join '.', grep $_, @_;
208 my ($value, $name, $filters) = @_;
209 return $value unless $name && $filters->{$name};
210 return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
211 return $filters->{$name}->($value);
215 my ($key, $value, $re, $subs) = @_;
217 while ($key =~ s/$re//) {
218 $value = _apply($value, $1, $subs);
230 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
234 use SL::Controller::Helper::ParseFilter;
235 SL::DB::Object->get_all(parse_filter($::form->{filter}));
238 SL::DB::Object->get_all(parse_filter($::form->{filter},
239 with_objects => [ qw(part customer) ]));
243 A search filter will usually search for things in relations of the actual
244 search target. A search for sales orders may be filtered by the name of the
245 customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
248 'customer.name' => 'John Doe',
249 'department.description' => [ ilike => '%Sales%' ],
250 'orddate' => [ lt => DateTime->today ],
253 Unfortunately, if you specify them in you form as these strings, the form
254 parser will convert them into nested structures like this:
264 And the substring match requires you to recognize the ilike, and modify the value.
266 C<parse_filter> tries to ease this by recognizing these structures and
267 providing suffixes for common search patterns.
273 =item C<parse_filter \%FILTER, [ %PARAMS ]>
275 First argument is the filter from form. It is highly recommended that you put
276 all filter attributes into a named container as to not confuse them with the
279 Nested structures will be parsed and interpreted as foreign references. For
280 example if you search for L<Order>s, this input will search for those with a
281 specific L<Salesman>:
283 [% L.select_tag('filter.salesman.id', ...) %]
285 Additionally you can add modifier to the name to set a certain method:
287 [% L.input_tag('filter.department.description:substr::ilike', ...) %]
289 This will add the "% .. %" wildcards for substr matching in SQL, and add an
290 C<< ilike => $value >> block around it to match case insensitively.
292 As a rule all value filters require a single colon and must be placed before
293 match method suffixes, which are appended with 2 colons. See below for a full
300 Unfortunately Template cannot parse the postfixes if you want to
301 rerender the filter. For this reason all colons filter keys are by
302 default laundered into underscores, so you can use them like this:
304 [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
306 Also Template has trouble when looking up the contents of arrays, so
307 these will get copied into a _ suffixed version as hashes:
309 [% L.checkbox_tag('filter.ids[]', value=15, checked=filter.ids_.15) %]
311 All of your original entries will stay intact. If you don't want this to
312 happen pass C<< no_launder => 1 >> as a parameter. Additionally you can pass a
313 different target for the laundered values with the C<launder_to> parameter. It
314 takes an hashref and will deep copy all values in your filter to the target. So
315 if you have a filter that looks like this:
318 'price:number::lt' => '2,30',
320 type => [ 'part', 'assembly' ],
325 parse_filter($filter, launder_to => $laundered_filter = { })
327 the original filter will be unchanged, and C<$laundered_filter> will end up
331 'price_number__lt' => '2,30',
333 'type_' => { part => 1, assembly => 1 },
336 =head1 INDIRECT FILTER METHODS
338 The reason for the method being last is that it is possible to specify the
339 method in another input. Suppose you want a date input and a separate
340 before/after/equal select, you can use the following:
342 [% L.date_tag('filter.appointed_date:date', ... ) %]
346 [% L.select_tag('filter.appointed_date:date::', ... ) %]
348 The special empty method will be used to set the method for the previous
351 =head1 CUSTOM FILTERS FROM OBJECTS
353 If the L<parse_filter> call contains a parameter C<class>, custom filters will
354 be honored. Suppose you have added a custom filter 'all' for parts which
355 expands to search both description and partnumber, the following
358 'part.all:substr::ilike' => 'A1',
365 part.description => { ilike => '%A1%' },
366 part.partnumber => { ilike => '%A1%' },
370 For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
372 =head1 FILTERS (leading with :)
374 The following filters are built in, and can be used.
380 Parses the input string with C<< DateTime->from_lxoffice >>
384 Pasres the input string with C<< Form->parse_amount >>
388 Parses the input string with C<< Form->parse_amount / 100 >>
392 Adds "%" at the end of the string.
396 Adds "%" at the end of the string.
400 Adds "% .. %" around the search string.
402 =item eq_ignore_empty
404 Ignores this item if it's empty. Otherwise compares it with the
405 standard SQL C<=> operator.
409 =head2 METHODS (leading with ::)
421 All these are recognized like the L<Rose::DB::Object> methods.
425 =head1 BUGS AND CAVEATS
427 This will not properly handle multiple versions of the same object in different
430 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
431 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
432 following will not work as you expect:
435 L.input_tag('customer.name:substr::ilike', ...)
436 L.input_tag('invoice.customer.name:substr::ilike', ...)
438 This will sarch for orders whose invoice has the _same_ customer, which matches
439 both inputs. This is because tables are aliased by their name and not by their
440 position in with_objects.
448 Additional filters shoud be pluggable.
454 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>