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