ParseFilter Mixin.
authorSven Schöling <s.schoeling@linet-services.de>
Thu, 4 Nov 2010 15:08:34 +0000 (16:08 +0100)
committerSven Schöling <s.schoeling@linet-services.de>
Wed, 26 Oct 2011 13:17:21 +0000 (15:17 +0200)
Erlaubt es semikomplexe Filter zu bauen und direkt an get_all weiterzureichen. Kompatibel mit dem Sorter Mixin.

SL/Controller/Helper/ParseFilter.pm [new file with mode: 0644]
t/controllers/helpers/parse_filter.t [new file with mode: 0644]

diff --git a/SL/Controller/Helper/ParseFilter.pm b/SL/Controller/Helper/ParseFilter.pm
new file mode 100644 (file)
index 0000000..96d3b32
--- /dev/null
@@ -0,0 +1,301 @@
+package SL::Controller::Helper::ParseFilter;
+
+use strict;
+
+use Exporter qw(import);
+our @EXPORT = qw(parse_filter);
+
+use DateTime;
+use SL::Helper::DateTime;
+use List::MoreUtils qw(uniq);
+use Data::Dumper;
+
+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] . '%' },
+);
+
+my %methods = (
+  lt     => sub { +{ lt    => $_[0] } },
+  gt     => sub { +{ gt    => $_[0] } },
+  ilike  => sub { +{ ilike => $_[0] } },
+  like   => sub { +{ like  => $_[0] } },
+  enable => sub { ;;;; },
+);
+
+sub parse_filter {
+  my ($filter, %params) = @_;
+
+  my $hint_objects = $params{with_objects} || [];
+
+  my ($flattened, $objects) = _pre_parse($filter, $hint_objects, '', %params);
+
+  my $query = _parse_filter($flattened, %params);
+
+  _launder_keys($filter) unless $params{no_launder};
+
+  return
+    query => $query,
+    @$objects ? ( with_objects => [ uniq @$objects ]) : ();
+}
+
+sub _launder_keys {
+  my ($filter) = @_;
+  return unless ref $filter eq 'HASH';
+  my @keys = keys %$filter;
+  for my $key (@keys) {
+    my $orig = $key;
+    $key =~ s/:/_/g;
+    $filter->{$key} = $filter->{$orig};
+    _launder_keys($filter->{$key});
+  };
+
+  return $filter;
+}
+
+sub _pre_parse {
+  my ($filter, $with_objects, $prefix, %params) = @_;
+
+  return () unless 'HASH'  eq ref $filter;
+  $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 : ());
+    } else {
+      push @result, _prefix($prefix, $key) => $value;
+    }
+  }
+
+  return \@result, $with_objects;
+}
+
+sub _parse_filter {
+  my ($flattened, %params) = @_;
+
+  return () unless 'ARRAY' eq ref $flattened;
+
+  my %sorted = ( @$flattened );
+
+  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};
+  }
+
+  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;
+  }
+  return [ %result ];
+}
+
+sub _prefix {
+  join '.', grep $_, @_;
+}
+
+sub _apply {
+  my ($value, $name, $filters) = @_;
+  return $value unless $name && $filters->{$name};
+  return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
+  return $filters->{$name}->($value);
+}
+
+sub _apply_all {
+  my ($key, $value, $re, $subs) = @_;
+
+  while ($key =~ s/$re//) {
+    $value = _apply($value, $1, $subs);
+  }
+
+  return $key, $value;
+}
+
+1;
+
+__END__
+
+=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}));
+
+  # or more complex
+  SL::DB::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:
+
+  query => [
+    customer.name => 'John Doe',
+    department.description => [ ilike => '%Sales%' ],
+    orddate => [ lt => DateTime->today ],
+  ]
+
+Unfortunately, if you specify them in you form as these strings, the form
+parser will convert them into nested structures like this:
+
+  $::form = bless {
+    filter => {
+      customer => {
+        name => 'John Doe',
+      },
+    },
+  }, Form;
+
+And the substring match requires you to recognize the ilike, and modify the value.
+
+C<parse_filter> tries to ease this by recognizing these structures and
+providing suffixes for common search patterns.
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item parse_amount \%FILTER, [ %PARAMS ]
+
+First argument is the filter from form. It is highly recommended that you put
+all filter attributes into a named container as to not confuse them with the
+rest of your form.
+
+Nested structures will be parsed and interpreted as foreign references. For
+example if you search for L<Order>s, this input will search for those with a
+specific L<Salesman>:
+
+  [% L.select_tag('filter.salesman.id', ...
+
+Additionally you can add modifier to the name to set a certain method:
+
+  [% L.input_tag('filter.department.description:substr::ilike' ...
+
+This will add the "% .. %" wildcards for substr matching in sql, and add an C<[
+ilike => $value ]> block around it to match case insensitively.
+
+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.
+
+=item Laundering filter
+
+Unfortunately Template cannot parse the postfixes if you want to rerender the
+filter. For this reason all colons filter keys are by default laundered into
+underscores. If you don't want this to happen pass C<no_launder => 1> as a
+parameter. A full select_tag then loks like this:
+
+  [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
+
+
+=back
+
+=head1 FILTERS (leading with :)
+
+The following filters are built in, and can be used.
+
+=over 4
+
+=item date
+
+Parses the input string with DateTime->from_lxoffice
+
+=item number
+
+Pasres the input string with Form->parse_amount
+
+=item percent
+
+Parses the input string with Form->parse_amount / 100
+
+=item head
+
+Adds "%" at the end of the string.
+
+=item tail
+
+Adds "%" at the end of the string.
+
+=item substr
+
+Adds "% .. %" around the search string.
+
+=back
+
+=head2 METHODS (leading with ::)
+
+=over 4
+
+=item lt
+
+=item gt
+
+=item ilike
+
+=item like
+
+All these are recognized like the L<Rose::DB::Object> methods.
+
+=back
+
+=head1 BUGS AND CAVEATS
+
+This will not properly handle multiple versions of the same object in different
+context.
+
+Suppose you want all L<SL::DB::Order>s which have either themselves a certain
+customer, or are linked to a L<SL::DB::Invoice> with this customer, the
+following will not work as you expect:
+
+  # does not work!
+  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
+both inputs. This is because tables are aliased by their name and not by their
+position in with_objects.
+
+=head1 TODO
+
+=over 4
+
+=item *
+
+Additional filters shoud be pluggable.
+
+=back
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
diff --git a/t/controllers/helpers/parse_filter.t b/t/controllers/helpers/parse_filter.t
new file mode 100644 (file)
index 0000000..5a1ca07
--- /dev/null
@@ -0,0 +1,130 @@
+use lib 't';
+
+use Test::More tests => 13;
+use Test::Deep;
+use Data::Dumper;
+
+use_ok 'Support::TestSetup';
+use_ok 'SL::Controller::Helper::ParseFilter';
+
+Support::TestSetup::login();
+my ($filter, $expected);
+
+sub test ($$$) {
+  my $got = { parse_filter($_[0]) };
+  cmp_deeply(
+    $got,
+    $_[1],
+    $_[2]
+  ) or do {
+    print STDERR "expected => ", Dumper($_[1]), "\ngot: => ", Dumper($got), $/;
+  }
+}
+
+test { }, {
+  query => []
+}, 'minimal test';
+
+test {
+  name => 'Test',
+  whut => 'moof',
+}, {
+  query => [ %{{
+    name => 'Test',
+    whut => 'moof'
+  }} ],
+}, 'basic test';
+
+test {
+  customer => {
+    name => 'rainer',
+  }
+}, {
+  query => [ 'customer.name' => 'rainer' ],
+  with_objects => [ 'customer' ],
+}, 'joining customers';
+
+test {
+  customer => {
+    chart => {
+      accno => 'test',
+    }
+  }
+}, {
+  query => [ 'customer.chart.accno' => 'test' ],
+  with_objects => [ 'customer', 'chart' ],
+}, 'nested joins';
+
+test {
+  'customer:substr' => 'Meyer'
+}, {
+  query => [ customer => '%Meyer%' ]
+}, 'simple filter substr';
+
+test {
+  'customer::ilike' => 'Meyer'
+}, {
+  query => [ customer => { ilike => 'Meyer' } ]
+}, 'simple method ilike';
+
+test {
+  customer => {
+    chart => {
+      'accno:tail::like' => '1200'
+    }
+  },
+},
+{
+  query => [ 'customer.chart.accno' => { like => '%1200' } ],
+  with_objects => ['customer', 'chart' ],
+}, 'all together';
+
+
+test {
+  customer => {
+    name => 'test',
+  },
+  invoice => {
+    customer => {
+      name => 'test',
+    },
+  },
+}, {
+  'query' => [ %{{
+               'invoice.customer.name'  => 'test',
+               'customer.name'          => 'test',
+             }} ],
+  'with_objects' => [
+                      'invoice',
+                      'customer'
+                    ]
+}, 'object in more than one relationship';
+
+test {
+  'orddate:date::' => 'lt',
+  'orddate:date' => '20.3.2010',
+}, {
+    'query' => [
+                 'orddate' => { 'lt' => isa('DateTime') }
+               ]
+
+}, 'method dispatch and date constructor';
+
+test {
+  id => [
+    123, 125, 157
+  ]
+}, {
+  query => [ id => [ 123,125,157 ] ],
+}, 'arrays as value';
+
+test {
+  'sellprice:number' => [
+    '123,4', '2,34', '0,4',
+  ]
+}, {
+  query => [
+    sellprice => [ 123.4, 2.34, 0.4 ],
+  ],
+}, 'arrays with filter';
+