The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.

NAME

DBIx::Class::EasyFixture::Tutorial - what it says on the tin

VERSION

version 0.13

RATIONALE

Managing test data is hard enough without having a clean way of maintaining fixtures. DBIx::Class::EasyFixture makes it easy to define fixtures. Different scenarios can be loaded on demand to test different facets of your system. Fixtures can take a while to write, but once defined, there's less cutting and pasting of code.

CREATING YOUR FIXTURE CLASS

To use DBIx::Class::EasyFixture, you must first create a subclass of it. It's required to define two methods: get_fixture and all_fixture_names. You may implement those any way you wish and you're not locked into a particular format. Here's one way to do it, using a big hash (there are plenty of other ways to do this, but this is easy for a tutorial.

package My::Fixtures;
use Moo;    # (Moose is also fine)
extends 'DBIx::Class::EasyFixture';

my %definition_for = (
    # keys are fixture names, values are the fixture definitions
);

sub get_definition {
    my ( $self, $name ) = @_;
    return $definition_for{$name};
}

sub all_fixture_names { return keys %definition_for }

__PACKAGE__->meta->make_immutable;

1;

A stand-alone fixture

Writing fixtures is easy, so let's start with something simple.

Imagine you have the following table:

CREATE TABLE people (
    person_id INTEGER PRIMARY KEY AUTOINCREMENT,
    name      VARCHAR(255) NOT NULL,
    email     VARCHAR(255)     NULL UNIQUE,
    birthday  DATETIME     NOT NULL
);

Its DBIx::Class definition might look like this:

package Sample::Schema::Result::Person;
use strict;
use warnings;
use base 'DBIx::Class::Core';
__PACKAGE__->load_components("InflateColumn::DateTime");

__PACKAGE__->table("people");

