How DBIx::Class::ResultSet::WithMetaData can help keep your controllers clean
A little note on code cleanliness
When you started using Catalyst with DBIx::Class
, I'm betting that you
generated a resultset in your controller, then passed it to your view
(TT for example), then iterated through it. In short you ended up doing
loads of really complicated stuff in your TT templates. As you probably
learned, this is really bad, because you eventually end up with
complicated and messy templates that are hard to maintain. You should be
doing your data preparation in Perl, then passing some nicely formatted
data structure to your view to happily render with a minimum of logic.
A nice approach is to harness the resultset chaining magic that
DBIx::Class
provides to build up your resultset in reusable stages,
so first you add some data:
my $new_rs = $rs->search({}, { prefetch => [qw/artist/] });
And then restrict it a bit:
my $newer_rs = $new_rs->search({ price => { '>' => 6 } });
And then use DBIx::Class::ResultClass::HashRefInflator
to dump the
resultset to an array of hashrefs and put that in your stash. The data
could look something like this:
$newer_rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); my @tunes = $newer_rs->all; # [{ # 'id' => 1, # 'name' => 'Catchy tune', # 'price' => '7', # 'artist' => { # 'name' => 'Some dude' # } # }, # { # 'id' => 2, # 'name' => 'Not so catchy tune', # 'price' => '7.5' # 'artist' => { # 'name' => 'Some other dude' # } # }] $c->stash->{tunes} = \@tunes;
And then your view can just iterate through the data structure without having to deal with row objects or pull in more data from the database. And that's really great. It's much better to pass an already formatted data structure to your view then it is to pass a resultset to your view, because application will be much more maintainable if you're doing all the data processing in Perl.
This approach of building up the resultset in stages is great if you're just prefetching, or otherwise adding data that's in the database, but what if you need to add some stuff to the data structure that isn't in the database? Probably you'll end up doing something like this:
$rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); my @tunes = $rs->all; foreach my $tune (@tunes) { $tune->{score} = $score_map{ $tune->{id} }; # and so on, adding more stuff to the row's hashref }
Typically you'll do that in the controller, which is bad, because you should be keeping your logic in the model. When you realise this is bad you'll move it to the model; maybe you'll make a resultset method and call it from your controller like this:
my $formatted_tunes = $rs->get_formatted_arrayref; $c->stash->{tunes} = $formatted_tunes;
Which is sort of better, until you realise that in another action you need to reuse this method from somewhere else in your application, but this time you need more stuff, so maybe you extend your method like this:
my $formatted_tunes_with_extra_stuff = $rs->get_formatted_arrayref( with_extra_stuff => 1 ); $c->stash->{tunes} = $formatted_tunes_with_extra_stuff;
And then in another action you realise you need the same thing, but with a different scoring mechanism:
my $formatted_tunes_with_extra_stuff_and_a different_scoring_mechanism = $rs->get_formatted_arrayref( with_extra_stuff => 1, scoring => 'different' );
And soon your get_formatted_arrayref
method is unmaintainable. I
thought that it would be cool if I could just add this extra stuff by
chaining resultsets together:
my $formatted_tunes = $rs->with_score->display; my $formatted_tunes_with_extra_stuff = $rs->with_score->with_extra_stuff->display; my $formatted_tunes_with_extra_stuff_and_a different_scoring_mechanism = $rs->with_score( mechanism => 'different' )->with_extra_stuff->display;
And until you call display on it, it's still just a resultset, with the usual resultset methods:
my $formatted_tunes = $rs->with_score ->with_extra_stuff ->search({}, { prefetch => 'artist' }) ->display;
DBIx::Class::ResultSet::WithMetaData
allows you to do this - you can
attach extra metadata to your resultset without first flattening it to
a data structure, which will allow you to separate your formatting out to
separate methods in a relatively clean way that promotes reuse.
Whoa there, how do I add my own resultset methods?
You need to use a custom resultset, which is just a subclass of the
usual DBIx::Class::ResultSet
. There are two ways to add custom
resultsets. Preferably, you'll use load_namespaces in your
DBIx::Class::Schema class
, like this:
package MyApp::Schema; ... __PACKAGE__->load_namespaces( result_namespace => 'Result', resultset_namespace => 'ResultSet', );
In which case your custom resultsets will be automatically picked up from MyApp::Schema::ResultSet::*.
If you're not using load_namespaces, you can still make it work. Have a look at this: https://metacpan.org/module/DBIx::Class::ResultSource#resultset_class
And a super simple resultset class might look like this:
package MyApp::Schema::ResultSet::Tune; use strict; use warnings; use DBIx::Class::ResultClass::HashRefInflator; use Moose; extends 'DBIx::Class::ResultSet'; sub display { my ($self) = @_; $rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); my @return = $rs->all; return \@return; } 1;
Which provides a display method on your Tune resultsets, like so:
my $displayed = $schema->resultset('Tune')->display;
So down to business, what does DBIx::Class::ResultSet::WithMetaData actually provide?
The key method is called add_row_info
, which allows you to attach the
extra metadata to the rows. For example, in the first section we were
adding a score to each row. You could do that like this:
package MyApp::Schema::ResultSet::Tune; ... extends 'DBIx::Class::ResultSet::WithMetaData'; sub with_score { my ($self, %params) = @_; foreach my $row ($self->all) { my $score = $self->score_map->{ $row->id }; $self->add_row_info(id => $row->id, info => { score => $score }); } } ...
The only other method you need to worry about is the display method,
which flattens the resultset to a data structure, much like using
DBIx::Class::ResultClass::HashRefInflator
. But it also merges the
extra info attached using add_row_info. For example
my @tunes = $tune_rs->with_score->display; # [{ # 'id' => 1, # 'name' => 'Catchy tune', # 'price' => '7', # 'score' => '1.7', # }, # { # 'id' => 2, # 'name' => 'Not so catchy tune', # 'price' => '7.5' # 'score' => '1.2' # }]
So how does this keep my code clean again?
These are simple examples. If you have a fairly complex application (like just about any real production application) then you might be building up complex chains to format your data ready for the view. You'll be add adding all sorts of extra stuff. Look at this example I've taken from one of my apps:
$c->stash->{products} = $rs->with_images ->with_primary_image ->with_seller ->with_primary_category ->with_related_products ->with_options ->with_token ->display;
In another part of the application I need the products but with less info, but I can just easily reuse what I already have:
$c->stash->{products} = $rs->with_images ->with_primary_image ->with_seller ->with_token ->display;
This might look complicated, but it's quite elegant when compared with writing one huge method which accepts a ton of flags determining whether or not to include different bits of info to the data structure, or writing separate methods for each use case, or doing it in the templates, or doing it in the controller.
To summarize:
- you're not doing your data pre-processing in the view where it doesn't belong
- you're not doing it in the controller where it doesn't belong either
- you've split up your formatting into reusable methods inside the model
- you'll sleep well at night and your colleagues won't hate you do much
It's not very efficient though is it?
It's not really, no. You'll find that you're looping through the resultset repeatedly in order to format it, and if you're working with large resultsets, this might not be for you. But my general attitude is that you should make the code work in a maintainable and clean way, and then optimize for performance. Don't start coding yourself into a mess before you know the clean alternatives are slow.
And it's fairly experimental.
Although used in production on a couple of my applications, it's still newish and kind of experimental. But it's simple enough so improvements and optimizations shouldn't hard to make, and if you want to help improve things, I'm liberal with commit bits and co-maint rights.
AUTHOR
Luke Saunders <luke.saunders@gmail.com>