Chained accessors in Moose

Posted Friday, May 15th at 4:02a.m. under Perl, Moose, Code


I've created my second repository on Github - MooseX::ChainedAccessors. The code is extremely simple, but very powerful: it provide a Moose attribute trait that allows you to method chain via write operations on your accessor methods. If you already know what this means and just want to grab the code, then you can do so from the github download page (I'm still waiting on my CPAN account being set up before I can upload it there).

Method chaining

Method chaining is a simple yet powerful way of creating concise APIs for objects which have lots of small, individual operations where we don't need to know about the return value. One of the most popular examples of method chaining in action is in the jQuery API:

var $mydiv = $('#my-div');
$mydiv.html('hello, world!');
$mydiv.slideUp();

// becomes...

$('#my-div').html('hello, world!').slideUp();

To achieve this, a new jQuery object is created and returned by the selector query and then each subsequent method call returns that same object. For example, a crude version of html might look like:

function html(html_as_str)
{
this.innerHTML = html_as_str;
return this;
}

And that one line - return this - is all there is to it. Regardless of the language, OO method chaining is achieved in the same way: in PHP it would be return $this, in Python return self, in Perl return $self, etc.

Accessor chaining

If method chaining is as simple as that, then adding chaining to accessors (methods which abstract read/write operations on class attributes) will be as simple as adding return $self to the end of write accessors. This is easy if you're manually writing your accessors, but Moose handles accessor creation for you using the has sugar subroutine. If you want method chaining on accessors, you'll need to write those accessors yourself - or extend Moose.

Why go to all the trouble? Well, the driving case for me was that I had created a Moose role which allowed me to display debug info on a per-instance basis. A simplified version of this Role is:

package MyApp::Roles::Debug;
use Moose::Role;

has 'debug' => (
is => 'rw',
isa => 'Bool',
default => sub { return 0; },
);

sub debug_message
{
my ($self, $message) = @_;
print $message . "\n" if $self->debug;
}

1;

This allowed me to attach my role to any complex class which required debugging when tests failed or strange things happened and I wanted an overview of what was happening. Typical usage was:

package MyApp::Model;
use Moose::Role;

with 'MyApp:Roles::Debug';

sub complex_method
{
my $self = shift;

# complex stuff in here.. lots of potential to go wrong
$self->debug_message("Here is some info about the current state");
# more complex stuff
return;
}


my $model = MyApp::Model->new(debug => 1);
$model->complex_method();

This worked fine, until I started trying to use this role with some of the APIs I had designed that took full advantage of method chaining and composition. In a previous post I showed some code from the Sugar::ORM project I'm currently working on. Well, the query interface (which is heavily based on the Django ORM) for that ORM looks like this:

my @model_objs = Model->query->filter(name__like => 'Example%');

The call to Model->query returns a Sugar::ORM::ResultSet instance which has a bunch of methods like filter, exclude, order_by and load_related which return $self unless called in list context. This allows us to build up complex filters based on criteria (like a query string from a GET request) or apply sorting and pagination all in one nice line:

my @bands = Band->query->filter(genre__name => 'Rock')->order_by(name => 'ASC');

# OR

my $query = Band->query->filter(events__date__gte => $now);
if(my @genres = CGI->param('genre'))
{
$query->filter(genre__id__in => [@genres]);
}
if(my $location = CGI->param('location'))
{
$query->filter(location__name => $location);
}
if(my $order_by = CGI->param('sort'))
{
$query->order_by(name => 'ASC');
}
my @bands = $query->limit(10);

In practice this has work really well, but an ORM is a fairly complex code base so there have been many edge cases that have led to subtle bugs. This has meant a lot of debugging to be done deep down in the ORM engine classes. I can add my debugging role to my ResultSet class but to turn on debugging on has meant splitting up the chained filters in test scripts:

my @results = Band->query->filter(genre__name => 'Rock')->order_by(name => 'ASC');

# becomes...

my $query = Band->query->filter(genre__name => 'Rock');
$query->debug(1);
my @results = $query->order_by(name => 'ASC');

This quickly became tedious, what I really wanted to do was set my attribute without breaking the chain.

MooseX::ChainedAccessors

