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);
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] . '%' },
23 enable => sub { ;;;; },
25 # since $_ is an alias it can't be used in a closure. even "".$_ or "$_"
26 # does not work, we need a real copy.
28 $_ => sub { +{ $_copy => $_[0] } },
29 } qw(similar match imatch regex regexp like ilike rlike is is_not ne eq lt gt le ge),
33 my ($filter, %params) = @_;
35 my $hint_objects = $params{with_objects} || [];
37 my ($flattened, $objects) = _pre_parse($filter, $hint_objects, '', %params);
39 my $query = _parse_filter($flattened, %params);
41 _launder_keys($filter, $params{launder_to}) unless $params{no_launder};
44 ($query && @$query ? (query => $query) : ()),
45 ($objects && @$objects ? ( with_objects => [ uniq @$objects ]) : ());
49 my ($filter, $launder_to) = @_;
50 $launder_to ||= $filter;
51 return unless ref $filter eq 'HASH';
52 for my $key (keys %$filter) {
55 if ('' eq ref $filter->{$orig}) {
56 $launder_to->{$key} = $filter->{$orig};
57 } elsif ('ARRAY' eq ref $filter->{$orig}) {
58 $launder_to->{$key} = [ @{ $filter->{$orig} } ];
60 $launder_to->{$key} ||= { };
61 _launder_keys($filter->{$key}, $launder_to->{$key});
67 my ($filter, $with_objects, $prefix, %params) = @_;
69 return (undef, $with_objects) unless 'HASH' eq ref $filter;
74 while (my ($key, $value) = each %$filter) {
75 next if !defined $value || $value eq ''; # 0 is fine
76 if ('HASH' eq ref $value) {
77 my ($query, $more_objects) = _pre_parse($value, $with_objects, _prefix($prefix, $key));
78 push @result, @$query if $query;
79 push @$with_objects, _prefix($prefix, $key), ($more_objects ? @$more_objects : ());
81 push @result, _prefix($prefix, $key) => $value;
85 return \@result, $with_objects;
89 my ($flattened, %params) = @_;
91 return () unless 'ARRAY' eq ref $flattened;
93 $flattened = _collapse_indirect_filters($flattened);
96 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
97 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
99 ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } });
100 ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
101 ($key, $value) = _dispatch_custom_filters($params{class}, $key, $value) if $params{class};
103 push @result, $key, $value;
108 sub _dispatch_custom_filters {
109 my ($class, $key, $value) = @_;
111 # the key should by now have no filters left
112 # if it has, catch it here:
113 die 'unrecognized filters' if $key =~ /:/;
115 my @tokens = split /\./, $key;
116 my $last_token = pop @tokens;
117 my $curr_class = $class->object_class;
119 for my $token (@tokens) {
121 $curr_class = $curr_class->meta->relationship($token)->class;
125 Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
129 my $manager = $curr_class->meta->convention_manager->auto_manager_class_name;
131 if ($manager->can('filter')) {
132 ($key, $value) = $manager->filter($last_token, $value, join '.', @tokens, '');
135 return ($key, $value);
138 sub _collapse_indirect_filters {
139 my ($flattened) = @_;
141 die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2;
143 my (%keys_to_delete, %keys_to_move, @collapsed);
145 # search keys matching /::$/;
146 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
147 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
149 next unless $key =~ /^(.*\b)::$/;
151 $keys_to_delete{$key}++;
152 $keys_to_move{$1} = $1 . '::' . $value;
155 for (my $i = 0; $i < scalar @$flattened; $i += 2) {
156 my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]);
158 if ($keys_to_move{$key}) {
159 push @collapsed, $keys_to_move{$key}, $value;
162 if (!$keys_to_delete{$key}) {
163 push @collapsed, $key, $value;
171 join '.', grep $_, @_;
175 my ($value, $name, $filters) = @_;
176 return $value unless $name && $filters->{$name};
177 return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
178 return $filters->{$name}->($value);
182 my ($key, $value, $re, $subs) = @_;
184 while ($key =~ s/$re//) {
185 $value = _apply($value, $1, $subs);
197 SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
201 use SL::Controller::Helper::ParseFilter;
202 SL::DB::Object->get_all(parse_filter($::form->{filter}));
205 SL::DB::Object->get_all(parse_filter($::form->{filter},
206 with_objects => [ qw(part customer) ]));
210 A search filter will usually search for things in relations of the actual
211 search target. A search for sales orders may be filtered by the name of the
212 customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
215 'customer.name' => 'John Doe',
216 'department.description' => [ ilike => '%Sales%' ],
217 'orddate' => [ lt => DateTime->today ],
220 Unfortunately, if you specify them in you form as these strings, the form
221 parser will convert them into nested structures like this:
231 And the substring match requires you to recognize the ilike, and modify the value.
233 C<parse_filter> tries to ease this by recognizing these structures and
234 providing suffixes for common search patterns.
240 =item C<parse_filter \%FILTER, [ %PARAMS ]>
242 First argument is the filter from form. It is highly recommended that you put
243 all filter attributes into a named container as to not confuse them with the
246 Nested structures will be parsed and interpreted as foreign references. For
247 example if you search for L<Order>s, this input will search for those with a
248 specific L<Salesman>:
250 [% L.select_tag('filter.salesman.id', ...) %]
252 Additionally you can add modifier to the name to set a certain method:
254 [% L.input_tag('filter.department.description:substr::ilike', ...) %]
256 This will add the "% .. %" wildcards for substr matching in SQL, and add an
257 C<< ilike => $value >> block around it to match case insensitively.
259 As a rule all value filters require a single colon and must be placed before
260 match method suffixes, which are appended with 2 colons. See below for a full
267 Unfortunately Template cannot parse the postfixes if you want to
268 rerender the filter. For this reason all colons filter keys are by
269 default laundered into underscores, so you can use them like this:
271 [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
273 All of your original entries will stay intactg. If you don't want this to
274 happen pass C<< no_launder => 1 >> as a parameter. Additionally you can pass a
275 different target for the laundered values with the C<launder_to> parameter. It
276 takes an hashref and will deep copy all values in your filter to the target. So
277 if you have a filter that looks liek this:
280 'price:number::lt' => '2,30',
286 parse_filter($filter, launder_to => $laundered_filter = { })
288 the original filter will be unchanged, and C<$laundered_filter> will end up
292 'price_number__lt' => '2,30',
296 =head1 INDIRECT FILTER METHODS
298 The reason for the method being last is that it is possible to specify the
299 method in another input. Suppose you want a date input and a separate
300 before/after/equal select, you can use the following:
302 [% L.date_tag('filter.appointed_date:date', ... ) %]
306 [% L.select_tag('filter.appointed_date:date::', ... ) %]
308 The special empty method will be used to set the method for the previous
311 =head1 CUSTOM FILTERS FROM OBJECTS
313 If the L<parse_filter> call contains a parameter C<class>, custom filters will
314 be honored. Suppose you have added a custom filter 'all' for parts which
315 expands to search both description and partnumber, the following
318 'part.all:substr::ilike' => 'A1',
325 part.description => { ilike => '%A1%' },
326 part.partnumber => { ilike => '%A1%' },
330 For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
332 =head1 FILTERS (leading with :)
334 The following filters are built in, and can be used.
340 Parses the input string with C<< DateTime->from_lxoffice >>
344 Pasres the input string with C<< Form->parse_amount >>
348 Parses the input string with C<< Form->parse_amount / 100 >>
352 Adds "%" at the end of the string.
356 Adds "%" at the end of the string.
360 Adds "% .. %" around the search string.
364 =head2 METHODS (leading with ::)
376 All these are recognized like the L<Rose::DB::Object> methods.
380 =head1 BUGS AND CAVEATS
382 This will not properly handle multiple versions of the same object in different
385 Suppose you want all L<SL::DB::Order>s which have either themselves a certain
386 customer, or are linked to a L<SL::DB::Invoice> with this customer, the
387 following will not work as you expect:
390 L.input_tag('customer.name:substr::ilike', ...)
391 L.input_tag('invoice.customer.name:substr::ilike', ...)
393 This will sarch for orders whose invoice has the _same_ customer, which matches
394 both inputs. This is because tables are aliased by their name and not by their
395 position in with_objects.
403 Additional filters shoud be pluggable.
409 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>