Managing Bi-directional Relationships in ColdFusion ORM - Array-based Collections
Posted At : March 29, 2010 6:00 AM | Posted By : Bob Silverberg
Related Categories: ColdFusion, CF ORM Integration
It's important to know that when you have a bi-directional relationship you should set the relationship on both sides when setting one side. There have been a number of discussions about this on the cf-orm-dev google group, including this one in which Barney Boisvert provides a very good explanation of why this is important. Brian Kotek has also written two articles on the subject in the past. If you're not already familiar with this topic I suggest you check out those links.
The general recommendation for addressing this requirement is to override the methods in your objects that set one side of the relationship (e.g., setX(), addX() and removeX()) so that they'll set both sides, rather than just the side of the object that was invoked. While doing some testing of the new CF9 ORM adapter for Model-Glue along with the new scaffolding mechanism that we're developing I needed to address this issue for a many-to-many bi-directional relationship. I found that there were a few wrinkles that made the task not quite as straightforward as I has originally imagined, so I figured I should share what I came up with.
The particular many-to-many in question used an array to store a collection of objects on one side, and a structure to store the collection of objects on the other side. I found that each of these implementations introduced their own wrinkles, so I'm going to start with a post about dealing with array-based collections and then follow up with a second post about struct-based collections. Let's start by looking at the cfcs in question. For this example I'm using Countries and Languages to experiment with many-to-manys. A County can have many Languages spoken in it and a Language can have many Countries in which it's spoken. Here's what the cfcs look like:
2{
3 property name="CountryId" fieldtype="id" generator="native";
4 property name="CountryCode" length="2" notnull="true";
5 property name="CountryName" notnull="true";
6 property name="Languages" fieldtype="many-to-many" cfc="Language"
7 type="array" singularname="Language" linktable="CountryLanguage";
8}
9
10component persistent="true" hint="This is Language.cfc"
11{
12 property name="LanguageId" fieldtype="id" generator="native";
13 property name="LanguageName" notnull="true";
14 property name="Countries" fieldtype="many-to-many" cfc="Country"
15 type="array" singularname="Country" linktable="CountryLanguage"
16 inverse="true";
17}
In order to ensure that both sides of the relationship are set whenever one side is explicitly set I need to override addLanguage() and removeLanguage() in Country.cfc and I need to override addCountry() and removeCountry() in Language.cfc. The code in each is virtually identical, as both sets of collections are implemented as arrays, so let's just look at Language.cfc:
2{
3 property name="LanguageId" fieldtype="id" generator="native";
4 property name="LanguageName" notnull="true";
5 property name="Countries" fieldtype="many-to-many" cfc="Country"
6 type="array" singularname="Country" linktable="CountryLanguage"
7 inverse="true";
8
9 public void function addCountry(required Country Country)
10 hint="set both sides of the bi-directional relationship" {
11 // set this side
12 if (not hasCountry(arguments.Country)) {
13 arrayAppend(variables.Countries,arguments.Country);
14 }
15 // set the other side
16 if (not arguments.Country.hasLanguage(this)) {
17 arguments.Country.addLanguage(this);
18 }
19 }
20
21 public void function removeCountry(required Country Country)
22 hint="set both sides of the bi-directional relationship" {
23 // set this side
24 var index = arrayFind(variables.Countries,arguments.Country);
25 if (index gt 0) {
26 arrayDeleteAt(variables.Countries,index);
27 }
28 // set the other side
29 if (arguments.Country.hasLanguage(this)) {
30 arguments.Country.removeLanguage(this);
31 }
32 }
33}
Let's walk through the code and discuss some of the issues I had to address, starting with the addCountry() method. Because I'm overriding the implicit addCountry() method I have to implement it myself, which means that I have to add the Country object that was passed in to the current Language object. I first check to see if the Country is already present in the Countries collection using the hasCountry() method, and if it is not then I add it to the Countries collection using arrayAppend(). Next I have to set the other side, meaning I have to add the current Language object to the Country object that was passed in. This is a simple matter of calling addLanguage() on the Country object and passing in this, which is the current Language object. You'll notice that before I do that I first check to make sure that the current Language isn't already assigned to the Country. Do you know why that's necessary?
If I didn't do that check then this routine would call the addLanguage() routine in Country.cfc, which would turn around and call the addCountry() routine in Language.cfc, which would then call the addLanguage() routine in Country.cfc, ad infinitum, and we'd have a marvelous infinite loop. I personally don't like infinite loops creeping into my code, so I make sure that I only call the addLanguage() method if the Language has not already been assigned.
Let's move on the the removeCountry() method. Just as with the addCountry() method, because I'm overriding the implicit removeCountry() method I have to implement it myself, which means that I have to remove the Country object that was passed in from the Countries collection in the current Language object. Removing a specific item from an array is not as straightforward as adding an item to an array, so I have to use arrayFind() to first locate the Country in the array and then use arrayDeleteAt() to remove it. I can then set the other side exactly as I have done in the addCountry() method. I use hasLanguage() to see whether the current Language is assigned to the Country, and if it is then I use removeLanguage() to remove it.
This all works pretty well, but there is one situation in which errors can be thrown with the above code, and that's when we're working with a brand new object. When ColdFusion creates an instance of Language.cfc, for example when we call entityNew("Language"), all of the properties start off as nulls, including any collections. This means that if we create a new Language object and then try to add a Country object to it, we'll get an error on the line that reads:
arrayAppend(variables.Countries,arguments.Country)because variables.Countries is not an array, it's a null. I've found the best way to deal with that is to default the collection to an empty array in the constructor. I add an init() method to my cfc that looks like this:
2 if (isNull(variables.Countries)) {
3 variables.Countries = [];
4 }
5 return this;
6}
Now I can always count on variables.Countries being an array, and my code should work in all situations.
As I mentioned earlier, the approach that one must take when a collection is implemented as a struct, rather than an array, is a bit different and comes with its own set of wrinkles, so I plan to cover that in a future post.
Note also that an altogether different approach could be taken in which one creates new methods for managing the relationships. One might create assignCountry() and clearCountry() methods which, because they are not overriding the implicit methods, could simply make use of the implicit addCountry() and removeCountry() methods, which would eliminate much of the complexity required above. Another advantage of taking that approach is that one ends up programming to an interface rather than an implementation, which is always to be desired. The downside to that approach is that you are essentially changing the API of your object, and I wanted to avoid doing that in this specific case as I was trying to get code to work with a generic ORM adapter, which would have no idea that it should be calling assignCountry() rather than addCountry(). As with everything, design decisions are full of tradeoffs.
I am a little less than pleased with how complex this task seems to be and perhaps there are much better ways of tackling this than I have documented above. If anyone has any suggestions please leave them as comments.
And just a nitpick, is there a reason you use capitalized property names? Generally, only class names are capitalized. So I'd expect the property to be named "countries" and not "Countries".
Regarding the capitalized property names, that's just the way I've always done it. I realize that it's not what everyone does, but it's a habit that I've found difficult to break. ;-)
Not a big fan of overriding those generated method too much.
To anyone else, please don't do what Henry is advocating. Leaving the objects in an invalid state and "hoping" that everything is cleared and reloaded is a recipe for complete disaster. If you have bidirectional relationships, you MUST enforce the validity of those relationships. Doing this in the setters is the best option since it ensures that the relationship is properly maintained without breaking encapsulation.
This also solves the cyclic problem: one side does both halves, the other side does nothing, so the delegation only happens in one direction.
In that case you'd have to implement all of the manipulation manually, right? Meaning that you wouldn't call the add or remove methods on the other object from within your overridden method. Is that correct?
Language.cfc:
function addCountry(cntry) {
cntry.addLanguage(this);
}
Country.cfc:
function addLanguage(lang) {
if (! hasLanguage(lang)) {
arrayAppend(languages, lang);
arrayAppend(lang.countries, this);
}
}
With the split implementation, calling country.addLanguage would result in four hasXXX checks, here there is only one.
The remove methods (which I've not shown) are equivalent, just with arrayFind/arrayDelete (or .remove()) instead of arrayAppend.
To make it work, replace "arrayAppend(lang.countries, this);" with "arrayAppend(lang.getCountries(), this);".
At first glance it may seem like this won't work, because you're not modifying the underlying array, you're modifying a copy returned from the getter, right? Actually, no. CF passes arrays by reference, not by value, so when the array is returned and you operate on it, the changes will be persisted.
Array's in CF are passed by value, not by reference and as per my testing I can't get yours/Barney's examples working.
See: http://www.bennadel.com/blog/275-Passing-Arrays-By... for details.
Dave