Beans pt2: docs, tests, and more types

A couple of comments on the first post suggested that I look
into the command-line bookkeeping application ledger, or
indeed, its Haskell version hledger. They look very
interesting, but rather hard to wrap my head around. So though I’m going to
bear them in mind for later, I’ll carry on doing these sketches till I
understand the problem space better, at which point, perhaps I’ll either a)
steal some ideas from them, or b) realise they are undoubtedly the way forward
and start using them.

I used Module::Starter to retrospectively turn this project
into something I can release as a distribution to CPAN, with docs, tests, a
Makefile, and so on.

module-starter --module=Beans --mi --author=osfameron --email=osfameron@cpan.org

This also creates some skeleton docs, so I’ve gone in to add a few actual
notes (mainly just pointing at this blog), and to delete a few sections that
module-starter puts in by default:

AnnoCPAN
(I don’t think this is particularly useful, and don’t see the need to
advertise it from my module, as it’s already linked to from search.cpan.org)
CPANratings
This is useful, but again, it’s already linked to from
search.cpan.org. Why should I link to it from my module? Should I also include
buttons to pimp it on reddit and digg?

Then I added some tests in t/01-basic.t, for example:

my $item = Beans::Item->new(
    name     => 'Mortgage',
    value    => 500.00,
    due_date => '2009-10-01',
    comment  => 'Home sweet home',
    tags     => [qw/ mortgage foo bar /],
    );

ok $item, 'Object created successfully';

is $item->name,  'Mortgage',      'Name ok';
is $item->value, 500,             'Value ok';
is $item->due_date->month, 10,    'Date ok';

All very noddy stuff, but it helped me find a bug in the version I’d blogged
earlier! I hadn’t told the date fields to use the coercion I’d set, so
the above code failed, complaining quite rightly that ’2009-10-01′ isn’t a
DateTime object!

So I amended the date fields like so:

     has due_date  => ( isa      => DateTime, 
                        is       => 'rw', 
                        required => 1, 
                        coerce   => 1,
                      );

and all was again well. Yay for failing tests!
This brings me to a suggestion from John Napiorkowski to use MooseX::Types::DateTimeX to get my coercion
for free. This does indeed work, and I’ve changed the code to use it, though
it doesn’t by default use DateTime::Format::Natural — we’ll come back to
this later.

While we’re looking at types, let’s fix the crufty implementation of tags.
We’ve currently got an ArrayRef[NonEmptyStr], but really, we don’t want
an Array, because we want to be able to:

  • Check whether a given tag is active
  • Enable a tag (without duplicating it if it’s already present)
  • Delete a tag.

These aren’t so much features of arrays as of hashes, or, even better,
sets. I was about to enter a rabbit hole of implementing using a hash
and ::Meta::Attribute::Native::Trait:: when Stevan suggested
Set::Object and its wrapper
MooseX::Types::Set::Object.

This changes our code to:

    has tags      => (
                       isa      => 'Set::Object',
                       is       => 'rw',
                       accessor => '_tags',
                       coerce   => 1, # also accept array refs
                       handles => {
                           tags       => 'members', # random order
                           add_tag    => 'insert',
                           remove_tag => 'remove',
                           has_tag    => 'member'
                         },
                     );

which we can test like so:

is_deeply [ sort $item->tags],
    [qw/ bar foo mortgage /],     'Tags ok'
        or diag Dumper($item->tags);

ok $item->has_tag('foo'),         'Has tag foo';
ok $item->has_tag('bar'),         'Has tag bar';
ok $item->has_tag('mortgage'),    'Has tag mortgage';
ok ! $item->has_tag('baz'),       'Nonexistant tag baz';
$item->add_tag('baz');
ok $item->has_tag('baz'),         'Now has baz';
$item->remove_tag('foo');
ok ! $item->has_tag('foo'),       'Now lost tag foo';

Set::Object’s members function returns the contents in hashed order
(i.e., effectively random), but given that we know our “objects” are actually
strings, I’d prefer them in sorted order, which would simplify the is_deeply
test above. We could do this as a method instead, or possibly use
around to sort the results, but we’ll come back to this soon!