Merge lp:~openerp-dev/openobject-server/trunk-orm-resequence-method-xmo into lp:openobject-server

Proposed by Xavier (Open ERP)
Status: Needs review
Proposed branch: lp:~openerp-dev/openobject-server/trunk-orm-resequence-method-xmo
Merge into: lp:openobject-server
Diff against target: 418 lines (+340/-2)
9 files modified
openerp/osv/orm.py (+94/-0)
openerp/tests/addons/test_resequence/__init__.py (+2/-0)
openerp/tests/addons/test_resequence/__openerp__.py (+15/-0)
openerp/tests/addons/test_resequence/ir.model.access.csv (+2/-0)
openerp/tests/addons/test_resequence/models.py (+15/-0)
openerp/tests/addons/test_resequence/tests/__init__.py (+13/-0)
openerp/tests/addons/test_resequence/tests/test_assumptions.py (+37/-0)
openerp/tests/addons/test_resequence/tests/test_resequence.py (+158/-0)
openerp/tests/common.py (+4/-2)
To merge this branch: bzr merge lp:~openerp-dev/openobject-server/trunk-orm-resequence-method-xmo
Reviewer Review Type Date Requested Status
OpenERP Core Team Pending
Review via email: mp+167250@code.launchpad.net

Description of the change

Adds a `resequence(ids, field='sequence')` to BaseModel. This is mostly used in drag&drop of the Kanban and List views, when the object has a sequencing field (which doesn't have to be named `sequence` though that's the most common case).

There are currently a pure-JS implementation in the list view and a basic Python implementation in a controller for the kanban view, both rewrite pretty much all records (not exactly true but close enough).

AL wanted resequencing to limit sequence number changes similar to the GTK client, which implements the behavior in `widget.model.group.ModelRecordGroup.set_sequence`, this was also the occasion to add the feature to the server so it doesn't have to be reimplemented everywhere all the time.

How the system works in the GTK client as far as I understood it:

* set_sequence gets the source and destination indexes for the moved record, and currently loaded records are set in `self.model`
* if there are duplicate sequence numbers across loaded models, all of `self.models` is resequenced by first taking existing unique sequence numbers then counting up from the biggest existing number (with an increment of 1).
* if there are no duplicates, find just the sub-sequence affected by the moved record (basically all records between the source and destination), rotate the sequence numbers (à la collections.deque.rotate) to match the move and write that out.

This both keeps sequence numbers more stable (especially if records are not moved around much) and optimizes the number of writes (the default length of a list view being 80 records…).

Theoretically, the optimization could be applied as long as there are no duplicates within the affected sub-sequence, but that's not implemented in the GTK client and there's more work to be done to avoid odd effects where the sub-sequence itself has no duplicates but there's a series of duplicates straddling the boundary (consecutive records with the same sequence number, but only the second one is included in the sub-sequence), since read() will order by oid (implicit) after having used _order, there's a risk of "shuffling" records as far as the user can see. So it's been left out for now.

To post a comment you must log in.
Revision history for this message
Vo Minh Thu (thu) wrote :

You assume `read()` follows `_order`, which seems good. But this means that you can't choose the `field` parameter in `resequence()`: you make a diff between the given ids and the ids as returned by `read()`.

You raise ValueError on some conditions. If multiple `insert` and `delete` are returned by get_opcodes(), they will reuse the same ops dictionary entry, so the len(ops)!=2 doesn't seem the right way to assert what you want. (You want exactly one insert and one delete.) Maybe you can loop over get_opcodes and directly assign `source` and `dest`... Maybe something like:

>>> source = None
>>> for tag, (i1, _, _, _) in get_opcodes():
... if tag == 'insert':
... source, multiple_sources = i1, source
...
>>> assert source and multiple_sources is None

I think the code would be simpler by having a single if source < dest, without factoring out to and reusing a single slice object.

zip(seq, ids) is funny, I would have thought of zip(ids, seq) instead :)

Otherwise, seems good to me.

Revision history for this message
Xavier (Open ERP) (xmo-deactivatedaccount) wrote :

> You assume `read()` follows `_order`, which seems good. But this means that you can't choose the `field` parameter in `resequence()`: you make a diff between the given ids and the ids as returned by `read()`.

