ColdFusion and ColdBox creating and using Nested Sets with ORM
Tags: ColdFusion 9, CFORM, Coldbox
I was curious on how to go about doing something like this in ColdFusion ORM, and how hard it would be to accomplish. The problem wasn't as hard as I thought it might be, in fact once you get around the concept of how Nested Sets work. It was fairly painless putting it together.
The first hurdle was setting up the Entity to be correct for our needs, and I ended up creating the following entity.
property name="categoryId" generator="identity" fieldtype="id";
property name="parent" column="parentId" fieldtype="many-to-one" CFC="forum" fkcolumn="parentId";
property name="left" column="leftId" type="numeric";
property name="right" column="rightId" type="numeric";
property name="name" ormtype="string" length="255";
property name="description" ormtype="text";
}
The first thing you might notice that I am doing here, is that I am extending the component with the BaseORMService from ColdBox. This is because later I am going to use some of its features, to make my life a little easier.
Now there are 3 points of interest in this code that really need to be explained, the first is the parent property where I am doing a many to one back on itself. This is not really important that you do it this way, you can very easily just use a normal numeric property on the entity, it is just that I would like to get the object with one line of code rather than two later.
The next 2 properties are left and right, which will be used to hold the left and right of this category.
I won't go into the workings of this, as it is fairly complicated to explain. However if you would like to read more and understand this, then just head on over to http://dev.mysql.com/tech-resources/articles/hierarchical-data.html.
Ok that is pretty much it as for the definition of the Entity, now comes the magical part.
The first bit of magic will be an ORMEvent that I will use called preInsert(), which will do a lot of work for us and the code will begin to look like this.
*************************************************************************************************/
public void function preInsert(any entity) {
var tmpSession = ORMGetSession();
var entityName = listLast(getMetaData(this).fullName,".");
var parentRight = 1;
if (isNull(getParent())) {
} else {
}
setLeft(parentRight);
setRight(parentRight + 1);
}
So far this is the beginning of what we are going to do, and I just want to explain a few things before giving away to much.
The very first thing we are doing here is getting a copy of the current session, so that we can continue along our way with whatever session management you have in place already.
The next thing I am doing is getting a copy of the entity name, this is not required and you'll see later why. But I did this because I actually could place this into a new file and extend it to reuse the code later, I haven't fully refactored this code as yet and is pretty much as it is since I got it happening. But if you aren't going to do that, or find yourself in a position where you'll ever only need this for one entity, then its up to you how you approach it.
And last but not least we then set the left and right properties of the entity we are trying to save, this will be explained how we calculate this a bit further on.
Now comes the interesting bit, we are going to see if the Entity object we are trying to save, actually has a parent or not. Because we need to do different things if it has or not.
So lets work on the fact that the Entity has no parent, and place this code in that block of code. I should also point out to people, that the executeQuery() is a method in the BaseORMService that we extended with earlier.
var lastSet = executeQuery(query='from #entityName# #entityName# order by #entityName#.right desc', max=1, asQuery=false);
if(arrayLen(lastSet)) {
parentRight = lastSet[1].getRight()+1;
} else {
parentRight = 1;
}
This is very simple and it gets the last used right, so if you followed the link explaining Nested Sets. You know the outer most parent will have the highest right, so you can then adjust the current Entity to use the next right on the left of this entity. That is pretty much it for saving an entity with no parent.
The next bit of magic will the come when the Entity does have a parent, and this will be handled with the following code.
savecontent variable="local.updateQuery" {
writeOutput("update #entityName# n set n.left = n.left + 2 where n.left > #parentRight#");
}
var updateLeft = tmpSession.createQuery(local.updateQuery);
updateLeft.executeUpdate();
savecontent variable="local.updateQuery" {
writeOutput("update #entityName# n set n.right = n.right + 2 where n.right >= #parentRight#");
}
var updateRight = tmpSession.createQuery(local.updateQuery);
updateRight.executeUpdate();
As you can see this is a little more complicated, so lets take a loot at it step by step.
The first thing we do is get the right value of the parent, we then go and update the left value of all data where the left is greater than then right of the parent. We then do the same with the right values, always increasing the left and right by the value of 2.
Now that takes care of all the adding of Categories in this example, but what if we want to delete a category from the database. And to overcome this we will need to use a method for deleting, which is going to look something like this.
*************************************************************************************************/
public void function delete(required any entity, boolean flush=false) {
var tmpSession = ORMGetSession();
var entityName = listLast(getMetaData(this).fullName,".");
// Delete the Entites between the Parent's left and Right nodes
savecontent variable="local.updateQuery" {
writeOutput("DELETE FROM #entityName# f WHERE f.left BETWEEN #entity.getLeft()# AND #entity.getRight()#");
}
var updateRight = tmpSession.createQuery(local.updateQuery);
updateRight.executeUpdate();
// Now update the remaining left nodes to take into consideration the change.
local.offset = (entity.getRight() - entity.getLeft()) + 1;
savecontent variable="local.updateQuery" {
writeOutput("update #entityName# n set n.left = n.left - #offset# where n.left > #entity.getRight()#");
}
updateRight = tmpSession.createQuery(local.updateQuery);
updateRight.executeUpdate();
// Now update the remaining right nodes to take into consideration the change.
savecontent variable="local.updateQuery" {
writeOutput("update #entityName# n set n.right = n.right - #local.offset# where n.right > #entity.getRight()#");
}
updateRight = tmpSession.createQuery(local.updateQuery);
updateRight.executeUpdate();
}
You can also see we are doing a bit of work in here as well, and basically it deletes the children and itself in the first HQL listed. But once we have done that we need to then adjust the left and right values, this enables that nested set is kept up to date when this occurs.
That's the hard part and at the same time, it becomes the easy part. The reason being is that by doing it here like this, we take all the extra work of having to lock, and manage the updates and rolling back etc., as it is all handled in the events.
And now that this work is out of the way, how do we go about using it.
category.setTitle('Furnishings');
ORMService.save(category);
category = new Category();
category.setTitle('Electronics');
ORMService.save(category);
parent = category.getCategoryId();
category = new Category();
category.setParent(parent);
category.setTitle('Televisons');
ORMService.save(category);
category = new Category();
category.setParent(parent);
category.setTitle('Computers');
ORMService.save(category);
parent = category.getCategoryId();
category = new Category();
category.setParent(parent);
category.setTitle('Desktops');
ORMService.save(category); category = new Category();
category.setParent(parent);
category.setTitle('Laptops');
ORMService.save(category);
This will basically create a Nested Set that has a top hierarchy called Furnishings and Electronics, and then create two sub categories for Electronics called Televisions and Computers, which computers will contain two more sub categories called desktops and laptops.
And all the maintenance of keeping the Nested Set up to date is taken care off.
Ok so we are able to create the categories, but how would we go about using them. Now please be aware that there are two ways to get this, and it will always depend on what your needs are but lets say I am wanting to mimic say All top parents, and all there children one level deep. If this doesn't make sense to you then phpBB uses this in their forum application, the first level is just the top categories with one level deep. And when you select a forum it then goes deeper by then getting a list to all children.
This example would mimic the way phpBB does the first screen on their forums.
writeOutput("
FROM category as category
where (
select Count(categorySubQuery)
from category categorySubQuery
where categorySubQuery.left < category.left and categorySubQuery.right > category.right) < 2
order by category.left
");
}
return ORMService.executeQuery(query=local.hqlQuery, asQuery=false);
I should also point out that as the delete is not an event, and it would have been so nice that ColdFusion had the postFlush() event. Then it would have made this so much smoother, but as the delete is a method then you will need to do this in your code.
category.delete(category);
Which isn't that much of a big deal, but it means that you can not use any other delete method, and you are forced to do it this way.
Well that's Nested Sets with ColdFusion ORM and ColdBox, hope you enjoyed this.
-
You don't mention what the problem is, but what I can tell you is that the entity is self referencing. That means that the problem you are talking about, is the actual name of the table or cfc.
In this example the name of the CFC is forum, hope that helps.# Posted By Andrew Scott | 5/19/11 5:42 PM -
Sorry, for not being more specific... I have a node component 'category'. The parent property is
"property name="parent" column="parentId" fieldtype="many-to-one" cfc="category" fkcolumn="parentId";"
for any reason I can't figure out why the self join doesn't work. 'parentRight = getParent().getRight(); ' throws an exception 'method was not found' after
category = new Category();
category.setLabel('Electronics');
entitySave(category);
parent = category.getCategoryId(); ;
category = new Category();
category.setParent(parent);
category.setLabel('Televisons');
entitySave(category); -
Can you contact me off list, so that I can chat with you.
The method not found might be the ColdBox stuff, if you aren't using ColdBox V3.0 you might experience what you might be describing.
Send me an email off line with the stack trace.# Posted By Andrew Scott | 5/20/11 10:09 AM -
Any chance you have a completed block of code? Your fields and things are different but it seems like that would help.
# Posted By John Farrar | 8/21/11 12:33 PM -
In what way are they different?
that is from my a project I was working on. But if you like to ping me by email, to ask more what you are after I will see what I can do for you.# Posted By Andrew Scott | 8/21/11 2:08 PM -
I got mine working also BTW. I think it was a ColdBox method you were using that confused me. :)
# Posted By John Farrar | 8/21/11 3:11 PM -
Ooops, you are not the first to get caught on that one.
Btw there is a known issue, with the creation of the tree. As per my example above you can't run them like I have done. This will provide false results, but rest assured that it does work provided the change is one per session request.# Posted By Andrew Scott | 8/21/11 3:18 PM -
Andrew - nice work here, thanks for sharing. I've been looking at the best way to handle nested sets, hopefully using CF ORM. I note you've got insertion handled, though I'm wondering how you are handling updates such as reparenting of an existing node. I haven't tried in this context, though I recall having trouble changing properties in preUpdate() (could just have been a mistake on my part). I suppose I could move to a service to encapsulate that logic, though I was curious how you're handling it as you've used the ORM entity itself for the insertion logic.
-
The updating is handled by the insert handler, this was more a proof of concept so I haven't run it through it is entire paces. But when you say update when are you referring to the update to happen, if you are changing the name of the entity then no you don't need to do anything, if you are deleting a node then this is handled by the delete handler, if you are inserting a node then this is also handled by the insert handler.
This was converted from a Java implementation, and the thing is that the correct ORM Events are not yet supported by ColdFusion.# Posted By Andrew Scott | 8/22/11 5:51 PM -
There is a discussion going on at the moment with this over on the CF-ORM dev mailing list, the idea behind updating is that you remove the node first. This would then fire the event delete that updates the nodes, and then you would insert it where you want it.
Due to a limitation in ColdFusion not allowing all ORM Events this is a hack at the moment, and not fully tested. As I still haven't run this thing through all scenarios due to putting this project on hold a long time ago.# Posted By Andrew Scott | 8/22/11 7:20 PM -
Andrew, I've been implementing your methods and they are working great while inserting and deleting of the hierarchical data. The problem that I am running into though is updating. If I have added a parent to an entity, this works fine. If I delete that entity, this works fine. However, if I remove the parent from the entity, the left and right values do not get updated. So the old parent still sees the entity as being a child and the old child still has left and right values that fall between the old parent's left and right values.
Do you have any thoughts on either a pre or post update method to be able to remove a parent from an entity?# Posted By Dean Lawrence | 11/1/11 8:37 AM -
Andrew, I'm sorry, I didn't read all the comments on the thread before I sent my previous question. I see that you already discussed this with Kerr.
# Posted By Dean Lawrence | 11/1/11 9:27 AM



TweetBacks