107dc80ffb35bc3abd3da1e2ef3018688555e297
[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         # build up condition
447         foreach my $column ( keys %$param ) {
448             if ( $source->has_relationship($column) ) {
449
450                 # check if the value isn't a hashref
451                 unless ( ref( $param->{$column} )
452                     && reftype( $param->{$column} ) eq 'HASH' )
453                 {
454                     $search_params->{ join( '.', $base, $column ) } =
455                         $param->{$column};
456                     next;
457                 }
458
459                 $search_params = {
460                     %$search_params,
461                     %{  $self->generate_column_parameters(
462                             $source->related_source($column),
463                             $param->{$column},
464                             Catalyst::Controller::DBIC::API::JoinBuilder->new(
465                                 parent => $join,
466                                 name   => $column
467                             ),
468                             $column
469                         )
470                     }
471                 };
472             }
473             elsif ( $source->has_column($column) ) {
474                 $search_params->{ join( '.', $base, $column ) } =
475                     $param->{$column};
476             }
477
478             # might be a sql function instead of a column name
479             # e.g. {colname => {like => '%foo%'}}
480             else {
481                 # but only if it's not a hashref
482                 unless ( ref( $param->{$column} )
483                     && reftype( $param->{$column} ) eq 'HASH' )
484                 {
485                     $search_params->{ join( '.', $base, $column ) } =
486                         $param->{$column};
487                 }
488                 else {
489                     die "$column is neither a relationship nor a column\n";
490                 }
491             }
492         }
493
494         return $search_params;
495     };
496
497 =method_protected generate_parameters_attributes
498
499 Takes the raw search arguments and formats them by calling
500 format_search_parameters. Then builds the related attributes, preferring
501 request-provided arguments for things like grouped_by over statically configured
502 options. Finally tacking on the appropriate joins.
503
504 Returns a list of both formatted search parameters and attributes.
505
506 =cut
507
508     method generate_parameters_attributes => sub {
509         my ( $self, $args ) = @_;
510
511         return ( $self->format_search_parameters($args),
512             $self->search_attributes );
513     };
514
515     method _build_search_attributes => sub {
516         my ( $self, $args ) = @_;
517         my $static            = $self->_controller;
518         my $search_attributes = {
519             group_by => $self->grouped_by
520                 || (
521                 ( scalar( @{ $static->grouped_by } ) ) ? $static->grouped_by
522                 : undef
523                 ),
524             order_by => $self->ordered_by
525                 || (
526                 ( scalar( @{ $static->ordered_by } ) ) ? $static->ordered_by
527                 : undef
528                 ),
529             select => $self->select
530                 || (
531                 ( scalar( @{ $static->select } ) ) ? $static->select
532                 : undef
533                 ),
534             as => $self->as
535                 || ( ( scalar( @{ $static->as } ) ) ? $static->as : undef ),
536             prefetch => $self->prefetch || $static->prefetch || undef,
537             rows     => $self->count    || $static->count,
538             page     => $static->page,
539             offset   => $self->offset,
540             join     => $self->build_joins,
541         };
542
543         if ( $self->has_page ) {
544             $search_attributes->{page} = $self->page;
545         }
546         elsif (!$self->has_page
547             && defined( $search_attributes->{offset} )
548             && defined( $search_attributes->{rows} ) )
549         {
550             $search_attributes->{page} =
551                 $search_attributes->{offset} / $search_attributes->{rows} + 1;
552             delete $search_attributes->{offset};
553         }
554
555         $search_attributes = {
556             map {@$_}
557                 grep {
558                 defined( $_->[1] )
559                     ? ( ref( $_->[1] )
560                         && reftype( $_->[1] ) eq 'HASH'
561                         && keys %{ $_->[1] } )
562                     || ( ref( $_->[1] )
563                     && reftype( $_->[1] ) eq 'ARRAY'
564                     && @{ $_->[1] } )
565                     || length( $_->[1] )
566                     : undef
567                 }
568                 map { [ $_, $search_attributes->{$_} ] }
569                 keys %$search_attributes
570         };
571
572         if ( $search_attributes->{page} && !$search_attributes->{rows} ) {
573             die 'list_page can only be used with list_count';
574         }
575
576         if ( $search_attributes->{select} ) {
577
578             # make sure all columns have an alias to avoid ambiguous issues
579             # but allow non strings (eg. hashrefs for db procs like 'count')
580             # to pass through unmolested
581             $search_attributes->{select} = [
582                 map { ( Str->check($_) && $_ !~ m/\./ ) ? "me.$_" : $_ }
583                     ( ref $search_attributes->{select} )
584                 ? @{ $search_attributes->{select} }
585                 : $search_attributes->{select}
586             ];
587         }
588
589         return $search_attributes;
590
591     };
592
593 };
594
595 =head1 DESCRIPTION
596
597 RequestArguments embodies those arguments that are provided as part of a request
598 or effect validation on request arguments. This Role can be consumed in one of
599 two ways. As this is a parameterized Role, it accepts a single argument at
600 composition time: 'static'. This indicates that those parameters should be
601 stored statically and used as a fallback when the current request doesn't
602 provide them.
603
604 =cut
605
606 1;