CakePHP Sequence Behavior
A CakePHP behavior for maintaining a contiguous sequence of order values. Records can be grouped by zero, one or multiple fields and the contiguous sequence is maintained for each group when adding, editing or deleting records.
UPDATE 2010-02-01: I’ve refactored the behavior and the test cases to make it a bit more robust, and moved it to a CakePHP plugin in it’s own github repository. Please let me know if you have any problems either in the comments or via github. I’ve also updated the attachment instructions below for the fact that it’s now in a plugin. I’ve not updated the demo yet though.
For the impatient among you, here’s a demo (drag’n'drop the table rows, then click refresh to see the order field updated) and the code.
Controlling the order of things on a web site is a very frequent requirement.
It might apply to navigation items, lists of frequently asked questions, blog post categories, blocks of content on a page, images in a gallery etc etc.
You also often need to maintain separate lists in the same database table, identified by a group field, e.g. navigation items are typically grouped by a parent field.
The traditional way of controlling order is to have an order field in your database table, and drag and drop functionality in your index views which, when you drop a row, sends a serialized list of record IDs and their orders via Ajax to a specified controller action. Your application then unserializes the data and iterates through it, updating the order of each record in turn.
This approach is rubbish, for 2 reasons:
- A separate database update for every single record.
- If you move a record from one group to another, you need special logic to make sure the moved record appears at the bottom of the new group, and the orders in the old group have gaps, until you reorder it again!
This is inefficient and messy, so I wrote the SequenceBehavior.
The SequenceBehavior maintains a contiguous sequence of order values for all records in a database table whether adding, editing (including moving a record to a new group) or deleting them, and they can be grouped by zero, one or multiple fields. It works out which rows need updating then does them all in a single db call.
Here’s some pseudo code:
- Save:
- If adding new record:
- If order not specified:
- Insert at end of list i.e. highest order + 1
- If order specified:
- Inserts at specified order
- Increments order of all other records whose order >= order of inserted record
- If order not specified:
- If editing existing record:
- If order not specified and group not specified, or same:
- No Action
- If group specified and different:
- Decrement order of all records whose order > old order in the old group, and change order to highest order in new group + 1
- If order specified and group not specified or same:
- If new order < old order:
- Increments order of all other records whose order >= new order and order < old order
- If new order > old order:
- Decrements order of all other records whose order > old order and <= new order
- If new order == old order:
- No action
- If new order < old order:
- If order not specified and group not specified, or same:
- If adding new record:
- Delete:
- Decrement order of all records whose order > order of deleted record
To add it to your application, just grab the behavior from my github account and save it in your app/models/behaviors/ directory, then attach it to your models as follows:
For a model whose records are not grouped:
<?php
class Item extends AppModel {
var $name = 'Item';
var $actsAs = array('Sequence.Sequence');
}
?>
For a model whose records are grouped by a single field:
<?php
class GroupedItem extends AppModel {
var $name = 'GroupedItem';
var $actsAs = array('Sequence.Sequence' => array('group_fields' => 'group_field'));
}
?>
For a model whose records are grouped by multiple fields:
<?php
class MultiGroupedItem extends AppModel {
var $name = 'MultiGroupedItem';
var $actsAs = array('Sequence.Sequence' => array('group_fields' => array('group_field_1', 'group_field_2')));
}
?>
There are 2 other configuration options not seen in the above examples. Firstly, the default order field is ‘order’, but you can override this by passing in an ‘order_field’ option in the config array. The second is a ’start_at’ option which determines the order number your sequence starts at, typically 0 (default), or 1.
The SequenceBehavior also modifies any model’s order property that it is attached to, which automaically gets used as the default order in CakePHP’s Model::find() calls.
I’ve put a demo of the behavior on the labs section of my site. The 3 models listed above are availalable to play with. It’s just a quick baked demo, with some small additions for filtering the results into groups. I’ve also added some javascript magic to do the drag and drop re-ordering and Ajax save with a bit of help from jQuery and the Table Drag n Drop jQuery plugin from Isocra. Drag the rows then to check it’s working you can refresh the page and the row you dragged should be where you left it and the orders of all the records should update to maintain the sequence.
My jQuery is extreme beginner level, but here’s the code I added to my app/layouts/default.ctp to hook the parts together:
<script type="text/javascript">
$(document).ready(function() {
$("div.index table").tableDnD({
url: "<?php echo Router::url(array('action' => 'save_order')); ?>",
originalOrder: null,
onDragClass: "myDragClass",
onDragStart: function(table, row) {
originalOrder = jQuery.inArray(row, $("tr", table));
},
onDrop: function(table, row) {
var newOrder = jQuery.inArray(row, $("tr", table)) - 1;
if (newOrder != originalOrder) {
$.post(this.url+'/'+row.id, { 'data[<?php echo $model; ?>][order]': newOrder });
}
},
});
});
</script>
Basically it add the drag’n'drop shizzle to all tables inside a div class index, and when you start dragging a row it notes the initial order/offset, then when you drop, if different, it posts the new order/offset to a controller/action in my app which saves the order, the code for that is not very pretty, but I’ll include it here for completion:
<?php
class AppController extends Controller {
var $components = array('RequestHandler');
var $helpers = array('Javascript');
function save_order($id = null) {
Configure::write('debug', 0);
if (!$id || !is_numeric($id) || !isset($this->data[$this->modelClass]['order'])
|| empty($this->data[$this->modelClass]['order'])
|| !is_numeric($this->data[$this->modelClass]['order'])) {
exit;
}
die(json_encode($this->{$this->modelClass}->save($this->data, true, array('order'))));
}
?>
That’s it, it’s MIT licensed, so enjoy.



(6 votes, average: 3.83 out of 5)
18 Responses so far
February 10th, 2009
8:03 am
[...] CakePHP Sequence Behavior [...]
February 11th, 2009
4:36 pm
Really great! Thanks! This is just what I need for a project I’m working on.
June 16th, 2009
10:56 pm
Nice behavior – I was just about to write something with similar functionality but yours is great. Thanks for sharing!
June 17th, 2009
9:09 pm
Awesome work Neil! Exactly what I needed.
I did have to add:
$this->{$this->modelClass}->id = $id;
to avoid new records being inserted by save.
Thanks again!
July 23rd, 2009
5:13 pm
[...] http://www.neilcrookes.com/2009/02/09/cakephp-sequence-behavior/ [...]
August 19th, 2009
1:20 pm
Great behavior, and also a nice tutorial helping with jQuery TableDnD! However, the following line will cause attempts to move rows resulting in order=0 to fail.
empty($this->data[$this->modelClass]['order'])
My solution was simply to remove that sanity check, even though that might not be very elegant.
Another solution might of course have been to set the startAt value to 1.
September 18th, 2009
6:42 pm
Extremely useful behavior, thanks very much for sharing it!
However, I’m having a couple of problems using it and I’ve noticed they’re not working in your demo version either.
1) Handling of non-specified Order no longer works – if I try this on your version I get the error:
SQL Error: 1048: Column ‘order’ cannot be null [CORE/cake/libs/model/datasources/dbo_source.php, line 514]
I tried making the column allowed to be null on my version and all that happens is that I end up with a null order value (which then breaks the OldOrder code for any subsequent updates).
2) If I try to reorder the rows using the JQuery drag and drop rather than simply changing the order using the edit option, the moved row always appears one place higher in the order than it should be. So if I move an item into position 3, when I refresh it’ll appear in position 2.
I don’t know if this is perhaps related to the fact I can also drag an item above the paginate headings? (Meaning in the demo with orders 1, 2, 3, 4 and 5, dragging above the headings gives you order 0).
Do you happen to have a fix for either of these issues before I start working out one of my own?
Thanks again,
Nat.
September 29th, 2009
10:42 am
Great article, keep up the good works.
November 10th, 2009
8:14 am
Good stuff Neil!
I am going to try this in a project soon.
Thanks or sharing.
November 15th, 2009
11:24 pm
[...] CakePHP Sequence Behavior – Neil Crookes (February 9, 2009) Like so many other features, I was researching the best way to handle re-ordering of records via a CMS (e.g. so a user can change the order of photos in a gallery) when I chanced upon Neil’s handy little model behavior. His implementation is pretty smart in that it only executes database queries when it absolutely needs to, rather than just updating every record in a group when any order is changed. Throw in some JQuery AJAX drag and drop that Neil also provides – I’ve been a Prototype/Scriptaculous guy for a long time, but this is easy as pie! – and you’ve got a great re-ordering system in no time. [...]
January 26th, 2010
12:21 pm
Nice and usefull behaviour, but i found some problems with it.
at behaviour _setup() on line 171
$model->order = $this->escapedOrderField = $model->escapeField($this->orderField);
escapedOrderField is not correctly set. If use behaviour at many models. I was getting incorectly generated query, it was strange that in query was values from other model which is not belong to current model in any way. So figured out that it was problem in
$this->update = array(
‘action’ => array(
$this->escapedOrderField => $this->escapedOrderField . ‘ + 1′
),
‘conditions’ => array(
array($this->escapedOrderField . ‘ >=’ => $this->newOrder),
array($this->escapedOrderField . ‘ $this->oldOrder),
),
);
as it uses $this->escapeOrderField, and value was always the same model not currently updating.
Fixed it by moving escapeOrderField initialization to setSaveUpdateData method, and it is working fine.
Don’t know if it good way to solve this, as escapedOrderField is used also on other methods. Just take a look ;)
Thanks,
Aurimas
January 27th, 2010
1:43 am
@Aurimas thanks for the comment, I’ll check it out. Going to try and fine the time to refactor the behavior into a plugin so it can be added to project more easily. I think you’re right, there are some funny things going on when you have it attached to lots of models and you are doing multiple save operations on more than one of them, perhaps triggered by callbacks on other models. I’ll have a look at this at the same time.
February 11th, 2010
2:21 am
http://pastebin.com/f7a5bbae9
I’ve added moveUp() and moveDown() functions for moving an element so it could be compatible with the core CakePHP TreeBehavior. Hope You enjoy :).
@Neil
You could merge it into Your behavior on github :)
February 11th, 2010
11:52 pm
@prgTW, nice one, thanks for your contribution. I’ll add the methods as soon as I get chance.
February 22nd, 2010
10:47 am
Hello,
Thanks for fixing some problems, but found some new ^^. If both belonging models have sequence behaviour i was getting fatal error.
Fatal error: [] operator not supported for strings in C:\..\cake\libs\model\datasources\dbo_source.php on line 1126
For ex. User belongsTo Group and both have behaviour attached.
Have find quick fix 171 line change:
$queryData['order']
to
$queryData['order'][]
i would commit changes but never used git ^^
Regards,
Aurimas
February 23rd, 2010
12:32 am
@Aurimas, thanks, I’ll check it out.
March 9th, 2010
10:08 pm
Great behavior — Just what I needed!
fwiw, I’m grouping on three columns, and I find that I need to specify the value for each of the three when I want to change the value of any one of them. That is, if I change the value of one grouping field, I need to also provide the existing values for the other two fields when I update the record.
Otherwise _getHighestOrder() will not find any matching records, and will use the default start_at order number.
This is probably the expected behavior, but I thought I’d mention it.
March 10th, 2010
12:55 am
@craig, thanks for that, what might work is merging the values for old groups and new groups before passing it to _getHighestOrder().
E.g. in beforeSave() > Editing > Changing group > Order not specified section, change it to:
// Insert at end of new group
$model->data[$model->alias][$orderField] = $this->_getHighestOrder(
$model,
array_merge(
$this->_oldGroups[$model->alias]
$this->_newGroups[$model->alias]
)
) + 1;
I’ll check it out when I get chance, and add it with suitable test cases.
Leave a comment