Skip to content

Commit 99abc17

Browse files
committed
Lazy evaluation of RecordList, in order to use search_read
1 parent f243734 commit 99abc17

File tree

4 files changed

+84
-29
lines changed

4 files changed

+84
-29
lines changed

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ Changelog
99
``limit`` and ``offset`` become keyword-only, and undocumented
1010
argument ``reverse`` is abandoned.
1111

12+
* New: :meth:`Model.search` returns a :class:`RecordList` which is
13+
lazily evaluated. API method is called only when needed: if attributes
14+
are read or methods are called. It will use `search_read` API method
15+
when it's adequate.
16+
1217
* Remove undocumented :meth:`Env._web`.
1318

1419
* Refactor code for ``read`` field formatter.

odooly.py

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -974,11 +974,7 @@ def models(self, name='', transient=False):
974974
fld_transient = 'transient' if 'transient' in ir_model._keys else 'osv_memory'
975975
domain = [('abstract', '=', False)] if 'abstract' in ir_model._keys else [] # Odoo 19
976976
try:
977-
if self.client.version_info < 8.0:
978-
recs = ir_model.search(domain)
979-
models = ir_model.read(recs.ids, ('model', fld_transient))
980-
else:
981-
models = ir_model.search_read(domain, ('model', fld_transient))
977+
models = ir_model.search_read(domain, ('model', fld_transient))
982978
except ServerError:
983979
# Only Odoo 15 prevents non-admin user to retrieve models
984980
models = ir_model.get_available_models() if self.client.version_info >= 16.0 else {}
@@ -1578,8 +1574,6 @@ def drop_database(self, passwd, database):
15781574

15791575
class BaseModel:
15801576

1581-
ids = ()
1582-
15831577
def sudo(self, user=None):
15841578
"""Attach to the provided user, or Superuser."""
15851579
return self.with_env(self.env.sudo(user=user))
@@ -1675,13 +1669,22 @@ def browse(self, ids=()):
16751669

16761670
def search(self, domain, **kwargs):
16771671
"""Search for records in the `domain`."""
1678-
ids = self._execute('search', domain, **kwargs)
1679-
return RecordList(self, ids)
1672+
return RecordList._prepared(self, domain, kwargs)
16801673

16811674
def search_count(self, domain=None):
16821675
"""Count the records in the `domain`."""
16831676
return self._execute('search_count', domain or [])
16841677

1678+
def search_read(self, domain=None, fields=None, **kwargs):
1679+
"""Combine search and read."""
1680+
fields, fmt = self._parse_format(fields, browse=False)
1681+
if self.env.client.version_info < 8.0:
1682+
ids = self._execute('search', domain or [], **kwargs)
1683+
res = self._execute('read', ids, fields)
1684+
else:
1685+
res = self._execute('search_read', domain or [], fields, **kwargs)
1686+
return fmt(res)
1687+
16851688
def get(self, domain, *args, **kwargs):
16861689
"""Return a single :class:`Record`.
16871690
@@ -1888,7 +1891,7 @@ def __init__(self, res_model, arg):
18881891
self.__dict__.update(attrs)
18891892

18901893
def __repr__(self):
1891-
ids = f'length={len(self.ids)}' if len(self.ids) > 16 else self.id
1894+
ids = f'length={len(self.ids)}' if len(self.ids) > 6 else self.id
18921895
return f"<{self.__class__.__name__} '{self._name},{ids}'>"
18931896

18941897
def __dir__(self):
@@ -2131,15 +2134,34 @@ class RecordList(BaseRecord):
21312134
to assign a single value to all the selected records.
21322135
"""
21332136

