Use JSON::MaybeXS for improved performance
[catagits/Catalyst-Controller-DBIC-API.git] / lib / Catalyst / Controller / DBIC / API.pm
CommitLineData
d2739840 1package Catalyst::Controller::DBIC::API;
2
3#ABSTRACT: Provides a DBIx::Class web service automagically
4use Moose;
26e9dcd6 5BEGIN { extends 'Catalyst::Controller'; }
d2739840 6
7use CGI::Expand ();
8use DBIx::Class::ResultClass::HashRefInflator;
a5949bfd 9use JSON::MaybeXS ();
d2739840 10use Test::Deep::NoTest('eq_deeply');
11use MooseX::Types::Moose(':all');
12use Moose::Util;
8ea592cb 13use Scalar::Util( 'blessed', 'reftype' );
d2739840 14use Try::Tiny;
15use Catalyst::Controller::DBIC::API::Request;
16use namespace::autoclean;
17
0b0bf911 18has '_json' => (
8ea592cb 19 is => 'ro',
a5949bfd 20 isa => JSON::MaybeXS::JSON(),
0b0bf911 21 lazy_build => 1,
22);
23
24sub _build__json {
8ea592cb 25
0b0bf911 26 # no ->utf8 here because the request params get decoded by Catalyst
a5949bfd 27 return JSON::MaybeXS->new;
0b0bf911 28}
29
d6993542 30with 'Catalyst::Controller::DBIC::API::StoredResultSource',
8ea592cb 31 'Catalyst::Controller::DBIC::API::StaticArguments';
4e5983f2 32
33with 'Catalyst::Controller::DBIC::API::RequestArguments' => { static => 1 };
d2739840 34
35__PACKAGE__->config();
36
37=head1 SYNOPSIS
38
39 package MyApp::Controller::API::RPC::Artist;
40 use Moose;
41 BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' }
42
43 __PACKAGE__->config
8ea592cb 44 ( # define parent chain action and PathPart
45 action => {
46 setup => {
47 Chained => '/api/rpc/rpc_base',
48 PathPart => 'artist',
49 }
50 },
d6993542 51 class => 'MyAppDB::Artist',
67431358 52 resultset_class => 'MyAppDB::ResultSet::Artist',
d6993542 53 create_requires => ['name', 'age'],
54 create_allows => ['nickname'],
55 update_allows => ['name', 'age', 'nickname'],
56 update_allows => ['name', 'age', 'nickname'],
8ea592cb 57 select => ['name', 'age'],
d6993542 58 prefetch => ['cds'],
59 prefetch_allows => [
d2739840 60 'cds',
d2739840 61 { cds => 'tracks' },
8ea592cb 62 { cds => ['tracks'] },
d2739840 63 ],
8ea592cb 64 ordered_by => ['age'],
65 search_exposes => ['age', 'nickname', { cds => ['title', 'year'] }],
7986e6b7 66 data_root => 'list',
67 item_root => 'data',
d6993542 68 use_json_boolean => 1,
69 return_object => 1,
d2739840 70 );
71
72 # Provides the following functional endpoints:
73 # /api/rpc/artist/create
74 # /api/rpc/artist/list
75 # /api/rpc/artist/id/[id]/delete
76 # /api/rpc/artist/id/[id]/update
77=cut
78
533075c7 79=method_private begin
80
81 :Private
82
c0c8e1c6 83begin is provided in the base class to setup the Catalyst request object by
84applying the DBIC::API::Request role.
533075c7 85
86=cut
87
8ea592cb 88sub begin : Private {
89 my ( $self, $c ) = @_;
533075c7 90
8ea592cb 91 Moose::Util::ensure_all_roles( $c->req,
92 'Catalyst::Controller::DBIC::API::Request' );
533075c7 93}
94
d2739840 95=method_protected setup
96
97 :Chained('specify.in.subclass.config') :CaptureArgs(0) :PathPart('specify.in.subclass.config')
98
8ea592cb 99This action is the chain root of the controller. It must either be overridden or
100configured to provide a base PathPart to the action and also a parent action.
101For example, for class MyAppDB::Track you might have
d2739840 102
103 package MyApp::Controller::API::RPC::Track;
406086f3 104 use Moose;
533075c7 105 BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC'; }
d2739840 106
107 __PACKAGE__->config
406086f3 108 ( action => { setup => { PathPart => 'track', Chained => '/api/rpc/rpc_base' } },
d2739840 109 ...
110 );
111
112 # or
113
5fca1d92 114 sub setup :Chained('/api/rpc/rpc_base') :CaptureArgs(0) :PathPart('track') {
d2739840 115 my ($self, $c) = @_;
116
117 $self->next::method($c);
118 }
119
533075c7 120This action does nothing by default.
d2739840 121
122=cut
123
8ea592cb 124sub setup : Chained('specify.in.subclass.config') : CaptureArgs(0) :
125 PathPart('specify.in.subclass.config') { }
d2739840 126
127=method_protected deserialize
128
533075c7 129 :Chained('setup') :CaptureArgs(0) :PathPart('') :ActionClass('Deserialize')
130
c0c8e1c6 131Absorbs the request data and transforms it into useful bits by using
132CGI::Expand->expand_hash and a smattering of JSON->decode for a handful of
133arguments.
134
135Current only the following arguments are capable of being expressed as JSON:
d2739840 136
137 search_arg
138 count_arg
139 page_arg
140 ordered_by_arg
141 grouped_by_arg
142 prefetch_arg
143
c0c8e1c6 144It should be noted that arguments can used mixed modes in with some caveats.
145Each top level arg can be expressed as CGI::Expand with their immediate child
146keys expressed as JSON when sending the data application/x-www-form-urlencoded.
147Otherwise, you can send content as raw json and it will be deserialized as is
148with no CGI::Expand expasion.
d2739840 149
150=cut
151
8ea592cb 152sub deserialize : Chained('setup') : CaptureArgs(0) : PathPart('') :
153 ActionClass('Deserialize') {
154 my ( $self, $c ) = @_;
d2739840 155 my $req_params;
156
8ea592cb 157 if ( $c->req->data && scalar( keys %{ $c->req->data } ) ) {
d2739840 158 $req_params = $c->req->data;
159 }
8ea592cb 160 else {
161 $req_params = CGI::Expand->expand_hash( $c->req->params );
162
163 foreach my $param (
164 @{ [ $self->search_arg, $self->count_arg,
165 $self->page_arg, $self->offset_arg,
166 $self->ordered_by_arg, $self->grouped_by_arg,
167 $self->prefetch_arg
168 ]
169 }
170 )
d2739840 171 {
172 # these params can also be composed of JSON
173 # but skip if the parameter is not provided
174 next if not exists $req_params->{$param};
8ea592cb 175
d2739840 176 # find out if CGI::Expand was involved
8ea592cb 177 if ( ref $req_params->{$param} eq 'HASH' ) {
178 for my $key ( keys %{ $req_params->{$param} } ) {
179
0b0bf911 180 # copy the value because JSON::XS will alter it
181 # even if decoding failed
182 my $value = $req_params->{$param}->{$key};
8ea592cb 183 try {
0b0bf911 184 my $deserialized = $self->_json->decode($value);
d2739840 185 $req_params->{$param}->{$key} = $deserialized;
186 }
8ea592cb 187 catch {
188 $c->log->debug(
189 "Param '$param.$key' did not deserialize appropriately: $_"
190 ) if $c->debug;
d2739840 191 }
192 }
193 }
8ea592cb 194 else {
195 try {
196 my $value = $req_params->{$param};
0b0bf911 197 my $deserialized = $self->_json->decode($value);
d2739840 198 $req_params->{$param} = $deserialized;
199 }
8ea592cb 200 catch {
201 $c->log->debug(
202 "Param '$param' did not deserialize appropriately: $_"
203 ) if $c->debug;
d2739840 204 }
205 }
206 }
207 }
406086f3 208
8ea592cb 209 $self->inflate_request( $c, $req_params );
d2739840 210}
211
9a29ee35 212=method_protected generate_rs
213
5a3fd922 214generate_rs is used by inflate_request to get a resultset for the current
215request. It receives $c as its only argument.
216By default it returns a resultset of the controller's class.
217Override this method if you need to manipulate the default implementation of
218getting a resultset.
9a29ee35 219
220=cut
221
8ea592cb 222sub generate_rs {
223 my ( $self, $c ) = @_;
def4bb3d 224
8ea592cb 225 return $c->model( $c->stash->{class} || $self->class );
9a29ee35 226}
227
d2739840 228=method_protected inflate_request
406086f3 229
c0c8e1c6 230inflate_request is called at the end of deserialize to populate key portions of
231the request with the useful bits.
d2739840 232
233=cut
234
8ea592cb 235sub inflate_request {
236 my ( $self, $c, $params ) = @_;
d2739840 237
8ea592cb 238 try {
d2739840 239 # set static arguments
406086f3 240 $c->req->_set_controller($self);
d2739840 241
242 # set request arguments
243 $c->req->_set_request_data($params);
8cf0b66a 244
245 # set the current resultset
8ea592cb 246 $c->req->_set_current_result_set( $self->generate_rs($c) );
406086f3 247
d2739840 248 }
8ea592cb 249 catch {
d2739840 250 $c->log->error($_);
8ea592cb 251 $self->push_error( $c, { message => $_ } );
d2739840 252 $c->detach();
253 }
8cf0b66a 254}
255
256=method_protected object_with_id
257
258 :Chained('deserialize') :CaptureArgs(1) :PathPart('')
259
c0c8e1c6 260This action is the chain root for all object level actions (such as delete and
261update) that operate on a single identifer. The provided identifier will be used
262to find that particular object and add it to the request's store ofobjects.
263
d93988fd 264Please see L<Catalyst::Controller::DBIC::API::Request::Context> for more
265details on the stored objects.
8cf0b66a 266
267=cut
268
8ea592cb 269sub object_with_id : Chained('deserialize') : CaptureArgs(1) : PathPart('') {
270 my ( $self, $c, $id ) = @_;
271
272 my $vals = $c->req->request_data->{ $self->data_root };
273 unless ( defined($vals) ) {
8cf0b66a 274
8cf0b66a 275 # no data root, assume the request_data itself is the payload
276 $vals = $c->req->request_data;
277 }
278
8ea592cb 279 try {
8cf0b66a 280 # there can be only one set of data
8ea592cb 281 $c->req->add_object( [ $self->object_lookup( $c, $id ), $vals ] );
8cf0b66a 282 }
8ea592cb 283 catch {
8cf0b66a 284 $c->log->error($_);
8ea592cb 285 $self->push_error( $c, { message => $_ } );
8cf0b66a 286 $c->detach();
287 }
288}
289
290=method_protected objects_no_id
291
292 :Chained('deserialize') :CaptureArgs(0) :PathPart('')
293
c0c8e1c6 294This action is the chain root for object level actions (such as create, update,
295or delete) that can involve more than one object. The data stored at the
296data_root of the request_data will be interpreted as an array of hashes on which
297to operate. If the hashes are missing an 'id' key, they will be considered a
298new object to be created. Otherwise, the values in the hash will be used to
299perform an update. As a special case, a single hash sent will be coerced into
300an array.
301
d93988fd 302Please see L<Catalyst::Controller::DBIC::API::Request::Context> for more
303details on the stored objects.
8cf0b66a 304
305=cut
306
8ea592cb 307sub objects_no_id : Chained('deserialize') : CaptureArgs(0) : PathPart('') {
308 my ( $self, $c ) = @_;
406086f3 309
8ea592cb 310 if ( $c->req->has_request_data ) {
533075c7 311 my $data = $c->req->request_data;
312 my $vals;
406086f3 313
8ea592cb 314 if ( exists( $data->{ $self->data_root } )
315 && defined( $data->{ $self->data_root } ) )
8cf0b66a 316 {
8ea592cb 317 my $root = $data->{ $self->data_root };
318 if ( reftype($root) eq 'ARRAY' ) {
533075c7 319 $vals = $root;
320 }
8ea592cb 321 elsif ( reftype($root) eq 'HASH' ) {
533075c7 322 $vals = [$root];
323 }
8ea592cb 324 else {
533075c7 325 $c->log->error('Invalid request data');
8ea592cb 326 $self->push_error( $c,
327 { message => 'Invalid request data' } );
533075c7 328 $c->detach();
329 }
8cf0b66a 330 }
8ea592cb 331 else {
533075c7 332 # no data root, assume the request_data itself is the payload
8ea592cb 333 $vals = [ $c->req->request_data ];
8cf0b66a 334 }
533075c7 335
8ea592cb 336 foreach my $val (@$vals) {
337 unless ( exists( $val->{id} ) ) {
338 $c->req->add_object(
339 [ $c->req->current_result_set->new_result( {} ), $val ] );
533075c7 340 next;
341 }
342
8ea592cb 343 try {
344 $c->req->add_object(
345 [ $self->object_lookup( $c, $val->{id} ), $val ] );
533075c7 346 }
8ea592cb 347 catch {
533075c7 348 $c->log->error($_);
8ea592cb 349 $self->push_error( $c, { message => $_ } );
533075c7 350 $c->detach();
351 }
8cf0b66a 352 }
353 }
354}
355
356=method_protected object_lookup
357
c0c8e1c6 358This method provides the look up functionality for an object based on 'id'.
359It is passed the current $c and the id to be used to perform the lookup.
360Dies if there is no provided id or if no object was found.
8cf0b66a 361
362=cut
363
8ea592cb 364sub object_lookup {
365 my ( $self, $c, $id ) = @_;
8cf0b66a 366
367 die 'No valid ID provided for look up' unless defined $id and length $id;
368 my $object = $c->req->current_result_set->find($id);
369 die "No object found for id '$id'" unless defined $object;
370 return $object;
d2739840 371}
372
373=method_protected list
374
c0c8e1c6 375list's steps are broken up into three distinct methods:
376
377=over
378
379=item L</list_munge_parameters>
380
381=item L</list_perform_search>
d2739840 382
c0c8e1c6 383=item L</list_format_output>.
d2739840 384
c0c8e1c6 385=back
d2739840 386
c0c8e1c6 387The goal of this method is to call ->search() on the current_result_set,
388change the resultset class of the result (if needed), and return it in
389$c->stash->{$self->stash_key}->{$self->data_root}.
390
391Please see the individual methods for more details on what actual processing
392takes place.
393
394If the L</select> config param is defined then the hashes will contain only
395those columns, otherwise all columns in the object will be returned.
396L</select> of course supports the function/procedure calling semantics that
397L<DBIx::Class::ResultSet/select> supports.
398
399In order to have proper column names in the result, provide arguments in L</as>
400(which also follows L<DBIx::Class::ResultSet/as> semantics.
401Similarly L</count>, L</page>, L</grouped_by> and L</ordered_by> affect the
402maximum number of rows returned as well as the ordering and grouping.
403
404Note that if select, count, ordered_by or grouped_by request parameters are
405present, these will override the values set on the class with select becoming
406bound by the select_exposes attribute.
407
408If not all objects in the resultset are required then it's possible to pass
409conditions to the method as request parameters. You can use a JSON string as
410the 'search' parameter for maximum flexibility or use L<CGI::Expand> syntax.
411In the second case the request parameters are expanded into a structure and
412then used as the search condition.
d2739840 413
414For example, these request parameters:
415
416 ?search.name=fred&search.cd.artist=luke
417 OR
418 ?search={"name":"fred","cd": {"artist":"luke"}}
419
c0c8e1c6 420Would result in this search (where 'name' is a column of the result class, 'cd'
421is a relation of the result class and 'artist' is a column of the related class):
d2739840 422
423 $rs->search({ name => 'fred', 'cd.artist' => 'luke' }, { join => ['cd'] })
424
425It is also possible to use a JSON string for expandeded parameters:
426
427 ?search.datetime={"-between":["2010-01-06 19:28:00","2010-01-07 19:28:00"]}
428
c0c8e1c6 429Note that if pagination is needed, this can be achieved using a combination of
430the L</count> and L</page> parameters. For example:
d2739840 431
432 ?page=2&count=20
433
434Would result in this search:
406086f3 435
d2739840 436 $rs->search({}, { page => 2, rows => 20 })
437
438=cut
439
8ea592cb 440sub list {
441 my ( $self, $c ) = @_;
d2739840 442
443 $self->list_munge_parameters($c);
444 $self->list_perform_search($c);
445 $self->list_format_output($c);
533075c7 446
447 # make sure there are no objects lingering
406086f3 448 $c->req->clear_objects();
d2739840 449}
450
451=method_protected list_munge_parameters
452
c0c8e1c6 453list_munge_parameters is a noop by default. All arguments will be passed through
454without any manipulation. In order to successfully manipulate the parameters
455before the search is performed, simply access
456$c->req->search_parameters|search_attributes (ArrayRef and HashRef respectively),
457which correspond directly to ->search($parameters, $attributes).
458Parameter keys will be in already-aliased form.
459To store the munged parameters call $c->req->_set_search_parameters($newparams)
460and $c->req->_set_search_attributes($newattrs).
d2739840 461
462=cut
463
8ea592cb 464sub list_munge_parameters { } # noop by default
d2739840 465
466=method_protected list_perform_search
467
c0c8e1c6 468list_perform_search executes the actual search. current_result_set is updated to
469contain the result returned from ->search. If paging was requested,
470search_total_entries will be set as well.
d2739840 471
472=cut
473
8ea592cb 474sub list_perform_search {
475 my ( $self, $c ) = @_;
406086f3 476
8ea592cb 477 try {
d2739840 478 my $req = $c->req;
406086f3 479
8ea592cb 480 my $rs =
481 $req->current_result_set->search( $req->search_parameters,
482 $req->search_attributes );
d2739840 483
484 $req->_set_current_result_set($rs);
485
8ea592cb 486 $req->_set_search_total_entries(
487 $req->current_result_set->pager->total_entries )
488 if $req->has_search_attributes
489 && ( exists( $req->search_attributes->{page} )
490 && defined( $req->search_attributes->{page} )
491 && length( $req->search_attributes->{page} ) );
d2739840 492 }
8ea592cb 493 catch {
d2739840 494 $c->log->error($_);
8ea592cb 495 $self->push_error( $c,
496 { message => 'a database error has occured.' } );
d2739840 497 $c->detach();
498 }
499}
500
501=method_protected list_format_output
502
c0c8e1c6 503list_format_output prepares the response for transmission across the wire.
504A copy of the current_result_set is taken and its result_class is set to
505L<DBIx::Class::ResultClass::HashRefInflator>. Each row in the resultset is then
506iterated and passed to L</row_format_output> with the result of that call added
507to the output.
d2739840 508
509=cut
510
8ea592cb 511sub list_format_output {
512 my ( $self, $c ) = @_;
d2739840 513
514 my $rs = $c->req->current_result_set->search;
8ea592cb 515 $rs->result_class( $self->result_class ) if $self->result_class;
406086f3 516
8ea592cb 517 try {
518 my $output = {};
d2739840 519 my $formatted = [];
406086f3 520
8ea592cb 521 foreach my $row ( $rs->all ) {
522 push( @$formatted, $self->row_format_output( $c, $row ) );
d2739840 523 }
406086f3 524
8ea592cb 525 $output->{ $self->data_root } = $formatted;
d2739840 526
8ea592cb 527 if ( $c->req->has_search_total_entries ) {
528 $output->{ $self->total_entries_arg } =
529 $c->req->search_total_entries + 0;
d2739840 530 }
531
8ea592cb 532 $c->stash->{ $self->stash_key } = $output;
d2739840 533 }
8ea592cb 534 catch {
d2739840 535 $c->log->error($_);
8ea592cb 536 $self->push_error( $c,
537 { message => 'a database error has occured.' } );
d2739840 538 $c->detach();
539 }
540}
541
542=method_protected row_format_output
543
c0c8e1c6 544row_format_output is called each row of the inflated output generated from the
545search. It receives two arguments, the catalyst context and the hashref that
546represents the row. By default, this method is merely a passthrough.
d2739840 547
548=cut
549
8ea592cb 550sub row_format_output {
551
def4bb3d 552 #my ($self, $c, $row) = @_;
8ea592cb 553 my ( $self, undef, $row ) = @_;
554 return $row; # passthrough by default
4cb15235 555}
d2739840 556
609916e5 557=method_protected item
609916e5 558
c0c8e1c6 559item will return a single object called by identifier in the uri. It will be
560inflated via each_object_inflate.
609916e5 561
562=cut
563
8ea592cb 564sub item {
565 my ( $self, $c ) = @_;
609916e5 566
8ea592cb 567 if ( $c->req->count_objects != 1 ) {
609916e5 568 $c->log->error($_);
8ea592cb 569 $self->push_error( $c,
570 { message => 'No objects on which to operate' } );
609916e5 571 $c->detach();
572 }
8ea592cb 573 else {
574 $c->stash->{ $self->stash_key }->{ $self->item_root } =
575 $self->each_object_inflate( $c, $c->req->get_object(0)->[0] );
609916e5 576 }
577}
578
d2739840 579=method_protected update_or_create
580
c0c8e1c6 581update_or_create is responsible for iterating any stored objects and performing
582updates or creates. Each object is first validated to ensure it meets the
583criteria specified in the L</create_requires> and L</create_allows> (or
584L</update_allows>) parameters of the controller config. The objects are then
585committed within a transaction via L</transact_objects> using a closure around
586L</save_objects>.
d2739840 587
588=cut
589
8ea592cb 590sub update_or_create {
591 my ( $self, $c ) = @_;
406086f3 592
8ea592cb 593 if ( $c->req->has_objects ) {
d2739840 594 $self->validate_objects($c);
8ea592cb 595 $self->transact_objects( $c, sub { $self->save_objects( $c, @_ ) } );
d2739840 596 }
8ea592cb 597 else {
d2739840 598 $c->log->error($_);
8ea592cb 599 $self->push_error( $c,
600 { message => 'No objects on which to operate' } );
d2739840 601 $c->detach();
602 }
603}
604
605=method_protected transact_objects
606
c0c8e1c6 607transact_objects performs the actual commit to the database via $schema->txn_do.
608This method accepts two arguments, the context and a coderef to be used within
609the transaction. All of the stored objects are passed as an arrayref for the
610only argument to the coderef.
d2739840 611
612=cut
613
8ea592cb 614sub transact_objects {
615 my ( $self, $c, $coderef ) = @_;
406086f3 616
8ea592cb 617 try {
618 $self->stored_result_source->schema->txn_do( $coderef,
619 $c->req->objects );
d2739840 620 }
8ea592cb 621 catch {
d2739840 622 $c->log->error($_);
8ea592cb 623 $self->push_error( $c,
624 { message => 'a database error has occured.' } );
d2739840 625 $c->detach();
626 }
627}
628
629=method_protected validate_objects
630
c0c8e1c6 631This is a shortcut method for performing validation on all of the stored objects
632in the request. Each object's provided values (for create or update) are updated
633to the allowed values permitted by the various config parameters.
d2739840 634
635=cut
636
8ea592cb 637sub validate_objects {
638 my ( $self, $c ) = @_;
d2739840 639
8ea592cb 640 try {
641 foreach my $obj ( $c->req->all_objects ) {
642 $obj->[1] = $self->validate_object( $c, $obj );
d2739840 643 }
644 }
8ea592cb 645 catch {
d2739840 646 my $err = $_;
647 $c->log->error($err);
bec622aa 648 $err =~ s/\s+at\s+.+\n$//g;
8ea592cb 649 $self->push_error( $c, { message => $err } );
d2739840 650 $c->detach();
651 }
652}
653
654=method_protected validate_object
655
c0c8e1c6 656validate_object takes the context and the object as an argument. It then filters
657the passed values in slot two of the tuple through the create|update_allows
658configured. It then returns those filtered values. Values that are not allowed
659are silently ignored. If there are no values for a particular key, no valid
660values at all, or multiple of the same key, this method will die.
d2739840 661
662=cut
663
8ea592cb 664sub validate_object {
665 my ( $self, $c, $obj ) = @_;
666 my ( $object, $params ) = @$obj;
d2739840 667
668 my %values;
8ea592cb 669 my %requires_map = map { $_ => 1 } @{
670 ( $object->in_storage )
406086f3 671 ? []
d2739840 672 : $c->stash->{create_requires} || $self->create_requires
673 };
406086f3 674
8ea592cb 675 my %allows_map = map { ( ref $_ ) ? %{$_} : ( $_ => 1 ) } (
406086f3 676 keys %requires_map,
8ea592cb 677 @{ ( $object->in_storage )
678 ? ( $c->stash->{update_allows} || $self->update_allows )
679 : ( $c->stash->{create_allows} || $self->create_allows )
d2739840 680 }
681 );
682
8ea592cb 683 foreach my $key ( keys %allows_map ) {
684
d2739840 685 # check value defined if key required
686 my $allowed_fields = $allows_map{$key};
406086f3 687
8ea592cb 688 if ( ref $allowed_fields ) {
d2739840 689 my $related_source = $object->result_source->related_source($key);
690 my $related_params = $params->{$key};
691 my %allowed_related_map = map { $_ => 1 } @$allowed_fields;
8ea592cb 692 my $allowed_related_cols =
693 ( $allowed_related_map{'*'} )
694 ? [ $related_source->columns ]
695 : $allowed_fields;
696
697 foreach my $related_col ( @{$allowed_related_cols} ) {
698 if (defined(
699 my $related_col_value =
700 $related_params->{$related_col}
701 )
702 )
703 {
d2739840 704 $values{$key}{$related_col} = $related_col_value;
705 }
706 }
707 }
8ea592cb 708 else {
d2739840 709 my $value = $params->{$key};
710
8ea592cb 711 if ( $requires_map{$key} ) {
712 unless ( defined($value) ) {
713
d2739840 714 # if not defined look for default
8ea592cb 715 $value = $object->result_source->column_info($key)
716 ->{default_value};
717 unless ( defined $value ) {
d2739840 718 die "No value supplied for ${key} and no default";
719 }
720 }
721 }
406086f3 722
d2739840 723 # check for multiple values
a5949bfd 724 if ( ref($value) && !( reftype($value) eq reftype(JSON::MaybeXS::true) ) )
d2739840 725 {
726 require Data::Dumper;
8ea592cb 727 die
728 "Multiple values for '${key}': ${\Data::Dumper::Dumper($value)}";
d2739840 729 }
730
731 # check exists so we don't just end up with hash of undefs
732 # check defined to account for default values being used
8ea592cb 733 $values{$key} = $value
734 if exists $params->{$key} || defined $value;
d2739840 735 }
736 }
737
8ea592cb 738 unless ( keys %values || !$object->in_storage ) {
d2739840 739 die 'No valid keys passed';
740 }
741
406086f3 742 return \%values;
d2739840 743}
744
745=method_protected delete
746
c0c8e1c6 747delete operates on the stored objects in the request. It first transacts the
748objects, deleting them in the database using L</transact_objects> and a closure
749around L</delete_objects>, and then clears the request store of objects.
d2739840 750
751=cut
752
8ea592cb 753sub delete {
754 my ( $self, $c ) = @_;
406086f3 755
8ea592cb 756 if ( $c->req->has_objects ) {
757 $self->transact_objects( $c,
758 sub { $self->delete_objects( $c, @_ ) } );
d2739840 759 $c->req->clear_objects;
760 }
8ea592cb 761 else {
d2739840 762 $c->log->error($_);
8ea592cb 763 $self->push_error( $c,
764 { message => 'No objects on which to operate' } );
d2739840 765 $c->detach();
766 }
767}
768
b421ef50 769=method_protected save_objects
d2739840 770
c0c8e1c6 771This method is used by update_or_create to perform the actual database
772manipulations. It iterates each object calling L</save_object>.
d2739840 773
b421ef50 774=cut
775
8ea592cb 776sub save_objects {
777 my ( $self, $c, $objects ) = @_;
d2739840 778
8ea592cb 779 foreach my $obj (@$objects) {
780 $self->save_object( $c, $obj );
b421ef50 781 }
782}
d2739840 783
b421ef50 784=method_protected save_object
d2739840 785
c0c8e1c6 786save_object first checks to see if the object is already in storage. If so, it
787calls L</update_object_from_params> otherwise L</insert_object_from_params>.
d2739840 788
789=cut
790
8ea592cb 791sub save_object {
792 my ( $self, $c, $obj ) = @_;
d2739840 793
8ea592cb 794 my ( $object, $params ) = @$obj;
b421ef50 795
8ea592cb 796 if ( $object->in_storage ) {
797 $self->update_object_from_params( $c, $object, $params );
b421ef50 798 }
8ea592cb 799 else {
800 $self->insert_object_from_params( $c, $object, $params );
b421ef50 801 }
802
803}
804
805=method_protected update_object_from_params
806
c0c8e1c6 807update_object_from_params iterates through the params to see if any of them are
808pertinent to relations. If so it calls L</update_object_relation> with the
809object, and the relation parameters. Then it calls ->update on the object.
b421ef50 810
811=cut
812
8ea592cb 813sub update_object_from_params {
814 my ( $self, $c, $object, $params ) = @_;
b421ef50 815
8ea592cb 816 foreach my $key ( keys %$params ) {
b421ef50 817 my $value = $params->{$key};
a5949bfd 818 if ( ref($value) && !( reftype($value) eq reftype(JSON::MaybeXS::true) ) ) {
8ea592cb 819 $self->update_object_relation( $c, $object,
820 delete $params->{$key}, $key );
d2739840 821 }
8ea592cb 822
16337d41 823 # accessor = colname
8ea592cb 824 elsif ( $object->can($key) ) {
16337d41 825 $object->$key($value);
826 }
8ea592cb 827
16337d41 828 # accessor != colname
829 else {
8ea592cb 830 my $accessor =
831 $object->result_source->column_info($key)->{accessor};
16337d41 832 $object->$accessor($value);
833 }
d2739840 834 }
406086f3 835
3b12c2cd 836 $object->update();
b421ef50 837}
838
839=method_protected update_object_relation
840
c0c8e1c6 841update_object_relation finds the relation to the object, then calls ->update
842with the specified parameters.
b421ef50 843
844=cut
845
8ea592cb 846sub update_object_relation {
847 my ( $self, $c, $object, $related_params, $relation ) = @_;
848 my $row = $object->find_related( $relation, {}, {} );
8516bd76 849
850 if ($row) {
8ea592cb 851 foreach my $key ( keys %$related_params ) {
3b12c2cd 852 my $value = $related_params->{$key};
a5949bfd 853 if ( ref($value) && !( reftype($value) eq reftype(JSON::MaybeXS::true) ) )
16337d41 854 {
8ea592cb 855 $self->update_object_relation( $c, $row,
856 delete $related_params->{$key}, $key );
16337d41 857 }
8ea592cb 858
16337d41 859 # accessor = colname
8ea592cb 860 elsif ( $row->can($key) ) {
0f0f8776 861 $row->$key($value);
16337d41 862 }
8ea592cb 863
16337d41 864 # accessor != colname
865 else {
8ea592cb 866 my $accessor =
867 $row->result_source->column_info($key)->{accessor};
0f0f8776 868 $row->$accessor($value);
16337d41 869 }
3b12c2cd 870 }
871 $row->update();
8516bd76 872 }
873 else {
8ea592cb 874 $object->create_related( $relation, $related_params );
8516bd76 875 }
b421ef50 876}
877
878=method_protected insert_object_from_params
879
c0c8e1c6 880Sets the columns of the object, then calls ->insert.
b421ef50 881
882=cut
883
8ea592cb 884sub insert_object_from_params {
885
def4bb3d 886 #my ($self, $c, $object, $params) = @_;
8ea592cb 887 my ( $self, undef, $object, $params ) = @_;
d8921389 888
889 my %rels;
8ea592cb 890 while ( my ( $key, $value ) = each %{$params} ) {
a5949bfd 891 if ( ref($value) && !( reftype($value) eq reftype(JSON::MaybeXS::true) ) ) {
c50b4fa4 892 $rels{$key} = $value;
d8921389 893 }
8ea592cb 894
c50b4fa4 895 # accessor = colname
8ea592cb 896 elsif ( $object->can($key) ) {
c50b4fa4 897 $object->$key($value);
898 }
8ea592cb 899
c50b4fa4 900 # accessor != colname
d8921389 901 else {
8ea592cb 902 my $accessor =
903 $object->result_source->column_info($key)->{accessor};
c50b4fa4 904 $object->$accessor($value);
d8921389 905 }
906 }
907
b421ef50 908 $object->insert;
d8921389 909
8ea592cb 910 while ( my ( $k, $v ) = each %rels ) {
911 $object->create_related( $k, $v );
d8921389 912 }
d2739840 913}
914
b421ef50 915=method_protected delete_objects
916
c0c8e1c6 917Iterates through each object calling L</delete_object>.
b421ef50 918
919=cut
920
8ea592cb 921sub delete_objects {
922 my ( $self, $c, $objects ) = @_;
b421ef50 923
8ea592cb 924 map { $self->delete_object( $c, $_->[0] ) } @$objects;
b421ef50 925}
926
927=method_protected delete_object
928
c0c8e1c6 929Performs the actual ->delete on the object.
b421ef50 930
931=cut
932
8ea592cb 933sub delete_object {
934
def4bb3d 935 #my ($self, $c, $object) = @_;
8ea592cb 936 my ( $self, undef, $object ) = @_;
d2739840 937
b421ef50 938 $object->delete;
d2739840 939}
940
941=method_protected end
942
c0c8e1c6 943end performs the final manipulation of the response before it is serialized.
944This includes setting the success of the request both at the HTTP layer and
945JSON layer. If configured with return_object true, and there are stored objects
946as the result of create or update, those will be inflated according to the
947schema and get_inflated_columns
d2739840 948
949=cut
950
8ea592cb 951sub end : Private {
952 my ( $self, $c ) = @_;
d2739840 953
e2f6c772 954 # don't change the http status code if already set elsewhere
8ea592cb 955 unless ( $c->res->status && $c->res->status != 200 ) {
956 if ( $self->has_errors($c) ) {
e2f6c772 957 $c->res->status(400);
958 }
959 else {
960 $c->res->status(200);
961 }
962 }
d2739840 963
8ea592cb 964 if ( $c->res->status == 200 ) {
965 $c->stash->{ $self->stash_key }->{success} =
a5949bfd 966 $self->use_json_boolean ? JSON::MaybeXS::true : 'true';
e3160171 967 if ( $self->return_object
968 && $c->req->has_objects
969 && ! exists $c->stash->{ $self->stash_key }->{ $self->data_root } ) {
e2f6c772 970 my $returned_objects = [];
8ea592cb 971 push( @$returned_objects, $self->each_object_inflate( $c, $_ ) )
972 for map { $_->[0] } $c->req->all_objects;
973 $c->stash->{ $self->stash_key }->{ $self->data_root } =
974 scalar(@$returned_objects) > 1
975 ? $returned_objects
976 : $returned_objects->[0];
e2f6c772 977 }
d2739840 978 }
e2f6c772 979 else {
8ea592cb 980 $c->stash->{ $self->stash_key }->{success} =
a5949bfd 981 $self->use_json_boolean ? JSON::MaybeXS::false : 'false';
8ea592cb 982 $c->stash->{ $self->stash_key }->{messages} = $self->get_errors($c)
e2f6c772 983 if $self->has_errors($c);
8ea592cb 984
e2f6c772 985 # don't return data for error responses
8ea592cb 986 delete $c->stash->{ $self->stash_key }->{ $self->data_root };
d2739840 987 }
d2739840 988
d2739840 989 $c->forward('serialize');
990}
991
c9b8a798 992=method_protected each_object_inflate
993
c0c8e1c6 994each_object_inflate executes during L</end> and allows hooking into the process
995of inflating the objects to return in the response. Receives, the context, and
996the object as arguments.
c9b8a798 997
c0c8e1c6 998This only executes if L</return_object> if set and if there are any objects to
999actually return.
c9b8a798 1000
1001=cut
1002
8ea592cb 1003sub each_object_inflate {
1004
def4bb3d 1005 #my ($self, $c, $object) = @_;
8ea592cb 1006 my ( $self, undef, $object ) = @_;
d2739840 1007
8ee81496 1008 return { $object->get_columns };
d2739840 1009}
1010
b66d4310 1011=method_protected serialize
1012
1013multiple actions forward to serialize which uses Catalyst::Action::Serialize.
1014
1015=cut
1016
c9b8a798 1017# from Catalyst::Action::Serialize
8ea592cb 1018sub serialize : ActionClass('Serialize') { }
c9b8a798 1019
d2739840 1020=method_protected push_error
1021
c0c8e1c6 1022Stores an error message into the stash to be later retrieved by L</end>.
1023Accepts a Dict[message => Str] parameter that defines the error message.
d2739840 1024
1025=cut
1026
8ea592cb 1027sub push_error {
d2739840 1028 my ( $self, $c, $params ) = @_;
a80eb0e8 1029 die 'Catalyst app object missing'
1030 unless defined $c;
7821bdec 1031 my $error = 'unknown error';
8ea592cb 1032 if ( exists $params->{message} ) {
7821bdec 1033 $error = $params->{message};
8ea592cb 1034
7821bdec 1035 # remove newline from die "error message\n" which is required to not
1036 # have the filename and line number in the error text
1037 $error =~ s/\n$//;
1038 }
8ea592cb 1039 push( @{ $c->stash->{_dbic_crud_errors} }, $error );
d2739840 1040}
1041
1042=method_protected get_errors
1043
c0c8e1c6 1044Returns all of the errors stored in the stash.
d2739840 1045
1046=cut
1047
8ea592cb 1048sub get_errors {
d2739840 1049 my ( $self, $c ) = @_;
a80eb0e8 1050 die 'Catalyst app object missing'
1051 unless defined $c;
d2739840 1052 return $c->stash->{_dbic_crud_errors};
1053}
1054
71c17090 1055=method_protected has_errors
1056
c0c8e1c6 1057Returns true if errors are stored in the stash.
71c17090 1058
1059=cut
1060
1061sub has_errors {
1062 my ( $self, $c ) = @_;
1063 die 'Catalyst app object missing'
1064 unless defined $c;
1065 return exists $c->stash->{_dbic_crud_errors};
1066}
1067
d2739840 1068=head1 DESCRIPTION
1069
c0c8e1c6 1070Easily provide common API endpoints based on your L<DBIx::Class> schema classes.
1071Module provides both RPC and REST interfaces to base functionality.
1072Uses L<Catalyst::Action::Serialize> and L<Catalyst::Action::Deserialize> to
1073serialize response and/or deserialise request.
d2739840 1074
1075=head1 OVERVIEW
1076
c0c8e1c6 1077This document describes base functionlity such as list, create, delete, update
1078and the setting of config attributes. L<Catalyst::Controller::DBIC::API::RPC>
1079and L<Catalyst::Controller::DBIC::API::REST> describe details of provided
1080endpoints to those base methods.
d2739840 1081
c0c8e1c6 1082You will need to create a controller for each schema class you require API
1083endpoints for. For example if your schema has Artist and Track, and you want to
1084provide a RESTful interface to these, you should create
1085MyApp::Controller::API::REST::Artist and MyApp::Controller::API::REST::Track
1086which both subclass L<Catalyst::Controller::DBIC::API::REST>.
1087Similarly if you wanted to provide an RPC style interface then subclass
1088L<Catalyst::Controller::DBIC::API::RPC>. You then configure these individually
1089as specified in L</CONFIGURATION>.
d2739840 1090
c0c8e1c6 1091Also note that the test suite of this module has an example application used to
1092run tests against. It maybe helpful to look at that until a better tutorial is
1093written.
d2739840 1094
1095=head2 CONFIGURATION
1096
c0c8e1c6 1097Each of your controller classes needs to be configured to point at the relevant
1098schema class, specify what can be updated and so on, as shown in the L</SYNOPSIS>.
d2739840 1099
c0c8e1c6 1100The class, create_requires, create_allows and update_requires parameters can
1101also be set in the stash like so:
d2739840 1102
1103 sub setup :Chained('/api/rpc/rpc_base') :CaptureArgs(1) :PathPart('any') {
1104 my ($self, $c, $object_type) = @_;
1105
1106 if ($object_type eq 'artist') {
1107 $c->stash->{class} = 'MyAppDB::Artist';
1108 $c->stash->{create_requires} = [qw/name/];
1109 $c->stash->{update_allows} = [qw/name/];
1110 } else {
1111 $self->push_error($c, { message => "invalid object_type" });
1112 return;
1113 }
1114
1115 $self->next::method($c);
1116 }
1117
c0c8e1c6 1118Generally it's better to have one controller for each DBIC source with the
1119config hardcoded, but in some cases this isn't possible.
d2739840 1120
c0c8e1c6 1121Note that the Chained, CaptureArgs and PathPart are just standard Catalyst
1122configuration parameters and that then endpoint specified in Chained - in this
1123case '/api/rpc/rpc_base' - must actually exist elsewhere in your application.
1124See L<Catalyst::DispatchType::Chained> for more details.
d2739840 1125
c0c8e1c6 1126Below are explanations for various configuration parameters. Please see
1127L<Catalyst::Controller::DBIC::API::StaticArguments> for more details.
d2739840 1128
1129=head3 class
1130
c0c8e1c6 1131Whatever you would pass to $c->model to get a resultset for this class.
1132MyAppDB::Track for example.
d2739840 1133
a0a4ed30 1134=head3 resultset_class
1135
c0c8e1c6 1136Desired resultset class after accessing your model. MyAppDB::ResultSet::Track
1137for example. By default, it's DBIx::Class::ResultClass::HashRefInflator.
1138Set to empty string to leave resultset class without change.
a0a4ed30 1139
810de6af 1140=head3 stash_key
1141
1142Controls where in stash request_data should be stored, and defaults to 'response'.
1143
d2739840 1144=head3 data_root
1145
7986e6b7 1146By default, the response data of multiple item actions is serialized into
c0c8e1c6 1147$c->stash->{$self->stash_key}->{$self->data_root} and data_root defaults to
1148'list' to preserve backwards compatibility. This is now configuable to meet
1149the needs of the consuming client.
d2739840 1150
7986e6b7 1151=head3 item_root
1152
1153By default, the response data of single item actions is serialized into
1154$c->stash->{$self->stash_key}->{$self->item_root} and item_root default to
1155'data'.
1156
d2739840 1157=head3 use_json_boolean
1158
c0c8e1c6 1159By default, the response success status is set to a string value of "true" or
a5949bfd 1160"false". If this attribute is true, JSON::MaybeXS's true() and false() will be
1161used instead. Note, this does not effect other internal processing of boolean
1162values.
d2739840 1163
1164=head3 count_arg, page_arg, select_arg, search_arg, grouped_by_arg, ordered_by_arg, prefetch_arg, as_arg, total_entries_arg
1165
c0c8e1c6 1166These attributes allow customization of the component to understand requests
1167made by clients where these argument names are not flexible and cannot conform
1168to this components defaults.
d2739840 1169
1170=head3 create_requires
1171
c0c8e1c6 1172Arrayref listing columns required to be passed to create in order for the
1173request to be valid.
d2739840 1174
1175=head3 create_allows
1176
c0c8e1c6 1177Arrayref listing columns additional to those specified in create_requires that
1178are not required to create but which create does allow. Columns passed to create
1179that are not listed in create_allows or create_requires will be ignored.
d2739840 1180
1181=head3 update_allows
1182
c0c8e1c6 1183Arrayref listing columns that update will allow. Columns passed to update that
1184are not listed here will be ignored.
d2739840 1185
1186=head3 select
1187
c0c8e1c6 1188Arguments to pass to L<DBIx::Class::ResultSet/select> when performing search for
1189L</list>.
d2739840 1190
1191=head3 as
1192
c0c8e1c6 1193Complements arguments passed to L<DBIx::Class::ResultSet/select> when performing
1194a search. This allows you to specify column names in the result for RDBMS
1195functions, etc.
d2739840 1196
1197=head3 select_exposes
1198
c0c8e1c6 1199Columns and related columns that are okay to return in the resultset since
1200clients can request more or less information specified than the above select
1201argument.
d2739840 1202
1203=head3 prefetch
1204
c0c8e1c6 1205Arguments to pass to L<DBIx::Class::ResultSet/prefetch> when performing search
1206for L</list>.
d2739840 1207
1208=head3 prefetch_allows
1209
1210Arrayref listing relationships that are allowed to be prefetched.
1211This is necessary to avoid denial of service attacks in form of
1212queries which would return a large number of data
1213and unwanted disclosure of data.
1214
1215=head3 grouped_by
1216
c0c8e1c6 1217Arguments to pass to L<DBIx::Class::ResultSet/group_by> when performing search
1218for L</list>.
d2739840 1219
1220=head3 ordered_by
1221
c0c8e1c6 1222Arguments to pass to L<DBIx::Class::ResultSet/order_by> when performing search
1223for L</list>.
d2739840 1224
1225=head3 search_exposes
1226
c0c8e1c6 1227Columns and related columns that are okay to search on. For example if only the
1228position column and all cd columns were to be allowed
d2739840 1229
1230 search_exposes => [qw/position/, { cd => ['*'] }]
1231
c0c8e1c6 1232You can also use this to allow custom columns should you wish to allow them
1233through in order to be caught by a custom resultset. For example:
d2739840 1234
1235 package RestTest::Controller::API::RPC::TrackExposed;
406086f3 1236
d2739840 1237 ...
406086f3 1238
d2739840 1239 __PACKAGE__->config
1240 ( ...,
1241 search_exposes => [qw/position title custom_column/],
1242 );
1243
1244and then in your custom resultset:
1245
1246 package RestTest::Schema::ResultSet::Track;
406086f3 1247
d2739840 1248 use base 'RestTest::Schema::ResultSet';
406086f3 1249
d2739840 1250 sub search {
1251 my $self = shift;
1252 my ($clause, $params) = @_;
1253
1254 # test custom attrs
1255 if (my $pretend = delete $clause->{custom_column}) {
1256 $clause->{'cd.year'} = $pretend;
1257 }
1258 my $rs = $self->SUPER::search(@_);
1259 }
1260
1261=head3 count
1262
c0c8e1c6 1263Arguments to pass to L<DBIx::Class::ResultSet/rows> when performing search for
1264L</list>.
d2739840 1265
1266=head3 page
1267
c0c8e1c6 1268Arguments to pass to L<DBIx::Class::ResultSet/page> when performing search for
1269L</list>.
d2739840 1270
1271=head1 EXTENDING
1272
c0c8e1c6 1273By default the create, delete and update actions will not return anything apart
1274from the success parameter set in L</end>, often this is not ideal but the
1275required behaviour varies from application to application. So normally it's
1276sensible to write an intermediate class which your main controller classes
1277subclass from.
d2739840 1278
c0c8e1c6 1279For example if you wanted create to return the JSON for the newly created
1280object you might have something like:
d2739840 1281
1282 package MyApp::ControllerBase::DBIC::API::RPC;
1283 ...
1284 use Moose;
1285 BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' };
1286 ...
1287 sub create :Chained('setup') :Args(0) :PathPart('create') {
1288 my ($self, $c) = @_;
1289
1290 # $c->req->all_objects will contain all of the created
1291 $self->next::method($c);
1292
406086f3 1293 if ($c->req->has_objects) {
810de6af 1294 # $c->stash->{$self->stash_key} will be serialized in the end action
1295 $c->stash->{$self->stash_key}->{$self->data_root} = [ map { { $_->get_inflated_columns } } ($c->req->all_objects) ] ;
d2739840 1296 }
1297 }
1298
d2739840 1299 package MyApp::Controller::API::RPC::Track;
1300 ...
1301 use Moose;
1302 BEGIN { extends 'MyApp::ControllerBase::DBIC::API::RPC' };
1303 ...
1304
c0c8e1c6 1305It should be noted that the return_object attribute will produce the above
1306result for you, free of charge.
d2739840 1307
c0c8e1c6 1308Similarly you might want create, update and delete to all forward to the list
1309action once they are done so you can refresh your view. This should also be
1310simple enough.
d2739840 1311
c0c8e1c6 1312If more extensive customization is required, it is recommened to peer into the
1313roles that comprise the system and make use
d2739840 1314
1315=head1 NOTES
1316
c0c8e1c6 1317It should be noted that version 1.004 and above makes a rapid depature from the
1318status quo. The internals were revamped to use more modern tools such as Moose
1319and its role system to refactor functionality out into self-contained roles.
1320
1321To this end, internally, this module now understands JSON boolean values (as
a5949bfd 1322represented by the JSON::MaybeXS module) and will Do The Right Thing in
1323handling those values. This means you can have ColumnInflators installed that
1324can covert between JSON booleans and whatever your database wants for boolean
1325values.
d2739840 1326
c0c8e1c6 1327Validation for various *_allows or *_exposes is now accomplished via
1328Data::DPath::Validator with a lightly simplified, via a subclass of
1329Data::DPath::Validator::Visitor.
d2739840 1330
c0c8e1c6 1331The rough jist of the process goes as follows: Arguments provided to those
1332attributes are fed into the Validator and Data::DPaths are generated.
1333Then incoming requests are validated against these paths generated.
1334The validator is set in "loose" mode meaning only one path is required to match.
1335For more information, please see L<Data::DPath::Validator> and more specifically
1336L<Catalyst::Controller::DBIC::API::Validator>.
d2739840 1337
4cb8623a 1338Since 2.001:
c0c8e1c6 1339Transactions are used. The stash is put aside in favor of roles applied to the
1340request object with additional accessors.
d2739840 1341Error handling is now much more consistent with most errors immediately detaching.
1342The internals are much easier to read and understand with lots more documentation.
1343
4cb8623a 1344Since 2.006:
1345The SQL::Abstract -and, -not and -or operators are supported.
1346
d2739840 1347=cut
1348
13491;