__PACKAGE__->add_columns(
    "person_id",
    { data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
    "name",
    { data_type => "varchar", is_nullable => 0, size => 255 },
    "email",
    { data_type => "varchar", is_nullable => 1, size => 255 },
    "birthday",
    { data_type => "datetime", is_nullable => 0 },
);

__PACKAGE__->set_primary_key("person_id");
__PACKAGE__->add_unique_constraint( "email_unique", ["email"] );

1;

(After this, we won't show much of the DBIx::Class code).

To define a fixture with a birthday, the email not@home.com and the name bob, you might have this:

basic_person => {
    new   => 'Person',
    using => {
        name     => 'Bob',
        email    => 'not@home.com',
        birthday => $datetime_object,
    },
}

The format of simple fixture is:

$fixture_name => {
    new   => $dbix_class_resultsource_name,
    using => $arguments_to_constructor,
}

Putting the above together, we get this:

package My::Fixtures;
use Moo;
use DateTime;
extends 'DBIx::Class::EasyFixture';
use namespace::autoclean;

my %definition_for = (
    basic_person => {
        new   => 'Person',
        using => {
            name     => 'Bob',
            email    => 'not@home.com',
            birthday => DateTime->new(
                year  => 1983,
                month => 12,
                day   => 25,
            ),
        },
    },
);

sub get_definition {
    my ( $self, $name ) = @_;
    return $definition_for{$name};
}

sub all_fixture_names { return keys %definition_for }

__PACKAGE__->meta->make_immutable;

1;

To use that in your test code:

use Test::More;
use My::Schema;
my $schema = My::Schema->new;

use My::Fixtures;
my $fixtures = My::Fixtures->new( { schema => $schema } );
$fixtures->load('basic_person');

my $person = $schema->resultset('Person')->find( { email => 'not@home.com' } );
is $person->name 'bob', 'Everything is OK. We found bob';

$fixtures->unload; # fixtures removed (transaction is rolled back)

done_testing;

As a convenience, you can also get "bob" by doing this:

$fixtures->load('basic_person');
my $person = $fixtures->get_result('basic_person');

Or by doing this:

my $person = $fixtures->load('basic_person');

One-to-one relationships

OK, that was easy, but what about this?

CREATE TABLE customers (
    customer_id    INTEGER PRIMARY KEY AUTOINCREMENT,
    person_id      INTEGER  NOT NULL UNIQUE,
    first_purchase DATETIME NOT NULL,
    FOREIGN KEY(person_id) REFERENCES people(person_id)
);

The customers table has a unique constraint against person_id. That means each person might be a customer in a one-to-one relation (to be fair, one-to-one relationships are merely a special case of one-to-many relationships and works the same way in this module). We can turn 'bob' into a customer by adding a new fixture, but for this example, 'bob' won't be a customer. Instead, we'll create a separate person, 'sally', and make them a customer:

person_with_customer => {
    new   => 'Person',
    using => {
        name     => "sally",
        email    => 'person@customer.com',
        birthday => $birthday,
    },
    next => [qw/basic_customer/],
},
basic_customer => {
    new      => 'Customer',
    using    => {
        first_purchase => $datetime_object,
        person_id      => { person_with_customer => 'person_id' },
    },
},

next

If you have a next key in your fixture definition, it takes an array reference of fixture names and tells us to load those fixtures after the current one.

requires

If you have a requires key, it takes a hash reference. They keys are fixtures to be loaded before the current fixture. The values are hash references of attribute mappings. The our key is our attribute name and the their key is the required fixture's method name. The required fixture(s) is loaded and the their method is called. The resulting value is passed to the using attribute of the fixture you're loading.

For example, for the basic_customer, we have to have a person_id. The requires section says "load the person_with_customer fixture". Then, if the person_with_customer.person_id is 3, the basic_customer's using block effectively becomes this:

using => {
    first_purchase => $datetime_object,
    person_id      => 3,
}

That allows you to create your fixture correctly since the customer.person_id is required.

As a short-cut, if our attribute name is the same as their attribute name, you can just do this:

requires => {
    person_with_customer => 'person_id',
}

The reason the our and their are separate is because many people just use a primary key name of id. This lets you do this:

requires => {
    person_with_customer => {
        our   => 'person_id',
        their => 'id',
    }
}

Naturally, you can supply multiple "required" objects.

If the "required" objects don't have attribute values that need to be passed to the current object, they should probably be passed in the next parameter instead.

If all of that for the requires section seems complicated, just forget about it. We also let you do this:

{
    new      => 'Customer',
    using    => {
        first_purchase => $datetime_object,
        person_id      => { some_person => 'id' },
    },
}

In other words, if the value of a using attribute is a hashref, we assume that the single key is the name of another fixture and we using that fixture's attribute to populate the current fixture's attribute. Internally, it's converted to a requires block, but you don't need to know about that.

If both the current fixture and the other fixture it requires have the same name for the attribute, a reference to the other fixture name (scalar reference) will suffice:

an_order_item => {
    new => 'OrderItem',
    using => {
        price    => $some_price,
        order_id => \'basic_order',
        item_id  => \'item_screwdriver',
    },
}

In the above example, we have a fixture named an_order_item that will create a new OrderItem object and its order_id will be the order_id from the basic_order fixture and the item_id will be the item_id from the item_screwdriver fixture.

Loading your one-to-one relationships.

This is the same as loading a standalone object:

my $fixtures = My::Fixtures->new( { schema => $schema } );
$fixtures->load('basic_customer');

# this is equivalent since these two fixtures require each other
$fixtures->load('person_with_customer');

# of course, you can still use $schema->resultset(...)->find to get these
my $person   = $fixtures->get_fixture('person_with_customer');
my $customer = $fixtures->get_fixture('basic_customer');

is $person->id, $customer->person_id, 'One-to-one relationships work';

The main difference between those two load calls is that the first returns a the basic_customer fixture and the second returns the person_with_customer fixture, though both load the same data:

my $customer = $fixtures->load('basic_customer');
my $person   = $fixtures->load('person_with_customer');

One-to-many relationships

You actually know everything there is to know about defining fixtures, but we'll give some more concrete examples.

Let's look at an orders table. A customer might have many orders.

CREATE TABLE orders (
    order_id    INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER  NOT NULL,
    order_date  DATETIME NOT NULL,
    FOREIGN KEY(customer_id) REFERENCES customers(customer_id)
);

Let's add two orders for our basic_customer.

order_without_items => {
    new      => 'Order',
    using    => {
        order_date  => $purchase_date,
        customer_id => { basic_customer => 'customer_id' },
    },
},
second_order_without_items => {
    new      => 'Order',
    using    => {
        order_date  => $purchase_date,
        customer_id => { basic_customer => 'customer_id' },
    },
},

And if you want to load both in your test code:

my @orders = $fixtures->load(qw/
    order_without_items
    second_order_without_items
/);

That will properly load the basic_customer (and, of course, the person_with_customer). However, because basic_customer did not have a next key, loading the basic_customer by itself does not load orders:

$fixtures->load('basic_person'); # doesn't load orders

If you want to force the customer to have these two orders, add the next key:

basic_customer => {
    new      => 'Customer',
    using    => {
        first_purchase => $datetime_object,
        person_id      => { person_with_customer => 'person_id' },
    },
    next => [qw/order_without_items second_order_without_items/],
},

Many-to-many relationships.

Orders aren't useful without items on them, so let's add two more tables:

CREATE TABLE items (
    item_id INTEGER PRIMARY KEY AUTOINCREMENT,
    name    VARCHAR(255) NOT NULL,
    price   REAL         NOT NULL
);

CREATE TABLE order_item (
    order_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id       INTEGER NOT NULL,
    order_id      INTEGER NOT NULL,
    price         REAL    NOT NULL,
    FOREIGN KEY(item_id)  REFERENCES items(item_id),
    FOREIGN KEY(order_id) REFERENCES orders(order_id)
);

(Side note about database normalization: the order_item table also has a price column because the the price of the item might change over time, or might be on sale. Fetching the price of orders should rely on the price the time the order was placed, not on the current item price)

So let's create hammer and screwdriver items, create an order for them and two order items.

# create an order with two items on it
item_hammer => {
    new   => 'Item',
    using => { name => "Hammer", price => 1.2 },
},
item_screwdriver => {
    new   => 'Item',
    using => { name => "Screwdriver", price => 1.4 },
},
order_item_hammer => {
    new      => 'OrderItem',
    using    => {
        price    => 1.2,
        item_id  => \'item_hammer',
        order_id => \'order_with_items',
    },
},
order_item_screwdriver => {
    new      => 'OrderItem',
    using    => {
        price    => .7,
        item_id  => \'item_screwdriver',
        order_id => \'order_with_items',
    },
},
order_with_items => {
    new      => 'Order',
    using    => {
        order_date  => $purchase_date,
        customer_id => \'basic_customer',
    },
    next => [qw/order_item_hammer order_item_screwdriver/],
},

Note that the order_with_items also uses the basic customer. You can reuse fixtures like this because internally we cache created fixtures.

Bi-directional relationships (deferred requires).

Occasionally you might need two entities that relate to each other, signifying different relationships:

CREATE TABLE people (
    person_id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name              VARCHAR(255) NOT NULL,
    favorite_album_id INTEGER,
    FOREIGN KEY(favorite_album_id) REFERENCES album(album_id)
);

CREATE TABLE album (
    album_id    INTEGER PRIMARY KEY AUTOINCREMENT,
    name        VARCHAR(255) NOT NULL,
    producer_id INTEGER,
    FOREIGN KEY(producer_id) REFERENCES people(person_id)
);

Say producer Rick Rubin produced ZZ Top's album "La Futura", and that it is his favorite album. You would specify that relationship in this way:

person_rick_rubin => {
    new => 'Person',
    using => { name => 'Rick Rubin' },
    requires => {
        album_la_futura => {
            our      => 'favorite_album_id'
            their    => 'album_id',
            deferred => 1,
        },
    },
},
album_la_futura => {
    new => 'Album',
    using => { name => 'La Futura' },
    requires => {
        person_rick_rubin => {
            our      => 'producer_id',
            their    => 'person_id',
            deferred => 1,
        },
    },
},

This has the net effect of creating both results, then backfilling the appropriate values on each result. Note that both sides of the relationship are marked as deferred.

If the foreign key on one of the two tables was defined as NOT NULL:

CREATE TABLE album (
    album_id    INTEGER PRIMARY KEY AUTOINCREMENT,
    name        VARCHAR(255) NOT NULL,
    producer_id INTEGER NOT NULL,
    FOREIGN KEY(producer_id) REFERENCES people(person_id)
);

Then that side of the relationship would not be deferrable, and should be defined thusly:

person_rick_rubin => {
    # same as above
},
album_la_futura => {
    new => 'Album',
    using => { name => 'La Futura' },
    requires => {
        person_rick_rubin => {
            our      => 'producer_id',
            their    => 'person_id',
            # Nope!
            # deferred => 1,
        },
    },
},

This would have the net effect of creating the person_rick_rubin result, then creating the album_la_futura result including the foreign key to its producer, and finally backfilling the favorite_album_id foreign key on the producer_rick_rubin result.

Groups

Sometimes you want to load multiple fixtures at once. One way to do this is like this:

$fixtures->load(@list_of_fixtures_to_load);

However, if you do this a lot, just create a group in your fixtures definition:

people => [qw/basic_person person_with_customer/]

Instead of a hashref for the key, have an array reference with all fixtures listed.

NOTES

As a general rule, when you call $fixtures->load(@fixture_names), any fixtures loaded will be cached. If a subsequent fixture attempts to load a fixture already loaded, it won't be reloaded.

Calling $fixtures->unload (or letting the $fixtures object drop out of scope) will clear the cache and allow you to start fresh,

AUTHOR

Curtis "Ovid" Poe <ovid@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2014 by Curtis "Ovid" Poe.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.