2134-
def __init__(self, res_model, arg):
2137+
def __init__(self, res_model, arg, search=None):
21352138
super().__init__(res_model, arg)
2136-
idnames = arg or ()
2137-
ids = list(idnames)
2138-
for index, id_ in enumerate(arg):
2139-
if isinstance(id_, (list, tuple)):
2140-
ids[index] = id_ = id_[0]
2141-
assert isinstance(id_, int), repr(id_)
2142-
self.__dict__.update({'id': ids, 'ids': ids, '_idnames': idnames})
2139+
if search is None:
2140+
idnames = arg or ()
2141+
ids = list(idnames)
2142+
for index, id_ in enumerate(arg):
2143+
if isinstance(id_, (list, tuple)):
2144+
ids[index] = id_ = id_[0]
2145+
assert isinstance(id_, int), repr(id_)
2146+
self.__dict__.update({'id': ids, 'ids': ids, '_idnames': idnames, '_search_args': None})
2147+
else:
2148+
self.__dict__['_search_args'] = search
2149+
2150+
@classmethod
2151+
def _prepared(cls, res_model, domain, params):
2152+
[domain] = searchargs((domain,))
2153+
return cls(res_model, None, search={'domain': domain, **params})
2154+
2155+
def refresh(self):
2156+
"""Reset :class:`RecordList` content."""
2157+
if self._search_args:
2158+
for key in 'id', 'ids', '_idnames':
2159+
self.__dict__.pop(key, None)
2160+
2161+
def with_env(self, env):
2162+
if 'id' in self.__dict__:
2163+
return super().with_env(env)
2164+
return RecordList(env[self._name], None, {**self._search_args})
21432165

