[Catalyst] Making Controllers thin and Models thick
Kee Hinckley
nazgul at somewhere.com
Tue Jul 17 15:33:44 GMT 2007
[Was: Re: [Catalyst] Command-line utility using Controller - examples?]
On Jul 17, 2007, at 9:21 AM, Ronald J Kimball wrote:
> (The Controller in this case is probably not as thin as it should
> be. :)
The issue I had is that Catalyst makes it very easy to do the
database part of the Model, but it's not immediately obvious where to
put the rest of the business logic. So it ends up going in the
Controller.
I finally resolved that by taking advantage of the ability to specify
the return class of the ResultSet. That allows me to create a custom
class for each table (along with a common super-class for generic and
cross-table stuff). The Controller has easy access to the ResultSet
class, and the ResultSet class has easy access to the database, so it
seemed like an ideal place to put the heavy logic.
The outline follows below. I'd be interested in what people think of
it, since this is my first Catalyst app.
----------
#
# Define the database portion of the Model
#
package Somewhere::Schema::MDB::Persona;
use base qw/Somewhere::Schema::Super::Class/;
__PACKAGE__->load_components(qw/ UTF8Columns Core /);
__PACKAGE__->table('persona', extra => { mysql_charset => 'utf8' });
__PACKAGE__->add_columns(
...
#
# Note that if you are defining the DB here, it's handy to have some
helper routines for common types like
# boolean and foreign-key
#
...
#
# Now the critical part. Specify that the result set returned for
this table has the following class.
#
__PACKAGE__->resultset_class(__PACKAGE__ . '::ResultSet');
#
# Now define the business logic of the Model,
#
# This could be in a separate file, but it seemed to make sense to
keep it here, so I just keep right on going.
# That makes it real easy to update the biz-logic if the db changes.
#
package Somewhere::Schema::MDB::Persona::ResultSet;
use base 'Somewhere::Schema::Super::ResultSet';
use Error qw(:try);
use Error::Throws;
use Somewhere::Util;
use NEXT;
throws ::Error::InUse, -format => 'The [_1] "[_2]" is already in use.';
------------
Now I can add helper methods to be called by the Controller. And
since they are attached to the ResultSet, they have all the necessary
context to do their work. It should never be necessary to pass $c to
them, since that would break their independence. In addition, I can
override update/insert to add validation. Because really the
Controller should only be doing validation specific to the View--
things like checking to make sure that both of the password fields
match when the user enters their initial password. Checking for
things like field length and correct types of data belong in the Model.
Another way to look at it is to consider any non-web utility programs
you write. Presumably you can't trust them not to make mistakes
either--but they won't be calling the controller, so.... In fact, I
define a separate validate function in the Model for each table. The
Controller calls it before calling the actual create/update
operations. By default it continues checking everything even if it
gets an error, and then it returns a list of errors. Each error is
flagged with the field or fields it applies to. So the Controller
can pass the error objects back to the View, which then uses the
field info to highlight the appropriate form fields. If the
validation routine returns no errors, then the create/update
operation is called. It calls the validate operation *again* (don't
rely on the caller doing the right thing!), but this time telling it
to throw an exception as soon as it sees any error.
Also on the trust side, it's the Controller's responsibility to
ensure that only the appropriate arguments are passed to the Model.
No passing every single variable given by the web browser to the
view. Aside from security issues, this also provides an opportunity
to remap argument names if necessary (E.g. db changes, ui hasn't).
So here's a piece of Controller code for doing signup for a service.
------------------
sub signup : Local {
my ($self, $c) = @_;
my (@errors);
# If the form hasn't been submitted yet, just display it
if (!$c->request->params->{'-signup'}) {
$c->template('to/signup.html');
return 1;
}
# Set some shortcut variables
my $params = $c->request->params();
my $acctparams = $params->{account};
# Check to see if the user typed things correctly
if ($acctparams->{email} ne $acctparams->{email2}) {
push(@errors, record Error::DoubleCheck(
-fields => [qw( account.email account.email2 )],
-text => 'email addresses',
));
}
if ($acctparams->{password} ne $acctparams->{password2}) {
push(@errors, record Error::DoubleCheck(
-fields => [qw( account.password account.password2 )],
-text => 'passwords',
));
}
#
# All the other error checking needs to be handled at the
bizlogic level.
#
# $persona and $account are result sets, I've started improving
my naming conventions, but
# haven't backfilled to this file yet.
my ($persona, $account);
my ($acctslice, $persslice);
# Pull out only the required fields
$acctslice = hashslice($params->{account}, qw( login password
email ));
$persslice = hashslice($params->{persona}, qw( name uri ));
# Get a handle on the resultset classes
$persona = $c->model('MDB::Persona');
$account = $c->model('MDB::Account');
# Validate everything and store the errors. -fieldprefix is
used to tell the validator how to munge
# field names to that the View will understand them. -action
specifies the kind of checking to be done
push(@errors, $account->validate($acctslice, -fieldprefix =>
'account.', -action => 'create'));
push(@errors, $persona->validate($persslice, -fieldprefix =>
'persona.', -action => 'create'));
if (!@errors) {
# If there are no errors, try and do the work. At some
point I need to make a DB-specific version
# of try that handles transactions automatically.
try {
$account->txn_do(sub {
my $acct = $account->create($acctslice);
my $pers = $acct->create_related('personas', $persslice);
});
# If we made it here, there were no errors, log the user
in. The login action will do a redirect.
$c->forward('login', $acctslice->{login}, $acctslice->{password})
or throw Error::Unexpected(qq/The account "$acctslice->{login}" was
created, but we were unable to log you in./);
return 1;
} otherwise {
# Something went wrong, push the error on the list.
push(@errors, $@);
} default Somewhere::Controller::To::Error::Unexpected; #TODO Make
shortcuts work with default
}
# This helper routine will merge these errors with any to be
found in $c->error(). Save them in the stash for
# the view to handle, clear them, and then set the template to
the provided name. (A second template name
# can be provided for cases when there are no errors, but this
time we know there are.)
return $c->cleanup(\@errors, 'to/signup.html');
}
------------------
and here's the validate routine for account.
------------------
sub validate {
my $self = shift;
my ($params, %options) = @_;
my (@errors, $fast);
#TODO Does DBix::Class provide anything for validation?
#TODO If not, make something that at least allows us to do
length/type validation automatically
# The -fast option tells us to bail out as soon as we see an
error. If -throw is set, we'll throw rather
# than return, but our superclass will take care of that.
$fast = $options{-fast};
do {
# If we're doing a create, make sure we have all the
required non-empty fields.
if ($options{-action} eq 'create') {
if (my @missing = hasvalues($params, qw(login password email))) {
push(@errors, record Error::Required(-args => ['create', join(', ',
@missing)], -fields => \@missing));
last if ($fast);
}
}
# TODO When we validate update's we'll need to diff between
identifying fields and ones to change
# Make sure the login and email address aren't already in use.
if ($self->single({ login => $params->{login} })) {
push(@errors, record Error::InUse(-args => ['account name',
$params->{login}], -fields => ['login']));
last if ($fast);
}
if ($self->single({ email => $params->{email} })) {
push(@errors, record Error::InUse(-args => ['email address',
$params->{email}], -fields => ['email']));
last if ($fast);
}
};
# Let the superclass do the necessary cleanup work.
return $self->NEXT::validate($params, %options, -errors =>
\@errors);
}
------------------
The validate routine in Super::ResultSet does some generic work, and
then this.
------------------
if (@errors && ($options{-throw})) {
$errors[0]->rethrow() if (@errors == 1);
throw Error::Multiple(-args => [@errors]);
}
return @errors;
------------------
Just to take things all the way back to the view, here's the code
that handles flagging form fields that correspond to errors. Note
that I'm using Embperl::Object for the View. (I have a complete
rewrite of the plugin which has far tighter integration than the one
on CPAN. I hope to release that in a few weeks.) The following code
assumes the Prototype javascript library has been loaded.
Technically this should be called "onload", but I haven't gotten
around to it yet, and it will (usually) work if added after the form
in question has been completely displayed.
------------------
<script lang="javascript">
[$ if ($errors) $]
[$ foreach my $err (@$errors) $]
[$ foreach my $f (@{ $err->{-fields} }) $]
$("[+ $f +]").addClassName('error-field');
[$ endforeach $]
[$ endforeach $]
[$ endif $]
</script>
------------------
Comments? Thoughts?
More information about the Catalyst
mailing list