[Catalyst] Making Controllers thin and Models thick
Zbigniew Lukasiak
zzbbyy at gmail.com
Wed Jul 18 10:39:25 GMT 2007
Hi there,
I am too working on a thick model component. It's a ResultSet base
class with functions bearing (provisional?) names: build_create and
build_update. They do params validation and update the database in a
transaction with data for both the individual record and related
stuff.
In short the CRUD controller actions using it can be as simple as:
sub update : Local {
my ( $self, $c, $id ) = @_;
if ( $c->request->method eq 'POST' ){
my $update_result = $c->model( 'DB::Table' )->build_update(
$id, $c->request->params() );
if ( $update_result->{status} eq 'OK' ){
$c->res->redirect( $c->uri_for( "table/view", $id") );
}else{
$c->stash( update_result => $update_result );
}
}else{
$c->stash( item => $c->model( 'DB::Table' )->find( $id ) );
}
}
Conforming to the rule about redirecting after a succesfull POST.
For validation it uses Data::FormValidator.
Some day I plan publish it to CPAN. It's not too polished yet - but
if someone is interested I can put it on some public place (if my
employer agrees).
--
Zbyszek
On 7/17/07, Kee Hinckley <nazgul at somewhere.com> wrote:
> [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?
>
> _______________________________________________
> List: Catalyst at lists.rawmode.org
> Listinfo: http://lists.rawmode.org/mailman/listinfo/catalyst
> Searchable archive: http://www.mail-archive.com/catalyst@lists.rawmode.org/
> Dev site: http://dev.catalyst.perl.org/
>
--
Zbigniew Lukasiak
http://brudnopis.blogspot.com/
More information about the Catalyst
mailing list