21442166
def read(self, fields=None):
21452167
"""Read the `fields` of the :class:`RecordList`.
@@ -2151,10 +2173,16 @@ def read(self, fields=None):
21512173

21522174
if fields == ['id']:
21532175
values = [{'id': res_id} for res_id in self.ids]
2154-
elif self.id:
2155-
values = self._model.read(self.id, fields, order=True)
2176+
elif 'id' not in self.__dict__:
2177+
params = {**self._search_args}
2178+
domain = params.pop('domain')
2179+
values = self._model.search_read(domain, fields, **params)
2180+
ids = idnames = [val['id'] for val in values]
2181+
if values and 'display_name' in values[0]:
2182+
idnames = [(val['id'], val['display_name']) for val in values]
2183+
self.__dict__.update({'id': ids, 'ids': ids, '_idnames': idnames})
21562184
else:
2157-
values = []
2185+
values = self._model.read(self.id, fields, order=True) if self.id else []
21582186

21592187
return fmt(values)
21602188

@@ -2183,6 +2211,11 @@ def _external_id(self):
21832211
return [xml_ids.get(res_id, False) for res_id in self.id]
21842212

21852213
def __getattr__(self, attr):
2214+
if attr in ('id', 'ids', '_idnames'):
2215+
params = {**self._search_args}
2216+
ids = self._execute('search', params.pop('domain'), **params)
2217+
self.__dict__.update({'id': ids, 'ids': ids, '_idnames': ids})
2218+
return self.__dict__[attr]
21862219
if attr in self._model._keys:
21872220
return self.read(attr)
21882221
if attr.startswith('_'):

tests/test_client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,16 +348,16 @@ def test_list_modules(self):
348348

349349
def test_module_upgrade(self):
350350
self.service.object.execute_kw.side_effect = [
351-
(42, 0), [42], [], ANY, [42],
351+
(42, 0), [], [42], ANY, [42],
352352
[{'id': 42, 'state': ANY, 'name': ANY}], ANY]
353353

354354
result = self.env.upgrade('dummy')
355355
self.assertIsNone(result)
356356

357357
self.assertCalls(
358358
imm('update_list'),
359-
imm('search', [('name', 'in', ('dummy',))]),
360359
imm('search', [('state', 'not in', STABLE)]),
360+
imm('search', [('name', 'in', ('dummy',))]),
361361
imm('button_upgrade', [42]),
362362
imm('search', [('state', 'not in', STABLE)]),
363363
imm('read', [42], ['name', 'state']),
@@ -581,24 +581,25 @@ def test_report_get(self):
581581

582582
def _module_upgrade(self, button='upgrade'):
583583
execute_return = [
584-
[7, 0], [42], [], {'name': 'Upgrade'},
584+
[7, 0], [], [42], {'name': 'Upgrade'},
585585
[{'id': 4, 'state': ANY, 'name': ANY},
586586
{'id': 5, 'state': ANY, 'name': ANY},
587587
{'id': 42, 'state': ANY, 'name': ANY}], ANY]
588588
action = getattr(self.env, button)
589589

590590
expected_calls = [
591591
imm('update_list'),
592-
imm('search', [('name', 'in', ('dummy', 'spam'))]),
593592
imm('search_read', [('state', 'not in', STABLE)], ['name', 'state']),
593+
imm('search', [('name', 'in', ('dummy', 'spam'))]),
594594
imm('button_' + button, [42]),
595595
imm('search_read', [('state', 'not in', STABLE)], ['name', 'state']),
596596
bmu('upgrade_module', []),
597597
]
598598
if float(self.server_version) < 8.0:
599599
execute_return[4:4] = [[4, 42, 5]]
600-
expected_calls[2:5] = [
600+
expected_calls[1:5] = [
601601
imm('search', [('state', 'not in', STABLE)]),
602+
imm('search', [('name', 'in', ('dummy', 'spam'))]),
602603
imm('button_' + button, [42]),
603604
imm('search', [('state', 'not in', STABLE)]),
604605
imm('read', [4, 42, 5], ['name', 'state']),
@@ -632,7 +633,7 @@ def _module_upgrade(self, button='upgrade'):
632633

633634
self.service.object.execute_kw.side_effect = [[0, 0], []]
634635
self.assertIsNone(action())
635-
self.assertCalls(expected_calls[0], expected_calls[2])
636+
self.assertCalls(*expected_calls[:2])
636637
self.assertOutput('0 module(s) updated\n')
637638

638639
def test_module_upgrade(self):

tests/test_model.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,28 @@ def test_search(self):
183183
domain = [('name', 'like', 'Morice')]
184184
domain2 = [('name', '=', 'mushroom'), ('state', '!=', 'draft')]
185185

186+
# Search itself does not execute API calls
186187
self.assertIsInstance(FooBar.search([searchterm]), odooly.RecordList)
187188
FooBar.search([searchterm], limit=2)
188189
FooBar.search([searchterm], offset=80, limit=99)
189190
FooBar.search([searchterm], order='name ASC')
190191
FooBar.search(['name = mushroom', 'state != draft'])
191192
FooBar.search([('name', 'like', 'Morice')])
192-
FooBar._execute('search', [('name like Morice')])
193193
FooBar.search([])
194+
self.assertCalls()
195+
196+
# Low-level search will execute API call immediately
197+
FooBar._execute('search', [searchterm])
198+
self.assertCalls(OBJ('foo.bar', 'search', domain))
199+
200+
FooBar.search([searchterm], limit=2).ids
201+
FooBar.search([searchterm], offset=80, limit=99).id
202+
FooBar.search([searchterm], order='name ASC')[:3]
203+
FooBar.search(['name = mushroom', 'state != draft']) or 42
204+
FooBar.search([('name', 'like', 'Morice')]).ids
205+
FooBar._execute('search', [('name like Morice')])[0]
206+
FooBar.search([]).ids
194207
self.assertCalls(
195-
OBJ('foo.bar', 'search', domain),
196208
OBJ('foo.bar', 'search', domain, 0, 2, None),
197209
OBJ('foo.bar', 'search', domain, 80, 99, None),
198210
OBJ('foo.bar', 'search', domain, 0, None, 'name ASC'),
@@ -206,7 +218,11 @@ def test_search(self):
206218
FooBar.search(searchterm)
207219
FooBar.search([searchterm], limit=2, fields=['birthdate', 'city'])
208220
FooBar.search([searchterm], missingkey=42)
221+
self.assertCalls()
209222

223+
FooBar.search(searchterm).ids
224+
FooBar.search([searchterm], limit=2, fields=['birthdate', 'city']).ids
225+
FooBar.search([searchterm], missingkey=42).ids
210226
self.assertCalls(
211227
OBJ('foo.bar', 'search', searchterm),
212228
# Invalid keyword arguments are passed to the API

0 commit comments

Comments
 (0)