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]);
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};
109 push @result, $key, $value;
114 sub _dispatch_custom_filters {
115 my ($class, $with_objects, $key, $value) = @_;
117 # the key should by now have no filters left
118 # if it has, catch it here:
119 die 'unrecognized filters' if $key =~ /:/;
121 my @tokens = split /\./, $key;
122 my $last_token = pop @tokens;
123 my $curr_class = $class->object_class;
125 for my $token (@tokens) {
127 $curr_class = $curr_class->meta->relationship($token)->class;
131 Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
135 my $manager = $curr_class->meta->convention_manager->auto_manager_class_name;
136 my $obj_path = join '.', @tokens;
137 my $obj_prefix = join '.', @tokens, '';
139 if ($manager->can('filter')) {
140 ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix);
141 _add_uniq($with_objects, $obj) if $obj;
143 _add_uniq($with_objects, $obj_path) if $obj_path;
146 return ($key, $value);
150 my ($array, $what) = @_;
153 @$array = (uniq @$array, listify($what));
156 sub _collapse_indirect_filters {
157 my ($flattened) = @_;
159 die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2;
161 my (%keys_to_delete, %keys_to_move, @collapsed);
163 # search keys matching /::$/;
164 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
165 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
167 next unless $key =~ /^(.*\b)::$/;
169 $keys_to_delete{$key}++;
170 $keys_to_move{$1} = $1 . '::' . $value;
173 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
174 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
176 if ($keys_to_move{$key}) {
177 push @collapsed, $keys_to_move{$key}, $value;
180 if (!$keys_to_delete{$key}) {
181 push @collapsed, $key, $value;
189 join '.', grep $_, @_;
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);
200 my ($key, $value, $re, $subs) = @_;
202 while ($key =~ s/$re//) {
203 $value = _apply($value, $1, $subs);
215 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
219 use SL::Controller::Helper::ParseFilter;
220 SL::DB::Object->get_all(parse_filter($::form->{filter}));
223 SL::DB::Object->get_all(parse_filter($::form->{filter},
224 with_objects => [ qw(part customer) ]));
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:
233 'customer.name' => 'John Doe',
234 'department.description' => [ ilike => '%Sales%' ],
235 'orddate' => [ lt => DateTime->today ],
238 Unfortunately, if you specify them in you form as these strings, the form
239 parser will convert them into nested structures like this:
249 And the substring match requires you to recognize the ilike, and modify the value.
251 C<parse_filter> tries to ease this by recognizing these structures and
252 providing suffixes for common search patterns.
258 =item C<parse_filter \%FILTER, [ %PARAMS ]>
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
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>:
268 [% L.select_tag('filter.salesman.id', ...) %]
270 Additionally you can add modifier to the name to set a certain method:
272 [% L.input_tag('filter.department.description:substr::ilike', ...) %]
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.
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
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:
289 [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
291 Also Template has trouble when looking up the contents of arrays, so
292 these will get copied into a _ suffixed version as hashes:
294 [% L.checkbox_tag('filter.ids[]', value=15, checked=filter.ids_.15) %]
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:
303 'price:number::lt' => '2,30',
305 type => [ 'part', 'assembly' ],
310 parse_filter($filter, launder_to => $laundered_filter = { })
312 the original filter will be unchanged, and C<$laundered_filter> will end up
316 'price_number__lt' => '2,30',
318 'type_' => { part => 1, assembly => 1 },
321 =head1 INDIRECT FILTER METHODS
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:
327 [% L.date_tag('filter.appointed_date:date', ... ) %]
331 [% L.select_tag('filter.appointed_date:date::', ... ) %]
333 The special empty method will be used to set the method for the previous
336 =head1 CUSTOM FILTERS FROM OBJECTS
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
343 'part.all:substr::ilike' => 'A1',
350 part.description => { ilike => '%A1%' },
351 part.partnumber => { ilike => '%A1%' },
355 For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
357 =head1 FILTERS (leading with :)
359 The following filters are built in, and can be used.
365 Parses the input string with C<< DateTime->from_lxoffice >>
369 Pasres the input string with C<< Form->parse_amount >>
373 Parses the input string with C<< Form->parse_amount / 100 >>
377 Adds "%" at the end of the string.
381 Adds "%" at the end of the string.
385 Adds "% .. %" around the search string.
387 =item eq_ignore_empty
389 Ignores this item if it's empty. Otherwise compares it with the
390 standard SQL C<=> operator.
394 =head2 METHODS (leading with ::)
406 All these are recognized like the L<Rose::DB::Object> methods.
410 =head1 BUGS AND CAVEATS
412 This will not properly handle multiple versions of the same object in different
415 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
416 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
417 following will not work as you expect:
420 L.input_tag('customer.name:substr::ilike', ...)
421 L.input_tag('invoice.customer.name:substr::ilike', ...)
423 This will sarch for orders whose invoice has the _same_ customer, which matches
424 both inputs. This is because tables are aliased by their name and not by their
425 position in with_objects.
433 Additional filters shoud be pluggable.
439 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>