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