NAME
Catalyst::ActionRole::BuildDBICResult - Find a DBIC Results from Arguments
SYNOPSIS
The following is example usage for this role.
package MyApp::Controller::MyController;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller::ActionRole' }
__PACKAGE__->config(
action_args => {
user => { store => 'DBICSchema::User' },
}
);
sub user :Path :Args(1)
:Does('FindsDBICResult')
{
my ($self, $ctx, $id) = @_;
## This is always executed, and is done so before we dispatch to one of
## the following condition actions (but not before we attempt to find
## the @args in your store resultset.
}
sub user_FOUND :Action {
my ($self, $ctx, $user, $id) = @_;
## Your $id was found in DBICSchema::User and was passed to the action
## as $user. You also get the original $id in case you need it.
}
sub user_NOTFOUND :Action {
my ($self, $ctx, $id) = @_;
$ctx->go('/error/not_found');
}
sub user_ERROR :Action {
my ($self, $ctx, $error, $id = @_;
$ctx->log->error("Error finding User with $id: $error");
$ctx->detach; ## stop processing request;
}
Alternatively, use the subroutine attributes version, if you prefer to keep all the information related to actions closer together.
package MyApp::Controller::MyController;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller::ActionRole' }
sub user :Path :Args(1)
:Does('FindsDBICResult')
:Store('DBICSchema::User')
{
my ($self, $ctx, $id) = @_;
}
## remaining actions as in above example
Please see the test cases for more detailed examples.
DESCRIPTION
NOTE: For version 0.02 I added some code to make sure that when there are more than one find condition we don't stop on the first error. This was done so that if you are trying to match on a numeric column (like a common auto inc PK) and on some text column (such as a unique user name) we don't generate a hard stop error when trying to do a text find against the numeric PK. However this approach is not ideal, and as a result I am no longer convinced that feature is a good one. I hack around it in case people are using this in production code but I would encourage people to avoid using the multiply find condition feature when the matched columns are not of the same type.
This is a Moose::Role intending to enhance any Catalyst::Action, typically applied in your Catalyst::Controller::ActionRole based controllers, although it can also be consumed as a role on your custom action classes (such as any class which extends Catalyst::Action.)
Mapping incoming arguments to a particular result in a DBIx::Class based model is a pretty common development case. Making choices based on the return of that result is also quite common. For example, if you can't 'find' a record matching the args, you may wish to redirect to a not found error page. The goal of this action role is to reduce the amount of boilerplate code you have to write to get these common cases completed. It is intended to encapsulate all the boilerplate code required to perform this task correctly and safely.
Basically we encapsulate the logic: "For a given resultset, does the find condition return a valid result given the incoming arguments? Depending on the result, delegate to assigned handlers until the result is handled."
A find condition maps incoming action arguments to a resultset unique constraint. This condition resolves to one of three results: "FOUND", "NOTFOUND", "ERROR". Result condition "FOUND" returns when the find condition finds a single row against the defined ResultSet, NOTFOUND when the find condition fails and ERROR when trying to resolve the find condition results in a catchable error.
Based on the result condition we automatically forward to an action whose name matches a default template, as in the SYNOPSIS above. You may also override this default template via configuration. This makes it easy to configure common results to be handled by a common action.
Be default an ERROR result also calls a NOTFOUND (after calling the ERROR handler), since both conditions logically match. However ERROR is delegated to first, so if you go/detach in that action, the NOTFOUND will not be called.
When dispatching a result condition, such as ERROR, FOUND, etc., to a handler, we follow a hierachy of defaults or any handlers added in configuration. The first matching handler takes the request and the remaining are ignored.
It is not the intention of this action role to handle 'kitchen sink' tasks related to accessing the your DBIC model. If you need more we recommend looking at Catalyst::Controller::DBIC::API for general API access needs or for a more complete CRUD setup check out CatalystX::CRUD or Catalyst::Plugin::AutoCRUD.
EXAMPLES
Assuming "model("DBICSchema::User") is a DBIx::Class::ResultSet, we can replace the following code:
package MyApp::Controller::MyController;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller' }
sub user :Path :Args(1) {
my ($self, $ctx, $user_id) = @_;
my $user;
eval {
$user = $ctx->model('DBICSchema::User')
->find({user_id=>$user_id});
1;
} or $ctx->go('/error/server_error');
if($user) {
## You Found a User, do something useful...
} else {
## You didn't find a User (or got an error).
$ctx->go('/error/not_found');
}
}
With something like this code:
package MyApp::Controller::MyController;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller::Does' }
__PACKAGE__->config(
action_args => {
user => { store => 'Schema::User' },
}
);
sub user :Path :Args(1)
:Does('FindsDBICResult')
{
my ($self, $ctx, $arg) = @_;
}
sub user_FOUND :Action {
my ($self, $ctx, $user, $arg) = @_;
## You Found a User, do something useful...
}
sub user_NOTFOUND :Action {
my ($self, $ctx, $arg) = @_;
$ctx->go('/error/not_found')
}
sub user_ERROR :Action {
my ($self, $ctx, $error, $arg) = @_;
$ctx->go('/error/server_error', [$error]);
}
Or, if you don't need to handle any code for your exceptional conditions (such as NOTFOUND or ERROR) you can move more to the configuration:
package MyApp::Controller::MyController;
use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller::ActionRole' }
__PACKAGE__->config(
action_args => {
user => {
store => 'Schema::User',
handlers => {
notfound => { go => '/error/notfound' },
error => { go => '/error/server_error' },
},
},
},
);
sub user :Path :Args(1)
:Does('FindsDBICResult')
{
my ($self, $ctx, $arg) = @_;
}
sub user_FOUND :Action {
my ($self, $ctx, $user, $arg) = @_;
}
Another example this time with Chained actions and a more complex DBIC result find condition, as well as custom exception handlers:
__PACKAGE__->config(
action_args => {
user => {
store => { stash => 'user_rs' },
find_condition => { columns => ['email'] },
auto_stash => 'user',
handlers => {
notfound => { detach => '/error/notfound' },
error => { go => '/error/server_error' },
},
},
},
);
sub root :Chained :CaptureArgs(0) {
my ($self, $ctx) = @_;
$ctx->stash(user_rs=>$ctx->model('DBICSchema::User'));
}
sub find_user :Chained('root') :CaptureArgs(1)
:Does('FindsDBICResult') {}
sub show_details :Chained('user') :Args(0)
{
my ($self, $ctx, $arg) = @_;
my $user_details = $ctx->stash->{user};
## Do something with the #user_details, probably delegate to a View.
}
This would replace something like the following custom code:
sub root :Chained :CaptureArgs(0) {
my ($self, $ctx) = @_;
$ctx->stash(user_rs=>$ctx->model('DBICSchema::User'));
}
sub user :Chained('root') :CaptureArgs(1) {
my ($self, $ctx, $email) = @_;
my $user_rs = $ctx->stash->{user_rs};
my $user;
eval {
$user = $user_rs->find({email=>$email});
1;
} or $ctx->go('/error/server_error');
if($user) {
$ctx->stash(user => $user);
} else {
## You didn't find a User (or got an error).
$ctx->detach('/error/not_found');
}
}
sub details :Chained('user') :Args(0)
{
my ($self, $ctx, $arg) = @_;
my $user_details = $ctx->stash->{user};
## Do something with the details, probably delagate to a View, etc.
}
Another example where the controller is very thin, basically we are just getting a result (or not) from a store and letting a View pick it up:
package MyApp::Controller::User;
use Moose;
BEGIN {
extends 'Catalyst::Controller::ActionRole';
}
__PACKAGE__->config(
action => {
'user' => {
Path => 'user',
Args => 1,
Does => 'BuildDBICResult',
},
},
action_args => {
'user' => {
store => 'Schema::User',
auto_stash => 1,
handlers => {
notfound => { go => '/error/notfound' },
error => { go => '/error/server_error' },
},
},
}
);
sub user {};
1;
And assuming you have a root end action that is using Catalyst::Action::RenderView or similar, you will automatically delagate to a View object and send it a stash with a 'user' result.
Overall the idea here is to factor out a lot of boilerplate conditionals and replace them with a reasonable set of declarative conventions. Additionally more behavior is moved to configuration, which will allow more flexible and rapid development and more easily centralized behaviors.
NOTE: Variable and class names above choosen for documentation readability and should not be considered best practice recomendations. For example, I would not name my Catalyst::Model::DBIC::Schema based model 'DBICSchema'.
ATTRIBUTES
This role defines the following attributes.
store
This defines the accessor by which we get a DBIx::Class::ResultSet suitable for applying a "find_condition". The canonical form is a HashRef where the keys / values conform to the following template.
{ model||accessor||stash||value||code => Str||Code }
Default is accessor =
'model_resultset'>, details follow:
- {model => '$dbic_model_name'}
-
Store comes from a Catalyst::Model::DBIC::Schema based model. This is the string you put in "$c->model" as in "$c->model('DBICSchema::User')".
__PACKAGE__->config( action_args => { user => { store => { model => 'DBICSchema::User' }, }, } );
This retrieves a DBIx::Class::ResultSet via $ctx->model($dbic_model_name).
- {accessor => '$get_resultset'}
-
Calls a accessor on the containing controller. This is defined as a method which returns but doesn't mutate the instance data, such as created by "is=>'ro'" in a Moose attribute option list.
__PACKAGE__->config( action_args => { user => { store => { accessor => 'user_resultset' }, }, } ); has user_resultset => ( is => 'ro', lazy_build =>1, ); sub _build_user_resultset { my ($self) = @_; return $self->_app->model('Schema::User'); } sub user :Action :Does('BuildDBICResult') :Args(1) { my ($self, $ctx, $arg) = @_; } sub user_FOUND :Action { my ($self, $ctx, $user, $arg) = @_; }
The containing controller must define this accessor and it must return a proper DBIx::Class::ResultSet or an exception is thrown.
Since this is an accessor we are calling, we just invoke it with the calling controller instance only, as in $controller->$accessor. If you need a more flexible code object, or something that can have access to more information please see the 'code' store below.
- {stash => '$name_of_stash_key' }
-
Looks in $ctx->stash->{$name_of_stash_key} for a resultset.
__PACKAGE__->config( action_args => { user => { store => { stash => 'user_rs' }, }, } );
This is useful if you are descending a chain of actions and modifying or restricting a resultset based previous user actions.
- {value => $resultset_object}
-
Assigns a literal value, expected to be a value DBIx:Class::ResultSet
__PACKAGE__->config( action_args => { user => { store => { value => $schema->resultset('User') }, }, } );
Useful if you need to directly assign an already prepared resultset as the value for doing $rs->find against. You might use this with a more capable inversion of control container, such as Catalyst::Plugin::Bread::Board.
- {code => sub { ... }||'controller_method_name'}
-
Similar to the 'value' option above, might be useful if you are doing tricky setup. Should be a subroutine reference that return a DBIx::Class::ResultSet or the string name of a method inside the containing controller.
sub get_me_a_resultset { my ($controller, $action, $ctx, @args) = @_; ## Some custom instantiation needs return $resultset; } __PACKAGE__->config( action_args => { user => { store => { code => 'get_me_a_resultset', }, }, role => { store => { code => sub { my ($controller, $action, $ctx, @args) = @_; ## inlined code return #resultset; }, }, }, } );
The coderef gets the following arguments: $controller, which is the controller object containing the action, $action, which is the action object for the Catalyst::Action based instance, $ctx, which is the current context, and an array of arguments which are the arguments passed to the action.
NOTE: In order to reduce extra boilerplate and needless typing in your configuration, we will automatically try to coerce a String value to one of the listed HashRef values. We coerce depending on the String value given based on the following criteria:
- store => Str
-
We automatically coerce a Str value of $str to {model => $str}, IF $str begins with an uppercased letter or the string contains "::", indicating the value is a namespace target, and to {stash => $str} otherwise. We believe this is a common case for these types.
__PACKAGE__->config( action_args => { user => { ## Internally coerced to "store => {model=>'DBICSchema::User'}". store => 'DBICSchema::User', }, } ); ## Perl practices indicate you should Title Case object namespaces, but ## in case you have some of these we try to detect and do the right thing. __PACKAGE__->config( action_args => { user => { ## Internally coerced to "store => {model=>'schema::user'}". store => 'schema::user', }, } ); __PACKAGE__->config( action_args => { user => { ## Internally coerced to "store => {stash =>'user_rs'}". store => 'user_rs', }, } );
- store => blessed $object isa DBIx::Class::ResultSet
-
If the value is a blessed object of the correct type (DBIx::Class::ResultSet) we just assume your want a 'value' type.
__PACKAGE__->config( action_args => { user => { ## Internally coerced to "store => {value => $user_resultset}". store => $user_resultset, }, } );
- store => CodeRef
-
If the value is a subroutine reference, we coerce to the coderef type.
__PACKAGE__->config( action_args => { user => { ## Internally coerced to "store => { code => sub {...} }". store => sub { ... }, }, } );
Coercions are of course optional; you may wish to skip them to you want better self documenting code.
find_condition
This should a way for a given resultset (defined in "store" to find a single row. Not finding anything is also an accepted option. Everything else is some sort of error.
Canonically is an ArrayRef of HashRefs where:
[\%condition1, \%condition2, ...]
an where %condition is one of:
{constraint_name => '$name', ?match_order? => \@fields}
{columns => \@fields}
However we define some coercions for simple causes. If no value is supplied we default to {constraint_name => 'primary'}.
## in your DBIx::Class ResultSource
__PACKAGE__->set_primary_key('category_id');
__PACKAGE__->add_unique_constraint(category_name_is_unique => ['name']);
## in your L<Catalyst::Controller>
__PACKAGE__->config(
action_args => {
category => {
store => {model => 'DBICSchema::Category'},
find_condition => [
'primary',
'category_name_is_unique',
], ## ArrayRef[Str] coerced to {constraint_name => ...}
}
}
);
sub category :Path :Args(1) :Does('FindsDBICResult') {
my ($self, $ctx, $category_arg) = @_;
}
sub category_FOUND :action {}
sub category_NOTFOUND :action {}
sub category_ERROR :action {}
In this example $category_arg would first be checked as a primary key, and then as a category name field. This allows you a degree of polymorphism in your url design or web api.
Each unique constraint refers to one or more columns in your database. Incoming args to an action are mapped to columns by the order they are defined in the primary key or unique constraint condition, or in a configured order.
Example of reordering multi field unique constraints:
## in your DBIx::Class ResultSource
__PACKAGE__->add_unique_constraint(user_role_is_unique => ['user_id', 'role_id']);
## in your L<Catalyst::Controller>
__PACKAGE__->config(
action_args => {
user_role => {
store => {model => 'DBICSchema::UserRole'},
find_condition => [
{
constraint_name => 'category_name_is_unique',
match_order => ['role_id','user_id'],
}
],
}
}
);
In the above case 'match_order' is used to define an explict expected order to map incoming arguments to fields in a result store constraints. If you don't set the match_order for a constraint_name, we default to the order you defined in your result store. Since this might change we recommend using match_order when you have a multi field constraint.
Additionally since most developers don't bother to name their unique constraints we allow you to specify a constraint by its column(s):
## in your DBIx::Class ResultSource
__PACKAGE__->add_unique_constraint(['user_id', 'role_id']);
## in your L<Catalyst::Controller>
__PACKAGE__->config(
action_args => {
user_role => {
store => {model => 'DBICSchema::UserRole'},
find_condition => [
{
columns => ['user_id','role_id'],
}
],
}
}
);
sub role_user :Path :Args(2) {
my ($self, $ctx, $role_id, $user_id) = @_;
}
Please note that 'columns' is used merely to discover the unique constraint which has already been defined via 'add_unique_constraint'. You cannot name columns which are not already marked as fields in a unique constraint or in a primary key. The order you define fields in your columns option should map directly to the order expected by the incoming args. So if your find_condition style is columns, you don't need to use match_order.
We automatically handle the common case of mapping a single field primary key to a single argument in a controller "Args(1)". If you fail to defined a find_condition this is the default we use.
Please see "FIND CONDITIONS DETAILS" for more examples.
<B>NOTE:</B> The feature that allows more than a single find condition per action binding is now considered ill advised, since having a lookup across columns of different types can result in database bind type errors. We could probably solve this issue by performing some sanity tests on the conditions using the available column meta-data; test cases and patch very welcomed!
auto_stash
If this is true (default is false), upon a FOUND result, place the found result into the stash. If the value is alpha_numeric, that value is used as the stash key. if it is 1 or '1' we instead default to the name of the accessor associated with the consuming action. For example:
__PACKAGE__->config(
action_args => {
user => { store => 'DBICSchema::User', auto_stash => 1 },
},
);
sub user :Path :Args(1) {
my ($self, $ctx, $user_id) = @_;
## $ctx->stash->{user} is defined if $user_id is found.
}
This could be combined with the "handlers" attribute to make fast mocks and prototypes. See below.
NOTE: Currently if you set auto_stash to the string 'true' or 'TRUE', this will behave as though you are specifying the stash key (as in $c->stash(true=>$row)) which maybe not be what you want. This may change in the future in order to increase compatibility with configuration serialization that store booleans as "true", "false", etc. As a result we recommend avoiding using those key words as your stash key.
handlers
Expects a HashRef and is optional.
By default we delegate result conditions (FOUND, NOTFOUND, ERROR) to an action from a list of predefined options. These predefined options work very similarly to Catalyst::Action::REST, so if you are familiar with that system this will seem very natural.
First we try to match a result to an action specific handler, which follows the template $action_name .'_'. $result_condition. So for an action named 'user' which is consuming this role, there could be actions 'user_FOUND', 'user_NOTFOUND', 'user_ERROR' which would get $ctx->forwarded too AFTER executing the body of the consuming action.
If this template fails to match (as in you did not define such an action in the same Catalyst::Controller subclass as your consuming action) we then look for a 'global' action in the controller, which is in the form of an action named $result_condition (basically actions named FOUND, NOTFOUND or ERROR).
This could be useful if you wish to centralize control of execeptional conditions. For example you could create a base controller or controller role that defined the "NOTFOUND" or "ERROR" actions and then extend or consume that into the controller containing actions using this action role.
However there may be cases where you need direct control over the action that get's called for a given result condition. In this case you can add handlers to the end of the lookup list for a given result condition. This is a HashRef that accepts one or more of the following keys: found, notfound, error. Example:
handlers => {
found => { forward||detach||go||visit => $found_action_name },
notfound => { forward||detach||go||visit => $notfound_action_name },
error => { forward||detach||go||visit => $error_action_name },
}
Globalizing the 'error' and 'notfound' action handlers is probably the most useful. Each option key within 'handlers' canonically takes a hashref, where the key is either 'forward' or 'detach' and the value is the name of something we can call "$ctx->forward" or "$ctx->detach" on. We coerce from a string value into a hashref where 'detach' is the key. Example:
handlers => { notfound => '/notfound' },
would coerce to "handlers => {notfound => {detach => '/notfound'}}"
SUBROUTINE ATTRIBUTES
So far all the examples given have demonstrated used via configuration and the action_args
key. Personally, I think this is the most flexible and clean option. However, Catalyst actions have traditionally supported subroutine attributes as a means of configuration. Although subroutine attributes have some significant drawbacks, you may prefer them if you think of the configuration information as fundenmental to your action / controller design. If so, the following attributes can be set in this manner.
- Store
-
Same as
$action =
{store => $storage}>. Example:sub myaction :Action Store('{model=>"Schema::User"}')
- Find_condtion
-
Same as
$action =
{store => $storage}>. Example:sub myaction :Action Find_condition('{model=>"Schema::User"}')
Currently the options handlers
is not supported in this manner. This is because the data structures that compose this options are highly prone to error when I tried to write tests for them. Rational disagreement and patches in support would be very welcomed.
FIND CONDITION DETAILS
This section adds details regarding what a find condition is ond provides some examples.
defining a find condition
By default we automatically handle the most common case, where a single argument maps to a single column primary key field. In every other case, such as when you have multi field primary keys or you are finding by an alternative unique constraint (either single or multi fields) you need to declare the name of the DBIx::Class::ResultSource unique constraint you are matching against. Since DBIx::Class does not require you to name your unique constraints (many people let the underlying database follow its default convention in this matter), instead of a unique constraint name you may pass an ArrayRef of one or more columns which together define a uniqiue constraint. Please note if you use this form of defining a find condition, you must use an ArrayRef EVEN if your condition has only a single column.
Also note that in the case of multi field primary keys or unique constraints, we attempt to match against the field order as defined in your call to "primary_columns" in DBIx::Class::ResultSource or "add_unique_constraint" in DBIx::Class::ResultSource.
If you need to to specify the mapping of Catalyst arguments to unique constraint fields, please see 'match_order' options.
example find conditions
Find where one arg is mapped to a single field primary key (default case).
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::User',
find_condition => 'primary',
}
}
);
BTW, the above would internally 'canonicalize' the find_condition to:
find_condition => [{
constraint_name=>'primary',
match_order=>['user_id'],
}],
Same as above but the find condition can be any of several named constraints, all of which have the same number of fields. In this case we'd expect the underlying User ResultSource to define a primary key and a unique constraint named 'unique_email'.
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::User',
find_condition => ['primary', 'unique_email'],
}
}
);
Same as above, but the unique email constraint was not named so we need to map some fields to a unique constraint. Please note we actually look for a unique constraint using the named columns, failed matches throw an expection.
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::User',
find_condition => [
'primary',
{ columns => ['email'] },
],
},
},
);
An example where the find condition is a mult key unique constraint. This example also demonstrates the HashRef to ArrayRef of HashRefs coercion.
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::User',
find_condition => {
columns => ['user_id','role_id'],
},
},
},
);
As above but lets you specify an argument to field order mapping which is different from that defined in your DBIx::Class::ResultSource. This let's you decouple your Catalyst action arg definition from your DBIx::Class::ResultSource definition.
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::UserRole',
find_condition => {
constraint_name => 'primary',
match_order => ['fk_role_id','fk_user_id'],
},
}
}
);
Again, the above example coerces to ArrayRef of HashRefs. Please keep this in mind if you introspect the $action instance, since the coerced values may differ from those you placed in the configuration!
NOTES
The following section is additional notes regarding usage or questioned related to this action role.
Why an Action Role and not an Action Class?
Role are more flexible, you can combine many roles easily to compose flexible behavior in an elegant way. This does of course mean that you will need a more modern Catalyst based on Moose.
Why require such a modern Catalyst?
We need a version of Catalyst that is post the Moose migration; additionally we need equal to or greater than version '5.80025' for the ability to define 'action_args' in a controller. See Catalyst::Controller for more.
AUTHOR
John Napiorkowski <jjnapiork@cpan.org>
COPYRIGHT & LICENSE
Copyright 2010, John Napiorkowski <jjnapiork@cpan.org>
This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.