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
1=== modified file 'openerp/osv/orm.py'
2--- openerp/osv/orm.py 2013-07-25 10:49:14 +0000
3+++ openerp/osv/orm.py 2013-07-26 13:08:25 +0000
4@@ -44,6 +44,7 @@
5 import collections
6 import copy
7 import datetime
8+import difflib
9 import itertools
10 import logging
11 import operator
12@@ -5365,6 +5366,99 @@
13 raise AttributeError(
14 "'%s' object has no attribute '%s'" % (type(self).__name__, name))
15
16+ def resequence(self, cr, uid, ids, field='sequence', context=None):
17+ """ Re-sequences the records to the order specified by ``ids``
18+ attempting to conserve existing order and sequence numbers
19+
20+ .. note::
21+
22+ the records will only be resequenced if they have been reordered,
23+ if ``ids`` is in its "natural" order the reseq request will be
24+ ignored
25+
26+ :param cr:
27+ :param int uid:
28+ :param list(int) ids: all ids available, after the record to move has
29+ been moved to its new position
30+ :param str field: "sequencing" field, defaults to ``'sequence'``
31+ :param dict context:
32+ """
33+ records = self.read(cr, uid, ids, [field], context=context)
34+
35+ record_ids = [record['id'] for record in records]
36+
37+ # record list was not reordered -> don't resequence
38+ if ids == record_ids: return
39+
40+ seq = [record[field] for record in records]
41+ # Get all non-repeating sequence numbers
42+ unique_seq = sorted(set(seq))
43+
44+ if len(unique_seq) != len(ids):
45+ # Re-sequence everything, start with non-repeating sequence numbers
46+ # then count from the biggest existing sequence number to get
47+ # missing due to dupes
48+ seq_next = unique_seq[-1] + 1
49+ new_seq = itertools.chain(unique_seq, itertools.count(seq_next))
50+ else:
51+ # All sequence numbers are unique, we can minimize writes by just
52+ # swapping around sequence numbers between the source and
53+ # destination of the moved record. Theoretically we could do that
54+ # if the set between source and destination only had uniques,
55+ # but things may get hairier if there are duplicates straddling
56+ # the boundary so leave that case to generic handling of duplicate
57+ # seqs for now
58+ ops = dict(
59+ (tag, (i1, i2, j1, j2))
60+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, record_ids, ids).get_opcodes()
61+ if tag in ('insert', 'delete'))
62+
63+ if len(ops) != 2:
64+ raise ValueError(_("Re-sequencing can only handle one moved record"))
65+
66+ source, source_, _1, _2 = ops['delete']
67+ if source_ != source+1:
68+ raise ValueError(_("Re-sequencing can only move one record at a time"))
69+ dest = ops['insert'][0]
70+
71+ # if a record is moved from back to front we have to include his
72+ # old position, and Python slices are half-open intervals
73+ #
74+ # index 2 -> index 5
75+ # 1, 2, 3, 4, 5, 6, 7, 8
76+ # + ^
77+ # | |
78+ # +-------+
79+ # to resequence:
80+ # 1, 2, 4, 5, 3, 6, 7, 8
81+ # +-----+
82+ #
83+ # index 5 -> index 2
84+ # 1, 2, 3, 4, 5, 6, 7, 8
85+ # ^ +
86+ # | |
87+ # +---------+
88+ # to resequence:
89+ # 1, 2, 6, 3, 4, 5, 7, 8
90+ # +--------+
91+
92+ # select only the records (and seqs) between change boundaries for
93+ # rewriting
94+ if source < dest:
95+ s = slice(source, dest)
96+ new_seq = seq[s]
97+ new_seq.insert(0, new_seq.pop())
98+ else:
99+ s = slice(dest, source + 1)
100+ new_seq = seq[s]
101+ new_seq.append(new_seq.pop(0))
102+ ids = record_ids[s]
103+
104+ for i, id in itertools.izip(new_seq, ids):
105+ self.write(cr, uid, id, {
106+ field: i
107+ }, context=context)
108+
109 # keep this import here, at top it will cause dependency cycle errors
110 import expression
111
112
113=== added directory 'openerp/tests/addons/test_resequence'
114=== added file 'openerp/tests/addons/test_resequence/__init__.py'
115--- openerp/tests/addons/test_resequence/__init__.py 1970-01-01 00:00:00 +0000
116+++ openerp/tests/addons/test_resequence/__init__.py 2013-07-26 13:08:25 +0000
117@@ -0,0 +1,2 @@
118+# -*- coding: utf-8 -*-
119+import models
120
121=== added file 'openerp/tests/addons/test_resequence/__openerp__.py'
122--- openerp/tests/addons/test_resequence/__openerp__.py 1970-01-01 00:00:00 +0000
123+++ openerp/tests/addons/test_resequence/__openerp__.py 2013-07-26 13:08:25 +0000
124@@ -0,0 +1,15 @@
125+# -*- coding: utf-8 -*-
126+{
127+ 'name': 'Resequence testing',
128+ 'version': '0.1',
129+ 'category': 'Tests',
130+ 'description': """Testing of resequencing method""",
131+ 'author': 'OpenERP SA',
132+ 'maintainer': 'OpenERP SA',
133+ 'website': 'http://www.openerp.com',
134+ 'depends': ['base'],
135+ 'data': ['ir.model.access.csv'],
136+ 'installable': True,
137+ 'auto_install': False,
138+}
139+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
140
141=== added file 'openerp/tests/addons/test_resequence/ir.model.access.csv'
142--- openerp/tests/addons/test_resequence/ir.model.access.csv 1970-01-01 00:00:00 +0000
143+++ openerp/tests/addons/test_resequence/ir.model.access.csv 2013-07-26 13:08:25 +0000
144@@ -0,0 +1,2 @@
145+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
146+access_test_resequence_model,access_test_resequence_model,model_test_resequence_model,,1,1,1,1
147
148=== added file 'openerp/tests/addons/test_resequence/models.py'
149--- openerp/tests/addons/test_resequence/models.py 1970-01-01 00:00:00 +0000
150+++ openerp/tests/addons/test_resequence/models.py 2013-07-26 13:08:25 +0000
151@@ -0,0 +1,15 @@
152+# -*- coding: utf-8 -*-
153+from openerp.osv import orm, fields
154+
155+class Model(orm.Model):
156+ _name = 'test_resequence.model'
157+ _order = 'sequence'
158+
159+ _columns = {
160+ 'sequence': fields.integer(required=True)
161+ }
162+
163+ def write(self, cr, user, ids, vals, context=None):
164+ self.writes += 1
165+ return super(Model, self).write(cr, user, ids, vals, context)
166+
167
168=== added directory 'openerp/tests/addons/test_resequence/tests'
169=== added file 'openerp/tests/addons/test_resequence/tests/__init__.py'
170--- openerp/tests/addons/test_resequence/tests/__init__.py 1970-01-01 00:00:00 +0000
171+++ openerp/tests/addons/test_resequence/tests/__init__.py 2013-07-26 13:08:25 +0000
172@@ -0,0 +1,13 @@
173+# -*- coding: utf-8 -*-
174+
175+from . import test_assumptions, test_resequence
176+
177+fast_suite = [
178+]
179+
180+checks = [
181+ test_assumptions,
182+ test_resequence,
183+]
184+
185+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
186
187=== added file 'openerp/tests/addons/test_resequence/tests/test_assumptions.py'
188--- openerp/tests/addons/test_resequence/tests/test_assumptions.py 1970-01-01 00:00:00 +0000
189+++ openerp/tests/addons/test_resequence/tests/test_assumptions.py 2013-07-26 13:08:25 +0000
190@@ -0,0 +1,37 @@
191+# -*- coding: utf-8 -*-
192+import operator
193+import random
194+
195+from openerp.tests import common
196+
197+class TestAssumptions(common.TransactionCase):
198+ def setUp(self):
199+ super(TestAssumptions, self).setUp()
200+ self.Model = self.registry('test_resequence.model')
201+ def create(self, values):
202+ return self.Model.create(self.cr, self.uid, values)
203+
204+ def test_read_order(self):
205+ """
206+ The re-sequencing method assumes read() always returns its records
207+ according to ``_order``, regardless of input ordering.
208+ """
209+ # generate a bunch of sequence numbers
210+ seqs = sorted(set(random.sample(range(100), 20)))
211+ # create corresponding records, ids ordering <=> seqs ordering
212+ ids = [self.create({'sequence': i}) for i in seqs]
213+
214+ reverse = list(reversed(ids))
215+ ids_reversed = map(
216+ operator.itemgetter('id'),
217+ self.Model.read(self.cr, self.uid, reverse))
218+
219+ self.assertEqual(ids_reversed, ids)
220+
221+ shuffled = list(ids)
222+ random.shuffle(shuffled)
223+ ids_shuffled = map(
224+ operator.itemgetter('id'),
225+ self.Model.read(self.cr, self.uid, shuffled))
226+
227+ self.assertEqual(ids_shuffled, ids)
228
229=== added file 'openerp/tests/addons/test_resequence/tests/test_resequence.py'
230--- openerp/tests/addons/test_resequence/tests/test_resequence.py 1970-01-01 00:00:00 +0000
231+++ openerp/tests/addons/test_resequence/tests/test_resequence.py 2013-07-26 13:08:25 +0000
232@@ -0,0 +1,158 @@
233+# -*- coding: utf-8 -*-
234+from openerp.tests import common
235+
236+class SequenceCase(common.TransactionCase):
237+ def setUp(self):
238+ super(SequenceCase, self).setUp()
239+ self.Model = self.registry('test_resequence.model')
240+ self.Model.writes = 0
241+
242+ def make(self, sequences):
243+ return [
244+ self.Model.create(self.cr, self.uid, {'sequence': s})
245+ for s in sequences
246+ ]
247+
248+ def assertSeq(self, ids, seqs):
249+ """ Asserts that reading ``ids`` yields the same sequence numbers as
250+ ``seq``
251+ """
252+ self.assertEqual(
253+ self.Model.read(self.cr, self.uid, ids),
254+ map(lambda id, seq: {'id': id, 'sequence': seq},
255+ ids,
256+ seqs))
257+
258+def rotate(seq): return seq[-1:] + seq[:-1]
259+
260+class TestCompleteResequence(SequenceCase):
261+ """ Tests resequencing with multiple identical sequence values: should
262+ resequence the whole ids list provided
263+ """
264+
265+ def test_noop(self):
266+ """ When the input list has not been altered from its original order,
267+ nothing should be done
268+ """
269+ ids = self.make([1, 1])
270+
271+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, ids))
272+
273+ self.assertSeq(ids, [1, 1])
274+
275+ def test_trivial(self):
276+ """ If all records have the same sequence number, just resequence
277+ everything
278+ """
279+ ids = self.make([1, 1])
280+
281+ rids = rotate(ids)
282+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
283+
284+ self.assertSeq(rids, [1, 2])
285+
286+ ids = self.make([1, 1, 1, 1])
287+
288+ rids = rotate(ids)
289+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
290+
291+ self.assertSeq(rids, [1, 2, 3, 4])
292+
293+ def test_start(self):
294+ """ Should use the seq of the first record as its starting point
295+ """
296+ ids = self.make([45, 45])
297+
298+ rids = rotate(ids)
299+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
300+
301+ self.assertSeq(rids, [45, 46])
302+
303+ def test_reuse_existing(self):
304+ """ Should reuse all existing unique sequence numbers before starting
305+ its own counter
306+ """
307+ ids = self.make([5, 5, 8, 9, 9, 9, 9, 15, 16, 17, 42])
308+
309+ rids = rotate(ids)
310+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
311+
312+ self.assertSeq(rids, [5, 8, 9, 15, 16, 17, 42, 43, 44, 45, 46])
313+ self.assertEqual(self.Model.writes, len(ids))
314+
315+ def test_move(self):
316+ """ Technically could do a partial reseq since there are no duplicates
317+ in the move range of the record
318+ """
319+ # | -> |
320+ ids = self.make([1, 2, 2, 2, 3, 5, 8, 9, 9])
321+
322+ rids = list(ids)
323+ rids.insert(6, rids.pop(4))
324+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
325+
326+ self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
327+ self.assertEqual(self.Model.writes, len(ids))
328+
329+ def test_move_duplicate_straddling(self):
330+ """ Complication on previous case: duplicate seq numbers just on the
331+ boundary of moved object. Would it be possible to do partial reseqs
332+ here or is there no choice but to do a full reseq?
333+ """
334+ # | -> |
335+ ids = self.make([1, 2, 2, 3, 5, 8, 9, 9, 9])
336+
337+ rids = list(ids)
338+ rids.insert(6, rids.pop(4))
339+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
340+
341+ self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
342+ self.assertEqual(self.Model.writes, len(ids))
343+
344+ # | -> |
345+ ids = self.make([1, 1, 1, 2, 2, 3, 5, 8, 9])
346+
347+ rids = list(ids)
348+ rids.insert(6, rids.pop(4))
349+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
350+
351+ self.assertSeq(rids, [1, 2, 3, 5, 8, 9, 10, 11, 12])
352+
353+class TestPartialResequence(SequenceCase):
354+ """ When the sub-sequence comprising the original and final indices of a
355+ moved record only holds unique sequence ids, no need to write new ids from
356+ scratch, can just rotate the existing one on the sub-sequence.
357+ """
358+ def test_move_down(self):
359+ # | -> |
360+ seq = [2, 4, 5, 12, 13, 24, 26, 31, 32, 34]
361+ ids = self.make(seq)
362+
363+ rids = list(ids)
364+ rids.insert(6, rids.pop(2))
365+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
366+
367+ self.assertSeq(rids, seq)
368+ self.assertEqual(self.Model.writes, 5)
369+
370+ def test_move_up(self):
371+ seq = [2, 4, 5, 12, 13, 24, 26, 31, 32, 34]
372+ ids = self.make(seq)
373+
374+ rids = list(ids)
375+ rids.insert(5, rids.pop(8))
376+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
377+
378+ self.assertSeq(rids, seq)
379+ self.assertEqual(self.Model.writes, 4)
380+
381+ def test_swap(self):
382+ seq = [2, 5, 8, 12]
383+ ids = self.make(seq)
384+
385+ rids = list(ids)
386+ rids.insert(1, rids.pop(2))
387+ self.assertIsNone(self.Model.resequence(self.cr, self.uid, rids))
388+
389+ self.assertSeq(rids, seq)
390+ self.assertEqual(self.Model.writes, 2)
391
392=== modified file 'openerp/tests/common.py'
393--- openerp/tests/common.py 2013-01-31 13:10:51 +0000
394+++ openerp/tests/common.py 2013-07-26 13:08:25 +0000
395@@ -91,12 +91,13 @@
396 def setUp(self):
397 # Store cr and uid in class variables, to allow ref() and browse_ref to be BaseCase @classmethods
398 # and still access them
399- TransactionCase.cr = self.cursor()
400- TransactionCase.uid = openerp.SUPERUSER_ID
401+ type(self).cr = self.cursor()
402+ type(self).uid = openerp.SUPERUSER_ID
403
404 def tearDown(self):
405 self.cr.rollback()
406 self.cr.close()
407+ del type(self).cr
408
409
410 class SingleTransactionCase(BaseCase):
411@@ -114,6 +115,7 @@
412 def tearDownClass(cls):
413 cls.cr.rollback()
414 cls.cr.close()
415+ del cls.cr
416
417
418 class RpcCase(unittest2.TestCase):