Now, there are a couple of other ways I could have solved this problem: debug could have been passed into the call to Model->query or the debug attribute in my Role could have been changed to a method which set up chaining. But both solutions would have to be repeated any time I wanted chained accessors in the future. In the spirit of DRY, I wanted a more elegant and easier to implement solution. After a quick glance at the Moose docs and long look at how has works in Moose, the Chained trait was born. The debug attribute in my Role now needed one extra line:

package MyApp::Roles::Debug;
use Moose::Role;

has 'debug' =>
(
traits => ['Chained'],
is => 'rw',
isa => 'Bool',
default => sub { 0; },
);

# .. etc.

And I could now debug my ORM queries with one simple change:

Band->query->load_related->get(name => 'Mesa Verde');

# becomes ...

Band->query->debug(1)->load_related->get(name => 'Mesa Verde');

And that's all there is to it.

Installing

The package isn't on CPAN yet, so in the meantime you can install it manually by downloading from github, unpacking the tar into a temporary directory and running these commands:

perl Makefile.PL
make test
make install

That's it. The only dependency is, of course, Moose. Oh, and Module::Install (thanks Tim).

Posted by David McLaughlin on Friday, May 15th

7 Responses


1

Tim Heaney

Friday, May 15th

Also inc::Module::Install.

$ perl Makefile.PL
Can't locate inc/Module/Install.pm in @INC (@INC contains: /etc/perl /usr/local/lib/perl/5.10.0 /usr/local/share/perl/5.10.0 /usr/lib/perl5 /usr/share/perl5 /usr/lib/perl/5.10 /usr/share/perl/5.10 /usr/local/lib/site_perl .) at Makefile.PL line 3.
BEGIN failed--compilation aborted at Makefile.PL line 3.

2

David McLaughlin

Friday, May 15th

@ Tim

Thanks for the heads up. I'm new to the whole CPAN and automated install thing. Will fix the Makefile.

3

Tim

Friday, May 15th

I think the only thing you may need to fix is the assertion that the only dependency is Moose.

Is this blog working? I'm pretty sure I hit "Preview", but it posted right away.

4

David McLaughlin

Friday, May 15th

@Tim

Thanks, added it to the post.

I have been meaning to fix comment previews too. Should work now.

5

Tim

Friday, May 15th

Note that Module::Install has some fairly heavy prerequisites of its own. On my reasonably up-to-date system (Ubuntu 9.04), I got

cpanp> i Module::Install
Warning: prerequisite Archive::Tar 1.44 not found. We have 1.38.
Warning: prerequisite Devel::PPPort 3.16 not found. We have 3.13.
Warning: prerequisite ExtUtils::Install 1.52 not found. We have 1.44.
Warning: prerequisite ExtUtils::ParseXS 2.19 not found. We have 2.1802.
Warning: prerequisite File::Remove 1.42 not found.
Warning: prerequisite File::Spec 3.2701 not found. We have 3.2501.
Warning: prerequisite Module::CoreList 2.17 not found. We have 2.13.
Warning: prerequisite Module::ScanDeps 0.89 not found.
Warning: prerequisite PAR::Dist 0.29 not found.
Warning: prerequisite Test::Harness 3.13 not found. We have 2.64.
Warning: prerequisite YAML::Tiny 1.36 not found.
Writing Makefile for Module::Install

Thankfully, CPAN took care of all those for me. But woe betide the poor bastard trying to do this by hand.

Then MooseX::ChainedAccessors installed fine. Thanks for a terrific article (and module)!

6

Dave Rolsky

Friday, May 15th

Module::Install is designed to bundle itself with the distribution tarball when it is shipped. End users will not have to install all those prereqs.

However, when you try to build from the repository, you need to install Module::Install for yourself. Those prereqs are only needed for module authors using Module::Install, not end users.

7

draegtun

Friday, May 15th

Nice post & module.

BTW... did u notice MooseX::MutatorAttributes on CPAN? It does "same" thing but in slightly different way. I think an example of using this would be....

Band->query->set( debug => 1 )->load_related->get(name => 'Mesa Verde');

Never tried it but I remember it from Moose mailing list discussion.... http://groups.google.com/group/perl.moose/browse_frm/thread/700ba7408308e3f0/4ad4b7a641bf582f?lnk=gst&q;=benh#4ad4b7a641bf582f

/I3az/