adba96de15366c6a6e46571d6743b23c3bd4bca5
[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
14 =attribute_private search_validator
15
16 A Catalyst::Controller::DBIC::API::Validator instance used solely to validate search parameters
17
18 =cut
19
20 with 'MooseX::Role::BuildInstanceOf' =>
21 {
22     'target' => 'Catalyst::Controller::DBIC::API::Validator',
23     'prefix' => 'search_validator',
24 };
25
26 =attribute_private select_validator
27
28 A Catalyst::Controller::DBIC::API::Validator instance used solely to validate select parameters
29
30 =cut
31
32 with 'MooseX::Role::BuildInstanceOf' =>
33 {
34     'target' => 'Catalyst::Controller::DBIC::API::Validator',
35     'prefix' => 'select_validator',
36 };
37
38 =attribute_private prefetch_validator
39
40 A Catalyst::Controller::DBIC::API::Validator instance used solely to validate prefetch parameters
41
42 =cut
43
44 with 'MooseX::Role::BuildInstanceOf' =>
45 {
46     'target' => 'Catalyst::Controller::DBIC::API::Validator',
47     'prefix' => 'prefetch_validator',
48 };
49
50 parameter static => ( isa => Bool, default => 0 );
51
52 role {
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
67 count 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
81 page 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
93 =attribute_public offset is ro, isa: Int
94
95 offset 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
107 =attribute_public ordered_by is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/OrderedBy>
108
109 ordered_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
125 grouped_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
141 prefetch 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
181 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.
182
183 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.
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
234 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.
235
236 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.
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
254 =attribute_public search is: ro, isa: L<Catalyst::Controller::DBIC::API::Types/SearchParameters>
255
256 search contains the raw search parameters. Upon setting, a trigger will fire to format them, set search_parameters, and set search_attributes.
257
258 Please 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
303 =attribute_public search_parameters is:ro, isa: L<Catalyst::Controller::DBIC::API::Types/SearchParameters>
304
305 search_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
321 search_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
336 search_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
350 select_exposes limits what can actually be selected. Use this to whitelist database functions (such as COUNT).
351
352 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.
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
372 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.
373
374 Please 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
406 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.
407
408 Please 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
435 joins holds the top level JoinBuilder object used to keep track of joins automagically while formatting complex search parameters.
436
437 Provides 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
456 request_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         predicate => 'has_request_data',
466         trigger => sub
467         {
468             my ($self, $new) = @_;
469             my $controller = $self->_controller;
470             return unless defined($new) && keys %$new;
471             $self->_set_prefetch($new->{$controller->prefetch_arg}) if exists $new->{$controller->prefetch_arg};
472             $self->_set_select($new->{$controller->select_arg}) if exists $new->{$controller->select_arg};
473             $self->_set_as($new->{$controller->as_arg}) if exists $new->{$controller->as_arg};
474             $self->_set_grouped_by($new->{$controller->grouped_by_arg}) if exists $new->{$controller->grouped_by_arg};
475             $self->_set_ordered_by($new->{$controller->ordered_by_arg}) if exists $new->{$controller->ordered_by_arg};
476             $self->_set_count($new->{$controller->count_arg}) if exists $new->{$controller->count_arg};
477             $self->_set_page($new->{$controller->page_arg}) if exists $new->{$controller->page_arg};
478             $self->_set_offset($new->{$controller->offset_arg}) if exists $new->{$controller->offset_arg};
479             $self->_set_search($new->{$controller->search_arg}) if exists $new->{$controller->search_arg};
480         }
481     );
482
483     method _build_joins => sub { return Catalyst::Controller::DBIC::API::JoinBuilder->new(name => 'TOP') };
484
485 =method_protected format_search_parameters
486
487 format_search_parameters iterates through the provided params ArrayRef, calling generate_column_parameters on each one
488
489 =cut
490
491     method format_search_parameters => sub
492     {
493         my ($self, $params) = @_;
494
495         my $genparams = [];
496
497         foreach my $param (@$params)
498         {
499             push(@$genparams, $self->generate_column_parameters($self->stored_result_source, $param, $self->joins));
500         }
501
502         return $genparams;
503     };
504
505 =method_protected generate_column_parameters
506
507 generate_column_parameters recursively generates properly aliased parameters for search, building a new JoinBuilder each layer of recursion
508
509 =cut
510
511     method generate_column_parameters => sub
512     {
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
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;