X-Git-Url: http://wagnertech.de/git?a=blobdiff_plain;f=SL%2FController%2FHelper%2FParseFilter.pm;h=491eb63b58b9a6682b13b6b2f9280c892ed5f712;hb=8e35d05e35f63783ff403c7a4a5febe81cc43f8f;hp=ac086f594259f24ee9139cb8b486d9e6b65ebd9b;hpb=df5d9efd810efd8bd01349d22e3fd4f356cc99ac;p=kivitendo-erp.git diff --git a/SL/Controller/Helper/ParseFilter.pm b/SL/Controller/Helper/ParseFilter.pm index ac086f594..491eb63b5 100644 --- a/SL/Controller/Helper/ParseFilter.pm +++ b/SL/Controller/Helper/ParseFilter.pm @@ -8,7 +8,9 @@ our @EXPORT = qw(parse_filter); use DateTime; use SL::Helper::DateTime; use List::MoreUtils qw(uniq); +use SL::MoreCommon qw(listify); use Data::Dumper; +use Text::ParseWords; my %filters = ( date => sub { DateTime->from_lxoffice($_[0]) }, @@ -20,21 +22,28 @@ my %filters = ( ); my %methods = ( - lt => sub { +{ lt => $_[0] } }, - gt => sub { +{ gt => $_[0] } }, - ilike => sub { +{ ilike => $_[0] } }, - like => sub { +{ like => $_[0] } }, enable => sub { ;;;; }, + eq_ignore_empty => sub { ($_[0] // '') eq '' ? () : +{ eq => $_[0] } }, + map { + # since $_ is an alias it can't be used in a closure. even "".$_ or "$_" + # does not work, we need a real copy. + my $_copy = "$_"; + $_ => sub { +{ $_copy => $_[0] } }, + } qw(similar match imatch regex regexp like ilike rlike is is_not ne eq lt gt le ge), ); sub parse_filter { my ($filter, %params) = @_; - my $hint_objects = $params{with_objects} || []; + my $objects = $params{with_objects} || []; - my ($flattened, $objects) = _pre_parse($filter, $hint_objects, '', %params); + my ($flattened, $auto_objects) = flatten($filter, '', %params); - my $query = _parse_filter($flattened, %params); + if (!$params{class}) { + _add_uniq($objects, $_) for @$auto_objects; + } + + my $query = _parse_filter($flattened, $objects, %params); _launder_keys($filter, $params{launder_to}) unless $params{no_launder}; @@ -53,7 +62,7 @@ sub _launder_keys { if ('' eq ref $filter->{$orig}) { $launder_to->{$key} = $filter->{$orig}; } elsif ('ARRAY' eq ref $filter->{$orig}) { - $launder_to->{$key} = [ @{ $filter->{$orig} } ]; + $launder_to->{"${key}_"} = { map { $_ => 1 } @{ $filter->{$orig} } }; } else { $launder_to->{$key} ||= { }; _launder_keys($filter->{$key}, $launder_to->{$key}); @@ -61,20 +70,20 @@ sub _launder_keys { }; } -sub _pre_parse { - my ($filter, $with_objects, $prefix, %params) = @_; +sub flatten { + my ($filter, $prefix, %params) = @_; - return (undef, $with_objects) unless 'HASH' eq ref $filter; - $with_objects ||= []; + return (undef, []) unless 'HASH' eq ref $filter; + my $with_objects = []; my @result; while (my ($key, $value) = each %$filter) { next if !defined $value || $value eq ''; # 0 is fine if ('HASH' eq ref $value) { - my ($query, $more_objects) = _pre_parse($value, $with_objects, _prefix($prefix, $key)); - push @result, @$query if $query; - push @$with_objects, $key, ($more_objects ? @$more_objects : ()); + my ($query, $more_objects) = flatten($value, _prefix($prefix, $key)); + push @result, @$query if $query; + _add_uniq($with_objects, $_) for _prefix($prefix, $key), @$more_objects; } else { push @result, _prefix($prefix, $key) => $value; } @@ -84,25 +93,126 @@ sub _pre_parse { } sub _parse_filter { - my ($flattened, %params) = @_; + my ($flattened, $with_objects, %params) = @_; return () unless 'ARRAY' eq ref $flattened; - my %sorted = ( @$flattened ); + $flattened = _collapse_indirect_filters($flattened); + + my @result; + for (my $i = 0; $i < scalar @$flattened; $i += 2) { + my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]); + my ($type, $op) = $key =~ m{:(.+)::(.+)}; + + if ($key =~ s/:multi//) { + my @multi; + my $orig_key = $key; + for my $value (parse_line('\s+', 0, $value)) { + ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } }); + ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } }); + ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class}; + ($key, $value) = _apply_value_filters($key, $value, $type, $op); + push @multi, $key, $value; + $key = $orig_key; + } + ($key, $value) = (and => \@multi); + } else { + ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } }); + ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } }); + ($key, $value) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value) if $params{class}; + ($key, $value) = _apply_value_filters($key, $value, $type, $op); + } + + push @result, $key, $value if defined $key; + } + return \@result; +} + +sub _apply_value_filters { + my ($key, $value, $type, $op) = @_; + + return ($key, $value) unless $key && $value && $type && $op && (ref($value) eq 'HASH'); + + if (($type eq 'date') && ($op eq 'le')) { + my $date = delete $value->{le}; + $value->{lt} = $date->add(days => 1); + } + + return ($key, $value); +} + +sub _dispatch_custom_filters { + my ($class, $with_objects, $key, $value) = @_; + + # the key should by now have no filters left + # if it has, catch it here: + die 'unrecognized filters' if $key =~ /:/; + + my @tokens = split /\./, $key; + my $last_token = pop @tokens; + my $curr_class = $class->object_class; + + for my $token (@tokens) { + eval { + $curr_class = $curr_class->meta->relationship($token)->class; + 1; + } or do { + require Carp; + Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request"); + } + } + + my $manager = $curr_class->meta->convention_manager->auto_manager_class_name; + my $obj_path = join '.', @tokens; + my $obj_prefix = join '.', @tokens, ''; + + if ($manager->can('filter')) { + ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix, $obj_path); + _add_uniq($with_objects, $obj) if $obj; + } else { + _add_uniq($with_objects, $obj_path) if $obj_path; + } + + return ($key, $value); +} + +sub _add_uniq { + my ($array, $what) = @_; + + $array //= []; + @$array = (uniq @$array, listify($what)); +} + +sub _collapse_indirect_filters { + my ($flattened) = @_; + + die 'flattened filter array length is uneven, should be possible to use as hash' if @$flattened % 2; + + my (%keys_to_delete, %keys_to_move, @collapsed); + + # search keys matching /::$/; + for (my $i = 0; $i < scalar @$flattened; $i += 2) { + my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]); - my @keys = sort { length($b) <=> length($a) } keys %sorted; - for my $key (@keys) { next unless $key =~ /^(.*\b)::$/; - $sorted{$1 . '::' . delete $sorted{$key} } = delete $sorted{$1} if $sorted{$1} && $sorted{$key}; + + $keys_to_delete{$key}++; + $keys_to_move{$1} = $1 . '::' . $value; } - my %result; - while (my ($key, $value) = each %sorted) { - ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/, { %filters, %{ $params{filters} || {} } }); - ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } }); - $result{$key} = $value; + for (my $i = 0; $i < scalar @$flattened; $i += 2) { + my ($key, $value) = ($flattened->[$i], $flattened->[$i+1]); + + if ($keys_to_move{$key}) { + push @collapsed, $keys_to_move{$key}, $value; + next; + } + if (!$keys_to_delete{$key}) { + push @collapsed, $key, $value; + } } - return [ %result ]; + + return \@collapsed; } sub _prefix { @@ -147,7 +257,7 @@ SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get A search filter will usually search for things in relations of the actual search target. A search for sales orders may be filtered by the name of the -customer. L alloes you to search for these by filtering them prefixed with their table: +customer. L allows you to search for these by filtering them prefixed with their table: query => [ 'customer.name' => 'John Doe', @@ -155,7 +265,7 @@ customer. L alloes you to search for these by filtering them p 'orddate' => [ lt => DateTime->today ], ] -Unfortunately, if you specify them in you form as these strings, the form +Unfortunately, if you specify them in your form as these strings, the form parser will convert them into nested structures like this: $::form = bless { @@ -187,7 +297,7 @@ specific L: [% L.select_tag('filter.salesman.id', ...) %] -Additionally you can add modifier to the name to set a certain method: +Additionally you can add a modifier to the name to set a certain method: [% L.input_tag('filter.department.description:substr::ilike', ...) %] @@ -198,38 +308,31 @@ As a rule all value filters require a single colon and must be placed before match method suffixes, which are appended with 2 colons. See below for a full list of modifiers. -The reason for the method being last is that it is possible to specify the -method in another input. Suppose you want a date input and a separate -before/after/equal select, you can use the following: - - [% L.date_tag('filter.appointed_date:date', ... ) %] - -and later - - [% L.select_tag('filter.appointed_date::', ... ) %] - -The special empty method will be used to set the method for the previous -method-less input. - =back =head1 LAUNDERING Unfortunately Template cannot parse the postfixes if you want to -rerender the filter. For this reason all colons filter keys are by +rerender the filter. For this reason all colon filter keys are by default laundered into underscores, so you can use them like this: [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %] -All of your original entries will stay intactg. If you don't want this to +Also Template has trouble when looking up the contents of arrays, so +these will get copied into a _ suffixed version as hashes: + + [% L.checkbox_tag('filter.ids[]', value=15, checked=filter.ids_.15) %] + +All of your original entries will stay intact. If you don't want this to happen pass C<< no_launder => 1 >> as a parameter. Additionally you can pass a different target for the laundered values with the C parameter. It -takes an hashref and will deep copy all values in your filter to the target. So -if you have a filter that looks liek this: +takes a hashref and will deep copy all values in your filter to the target. So +if you have a filter that looks like this: $filter = { 'price:number::lt' => '2,30', - 'closed => '1', + closed => '1', + type => [ 'part', 'assembly' ], } and parse it with @@ -241,9 +344,46 @@ like this: $filter = { 'price_number__lt' => '2,30', - 'closed => '1', + closed => '1', + 'type_' => { part => 1, assembly => 1 }, + } + +=head1 INDIRECT FILTER METHODS + +The reason for the method being last is that it is possible to specify the +method in another input. Suppose you want a date input and a separate +before/after/equal select, you can use the following: + + [% L.date_tag('filter.appointed_date:date', ... ) %] + +and later + + [% L.select_tag('filter.appointed_date:date::', ... ) %] + +The special empty method will be used to set the method for the previous +method-less input. + +=head1 CUSTOM FILTERS FROM OBJECTS + +If the L call contains a parameter C, custom filters will +be honored. Suppose you have added a custom filter 'all' for parts which +expands to search both description and partnumber, the following + + $filter = { + 'part.all:substr::ilike' => 'A1', } +will expand to: + + query => [ + or => [ + part.description => { ilike => '%A1%' }, + part.partnumber => { ilike => '%A1%' }, + ] + ] + +For more about custom filters, see L. + =head1 FILTERS (leading with :) The following filters are built in, and can be used. @@ -274,6 +414,11 @@ Adds "%" at the end of the string. Adds "% .. %" around the search string. +=item eq_ignore_empty + +Ignores this item if it's empty. Otherwise compares it with the +standard SQL C<=> operator. + =back =head2 METHODS (leading with ::) @@ -305,7 +450,7 @@ following will not work as you expect: L.input_tag('customer.name:substr::ilike', ...) L.input_tag('invoice.customer.name:substr::ilike', ...) -This will sarch for orders whoe invoice has the _same_ customer, which matches +This will search for orders whose invoice has the _same_ customer, which matches both inputs. This is because tables are aliased by their name and not by their position in with_objects. @@ -315,7 +460,7 @@ position in with_objects. =item * -Additional filters shoud be pluggable. +Additional filters should be pluggable. =back