Yes, should probably assert that the provided field is the (first?) one in _order. Would that be enough?

> If multiple `insert` and `delete` are returned by get_opcodes(), they will reuse the same ops dictionary entry, so the len(ops)!=2 doesn't seem the right way to assert what you want.

That's why there's a second condition asserting the `delete` op only (re)moves a single element just below that: first assert there's only two operations (technically it should check that there's a single insert and a single delete but I'm assuming we won't be resequencing 2 removals, that would be weird), then assert that the deletion only affects a single record.

> I think the code would be simpler by having a single if source < dest, without factoring out to and reusing a single slice object.

Something along the lines of

    if source < dest:
        s = slice(source, dest)
        new_seq = seq[s]
        new_seq.insert(0, new_seq.pop())
    else:
        s = slice(dest, source + 1)
        new_seq = seq[s]
        new_seq.append(new_seq.pop(0))
    ids = record_ids[s]

?

> zip(seq, ids) is funny, I would have thought of zip(ids, seq) instead :)

I interpreted `seq` as an index number (which it fundamentally is) and modeled the order after `enumerate`.

4888. By Xavier (Open ERP)

[MERGE] from trunk

4889. By Xavier (Open ERP)

[IMP] simplify slicing of records to resequence

Unmerged revisions

4889. By Xavier (Open ERP)

[IMP] simplify slicing of records to resequence

4888. By Xavier (Open ERP)

[MERGE] from trunk

4887. By Xavier (Open ERP)

[IMP] check that the reseq optimization does indeed reduce the number of writes where it should

4886. By Xavier (Open ERP)

[IMP] add partial resequencing optimization

4885. By Xavier (Open ERP)

[ADD] basic resequencing

take all existing unique sequence numbers, set them on provided ids
list, extend with new sequence numbers (from biggest one available) if
there were seq num dupes.

4884. By Xavier (Open ERP)

[TESTS] check resequencing assumption about ordering of read result

4883. By Xavier (Open ERP)

[FIX] setting and cleanup of cr in TransactionCase

* set cr on current class, not TransactionCase itself, to better
  isolate various test cases

* delete cr attribute at the end of the test, so things blow up
  clearly when setUp was not correctly called (e.g. overridden but did
  not call its own parent), otherwise the result is an odd error about
  a cursor already closed within the test.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'openerp/osv/orm.py'
