News

Welcome to End Point’s blog

Ongoing observations by End Point people

Test Fixtures for CakePHP Has-and-Belongs-to-Many Relationships

CakePHP, a popular MVC framework in/for PHP, offers a pretty easy-to-use object-relational mapper, as well as fairly straightforward fixture class for test data. Consequently, it's fairly easy to get into test-driven development with CakePHP, though this can take some acclimation if you're coming from Rails or Django or some such; the need to go through a web interface to navigate to and execute your test cases feels, to me, a little unnatural. Nevertheless, you can get writing tests pretty quickly, and the openness of the testing framework means that it won't get in your way. Indeed, compared to the overwhelming plethora of testing options one gets in the Ruby space -- and the accompanying sense that the choice of testing framework is akin to one's choice of religion, political party, or top 10 desert island album list -- CakePHP's straightforward testing feels a little liberating.

Which is why it was a little surprising to me that getting a test fixture going for the join table on a has-and-belongs-to-many (HABTM) association is -- at least in my experience -- not the clearest thing in the world.

One can presumably configure the fixture to merely use the table option in the fixture's $import attribute. However, as I was following the table and model naming conventions, I felt that I must be doing something wrong in my attempts to get a fixture going for a HABTM relationship, and consequently I eschewed the (potentially) easy way out to try to find a solution that ought to work.

So, let's say my relations were:

  • Product model: some stuff to sell
  • Sale model: individual "sale" events when particular products are promoted
  • A products_sales join table establishes a many-to-many relationship (can we all acknowledge that "many-to-many" is much more convenient for meatspace communication than the horrendously awkward "has-and-belongs-to-many"?) between these two fabulous structures

You can go with the usual Cake-ish model definitions:

# in app/models/product.php
class Product extends AppModel {
    $name = 'Product';
    $hasAndBelongsToMany = array(
        'Sale' => array('className' => 'Sale')
    );
}

# in app/models/sale.php
class Sale extends AppModel {
    $name = 'Sale';
    $hasAndBelongsToMany = array(
        'Product' => array('className' => 'Product')
    );
}
Since we're following the naming conventions here (singular model name fronts pluralized table name, the join table for the HABTM relationship uses pluralized names for each relation joined, in alphabetical order), then the above code should be all you need for the relationship to work.

Indeed, as explained in this helpful article on the HABTM-in-CakePHP subject, you should find that queries using these models will automatically include 'ProductsSale' model entries in their result sets, with that model being dynamically generated by the HABTM association.

So, that means you should be able to create a test fixture for the ProductsSale model, right?

# in app/tests/fixtures/products_sale.php
class ProductsSale extends CakeTestFixture {
    $name = 'ProductsSale';
    $import = 'ProductsSale';
    $records = array(
       a buncha awesome stuff...
    );
}

Unfortunately, at least with my experience on CakePHP 1.2.5, that doesn't work. When your test case attempts to load the fixture, you'll get SQL errors indicating that the test-prefixed version of your "products_sales" table doesn't exist.

I haven't done a sufficiently exhaustive analysis of the Cake innards to sort out why this is, and may yet do so. My guess based on nothing other than observation and intuition is that the auto-generated model is related only to the models involved in the HABTM relationship, through the bindModel method, and does not get generated in any global capacity such that it exists as a model in its own right. Consequently, while the testing code can guess the correct table name for the join table based on the naming conventions used for the fixture, since it doesn't relate to an extant model, it fails to go through the model-wrapping procedures that typically take place per test-case (setting up the test-space table per model, populating it from the fixture, etc.)

Fortunately, as illustrated by the aforementioned helpful article, we can front the join table with a full-fledged model class, and use that model class within the association definitions. This solves the problem of the broken fixture, as the fixture will now refer to a standard model and successfully set up the test table, data, etc.

That means the code becomes:

# in app/models/products_sale.php
class ProductsSale extends AppModel {
    /* the naming convention assumes singularized model name
       based on the entire table name; it does not make inner
       names singular.  This feels a little unclean.  If it
       really bothers you, recall the language you're using
       and I suspect you'll get over it. */
    $name = 'ProductsSale';
    /* The join table belongs to both relations */
    $belongsTo = array('Product', 'Sale');
}

# in app/models/product.php
class Product extends AppModel {
    $name = 'Product';
    /* Use the 'with' option to join through the new model class */
    $hasAndBelongsToMany = array(
        'Sale' => array('with' => 'ProductsSale')
    );
}

# in app/models/sale.php
class Sale extends AppModel {
    $name = 'Sale';
    /* And again, the 'with' option */
    $hasAndBelongsToMany = array(
        'Product' => array('with' => 'ProductsSale')
    );
}

No changes are necessary to the fixture for ProductsSale; once that join model is in place, it'll be good.

It is not uncommon for ORMs to provide magical intelligence for establishing HABTM relationships, and as a matter of convenience it's pretty handy. It is similarly common to allow for HABTM association through an explicitly-defined model class. While this ups the ceremony for setting up your ORM, there are benefits that come with it; a reduced reliance on magic can be distinctly advantageous if you ever get into hairy situations with ORM query wrangling, and it is reasonably common for a HABTM association to have annotations on the relationship itself. In each case, you'll be happy to have your join table fronted by a model class.

Hopefully this will save somebody else some trouble.

1 comment:

Nate Bean said...

Thanks for posting this. This saved me a lot of time.