Enable offset based paging
[catagits/Catalyst-Controller-DBIC-API.git] / lib / Catalyst / Controller / DBIC / API / RequestArguments.pm
CommitLineData
d2739840 1package Catalyst::Controller::DBIC::API::RequestArguments;
2
3#ABSTRACT: Provides Request argument validation
4use MooseX::Role::Parameterized;
5use Catalyst::Controller::DBIC::API::Types(':all');
6use MooseX::Types::Moose(':all');
7use Scalar::Util('reftype');
8use Data::Dumper;
9use namespace::autoclean;
10
11use Catalyst::Controller::DBIC::API::JoinBuilder;
12
13
14=attribute_private search_validator
15
16A Catalyst::Controller::DBIC::API::Validator instance used solely to validate search parameters
17
18=cut
19
20with 'MooseX::Role::BuildInstanceOf' =>
21{
22 'target' => 'Catalyst::Controller::DBIC::API::Validator',
23 'prefix' => 'search_validator',
24};
25
26=attribute_private select_validator
27
28A Catalyst::Controller::DBIC::API::Validator instance used solely to validate select parameters
29
30=cut
31
32with 'MooseX::Role::BuildInstanceOf' =>
33{
34 'target' => 'Catalyst::Controller::DBIC::API::Validator',
35 'prefix' => 'select_validator',
36};
37
38=attribute_private prefetch_validator
39
40A Catalyst::Controller::DBIC::API::Validator instance used solely to validate prefetch parameters
41
42=cut
43
44with 'MooseX::Role::BuildInstanceOf' =>
45{
46 'target' => 'Catalyst::Controller::DBIC::API::Validator',
47 'prefix' => 'prefetch_validator',
48};
49
50parameter static => ( isa => Bool, default => 0 );
51
52role {
53
54 my $p = shift;
55
56 if($p->static)
57 {
58 requires qw/check_has_relation check_column_relation/;
59 }
60 else
61 {
62 requires qw/_controller check_has_relation check_column_relation/;
63 }
64
65=attribute_public count is: ro, isa: Int
66
67count is the number of rows to be returned during paging
68
69=cut
70
71 has 'count' =>
72 (
73 is => 'ro',
74 writer => '_set_count',
75 isa => Int,
76 predicate => 'has_count',
77 );
78
79=attribute_public page is: ro, isa: Int
80
81page is what page to return while paging
82
83=cut
84
85 has 'page' =>
86 (
87 is => 'ro',
88 writer => '_set_page',
89 isa => Int,
90 predicate => 'has_page',
91 );
92
33003023 93=attribute_public offset is ro, isa: Int
94
95offset specifies where to start the paged result (think SQL LIMIT)
96
97=cut
98
99 has 'offset' =>
100 (
101 is => 'ro',
102 writer => '_set_offset',
103 isa => Int,
104 predicate => 'has_offset',
105 );
106
d2739840 107=attribute_public ordered_by is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/OrderedBy>
108
109ordered_by is passed to ->search to determine sorting
110
111=cut
112
113 has 'ordered_by' =>
114 (
115 is => 'ro',
116 writer => '_set_ordered_by',
117 isa => OrderedBy,
118 predicate => 'has_ordered_by',
119 coerce => 1,
120 default => sub { $p->static ? [] : undef },
121 );
122
123=attribute_public groupd_by is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/GroupedBy>
124
125grouped_by is passed to ->search to determine aggregate results
126
127=cut
128
129 has 'grouped_by' =>
130 (
131 is => 'ro',
132 writer => '_set_grouped_by',
133 isa => GroupedBy,
134 predicate => 'has_grouped_by',
135 coerce => 1,
136 default => sub { $p->static ? [] : undef },
137 );
138
139=attribute_public prefetch is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/Prefetch>
140
141prefetch is passed to ->search to optimize the number of database fetches for joins
142
143=cut
144
145 has prefetch =>
146 (
147 is => 'ro',
148 writer => '_set_prefetch',
149 isa => Prefetch,
150 default => sub { $p->static ? [] : undef },
151 coerce => 1,
152 trigger => sub
153 {
154 my ($self, $new) = @_;
155 if($self->has_prefetch_allows and @{$self->prefetch_allows})
156 {
157 foreach my $pf (@$new)
158 {
159 if(HashRef->check($pf))
160 {
161 die qq|'${\Dumper($pf)}' is not an allowed prefetch in: ${\join("\n", @{$self->prefetch_validator->templates})}|
162 unless $self->prefetch_validator->validate($pf)->[0];
163 }
164 else
165 {
166 die qq|'$pf' is not an allowed prefetch in: ${\join("\n", @{$self->prefetch_validator->templates})}|
167 unless $self->prefetch_validator->validate({$pf => 1})->[0];
168 }
169 }
170 }
171 else
172 {
173 return if not defined($new);
174 die 'Prefetching is not allowed' if @$new;
175 }
176 },
177 );
178
179=attribute_public prefetch_allows is: ro, isa: ArrayRef[ArrayRef|Str|HashRef]
180
181prefetch_allows limits what relations may be prefetched when executing searches with joins. This is necessary to avoid denial of service attacks in form of queries which would return a large number of data and unwanted disclosure of data.
182
183Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator.
184
185 prefetch_allows => [ 'cds', { cds => tracks }, { cds => producers } ] # to be explicit
186 prefetch_allows => [ 'cds', { cds => '*' } ] # wildcard means the same thing
187
188=cut
189
190 has prefetch_allows =>
191 (
192 is => 'ro',
193 writer => '_set_prefetch_allows',
194 isa => ArrayRef[ArrayRef|Str|HashRef],
195 default => sub { [ ] },
196 predicate => 'has_prefetch_allows',
197 trigger => sub
198 {
199 my ($self, $new) = @_;
200
201 sub check_rel {
202 my ($self, $rel, $static) = @_;
203 if(ArrayRef->check($rel))
204 {
205 foreach my $rel_sub (@$rel)
206 {
207 $self->check_rel($rel_sub, $static);
208 }
209 }
210 elsif(HashRef->check($rel))
211 {
212 while(my($k,$v) = each %$rel)
213 {
214 $self->check_has_relation($k, $v, undef, $static);
215 }
216 $self->prefetch_validator->load($rel);
217 }
218 else
219 {
220 $self->check_has_relation($rel, undef, undef, $static);
221 $self->prefetch_validator->load($rel);
222 }
223 }
224
225 foreach my $rel (@$new)
226 {
227 $self->check_rel($rel, $p->static);
228 }
229 },
230 );
231
232=attribute_public search_exposes is: ro, isa: ArrayRef[Str|HashRef]
233
234search_exposes limits what can actually be searched. If a certain column isn't indexed or perhaps a BLOB, you can explicitly say which columns can be search and exclude that one.
235
236Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator.
237
238=cut
239
240 has 'search_exposes' =>
241 (
242 is => 'ro',
243 writer => '_set_search_exposes',
244 isa => ArrayRef[Str|HashRef],
245 predicate => 'has_search_exposes',
246 default => sub { [ ] },
247 trigger => sub
248 {
249 my ($self, $new) = @_;
250 $self->search_validator->load($_) for @$new;
251 },
252 );
253
d666a194 254=attribute_public search is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/SearchParameters>
d2739840 255
256search contains the raw search parameters. Upon setting, a trigger will fire to format them, set search_parameters, and set search_attributes.
257
258Please see L</generate_parameters_attributes> for details on how the format works.
259
260=cut
261
262 has 'search' =>
263 (
264 is => 'ro',
265 writer => '_set_search',
266 isa => SearchParameters,
267 predicate => 'has_search',
268 coerce => 1,
269 trigger => sub
270 {
271 my ($self, $new) = @_;
272
273 if($self->has_search_exposes and @{$self->search_exposes})
274 {
275 foreach my $foo (@$new)
276 {
277 while( my ($k, $v) = each %$foo)
278 {
279 local $Data::Dumper::Terse = 1;
280 die qq|{ $k => ${\Dumper($v)} } is not an allowed search term in: ${\join("\n", @{$self->search_validator->templates})}|
281 unless $self->search_validator->validate({$k=>$v})->[0];
282 }
283 }
284 }
285 else
286 {
287 foreach my $foo (@$new)
288 {
289 while( my ($k, $v) = each %$foo)
290 {
291 $self->check_column_relation({$k => $v});
292 }
293 }
294 }
295
296 my ($search_parameters, $search_attributes) = $self->generate_parameters_attributes($new);
297 $self->_set_search_parameters($search_parameters);
298 $self->_set_search_attributes($search_attributes);
299
300 },
301 );
302
d666a194 303=attribute_public search_parameters is:ro, isa: L<Catalyst::Controller::DBIC::API::Types/SearchParameters>
d2739840 304
305search_parameters stores the formatted search parameters that will be passed to ->search
306
307=cut
308
309 has search_parameters =>
310 (
311 is => 'ro',
312 isa => SearchParameters,
313 writer => '_set_search_parameters',
314 predicate => 'has_search_parameters',
315 coerce => 1,
316 default => sub { [{}] },
317 );
318
319=attribute_public search_attributes is:ro, isa: HashRef
320
321search_attributes stores the formatted search attributes that will be passed to ->search
322
323=cut
324
325 has search_attributes =>
326 (
327 is => 'ro',
328 isa => HashRef,
329 writer => '_set_search_attributes',
330 predicate => 'has_search_attributes',
331 lazy_build => 1,
332 );
333
334=attribute_public search_total_entries is: ro, isa: Int
335
336search_total_entries stores the total number of entries in a paged search result
337
338=cut
339
340 has search_total_entries =>
341 (
342 is => 'ro',
343 isa => Int,
344 writer => '_set_search_total_entries',
345 predicate => 'has_search_total_entries',
346 );
347
348=attribute_public select_exposes is: ro, isa: ArrayRef[Str|HashRef]
349
350select_exposes limits what can actually be selected. Use this to whitelist database functions (such as COUNT).
351
352Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator.
353
354=cut
355
356 has 'select_exposes' =>
357 (
358 is => 'ro',
359 writer => '_set_select_exposes',
360 isa => ArrayRef[Str|HashRef],
361 predicate => 'has_select_exposes',
362 default => sub { [ ] },
363 trigger => sub
364 {
365 my ($self, $new) = @_;
366 $self->select_validator->load($_) for @$new;
367 },
368 );
369
370=attribute_public select is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/SelectColumns>
371
372select is the search attribute that allows you to both limit what is returned in the result set, and also make use of database functions like COUNT.
373
374Please see L<DBIx::Class::ResultSet/select> for more details.
375
376=cut
377
378 has select =>
379 (
380 is => 'ro',
381 writer => '_set_select',
382 isa => SelectColumns,
383 predicate => 'has_select',
384 default => sub { $p->static ? [] : undef },
385 coerce => 1,
386 trigger => sub
387 {
388 my ($self, $new) = @_;
389 if($self->has_select_exposes)
390 {
391 foreach my $val (@$new)
392 {
393 die "'$val' is not allowed in a select"
394 unless $self->select_validator->validate($val);
395 }
396 }
397 else
398 {
399 $self->check_column_relation($_, $p->static) for @$new;
400 }
401 },
402 );
403
404=attribute_public as is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/AsAliases>
405
406as is the search attribute compliment to L</select> that allows you to label columns for object inflaction and actually reference database functions like COUNT.
407
408Please see L<DBIx::Class::ResultSet/as> for more details.
409
410=cut
411
412 has as =>
413 (
414 is => 'ro',
415 writer => '_set_as',
416 isa => AsAliases,
417 default => sub { $p->static ? [] : undef },
418 trigger => sub
419 {
420 my ($self, $new) = @_;
421 if($self->has_select)
422 {
423 die "'as' argument count (${\scalar(@$new)}) must match 'select' argument count (${\scalar(@{$self->select || []})})"
424 unless @$new == @{$self->select || []};
425 }
426 elsif(defined $new)
427 {
428 die "'as' is only valid if 'select is also provided'";
429 }
430 }
431 );
432
433=attribute_public joins is: ro, isa L<Catalyst::Controller::DBIC::API::Types/JoinBuilder>
434
435joins holds the top level JoinBuilder object used to keep track of joins automagically while formatting complex search parameters.
436
437Provides a single handle which returns the 'join' attribute for search_attributes:
438
439 build_joins => 'joins'
440
441=cut
442
443 has joins =>
444 (
445 is => 'ro',
446 isa => JoinBuilder,
447 lazy_build => 1,
448 handles =>
449 {
450 build_joins => 'joins',
451 }
452 );
453
454=attribute_public request_data is: ro, isa: HashRef
455
456request_data holds the raw (but deserialized) data for ths request
457
458=cut
459
460 has 'request_data' =>
461 (
462 is => 'ro',
463 isa => HashRef,
464 writer => '_set_request_data',
465 trigger => sub
466 {
467 my ($self, $new) = @_;
468 my $controller = $self->_controller;
469 $self->_set_prefetch($new->{$controller->prefetch_arg}) if exists $new->{$controller->prefetch_arg};
470 $self->_set_select($new->{$controller->select_arg}) if exists $new->{$controller->select_arg};
471 $self->_set_as($new->{$controller->as_arg}) if exists $new->{$controller->as_arg};
472 $self->_set_grouped_by($new->{$controller->grouped_by_arg}) if exists $new->{$controller->grouped_by_arg};
473 $self->_set_ordered_by($new->{$controller->ordered_by_arg}) if exists $new->{$controller->ordered_by_arg};
474 $self->_set_search($new->{$controller->search_arg}) if exists $new->{$controller->search_arg};
475 $self->_set_count($new->{$controller->count_arg}) if exists $new->{$controller->count_arg};
476 $self->_set_page($new->{$controller->page_arg}) if exists $new->{$controller->page_arg};
33003023 477 $self->_set_offset($new->{$controller->offset_arg}) if exists $new->{$controller->offset_arg};
d2739840 478 }
479 );
480
481 method _build_joins => sub { return Catalyst::Controller::DBIC::API::JoinBuilder->new(name => 'TOP') };
482
483=method_protected format_search_parameters
484
485format_search_parameters iterates through the provided params ArrayRef, calling generate_column_parameters on each one
486
487=cut
488
489 method format_search_parameters => sub
490 {
491 $DB::single = 1;
492 my ($self, $params) = @_;
493
494 my $genparams = [];
495
496 foreach my $param (@$params)
497 {
498 push(@$genparams, $self->generate_column_parameters($self->stored_result_source, $param, $self->joins));
499 }
500
501 return $genparams;
502 };
503
504=method_protected generate_column_parameters
505
506generate_column_parameters recursively generates properly aliased parameters for search, building a new JoinBuilder each layer of recursion
507
508=cut
509
510 method generate_column_parameters => sub
511 {
512 $DB::single = 1;
513 my ($self, $source, $param, $join, $base) = @_;
514 $base ||= 'me';
515 my $search_params;
516
517 # build up condition
518 foreach my $column (keys %$param)
519 {
520 if($source->has_relationship($column))
521 {
522 unless (ref($param->{$column}) && reftype($param->{$column}) eq 'HASH')
523 {
524 $search_params->{join('.', $base, $column)} = $param->{$column};
525 next;
526 }
527
528 %$search_params =
529 %{
530 $self->generate_column_parameters
531 (
532 $source->related_source($column),
533 $param->{$column},
534 Catalyst::Controller::DBIC::API::JoinBuilder->new(parent => $join, name => $column),
535 $column
536 )
537 };
538 }
539 else
540 {
541 $search_params->{join('.', $base, $column)} = $param->{$column};
542 }
543 }
544
545 return $search_params;
546 };
547
548=method_protected generate_parameters_attributes
549
550generate_parameters_attributes takes the raw search arguments and formats the parameters by calling format_search_parameters. Then builds the related attributes, preferring request-provided arguments for things like grouped_by over statically configured options. Finally tacking on the appropriate joins. Returns both formatted search parameters and the search attributes.
551
552=cut
553
554 method generate_parameters_attributes => sub
555 {
556 $DB::single = 1;
557 my ($self, $args) = @_;
558
559 return ( $self->format_search_parameters($args), $self->search_attributes );
560 };
561
562=method_protected _build_search_attributes
563
564This builder method generates the search attributes
565
566=cut
567
568 method _build_search_attributes => sub
569 {
570 my ($self, $args) = @_;
571 my $static = $self->_controller;
572 my $search_attributes =
573 {
574 group_by => $self->grouped_by || ((scalar(@{$static->grouped_by})) ? $static->grouped_by : undef),
575 order_by => $self->ordered_by || ((scalar(@{$static->ordered_by})) ? $static->ordered_by : undef),
576 select => $self->select || ((scalar(@{$static->select})) ? $static->select : undef),
577 as => $self->as || ((scalar(@{$static->as})) ? $static->as : undef),
578 prefetch => $self->prefetch || $static->prefetch || undef,
579 rows => $self->count || $static->count,
33003023 580 offset => $self->offset,
d2739840 581 join => $self->build_joins,
582 };
583
33003023 584 if($self->has_page)
585 {
586 $search_attributes->{page} = $self->page;
587 }
588 elsif(!$self->has_page && defined($search_attributes->{offset}) && defined($search_attributes->{rows}))
589 {
590 $search_attributes->{page} = $search_attributes->{offset} / $search_attributes->{rows} + 1;
591 }
592
593
d2739840 594 $search_attributes =
595 {
596 map { @$_ }
597 grep
598 {
599 defined($_->[1])
600 ?
601 (ref($_->[1]) && reftype($_->[1]) eq 'HASH' && keys %{$_->[1]})
602 || (ref($_->[1]) && reftype($_->[1]) eq 'ARRAY' && @{$_->[1]})
603 || length($_->[1])
604 :
605 undef
606 }
607 map { [$_, $search_attributes->{$_}] }
608 keys %$search_attributes
609 };
610
611
612 if ($search_attributes->{page} && !$search_attributes->{rows}) {
613 die 'list_page can only be used with list_count';
614 }
615
616 if ($search_attributes->{select}) {
617 # make sure all columns have an alias to avoid ambiguous issues
618 # but allow non strings (eg. hashrefs for db procs like 'count')
619 # to pass through unmolested
620 $search_attributes->{select} = [map { (Str->check($_) && $_ !~ m/\./) ? "me.$_" : $_ } (ref $search_attributes->{select}) ? @{$search_attributes->{select}} : $search_attributes->{select}];
621 }
622
623 return $search_attributes;
624
625 };
626
627};
628=head1 DESCRIPTION
629
630RequestArguments embodies those arguments that are provided as part of a request or effect validation on request arguments. This Role can be consumed in one of two ways. As this is a parameterized Role, it accepts a single argument at composition time: 'static'. This indicates that those parameters should be stored statically and used as a fallback when the current request doesn't provide them.
631
632=cut
633
634
6351;