Skip to content

Instantly share code, notes, and snippets.

@jjn1056
Created May 14, 2010 19:09
Show Gist options
  • Save jjn1056/401514 to your computer and use it in GitHub Desktop.
Save jjn1056/401514 to your computer and use it in GitHub Desktop.
package Catalyst::ActionRole::FindDBICResult;
use Moose::Role;
use namespace::autoclean;
## Lots of unwritten code :)
1;
=head1 NAME
Catalyst::ActionRole::FindDBICResult
=head1 SYNOPSIS
Assuming "model("DBICSchema::User") is a L<DBIx::Class::ResultSet>, we can
replace the following code:
sub user :Path :Args(1) {
my ($self, $ctx, $photo_id) = @_;
my $photo;
eval {
$photo = $ctx->model('DBICSchema::User')->find({user_id=>$photo_id});
1;
} or $ctx->log->error("Error finding User: $@");
if($photo) {
## You Found a Photo, do something useful...
} else {
## You didn't find a photo (or got an error).
$ctx->go('/error/not_found');
}
}
With this code:
__PACKAGE__->config(
action_args => {
user => { store => 'Schema::User' },
}
);
sub user :Path :Args(1)
:ActionRole('FindsDBICResult')
{
my ($self, $ctx, $arg) = @_;
## This is always executed, and is done so first.
}
sub user_FOUND :Action {
my ($self, $ctx, $user) = @_;
}
sub user_NOTFOUND :Action {
my ($self, $ctx, $arg) = @_;
$ctx->go('/error/not_found')
}
sub user_ERROR :Action {
my ($self, $ctx, $error, $arg) = @_;
$ctx->log->error("Error finding User: $error")
}
Another example this time with Chained actions:
__PACKAGE__->config(
action_args => {
user => {
store => { stash_key => 'user_rs' },
find_condition => { columns => ['email'] },
auto_stash => 1,
handlers => {
notfound => { detach => '/error/notfound' },
},
}
);
sub root :Chained :CaptureArgs(0) {
my ($self, $ctx) = @_;
$ctx->stash(user_rs=>$ctx->model('DBICSchema::User'));
}
sub user :Chained('root') :CaptureArgs(1)
:ActionRole('FindsDBICResult') {}
sub details :Chained('user') :Args(0)
{
use Data::Dumper;
my ($self, $ctx, $arg) = @_;
my $user_details = $ctx->stash->{user};
## Do something with the details, probably delagate to a View, etc.
}
Please see the test cases for more detailed examples.
=head1 DESCRIPTION
Mapping incoming arguments to a particular result in a L<DBIx::Class> based model
is a pretty common development case. Making choices based on the return of that
result is also quite common. The goal of this action role is to reduce the
amount of boilerplate code you have to write to get these common cases completed.
Additionally, by canonicalizing how to handle these common cases, we hope to
increase code comprehension familiarity as well as leverage the many eyes of
the community to solve bugs and create a solid solution suitable for reuse.
Basically we encapsulate the logic: "For a given DBIC resultset, does the find
condition return a valid result given the incoming arguments? Depending on the
result, follow a chain of assigned handlers until the result is handled."
A find condition basically maps incoming action arguments to a DBIC 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 thrown error.
Based on the result condition we automatically call 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, like NOTFOUND, 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.
When dispatching a result condition, such as ERROR, FOUND, etc., to a handler,
we follow a hierachy of defaults, followed by 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 L<Catalyst::Controller::DBIC::API> for general API access needs or for a
more complete CRUD setup check out L<CatalystX::CRUD> or L<Catalyst::Plugin::AutoCRUD>.
=head1 ATTRIBUTES
This role defines the following attributes
=head2 store
This defines the method by which we get a L<DBIx::Class::ResultSet> suitable
for applying a L</find_condition>. The canonical form is a HashRef where the
keys / values conform to the following template.
=over 4
=item {model => '$dbic_model_name'}
Store comes from a L<Catalyst::Model::DBIC::Schema > based model.
__PACKAGE__->config(
action_args => {
user => {
store => { model => 'DBICSchema::User' },
},
}
);
This retrieves a L<DBIx::Class::ResultSet> via $ctx->model($dbic_model_name).
This is the default common case.
=item {method => '$get_resultset'}
Calls a method on the containing controller.
__PACKAGE__->config(
action_args => {
user => {
store => { method => 'get_user_resultset' },
},
}
);
The containing controller must define this method and it must return a proper
L<DBIx::Class::ResultSet> or an exception is thrown.
=item {stash_key => '$name_of_stash_key' }
Looks in $ctx->stash->{$name_of_stash_key} for a resultset.
__PACKAGE__->config(
action_args => {
user => {
store => { stash_key => 'user_rs' },
},
}
);
This is useful if you are descending a chain of actions and modifying or
restricting a resultset based on the context or other logic.
=back
NOTE: We also automatically coerce a Str value of $str to {model => $str}, since
this is a common case. For example
__PACKAGE__->config(
action_args => {
user => {
## Internally coerced to "store => {model=>'DBICSchema::User'}".
store => 'DBICSchema::User',
},
}
);
=head2 find_condition
This should a way for a given resultset (defined in L</store> to find a single
row. Not finding anything is also an accepted option. Everything else is some
sort error.
Canonically, the find condition is an arrayref of unique constraints, as
defined in L<DBIx::Class::ResultSource> either with 'set_primary_key' or with
'add_unique_constraint'. for example:
## in your DBIx::Class ResultSource
__PACKAGE__->set_primary_key('category_id');
__PACKAGE__->add_unique_constraint(category_name_is_unique => ['name']);
## in your (canonical expressed) L<Catalyst::Controller>
__PACKAGE__->config(
action_args => {
category => {
store => {model => 'DBICSchema::Category'},
find_condition => [
'primary',
'category_name_is_unique',
],
}
}
);
sub category :Path :Args(1) :ActionRole('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'],
}
],
}
}
);
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'],
match_order => ['role_id','user_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. Additionally the order of columns used in 'columns' is not
relevent or meaningful; if you need to control how your action args order map
to DBIC fields, 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. See the L<SYNOPSIS> for this
example.
This is an API overview, please see L</FIND CONDITIONS DETAILS> for more.
=head2 detach_exceptions
detach_exceptions => 1, # default is 0
By default we $ctx->forward to expection handlers (NOTFOUND, ERROR), which we
believe gives you the most flexibility. You can always detach within a handling
action. However if you wish, you can force NOTFOUND or ERROR to detach instead
of forwarding by setting this option to any true value.
=head2 auto_stash
If this is true (default is false), upon a FOUND result, place the found
DBIC result into the stash. If the value is alpha_numeric, that value is
used as the stash key. if its either 1, '1', 'true' or 'TRUE' we default
to the name of the method 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 L</handlers> attribute to make fast mocks and
prototypes. See below
=head2 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 L<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 L<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 => $found_action_name },
notfound => { forward|detach => $notfound_action_name },
error => { forward|detach => $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 'forward' is the key (unless 'detach_exceptions' is true).
If youd actually set the key value, that value is used no matter what the state
of L</detach_exceptions>.
=head1 METHODS
This role defines the follow methods which subclasses may wish to override.
=head1 FIND CONDITION DETAILS
This section adds details regarding what a find condition is on provides some
examples.
=head2 defining a find condition
A find condition is the definition of something unique we can match and return
a single row or result. Basically this is anything you'd pass to the 'find'
method of L<DBIx::Class::ResultSet>.
Canonically a find_condition is an ArrayRef of key limited HashRefs, but we
coerce from some common cases to make things a bit easier. Examples follow.
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
L<DBIx::Class::ResultSource> unique constraint you are matching against. Since
L<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 nstraint. 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
L<DBIx::Class::ResultSource/primary_columns> or L<DBIx::Class::ResultSource/add_unique_constraint>.
If you need to to specify the mapping of L<Catalyst> arguments to unique
constraint fields, please see 'match_order' options.
=head2 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',
columns=>['user_id'],
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.
__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 L<DBIx::Class::ResultSource>. This let's
you decouple your L<Catalyst> action arg definition from your L<DBIx::Class::ResultSource>
definition.
__PACKAGE__->config(
action_args => {
photo => {
store => 'Schema::User',
find_condition => {
columns=>['user_id','role_id'],
match_order=>['role_id','user_id'],
},
}
}
);
This last would internally canonicalize to:
__PACKAGE__->config(
action_args => {
photo => {
store => {model => 'Schema::User'},
find_condition => [{
constraint_name=>'fk_user_id_fk_role_id',
columns=>['user_id','role_id'],
match_order=>['role_id','user_id'],
}],
}
}
);
Please note the 'constraint_name' in this case is provided by the underlying
storage, the value given is a reasonable guess.
=head2 subroutine handlers versus action handlers
Based on the result of the find condition we try to invoke methods or actions
in the containing controller, based on a naming convention. By default we first
try to invoke an action based on the template $action."_".$result
=head1 NOTES
The following section is additional notes regarding usage or questioned related
to this action role.
=head2 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 L<Catalyst> based on L<Moose>.
=head2 Why require such a modern L<Catalyst>?
We need a version of L<Catalyst that is post the L<Moose> migration; additionally
we need equal to or greater than version '5.80019' for the ability to define
'action_args' in a controller. See L<Catalyst::Controller> for more.
=head1 AUTHOR
John Napiorkowski <jjnapiork@cpan.org>
=head1 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.
=cut
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment