Projekt

Allgemein

Profil

« Zurück | Weiter » 

Revision e340c957

Von Sven Schöling vor etwa 13 Jahren hinzugefügt

  • ID e340c9571ec7d544f038b4226ab98f6a8038bbfa
  • Vorgänger 83bfd1e6
  • Nachfolger 945cd936

ParseFilter Mixin.

Erlaubt es semikomplexe Filter zu bauen und direkt an get_all weiterzureichen. Kompatibel mit dem Sorter Mixin.

Unterschiede anzeigen:

SL/Controller/Helper/ParseFilter.pm
1
package SL::Controller::Helper::ParseFilter;
2

  
3
use strict;
4

  
5
use Exporter qw(import);
6
our @EXPORT = qw(parse_filter);
7

  
8
use DateTime;
9
use SL::Helper::DateTime;
10
use List::MoreUtils qw(uniq);
11
use Data::Dumper;
12

  
13
my %filters = (
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] . '%' },
20
);
21

  
22
my %methods = (
23
  lt     => sub { +{ lt    => $_[0] } },
24
  gt     => sub { +{ gt    => $_[0] } },
25
  ilike  => sub { +{ ilike => $_[0] } },
26
  like   => sub { +{ like  => $_[0] } },
27
  enable => sub { ;;;; },
28
);
29

  
30
sub parse_filter {
31
  my ($filter, %params) = @_;
32

  
33
  my $hint_objects = $params{with_objects} || [];
34

  
35
  my ($flattened, $objects) = _pre_parse($filter, $hint_objects, '', %params);
36

  
37
  my $query = _parse_filter($flattened, %params);
38

  
39
  _launder_keys($filter) unless $params{no_launder};
40

  
41
  return
42
    query => $query,
43
    @$objects ? ( with_objects => [ uniq @$objects ]) : ();
44
}
45

  
46
sub _launder_keys {
47
  my ($filter) = @_;
48
  return unless ref $filter eq 'HASH';
49
  my @keys = keys %$filter;
50
  for my $key (@keys) {
51
    my $orig = $key;
52
    $key =~ s/:/_/g;
53
    $filter->{$key} = $filter->{$orig};
54
    _launder_keys($filter->{$key});
55
  };
56

  
57
  return $filter;
58
}
59

  
60
sub _pre_parse {
61
  my ($filter, $with_objects, $prefix, %params) = @_;
62

  
63
  return () unless 'HASH'  eq ref $filter;
64
  $with_objects = [];
65

  
66
  my @result;
67

  
68
  while (my ($key, $value) = each %$filter) {
69
    next if !defined $value || $value eq ''; # 0 is fine
70
    if ('HASH' eq ref $value) {
71
      my ($query, $more_objects) = _pre_parse($value, $with_objects, _prefix($prefix, $key));
72
      push @result,        @$query if $query;
73
      push @$with_objects, $key, ($more_objects ? @$more_objects : ());
74
    } else {
75
      push @result, _prefix($prefix, $key) => $value;
76
    }
77
  }
78

  
79
  return \@result, $with_objects;
80
}
81

  
82
sub _parse_filter {
83
  my ($flattened, %params) = @_;
84

  
85
  return () unless 'ARRAY' eq ref $flattened;
86

  
87
  my %sorted = ( @$flattened );
88

  
89
  my @keys = sort { length($b) <=> length($a) } keys %sorted;
90
  for my $key (@keys) {
91
    next unless $key =~ /^(.*\b)::$/;
92
    $sorted{$1 . '::' . delete $sorted{$key} } = delete $sorted{$1} if $sorted{$1} && $sorted{$key};
93
  }
94

  
95
  my %result;
96
  while (my ($key, $value) = each %sorted) {
97
    ($key, $value) = _apply_all($key, $value, qr/\b:(\w+)/,  { %filters, %{ $params{filters} || {} } });
98
    ($key, $value) = _apply_all($key, $value, qr/\b::(\w+)/, { %methods, %{ $params{methods} || {} } });
99
    $result{$key} = $value;
100
  }
101
  return [ %result ];
102
}
103

  
104
sub _prefix {
105
  join '.', grep $_, @_;
106
}
107

  
108
sub _apply {
109
  my ($value, $name, $filters) = @_;
110
  return $value unless $name && $filters->{$name};
111
  return [ map { _apply($_, $name, $filters) } @$value ] if 'ARRAY' eq ref $value;
112
  return $filters->{$name}->($value);
113
}
114

  
115
sub _apply_all {
116
  my ($key, $value, $re, $subs) = @_;
117

  
118
  while ($key =~ s/$re//) {
119
    $value = _apply($value, $1, $subs);
120
  }
121

  
122
  return $key, $value;
123
}
124

  
125
1;
126

  
127
__END__
128

  
129
=head1 NAME
130

  
131
SL::Controller::Helper::ParseFilter - Convert a form filter spec into a RDBO get_all filter
132

  
133
=head1 SYNOPSIS
134

  
135
  use SL::Controller::Helper::ParseFilter;
