use DateTime;
use SL::Helper::DateTime;
use List::MoreUtils qw(uniq);
+use SL::Util qw(trim);
use SL::MoreCommon qw(listify);
use Data::Dumper;
use Text::ParseWords;
+sub _lazy_bool_eq {
+ my ($key, $value) = @_;
+
+ return () if ($value // '') eq '';
+ return (or => [ $key => undef, $key => 0 ]) if !$value;
+ return ($key => 1);
+}
+
my %filters = (
date => sub { DateTime->from_lxoffice($_[0]) },
number => sub { $::form->parse_amount(\%::myconfig, $_[0]) },
percent => sub { $::form->parse_amount(\%::myconfig, $_[0]) / 100 },
- head => sub { $_[0] . '%' },
- tail => sub { '%' . $_[0] },
- substr => sub { '%' . $_[0] . '%' },
+ head => sub { trim($_[0]) . '%' },
+ tail => sub { '%' . trim($_[0]) },
+ substr => sub { '%' . trim($_[0]) . '%' },
+ trim => sub { trim($_[0]) },
);
my %methods = (
} qw(similar match imatch regex regexp like ilike rlike is is_not ne eq lt gt le ge),
);
+my %complex_methods = (
+ lazy_bool_eq => \&_lazy_bool_eq,
+);
+
sub parse_filter {
my ($filter, %params) = @_;
_add_uniq($objects, $_) for @$auto_objects;
}
- my $query = _parse_filter($flattened, $objects, %params);
-
_launder_keys($filter, $params{launder_to}) unless $params{no_launder};
+ my $query = _parse_filter($flattened, $objects, %params);
+
return
($query && @$query ? (query => $query) : ()),
($objects && @$objects ? ( with_objects => [ uniq @$objects ]) : ());
$flattened = _collapse_indirect_filters($flattened);
+ my $all_filters = { %filters, %{ $params{filters} || {} } };
+ my $all_methods = { %methods, %{ $params{methods} || {} } };
+ my $all_complex = { %complex_methods, %{ $params{complex_methods} || {} } };
+
my @result;
for (my $i = 0; $i < scalar @$flattened; $i += 2) {
+ my (@args, @filters, $method);
+
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);
+ my $is_multi = $key =~ s/:multi//;
+ my $is_any = $key =~ s/:any//;
+ my @value_tokens = $is_multi || $is_any ? parse_line('\s+', 0, $value) : ($value);
+
+ ($key, $method) = split m{::}, $key, 2;
+ ($key, @filters) = split m{:}, $key;
+
+ my $orig_key = $key;
+
+ for my $value_token (@value_tokens) {
+ $key = $orig_key;
+
+ $value_token = _apply($value_token, $_, $all_filters) for @filters;
+ $value_token = _apply($value_token, $method, $all_methods) if $method && exists $all_methods->{$method};
+ ($key, $value_token) = _apply_complex($key, $value_token, $method, $all_complex) if $method && exists $all_complex->{$method};
+ ($key, $value_token) = _dispatch_custom_filters($params{class}, $with_objects, $key, $value_token) if $params{class};
+ ($key, $value_token) = _apply_value_filters($key, $value_token, $type, $op);
+
+ push @args, $key, $value_token;
}
- push @result, $key, $value if defined $key;
+ next unless defined $key;
+
+ push @result, $is_multi ? (and => [ @args ]) : $is_any ? (or => [ @args ]) : @args;
}
return \@result;
}
die 'unrecognized filters' if $key =~ /:/;
my @tokens = split /\./, $key;
- my $last_token = pop @tokens;
my $curr_class = $class->object_class;
- for my $token (@tokens) {
+ # our key will consist of dot-delimited tokens
+ # like this: order.part.unit.name
+ # each of these tokens except the last one is one of:
+ # - a relationship in the parent object
+ # - a custom filter
+ #
+ # the last token must be
+ # - a custom filter
+ # - a column in the parent object
+ #
+ # find first token which is not a relationship,
+ # so we can pass the rest on
+ my $i = 0;
+ while ($i < $#tokens) {
eval {
- $curr_class = $curr_class->meta->relationship($token)->class;
- 1;
+ $curr_class = $curr_class->meta->relationship($tokens[$i])->class;
+ ++$i;
} or do {
- require Carp;
- Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
+ last;
}
}
my $manager = $curr_class->meta->convention_manager->auto_manager_class_name;
- my $obj_path = join '.', @tokens;
- my $obj_prefix = join '.', @tokens, '';
+ my $obj_path = join '.', @tokens[0..$i-1];
+ my $obj_prefix = join '.', @tokens[0..$i-1], '';
+ my $key_token = $tokens[$i];
+ my @additional_tokens = @tokens[$i+1..$#tokens];
if ($manager->can('filter')) {
- ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix, $obj_path);
+ ($key, $value, my $obj) = $manager->filter($key_token, $value, $obj_prefix, $obj_path, @additional_tokens);
_add_uniq($with_objects, $obj) if $obj;
} else {
_add_uniq($with_objects, $obj_path) if $obj_path;
return $filters->{$name}->($value);
}
-sub _apply_all {
- my ($key, $value, $re, $subs) = @_;
-
- while ($key =~ s/$re//) {
- $value = _apply($value, $1, $subs);
- }
-
- return $key, $value;
+sub _apply_complex {
+ my ($key, $value, $name, $filters) = @_;
+ return $key, $value unless $name && $filters->{$name};
+ return $filters->{$name}->($key, $value);
}
1;
__END__
+=pod
+
+=encoding utf8
+
=head1 NAME
SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
=head1 SYNOPSIS
use SL::Controller::Helper::ParseFilter;
- SL::DB::Object->get_all(parse_filter($::form->{filter}));
+ SL::DB::Manager::Object->get_all(parse_filter($::form->{filter}));
# or more complex
- SL::DB::Object->get_all(parse_filter($::form->{filter},
+ SL::DB::Manager::Object->get_all(parse_filter($::form->{filter},
with_objects => [ qw(part customer) ]));
=head1 DESCRIPTION
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<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
+customer. L<Rose::DB::Object> allows you to search for these by filtering them prefixed with their table:
query => [
'customer.name' => 'John Doe',
- 'department.description' => [ ilike => '%Sales%' ],
- 'orddate' => [ lt => DateTime->today ],
+ 'department.description' => { ilike => '%Sales%' },
+ '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 {
[% 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', ...) %]
=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 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<launder_to> parameter. It
-takes an hashref and will deep copy all values in your filter to the target. So
+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 = {
]
]
-For more abuot custom filters, see L<SL::DB::Helper::Filtered>.
+For more about custom filters, see L<SL::DB::Helper::Filtered>.
=head1 FILTERS (leading with :)
Parses the input string with C<< Form->parse_amount / 100 >>
+=item trim
+
+Removes whitespace characters (to be precice, characters with the \p{WSpace}
+property from beginning and end of the value.
+
=item head
-Adds "%" at the end of the string.
+Adds "%" at the end of the string and applies C<trim>.
=item tail
-Adds "%" at the end of the string.
+Adds "%" at the end of the string and applies C<trim>.
=item substr
-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.
+Adds "% .. %" around the search string and applies C<trim>.
=back
All these are recognized like the L<Rose::DB::Object> methods.
+=item lazy_bool_eq
+
+If the value is undefined or an empty string then this parameter will
+be completely removed from the query. Otherwise a falsish filter value
+will match for C<NULL> and C<FALSE>; trueish values will only match
+C<TRUE>.
+
+=item eq_ignore_empty
+
+Ignores this item if it's empty. Otherwise compares it with the
+standard SQL C<=> operator.
+
=back
=head1 BUGS AND CAVEATS
L.input_tag('customer.name:substr::ilike', ...)
L.input_tag('invoice.customer.name:substr::ilike', ...)
-This will sarch for orders whose 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.
=item *
-Additional filters shoud be pluggable.
+Additional filters should be pluggable.
=back