In GORM, when one class has a hasMany
relationship with another, a java.util.Set
is injected into the class. Sometimes, though, I want to use a List
instead in order to maintain ordering. The Grails reference documents (see section 5.2.4 specifically) discuss how to do that, but there are other issues that I needed to solve in order to make this work.
Consider an application that demonstrates the issue involved. It has only two domain classes, Quest
and Task
. A Quest
consists of many Task
s.
class Quest {
String name
static hasMany = [tasks:Task]
String toString() { name }
}
class Task {
String name
static belongsTo = [quest:Quest]
String toString() { name }
}
I’m using a bi-directional one-to-many association here, mostly because the dynamic scaffolding works well with it (not the best reason, of course, but it makes it easy to illustrate the point). The hasMany
assignment means that the Quest
class will have a Set
injected into it called tasks
, and the belongsTo
relationship means that all the cascade relationships (save, update, and delete) will work, too.
Before I take advantage of that in my boostrap code, though, I used the dynamic scaffolding just to make sure I could add quests and tasks through the normal views.
class QuestController { def scaffold = Quest }
class TaskController { def scaffold = Task }
As it happens, everything does work as advertised. A simple integration test that demonstrates it is shown below, which works.
void testAddTasks() {
Quest q = new Quest(name:'Seek the grail')
q.addToTasks(name:'Join King Arthur')
.addToTasks(name:'Defeat Knights Who Say Ni')
.addToTasks(name:'Fight Killer Rabbit')
.save()
assertEquals 3, Task.count()
}
Everything so far is standard stuff. One of the defining characteristics of a Set
, however, is that it does not support ordering. If I want ordering, there’s a chrysalis stage I can go through on the way to a List
, which is to use a SortedSet
(assuming Task
implements the Comparable
interface).
class Quest {
String name
SortedSet tasks
static hasMany = [tasks:Task]
String toString() { name }
}
class Task implements Comparable {
String name
static belongsTo = [quest:Quest]
String toString() { name }
int compareTo(Object o) {
return name.compareTo(o.name)
}
}
The dynamic scaffolding still works, too. I can add a task, as long as there is a quest available to add it to. The tasks are sorted by name, as they should be. I added the above quest and tasks to my bootstrap code, too, so they were available as soon as my server started.
Incidentally, there’s a down side to using a SortedSet
that I hadn’t realized right away. When I first wrote my application, I added a degree of difficulty to my tasks and tried sorting by them.
class Task implements Comparable {
String name
Integer difficulty
// ...
int compareTo(Object o) {
return difficulty - o.difficulty
}
}
That sorts tasks by difficulty all right, but there’s another consequence. I can only add a single task of a given difficulty to a particular quest! I can’t have two tasks both with the same difficulty. A SortedSet
may be sorted, but it’s still a set. 🙂
So now I move on to using a List
. As the reference documentation says, to do that, just declare tasks to be a reference of type List
.
class Quest {
String name
List tasks
// ...
static hasMany = [tasks:Task]
}
Now there’s trouble. The server starts, and the bootstrap code works, because it adds tasks to an existing quest before saving them. The dynamic scaffolding has a serious problem, though. When I go to the tasks list and try to add a new task, everything is fine until I try to save the new task.
If I try to add a new task through the “Create Task” page, I get an exception: “org.hibernate.PropertyValueException: not-null property references a null or transient value
“.
The reason is addressed in the reference documentation. First, changing to a list means that the database table for tasks now has an index column. Second, as the documentation says, you can’t save a task by itself any more — you have to add it to a quest first. It’s okay to say:
def t = new Task('Answer the bridgekeeper')
but I can’t save it by itself, or that index column will be a problem. I have to add the task to a quest first before saving.
Quest.get(1).addToTasks(t).save()
That works. Otherwise I get a null
in that index column, which throws an exception and down goes the server.
So, knowing that, how do I fix the system?
Well, I definitely have to abandon the dynamic scaffolding. The built-in save
method isn’t going to work, because it saves the task independently of the quest. So, it’s time to generate the real controllers.
After generating the task controller and views, the save
method looks like:
def save = {
def task = new Task(params)
if(!task.hasErrors() && task.save()) {
flash.message = "Task ${task.id} created"
redirect(action:show,id:task.id)
}
else {
render(view:'create',model:[task:task])
}
}
I need to add the task to a quest and then save the quest. Fortunately, one of the parameters in the request is the id of the quest, under params.quest.id
. That means my first try is to change the above code to this:
def save = {
def task = new Task(params)
def q = Quest.get(params.quest.id)
q.addToTasks(task)
if (!task.hasErrors() && q.save()) {
// ... etc ...
}
Unfortunately, this doesn’t work either. When I fill in the page to make a new task and try to save it, I get a NullPointerException
due to the fact that the task still has a null id.
This, I believe, turns out to be a Hibernate problem. Hibernate doesn’t do the save when I ask it to, but rather is waiting until the “right moment” to do the commit. Unfortunately, I need that commit right away.
Fortunately, there’s an answer to that, too. The save
method takes a flush
parameter which can be set to true
.
Therefore, I changed the above code to:
def save = {
def task = new Task(params)
def q = Quest.get(params.quest.id)
q.addToTasks(task)
if (!task.hasErrors() && q.save(flush:true)) {
// ... etc ...
}
Now, at long last, it all works. The key was to add the task to the quest and save the quest with flush set to true.
It’s possible that there are alternative solutions, but this one worked for me. If you know of better alternatives, please let me know.
Leave a Reply