136
  SL::DB::Object->get_all(parse_filter($::form->{filter}));
137

  
138
  # or more complex
139
  SL::DB::Object->get_all(parse_filter($::form->{filter},
140
    with_objects => [ qw(part customer) ]));
141

  
142
=head1 DESCRIPTION
143

  
144
A search filter will usually search for things in relations of the actual
145
search target. A search for sales orders may be filtered by the name of the
146
customer. L<Rose::DB::Object> alloes you to search for these by filtering them prefixed with their table:
147

  
148
  query => [
149
    customer.name => 'John Doe',
150
    department.description => [ ilike => '%Sales%' ],
151
    orddate => [ lt => DateTime->today ],
152
  ]
153

  
154
Unfortunately, if you specify them in you form as these strings, the form
155
parser will convert them into nested structures like this:
156

  
157
  $::form = bless {
158
    filter => {
159
      customer => {
160
        name => 'John Doe',
161
      },
162
    },
163
  }, Form;
164

  
165
And the substring match requires you to recognize the ilike, and modify the value.
166

  
167
C<parse_filter> tries to ease this by recognizing these structures and
168
providing suffixes for common search patterns.
169

  
170
=head1 FUNCTIONS
171

  
172
=over 4
173

  
174
=item parse_amount \%FILTER, [ %PARAMS ]
175

  
176
First argument is the filter from form. It is highly recommended that you put
177
all filter attributes into a named container as to not confuse them with the
178
rest of your form.
179

  
180
Nested structures will be parsed and interpreted as foreign references. For
181
example if you search for L<Order>s, this input will search for those with a
182
specific L<Salesman>:
183

  
184
  [% L.select_tag('filter.salesman.id', ...
185

  
186
Additionally you can add modifier to the name to set a certain method:
187

  
188
  [% L.input_tag('filter.department.description:substr::ilike' ...
189

  
190
This will add the "% .. %" wildcards for substr matching in sql, and add an C<[
191
ilike => $value ]> block around it to match case insensitively.
192

  
193
As a rule all value filters require a single colon and must be placed before
194
match method suffixes, which are appended with 2 colons. See below for a full
195
list of modifiers.
196

  
197
The reason for the method being last is that it is possible to specify the
198
method in another input. Suppose you want a date input and a separate
199
before/after/equal select, you can use the following:
200

  
201
  [% L.date_tag('filter.appointed_date:date', ... ) %]
202

  
203
and later
204

  
205
  [% L.select_tag('filter.appointed_date::', ... ) %]
206

  
207
The special empty method will be used to set the method for the previous
208
method-less input.
209

  
210
=item Laundering filter
211

  
212
Unfortunately Template cannot parse the postfixes if you want to rerender the
213
filter. For this reason all colons filter keys are by default laundered into
214
underscores. If you don't want this to happen pass C<no_launder => 1> as a
215
parameter. A full select_tag then loks like this:
216

  
217
  [% L.input_tag('filter.price:number::lt', filter.price_number__lt) %]
218

  
219

  
220
=back
221

  
222
=head1 FILTERS (leading with :)
223

  
224
The following filters are built in, and can be used.
225

  
226
=over 4
227

  
228
=item date
229

  
230
Parses the input string with DateTime->from_lxoffice
231

  
232
=item number
233

  
234
Pasres the input string with Form->parse_amount
235

  
236
=item percent
237

  
238
Parses the input string with Form->parse_amount / 100
239

  
240
=item head
241

  
242
Adds "%" at the end of the string.
243

  
244
=item tail
245

  
246
Adds "%" at the end of the string.
247

  
248
=item substr
249

  
250
Adds "% .. %" around the search string.
251

  
252
=back
253

  
254
=head2 METHODS (leading with ::)
255

  
256
=over 4
257

  
258
=item lt
259

  
260
=item gt
261

  
262
=item ilike
263

  
264
=item like
265

  
266
All these are recognized like the L<Rose::DB::Object> methods.
267

  
268
=back
269

  
270
=head1 BUGS AND CAVEATS
271

  
272
This will not properly handle multiple versions of the same object in different
273
context.
274

  
275
Suppose you want all L<SL::DB::Order>s which have either themselves a certain
276
customer, or are linked to a L<SL::DB::Invoice> with this customer, the
277
following will not work as you expect:
278

  
279
  # does not work!
280
  L.input_tag('customer.name:substr::ilike', ...
281
  L.input_tag('invoice.customer.name:substr::ilike', ...
282

  
283
This will sarch for orders whoe invoice has the _same_ customer, which matches
284
both inputs. This is because tables are aliased by their name and not by their
285
position in with_objects.
286

  
287
=head1 TODO
288

  
289
=over 4
290

  
291
=item *
292

  
293
Additional filters shoud be pluggable.
294

  
295
=back
296

  
297
=head1 AUTHOR
298

  
299
Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
300

  
301
=cut
t/controllers/helpers/parse_filter.t
1
use lib 't';
2

  
3
use Test::More tests => 13;
4
use Test::Deep;
5
use Data::Dumper;
6

  
7
use_ok 'Support::TestSetup';
8
use_ok 'SL::Controller::Helper::ParseFilter';
9

  
10
Support::TestSetup::login();
11
my ($filter, $expected);
12

  
13
sub test ($$$) {
14
  my $got = { parse_filter($_[0]) };
15
  cmp_deeply(
16
    $got,
17
    $_[1],
18
    $_[2]
19
  ) or do {
20
    print STDERR "expected => ", Dumper($_[1]), "\ngot: => ", Dumper($got), $/;
21
  }
22
}
23

  
24
test { }, {
25
  query => []
26
}, 'minimal test';
27

  
28
test {
29
  name => 'Test',
30
  whut => 'moof',
31
}, {
32
  query => [ %{{
33
    name => 'Test',
34
    whut => 'moof'
35
  }} ],
36
}, 'basic test';
37

  
38
test {
39
  customer => {
40
    name => 'rainer',
41
  }
42
}, {
43
  query => [ 'customer.name' => 'rainer' ],
44
  with_objects => [ 'customer' ],
45
}, 'joining customers';
46

  
47
test {
48
  customer => {
49
    chart => {
50
      accno => 'test',
51
    }
52
  }
53
}, {
54
  query => [ 'customer.chart.accno' => 'test' ],
55
  with_objects => [ 'customer', 'chart' ],
56
}, 'nested joins';
57

  
58
test {
59
  'customer:substr' => 'Meyer'
60
}, {
61
  query => [ customer => '%Meyer%' ]
62
}, 'simple filter substr';
63

  
64
test {
65
  'customer::ilike' => 'Meyer'
66
}, {
67
  query => [ customer => { ilike => 'Meyer' } ]
68
}, 'simple method ilike';
69

  
70
test {
71
  customer => {
72
    chart => {
73
      'accno:tail::like' => '1200'
74
    }
75
  },
76
},
77
{
78
  query => [ 'customer.chart.accno' => { like => '%1200' } ],
79
  with_objects => ['customer', 'chart' ],
80
}, 'all together';
81

  
82

  
83
test {
84
  customer => {
85
    name => 'test',
86
  },
87
  invoice => {
88
    customer => {
89
      name => 'test',
90
    },
91
  },
92
}, {
93
  'query' => [ %{{
94
               'invoice.customer.name'  => 'test',
95
               'customer.name'          => 'test',
96
             }} ],
97
  'with_objects' => [
98
                      'invoice',
99
                      'customer'
100
                    ]
101
}, 'object in more than one relationship';
102

  
103
test {
104
  'orddate:date::' => 'lt',
105
  'orddate:date' => '20.3.2010',
106
}, {
107
    'query' => [
108
                 'orddate' => { 'lt' => isa('DateTime') }
109
               ]
110

  
111
}, 'method dispatch and date constructor';
112

  
113
test {
114
  id => [
115
    123, 125, 157
116
  ]
117
}, {
118
  query => [ id => [ 123,125,157 ] ],
119
}, 'arrays as value';
120

  
121
test {
122
  'sellprice:number' => [
123
    '123,4', '2,34', '0,4',
124
  ]
125
}, {
126
  query => [
127
    sellprice => [ 123.4, 2.34, 0.4 ],
128
  ],
129
}, 'arrays with filter';
130

  

Auch abrufbar als: Unified diff