--- openerp/osv/orm.py 2013-07-25 10:49:14 +0000
+++ openerp/osv/orm.py 2013-07-26 13:08:25 +0000
@@ -44,6 +44,7 @@
44import collections44import collections
45import copy45import copy
46import datetime46import datetime
47import difflib
47import itertools48import itertools
48import logging49import logging
49import operator50import operator
@@ -5365,6 +5366,99 @@
5365 raise AttributeError(5366 raise AttributeError(
5366 "'%s' object has no attribute '%s'" % (type(self).__name__, name))5367 "'%s' object has no attribute '%s'" % (type(self).__name__, name))
53675368
5369 def resequence(self, cr, uid, ids, field='sequence', context=None):
5370 """ Re-sequences the records to the order specified by ``ids``
5371 attempting to conserve existing order and sequence numbers
5372
5373 .. note::
5374
5375 the records will only be resequenced if they have been reordered,
5376 if ``ids`` is in its "natural" order the reseq request will be
5377 ignored
5378
5379 :param cr:
5380 :param int uid:
5381 :param list(int) ids: all ids available, after the record to move has
5382 been moved to its new position
5383 :param str field: "sequencing" field, defaults to ``'sequence'``
5384 :param dict context:
5385 """
5386 records = self.read(cr, uid, ids, [field], context=context)
5387
5388 record_ids = [record['id'] for record in records]
5389
5390 # record list was not reordered -> don't resequence
5391 if ids == record_ids: return
5392
5393 seq = [record[field] for record in records]
5394 # Get all non-repeating sequence numbers
5395 unique_seq = sorted(set(seq))
5396
5397 if len(unique_seq) != len(ids):
5398 # Re-sequence everything, start with non-repeating sequence numbers
5399 # then count from the biggest existing sequence number to get
5400 # missing due to dupes
5401 seq_next = unique_seq[-1] + 1
5402 new_seq = itertools.chain(unique_seq, itertools.count(seq_next))
5403 else:
5404 # All sequence numbers are unique, we can minimize writes by just
5405 # swapping around sequence numbers between the source and
5406 # destination of the moved record. Theoretically we could do that
5407 # if the set between source and destination only had uniques,
5408 # but things may get hairier if there are duplicates straddling
5409 # the boundary so leave that case to generic handling of duplicate
5410 # seqs for now
5411 ops = dict(
5412 (tag, (i1, i2, j1, j2))
5413 for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, record_ids, ids).get_opcodes()
5414 if tag in ('insert', 'delete'))
5415
5416 if len(ops) != 2:
5417 raise ValueError(_("Re-sequencing can only handle one moved record"))
5418
5419 source, source_, _1, _2 = ops['delete']
5420 if source_ != source+1:
5421 raise ValueError(_("Re-sequencing can only move one record at a time"))
5422 dest = ops['insert'][0]
5423
5424 # if a record is moved from back to front we have to include his
5425 # old position, and Python slices are half-open intervals
5426 #
5427 # index 2 -> index 5
5428 # 1, 2, 3, 4, 5, 6, 7, 8
5429 # + ^
5430 # | |
5431 # +-------+
5432 # to resequence:
5433 # 1, 2, 4, 5, 3, 6, 7, 8
5434 # +-----+
5435 #
5436 # index 5 -> index 2
5437 # 1, 2, 3, 4, 5, 6, 7, 8
5438 # ^ +
5439 # | |
5440 # +---------+
5441 # to resequence:
5442 # 1, 2, 6, 3, 4, 5, 7, 8
5443 # +--------+
5444
5445 # select only the records (and seqs) between change boundaries for
5446 # rewriting
5447 if source < dest:
5448 s = slice(source, dest)
5449 new_seq = seq[s]
5450 new_seq.insert(0, new_seq.pop())
5451 else:
5452 s = slice(dest, source + 1)
5453 new_seq = seq[s]
5454 new_seq.append(new_seq.pop(0))
5455 ids = record_ids[s]
5456
5457 for i, id in itertools.izip(new_seq, ids):
5458 self.write(cr, uid, id, {
5459 field: i
5460 }, context=context)
5461
5368# keep this import here, at top it will cause dependency cycle errors5462# keep this import here, at top it will cause dependency cycle errors
5369import expression5463import expression
53705464
53715465
=== added directory 'openerp/tests/addons/test_resequence'
=== added file 'openerp/tests/addons/test_resequence/__init__.py'
--- openerp/tests/addons/test_resequence/__init__.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/__init__.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,2 @@
1# -*- coding: utf-8 -*-
2import models
03
=== added file 'openerp/tests/addons/test_resequence/__openerp__.py'
--- openerp/tests/addons/test_resequence/__openerp__.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/__openerp__.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,15 @@
1# -*- coding: utf-8 -*-
2{
3 'name': 'Resequence testing',
4 'version': '0.1',
5 'category': 'Tests',
6 'description': """Testing of resequencing method""",
7 'author': 'OpenERP SA',
8 'maintainer': 'OpenERP SA',
9 'website': 'http://www.openerp.com',
10 'depends': ['base'],
11 'data': ['ir.model.access.csv'],
12 'installable': True,
13 'auto_install': False,
14}
15# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
016
=== added file 'openerp/tests/addons/test_resequence/ir.model.access.csv'
--- openerp/tests/addons/test_resequence/ir.model.access.csv 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/ir.model.access.csv 2013-07-26 13:08:25 +0000
@@ -0,0 +1,2 @@
1"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
2access_test_resequence_model,access_test_resequence_model,model_test_resequence_model,,1,1,1,1
03
=== added file 'openerp/tests/addons/test_resequence/models.py'
--- openerp/tests/addons/test_resequence/models.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/models.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,15 @@
1# -*- coding: utf-8 -*-
2from openerp.osv import orm, fields
3
4class Model(orm.Model):
5 _name = 'test_resequence.model'
6 _order = 'sequence'
7
8 _columns = {
9 'sequence': fields.integer(required=True)
10 }
11
12 def write(self, cr, user, ids, vals, context=None):
13 self.writes += 1
14 return super(Model, self).write(cr, user, ids, vals, context)
15
016
=== added directory 'openerp/tests/addons/test_resequence/tests'
=== added file 'openerp/tests/addons/test_resequence/tests/__init__.py'
--- openerp/tests/addons/test_resequence/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/tests/__init__.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,13 @@
1# -*- coding: utf-8 -*-
2
3from . import test_assumptions, test_resequence
4
5fast_suite = [
6]
7
8checks = [
9 test_assumptions,
10 test_resequence,
11]
12
13# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
014
=== added file 'openerp/tests/addons/test_resequence/tests/test_assumptions.py'
--- openerp/tests/addons/test_resequence/tests/test_assumptions.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/tests/test_assumptions.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,37 @@
1# -*- coding: utf-8 -*-
2import operator
3import random
4
5from openerp.tests import common
6
7class TestAssumptions(common.TransactionCase):
8 def setUp(self):
9 super(TestAssumptions, self).setUp()
10 self.Model = self.registry('test_resequence.model')
11 def create(self, values):
12 return self.Model.create(self.cr, self.uid, values)
13
14 def test_read_order(self):
15 """
16 The re-sequencing method assumes read() always returns its records
17 according to ``_order``, regardless of input ordering.
18 """
19 # generate a bunch of sequence numbers
20 seqs = sorted(set(random.sample(range(100), 20)))
21 # create corresponding records, ids ordering <=> seqs ordering
22 ids = [self.create({'sequence': i}) for i in seqs]
23
24 reverse = list(reversed(ids))
25 ids_reversed = map(
26 operator.itemgetter('id'),
27 self.Model.read(self.cr, self.uid, reverse))
28
29 self.assertEqual(ids_reversed, ids)
30
31 shuffled = list(ids)
32 random.shuffle(shuffled)
33 ids_shuffled = map(
34 operator.itemgetter('id'),
35 self.Model.read(self.cr, self.uid, shuffled))
36
37 self.assertEqual(ids_shuffled, ids)
038
=== added file 'openerp/tests/addons/test_resequence/tests/test_resequence.py'
--- openerp/tests/addons/test_resequence/tests/test_resequence.py 1970-01-01 00:00:00 +0000
+++ openerp/tests/addons/test_resequence/tests/test_resequence.py 2013-07-26 13:08:25 +0000
@@ -0,0 +1,158 @@
1# -*- coding: utf-8 -*-
2from openerp.tests import common
3
4class SequenceCase(common.TransactionCase):
5 def setUp(self):
6 super(SequenceCase, self).setUp()
7 self.Model = self.registry('test_resequence.model')
8 self.Model.writes = 0
9
10 def make(self, sequences):
11 return [
12 self.Model.create(self.cr, self.uid, {'sequence': s})
13 for s in sequences
14 ]
15
16 def assertSeq(self, ids, seqs):
17 """ Asserts that reading ``ids`` yields the same sequence numbers as
18 ``seq``
19 """
20 self.assertEqual(
21 self.Model.read(self.cr, self.uid, ids),
22 map(lambda id, seq: {'id': id, 'sequence': seq},
23 ids,
24 seqs))
25
26def rotate(seq): return seq[-1:] + seq[:-1]
27
28class TestCompleteResequence(SequenceCase):
29 """ Tests resequencing with multiple identical sequence values: should
30 resequence the whole ids list provided
31 """
32
33 def test_noop(self):
34 """ When the input list has not been altered from its original order,
35 nothing should be done
36 """
37 ids = self.make([1, 1])
38
39 self.assertIsNone(self.Model.resequence(self.cr, self.uid, ids))
40
41 self.assertSeq(ids, [1, 1])
42
43 def test_trivial(self):
44 """ If all records have the same sequence number, just resequence
45 everything
46 """
47 ids = self.make([1, 1])
48
49 rids = rotate(ids)
50 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
51
52 self.assertSeq(rids, [1, 2])
53
54 ids = self.make([1, 1, 1, 1])
55
56 rids = rotate(ids)
57 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
58
59 self.assertSeq(rids, [1, 2, 3, 4])
60
61 def test_start(self):
62 """ Should use the seq of the first record as its starting point
63 """
64 ids = self.make([45, 45])
65
66 rids = rotate(ids)
67 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
68
69 self.assertSeq(rids, [45, 46])
70
71 def test_reuse_existing(self):
72 """ Should reuse all existing unique sequence numbers before starting
73 its own counter
74 """
75 ids = self.make([5, 5, 8, 9, 9, 9, 9, 15, 16, 17, 42])
76
77 rids = rotate(ids)
78 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
79
80 self.assertSeq(rids, [5, 8, 9, 15, 16, 17, 42, 43, 44, 45, 46])
81 self.assertEqual(self.Model.writes, len(ids))
82
83 def test_move(self):
84 """ Technically could do a partial reseq since there are no duplicates
85 in the move range of the record
86 """
87 # | -> |
88 ids = self.make([1, 2, 2, 2, 3, 5, 8, 9, 9])
89
90 rids = list(ids)
91 rids.insert(6, rids.pop(4))
92 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
93
94 self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
95 self.assertEqual(self.Model.writes, len(ids))
96
97 def test_move_duplicate_straddling(self):
98 """ Complication on previous case: duplicate seq numbers just on the
99 boundary of moved object. Would it be possible to do partial reseqs
100 here or is there no choice but to do a full reseq?
101 """
102 # | -> |
103 ids = self.make([1, 2, 2, 3, 5, 8, 9, 9, 9])
104
105 rids = list(ids)
106 rids.insert(6, rids.pop(4))
107 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
108
109 self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
110 self.assertEqual(self.Model.writes, len(ids))
111
112 # | -> |
113 ids = self.make([1, 1, 1, 2, 2, 3, 5, 8, 9])
114
115 rids = list(ids)
116 rids.insert(6, rids.pop(4))
117 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
118
119 self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
120
121class TestPartialResequence(SequenceCase):
122 """ When the sub-sequence comprising the original and final indices of a
123 moved record only holds unique sequence ids, no need to write new ids from
124 scratch, can just rotate the existing one on the sub-sequence.
125 """
126 def test_move_down(self):
127 # | -> |
128 seq = [2, 4, 5, 12, 13, 24, 26, 31, 32, 34]
129 ids = self.make(seq)
130
131 rids = list(ids)
132 rids.insert(6, rids.pop(2))
133 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
134
135 self.assertSeq(rids, seq)
136 self.assertEqual(self.Model.writes, 5)
137
138 def test_move_up(self):
139 seq = [2, 4, 5, 12, 13, 24, 26, 31, 32, 34]
140 ids = self.make(seq)
141
142 rids = list(ids)
143 rids.insert(5, rids.pop(8))
144 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
145
146 self.assertSeq(rids, seq)
147 self.assertEqual(self.Model.writes, 4)
148
149 def test_swap(self):
150 seq = [2, 5, 8, 12]
151 ids = self.make(seq)
152
153 rids = list(ids)
154 rids.insert(1, rids.pop(2))
155 self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
156
157 self.assertSeq(rids, seq)
158 self.assertEqual(self.Model.writes, 2)
0159
=== modified file 'openerp/tests/common.py'
--- openerp/tests/common.py 2013-01-31 13:10:51 +0000
+++ openerp/tests/common.py 2013-07-26 13:08:25 +0000
@@ -91,12 +91,13 @@
91 def setUp(self):91 def setUp(self):
92 # Store cr and uid in class variables, to allow ref() and browse_ref to be BaseCase @classmethods92 # Store cr and uid in class variables, to allow ref() and browse_ref to be BaseCase @classmethods
93 # and still access them93 # and still access them
94 TransactionCase.cr = self.cursor()94 type(self).cr = self.cursor()
95 TransactionCase.uid = openerp.SUPERUSER_ID95 type(self).uid = openerp.SUPERUSER_ID
9696
97 def tearDown(self):97 def tearDown(self):
98 self.cr.rollback()98 self.cr.rollback()
99 self.cr.close()99 self.cr.close()
100 del type(self).cr
100101
101102
102class SingleTransactionCase(BaseCase):103class SingleTransactionCase(BaseCase):
@@ -114,6 +115,7 @@
114 def tearDownClass(cls):115 def tearDownClass(cls):
115 cls.cr.rollback()116 cls.cr.rollback()
116 cls.cr.close()117 cls.cr.close()
118 del cls.cr
117119
118120
119class RpcCase(unittest2.TestCase):121class RpcCase(unittest2.TestCase):