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