[IMP] core: introduce search_fetch() and fetch()

This fulfills the goal of searching and fetching fields in a single SQL
query.  We introduce the new method search_fetch() for that purpose.
Also introduce method fetch() to fetch some fields for a recordset if
they are not in cache yet.

The call graph is as follows:

    search()        calls   search_fetch()
    search_read()   calls   search_fetch() and _read_format()
    read()          calls   fetch() and _read_format()

    search_count()  calls   _search()
    search_fetch()  calls   _search() and _fetch_query()
    fetch()         calls   _search() and _fetch_query()

The methods _search() and _fetch_query() are usually the ones to
override to implement business-specific logic.  The method _search()
returns a Query object to retrieve the records that satisfy the given
domain and are accessible for reading.  The method _fetch_query() uses a
Query object to retrieve fields from the database and store them in
cache.

Also use search_fetch() to save one query in search_read() and the
reading of one2many fields.

Part-of: odoo/odoo#112126
This commit is contained in:
Raphael Collet
2023-03-05 15:12:55 +01:00
parent 46c23fd64d
commit e962860c6f
21 changed files with 429 additions and 302 deletions
+16 -14
View File
@@ -481,35 +481,37 @@ class Meeting(models.Model):
return super(Meeting, self.with_context(prefetch_fields=False))._compute_field_value(field) return super(Meeting, self.with_context(prefetch_fields=False))._compute_field_value(field)
return super()._compute_field_value(field) return super()._compute_field_value(field)
def _read(self, fields): def _fetch_query(self, query, fields):
if self.env.is_system(): if self.env.is_system():
super()._read(fields) return super()._fetch_query(query, fields)
return
fields = set(fields) public_fnames = self._get_public_fields()
private_fields = fields - self._get_public_fields() private_fields = [field for field in fields if field.name not in public_fnames]
if not private_fields: if not private_fields:
super()._read(fields) return super()._fetch_query(query, fields)
return
private_fields.add('partner_ids') fields_to_fetch = list(fields) + [self._fields[name] for name in ('privacy', 'user_id', 'partner_ids')]
super()._read(fields | {'privacy', 'user_id', 'partner_ids'}) events = super()._fetch_query(query, fields_to_fetch)
# determine private events to which the user does not participate
current_partner_id = self.env.user.partner_id current_partner_id = self.env.user.partner_id
others_private_events = self.filtered( others_private_events = events.filtered(
lambda e: e.privacy == 'private' \ lambda e: e.privacy == 'private' \
and e.user_id != self.env.user \ and e.user_id != self.env.user \
and current_partner_id not in e.partner_ids and current_partner_id not in e.partner_ids
) )
if not others_private_events: if not others_private_events:
return return events
for field_name in private_fields: private_fields.append(self._fields['partner_ids'])
field = self._fields[field_name] for field in private_fields:
replacement = field.convert_to_cache( replacement = field.convert_to_cache(
_('Busy') if field_name == 'name' else False, _('Busy') if field.name == 'name' else False,
others_private_events) others_private_events)
self.env.cache.update(others_private_events, field, repeat(replacement)) self.env.cache.update(others_private_events, field, repeat(replacement))
return events
def write(self, values): def write(self, values):
detached_events = self.env['calendar.event'] detached_events = self.env['calendar.event']
recurrence_update_setting = values.pop('recurrence_update', None) recurrence_update_setting = values.pop('recurrence_update', None)
@@ -93,6 +93,8 @@ class TestAccessRights(TransactionCase):
privacy='public', privacy='public',
location='In Hell', location='In Hell',
) )
# invalidate cache before reading, otherwise read() might leak private data
self.env.invalidate_all()
[private_location, public_location] = self.read_event(self.raoul, private + public, 'location') [private_location, public_location] = self.read_event(self.raoul, private + public, 'location')
self.assertEqual(private_location, False, "Private value should be obfuscated") self.assertEqual(private_location, False, "Private value should be obfuscated")
self.assertEqual(public_location, 'In Hell', "Public value should not be obfuscated") self.assertEqual(public_location, 'In Hell', "Public value should not be obfuscated")
@@ -418,7 +418,7 @@ class TestUpdateRecurrentEvents(TestRecurrentEvents):
'start': event.start + relativedelta(days=4), 'start': event.start + relativedelta(days=4),
'stop': event.stop + relativedelta(days=5), 'stop': event.stop + relativedelta(days=5),
}) })
self.assertFalse(self.recurrence.calendar_event_ids, "Inactive event should not create recurrent events") self.assertFalse(self.recurrence.exists(), "Inactive event should not create recurrent events")
def test_shift_all_with_outlier(self): def test_shift_all_with_outlier(self):
outlier = self.events[1] outlier = self.events[1]
+9 -10
View File
@@ -774,13 +774,13 @@ class Lead(models.Model):
return result return result
@api.model @api.model
def search(self, domain, offset=0, limit=None, order=None): def search_fetch(self, domain, field_names, offset=0, limit=None, order=None):
""" Override to support ordering on my_activity_date_deadline. """ Override to support ordering on my_activity_date_deadline.
Ordering through web client calls search_read with an order parameter set. Ordering through web client calls search_read() with an order parameter
Search_read then calls search. In this override we therefore override search set. Method search_read() then calls search_fetch(). Here we override
to intercept a search without count with an order on my_activity_date_deadline. search_fetch() to intercept a search with an order on field
In that case we do the search in two steps. my_activity_date_deadline. In that case we do the search in two steps.
First step: fill with deadline-based results First step: fill with deadline-based results
@@ -805,8 +805,7 @@ class Lead(models.Model):
side effects. Search_count is not affected by this override. side effects. Search_count is not affected by this override.
""" """
if not order or 'my_activity_date_deadline' not in order: if not order or 'my_activity_date_deadline' not in order:
return super().search(domain, offset, limit, order) return super().search_fetch(domain, field_names, offset, limit, order)
order_items = [order_item.strip().lower() for order_item in (order or self._order).split(',')] order_items = [order_item.strip().lower() for order_item in (order or self._order).split(',')]
# Perform a read_group on my activities to get a mapping lead_id / deadline # Perform a read_group on my activities to get a mapping lead_id / deadline
@@ -826,7 +825,7 @@ class Lead(models.Model):
# Search leads linked to those activities and order them. See docstring # Search leads linked to those activities and order them. See docstring
# of this method for more details. # of this method for more details.
search_res = super().search(my_lead_domain, order=my_lead_order) search_res = super().search_fetch(my_lead_domain, field_names, order=my_lead_order)
my_lead_ids_ordered = sorted(search_res.ids, key=lambda lead_id: my_lead_mapping[lead_id], reverse=not activity_asc) my_lead_ids_ordered = sorted(search_res.ids, key=lambda lead_id: my_lead_mapping[lead_id], reverse=not activity_asc)
# keep only requested window (offset + limit, or offset+) # keep only requested window (offset + limit, or offset+)
my_lead_ids_keep = my_lead_ids_ordered[offset:(offset + limit)] if limit else my_lead_ids_ordered[offset:] my_lead_ids_keep = my_lead_ids_ordered[offset:(offset + limit)] if limit else my_lead_ids_ordered[offset:]
@@ -848,9 +847,9 @@ class Lead(models.Model):
lead_offset = 0 lead_offset = 0
lead_order = ', '.join(item for item in order_items if 'my_activity_date_deadline' not in item) lead_order = ', '.join(item for item in order_items if 'my_activity_date_deadline' not in item)
other_lead_res = super().search( other_lead_res = super().search_fetch(
expression.AND([[('id', 'not in', my_lead_ids_skip)], domain]), expression.AND([[('id', 'not in', my_lead_ids_skip)], domain]),
lead_offset, lead_limit, lead_order, field_names, lead_offset, lead_limit, lead_order,
) )
return self.browse(my_lead_ids_keep) + other_lead_res return self.browse(my_lead_ids_keep) + other_lead_res
+33 -13
View File
@@ -184,17 +184,45 @@ class HrEmployeePrivate(models.Model):
return super(HrEmployeePrivate, self).name_get() return super(HrEmployeePrivate, self).name_get()
return self.env['hr.employee.public'].browse(self.ids).name_get() return self.env['hr.employee.public'].browse(self.ids).name_get()
def _read(self, fields): def search_fetch(self, domain, field_names, offset=0, limit=None, order=None):
if self.check_access_rights('read', raise_exception=False): if self.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self)._read(fields) return super().search_fetch(domain, field_names, offset, limit, order)
# HACK: retrieve publicly available values from hr.employee.public and # HACK: retrieve publicly available values from hr.employee.public and
# copy them to the cache of self; non-public data will be missing from # copy them to the cache of self; non-public data will be missing from
# cache, and interpreted as an access error # cache, and interpreted as an access error
self.flush_recordset(fields) self._check_private_fields(field_names)
self.flush_model(field_names)
public = self.env['hr.employee.public'].search_fetch(domain, field_names, offset, limit, order)
employees = self.browse(public._ids)
employees._copy_cache_from(public, field_names)
return employees
def fetch(self, field_names):
if self.check_access_rights('read', raise_exception=False):
return super().fetch(field_names)
# HACK: retrieve publicly available values from hr.employee.public and
# copy them to the cache of self; non-public data will be missing from
# cache, and interpreted as an access error
self._check_private_fields(field_names)
self.flush_recordset(field_names)
public = self.env['hr.employee.public'].browse(self._ids) public = self.env['hr.employee.public'].browse(self._ids)
public.read(fields) public.fetch(field_names)
for fname in fields: self._copy_cache_from(public, field_names)
def _check_private_fields(self, field_names):
""" Check whether ``field_names`` contain private fields. """
public_fields = self.env['hr.employee.public']._fields
private_fields = [fname for fname in field_names if fname not in public_fields]
if private_fields:
raise AccessError(_('The fields "%s" you try to read is not available on the public employee profile.') % (','.join(private_fields)))
def _copy_cache_from(self, public, field_names):
# HACK: retrieve publicly available values from hr.employee.public and
# copy them to the cache of self; non-public data will be missing from
# cache, and interpreted as an access error
for fname in field_names:
values = self.env.cache.get_values(public, public._fields[fname]) values = self.env.cache.get_values(public, public._fields[fname])
if self._fields[fname].translate: if self._fields[fname].translate:
values = [(value.copy() if value else None) for value in values] values = [(value.copy() if value else None) for value in values]
@@ -221,14 +249,6 @@ class HrEmployeePrivate(models.Model):
user_id=responsible_user_id) user_id=responsible_user_id)
employees_scheduled.write({'work_permit_scheduled_activity': True}) employees_scheduled.write({'work_permit_scheduled_activity': True})
def read(self, fields=None, load='_classic_read'):
if self.check_access_rights('read', raise_exception=False):
return super(HrEmployeePrivate, self).read(fields, load=load)
private_fields = set(fields or self._fields).difference(self.env['hr.employee.public']._fields)
if private_fields:
raise AccessError(_('The fields "%s" you try to read is not available on the public employee profile.') % (','.join(private_fields)))
return self.env['hr.employee.public'].browse(self.ids).read(fields, load=load)
@api.model @api.model
def get_view(self, view_id=None, view_type='form', **options): def get_view(self, view_id=None, view_type='form', **options):
if self.check_access_rights('read', raise_exception=False): if self.check_access_rights('read', raise_exception=False):
+5 -4
View File
@@ -1025,14 +1025,15 @@ class HrExpenseSheet(models.Model):
# ORM Overrides # ORM Overrides
# ---------------------------------------- # ----------------------------------------
def read(self, fields=None, load='_classic_read'): def _read_format(self, fnames, load='_classic_read'):
# setting the context in the field on the view is not enough # setting the context in the field on the view is not enough
return super(HrExpenseSheet, self.with_context(show_payment_journal_id=True)).read(fields=fields, load=load) self = self.with_context(show_payment_journal_id=True)
return super()._read_format(fnames, load)
def onchange(self, values, field_name, field_onchange): def onchange(self, values, field_name, field_onchange):
# setting the context in the field on the view is not enough # setting the context in the field on the view is not enough
self_ctx = self.with_context(show_payment_journal_id=True) self = self.with_context(show_payment_journal_id=True)
return super(HrExpenseSheet, self_ctx).onchange(values, field_name, field_onchange) return super().onchange(values, field_name, field_onchange)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
@@ -73,13 +73,14 @@ class LeaveReportCalendar(models.Model):
); );
""") """)
def _read(self, fields): def _fetch_query(self, query, fields):
res = super()._read(fields) records = super()._fetch_query(query, fields)
if self.env.context.get('hide_employee_name') and 'employee_id' in self.env.context.get('group_by', []): if self.env.context.get('hide_employee_name') and 'employee_id' in self.env.context.get('group_by', []):
name_field = self._fields['name'] self.env.cache.update(records, self._fields['name'], [
for record in self.with_user(SUPERUSER_ID): record.name.split(':')[-1].strip()
self.env.cache.set(record, name_field, record.name.split(':')[-1].strip()) for record in records.with_user(SUPERUSER_ID)
return res ])
return records
@api.model @api.model
def get_unusual_days(self, date_from, date_to=None): def get_unusual_days(self, date_from, date_to=None):
@@ -14,7 +14,7 @@ class TestPerformanceTimesheet(TestCommonTimesheet):
self.env.invalidate_all() self.env.invalidate_all()
projects.clear_caches() projects.clear_caches()
tasks.clear_caches() tasks.clear_caches()
with self.assertQueryCount(4): with self.assertQueryCount(5):
self.env['account.analytic.line']._timesheet_preprocess([ self.env['account.analytic.line']._timesheet_preprocess([
{'task_id': task.id} for task in tasks for _i in range(10) {'task_id': task.id} for task in tasks for _i in range(10)
]) ])
@@ -32,7 +32,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2)) self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2))
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
with self.assertQueryCount(__system__=91, admin=95): with self.assertQueryCount(__system__=92, admin=96):
leave.action_validate() leave.action_validate()
leave.action_refuse() leave.action_refuse()
@@ -108,11 +108,10 @@ class TestWorkEntryHolidaysPerformancesBigData(TestWorkEntryHolidaysBase):
cls.leaves.action_approve() cls.leaves.action_approve()
cls.leaves.action_validate() cls.leaves.action_validate()
def test_work_entries_generation_perf(self): def test_work_entries_generation_perf(self):
# Test Case 7: Try to generate work entries for # Test Case 7: Try to generate work entries for
# a hundred employees over a month # a hundred employees over a month
with self.assertQueryCount(__system__=406, admin=406): with self.assertQueryCount(__system__=413):
work_entries = self.contracts._generate_work_entries(date(2020, 7, 1), date(2020, 8, 31)) work_entries = self.contracts._generate_work_entries(date(2020, 7, 1), date(2020, 8, 31))
# Original work entries to generate when we don't adapt date_generated_from and # Original work entries to generate when we don't adapt date_generated_from and
+2 -2
View File
@@ -1683,10 +1683,10 @@ class Task(models.Model):
can normally see. can normally see.
(In other words, this compute is only used in project sharing views to see all assignees for each task) (In other words, this compute is only used in project sharing views to see all assignees for each task)
""" """
if self.ids: if self._origin:
# fetch 'user_ids' in superuser mode (and override value in cache # fetch 'user_ids' in superuser mode (and override value in cache
# browse is useful to avoid miscache because of the newIds contained in self # browse is useful to avoid miscache because of the newIds contained in self
self.browse(self.ids)._read(['user_ids']) self._origin.fetch(['user_ids'])
for task in self.with_context(prefetch_fields=False): for task in self.with_context(prefetch_fields=False):
task.portal_user_names = ', '.join(task.user_ids.mapped('name')) task.portal_user_names = ', '.join(task.user_ids.mapped('name'))
+1 -1
View File
@@ -655,7 +655,7 @@ class PurchaseOrder(models.Model):
# invoices related to the purchase order, we read them in sudo to fill the # invoices related to the purchase order, we read them in sudo to fill the
# cache. # cache.
self.invalidate_model(['invoice_ids']) self.invalidate_model(['invoice_ids'])
self.sudo()._read(['invoice_ids']) self.sudo().fetch(['invoice_ids'])
invoices = self.invoice_ids invoices = self.invoice_ids
result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_in_invoice_type') result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_in_invoice_type')
-4
View File
@@ -603,10 +603,6 @@ class IrAttachment(models.Model):
return self.browse(result)._as_query(order) return self.browse(result)._as_query(order)
def _read(self, fields):
self.check('read')
return super(IrAttachment, self)._read(fields)
def write(self, vals): def write(self, vals):
self.check('write', values=vals) self.check('write', values=vals)
# remove computed field depending of datas # remove computed field depending of datas
+7 -12
View File
@@ -458,19 +458,14 @@ class Users(models.Model):
def onchange_parent_id(self): def onchange_parent_id(self):
return self.partner_id.onchange_parent_id() return self.partner_id.onchange_parent_id()
def _read(self, fields): def _fetch_query(self, query, fields):
super(Users, self)._read(fields) records = super()._fetch_query(query, fields)
if set(USER_PRIVATE_FIELDS).intersection(fields): if not set(USER_PRIVATE_FIELDS).isdisjoint(field.name for field in fields):
if self.check_access_rights('write', raise_exception=False): if self.check_access_rights('write', raise_exception=False):
return return records
for record in self: for fname in USER_PRIVATE_FIELDS:
for f in USER_PRIVATE_FIELDS: self.env.cache.update(records, self._fields[fname], repeat('********'))
try: return records
record._cache[f]
record._cache[f] = '********'
except Exception:
# skip SpecialValue (e.g. for missing record or access right)
pass
@api.constrains('company_id', 'company_ids', 'active') @api.constrains('company_id', 'company_ids', 'active')
def _check_company(self): def _check_company(self):
+4 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models from odoo import api, fields, models
class SomeObj(models.Model): class SomeObj(models.Model):
_name = 'test_access_right.some_obj' _name = 'test_access_right.some_obj'
@@ -43,10 +43,11 @@ class ObjCateg(models.Model):
name = fields.Char(required=True) name = fields.Char(required=True)
def search(self, domain, *args, **kwargs): @api.model
def search_fetch(self, domain, field_names, offset=0, limit=None, order=None):
if self.env.context.get('only_media'): if self.env.context.get('only_media'):
domain += [('name', '=', 'Media')] domain += [('name', '=', 'Media')]
return super(ObjCateg, self).search(domain, **kwargs) return super().search_fetch(domain, field_names, offset, limit, order)
class FakeTicket(models.Model): class FakeTicket(models.Model):
@@ -69,13 +69,13 @@ class Category(models.Model):
# assign name of last category, and reassign display_name (to normalize it) # assign name of last category, and reassign display_name (to normalize it)
cat.name = names[-1].strip() cat.name = names[-1].strip()
def _read(self, fields): def _fetch_query(self, query, fields):
# DLE P45: `test_31_prefetch`, # DLE P45: `test_31_prefetch`,
# with self.assertRaises(AccessError): # with self.assertRaises(AccessError):
# cat1.name # cat1.name
if self.search_count([('id', 'in', self._ids), ('name', '=', 'NOACCESS')]): if self.search_count([('id', 'in', self._ids), ('name', '=', 'NOACCESS')]):
raise AccessError('Sorry') raise AccessError('Sorry')
return super(Category, self)._read(fields) return super()._fetch_query(query, fields)
class Discussion(models.Model): class Discussion(models.Model):
@@ -2609,16 +2609,21 @@ class TestFields(TransactionCaseWithUserDemo):
# only one query as admin: reading pivot table # only one query as admin: reading pivot table
with self.assertQueryCount(1): with self.assertQueryCount(1):
# trick: if value is in cache, read() does not make any query
record.invalidate_recordset(['tags'])
record.read(['tags']) record.read(['tags'])
user = self.env['res.users'].create({'name': "user", 'login': "user"}) user = self.env['res.users'].create({'name': "user", 'login': "user"})
record_user = record.with_user(user) record_user = record.with_user(user)
# prep the following query count by caching access check related data # prep the following query count by caching access check related data
record_user.invalidate_recordset(['tags'])
record_user.read(['tags']) record_user.read(['tags'])
# only one query as user: reading pivot table # only one query as user: reading pivot table
with self.assertQueryCount(1): with self.assertQueryCount(1):
# trick: if value is in cache, read() does not make any query
record_user.invalidate_recordset(['tags'])
record_user.read(['tags']) record_user.read(['tags'])
# create a passing ir.rule # create a passing ir.rule
@@ -2628,11 +2633,14 @@ class TestFields(TransactionCaseWithUserDemo):
}) })
# prep the following query count by caching access check related data # prep the following query count by caching access check related data
record_user.invalidate_recordset(['tags'])
record_user.read(['tags']) record_user.read(['tags'])
# still only 1 query: reading pivot table # still only 1 query: reading pivot table
# access rules are checked in python in this case # access rules are checked in python in this case
with self.assertQueryCount(1): with self.assertQueryCount(1):
# trick: if value is in cache, read() does not make any query
record_user.invalidate_recordset(['tags'])
record_user.read(['tags']) record_user.read(['tags'])
# create a blocking ir.rule # create a blocking ir.rule
@@ -2683,28 +2691,27 @@ class TestFields(TransactionCaseWithUserDemo):
self.env.invalidate_all() self.env.invalidate_all()
with self.assertQueries([""" with self.assertQueries(["""
SELECT SELECT "test_new_api_prefetch"."id",
"test_new_api_prefetch"."id" AS "id", "test_new_api_prefetch"."name"->>'en_US',
"test_new_api_prefetch"."name"->>'en_US' AS "name", "test_new_api_prefetch"."description"->>'en_US',
"test_new_api_prefetch"."description"->>'en_US' AS "description", "test_new_api_prefetch"."html_description"->>'en_US',
"test_new_api_prefetch"."html_description"->>'en_US' AS "html_description", "test_new_api_prefetch"."create_uid",
"test_new_api_prefetch"."create_uid" AS "create_uid", "test_new_api_prefetch"."create_date",
"test_new_api_prefetch"."create_date" AS "create_date", "test_new_api_prefetch"."write_uid",
"test_new_api_prefetch"."write_uid" AS "write_uid", "test_new_api_prefetch"."write_date"
"test_new_api_prefetch"."write_date" AS "write_date"
FROM "test_new_api_prefetch" FROM "test_new_api_prefetch"
WHERE "test_new_api_prefetch".id IN %s WHERE ("test_new_api_prefetch"."id" IN %s)
"""]): """]):
records.mapped('name') # fetch all fields with prefetch=True records.mapped('name') # fetch all fields with prefetch=True
with self.assertQueries([""" with self.assertQueries(["""
SELECT SELECT
"test_new_api_prefetch"."id" AS "id", "test_new_api_prefetch"."id",
"test_new_api_prefetch"."harry" AS "harry", "test_new_api_prefetch"."harry",
"test_new_api_prefetch"."hermione" AS "hermione", "test_new_api_prefetch"."hermione",
"test_new_api_prefetch"."ron" AS "ron" "test_new_api_prefetch"."ron"
FROM "test_new_api_prefetch" FROM "test_new_api_prefetch"
WHERE "test_new_api_prefetch".id IN %s WHERE ("test_new_api_prefetch"."id" IN %s)
"""]): """]):
records.mapped('harry') # fetch all fields with prefetch='Harry Potter' records.mapped('harry') # fetch all fields with prefetch='Harry Potter'
records.mapped('hermione') # fetched already records.mapped('hermione') # fetched already
@@ -2712,11 +2719,11 @@ class TestFields(TransactionCaseWithUserDemo):
with self.assertQueries([""" with self.assertQueries(["""
SELECT SELECT
"test_new_api_prefetch"."id" AS "id", "test_new_api_prefetch"."id",
"test_new_api_prefetch"."hansel" AS "hansel", "test_new_api_prefetch"."hansel",
"test_new_api_prefetch"."gretel" AS "gretel" "test_new_api_prefetch"."gretel"
FROM "test_new_api_prefetch" FROM "test_new_api_prefetch"
WHERE "test_new_api_prefetch".id IN %s WHERE ("test_new_api_prefetch"."id" IN %s)
"""]): """]):
records.mapped('hansel') # fetch all fields with prefetch='Hansel and Gretel' records.mapped('hansel') # fetch all fields with prefetch='Hansel and Gretel'
records.mapped('gretel') # fetched already records.mapped('gretel') # fetched already
@@ -3760,10 +3767,10 @@ def select(model, *fnames):
""" Return the expected query string to SELECT the given columns. """ """ Return the expected query string to SELECT the given columns. """
table = model._table table = model._table
terms = ", ".join( terms = ", ".join(
f'"{table}"."{fname}" AS "{fname}"' f'"{table}"."{fname}"'
for fname in ['id'] + list(fnames) for fname in ['id'] + list(fnames)
) )
return f'SELECT {terms} FROM "{table}" WHERE "{table}".id IN %s' return f'SELECT {terms} FROM "{table}" WHERE ("{table}"."id" IN %s)'
def insert(model, *fnames, rowcount=1): def insert(model, *fnames, rowcount=1):
@@ -4282,14 +4289,20 @@ class TestModifiedPerformance(common.TransactionCase):
self.modified_line_a_child.price self.modified_line_a_child.price
with self.assertQueries([""" with self.assertQueries(["""
SELECT "test_new_api_modified_line"."id" AS "id", "test_new_api_modified_line"."modified_id" AS "modified_id", SELECT "test_new_api_modified_line"."id",
"test_new_api_modified_line"."quantity" AS "quantity", "test_new_api_modified_line"."price" AS "price", "test_new_api_modified_line"."modified_id",
"test_new_api_modified_line"."parent_id" AS "parent_id", "test_new_api_modified_line"."create_uid" AS "create_uid", "test_new_api_modified_line"."quantity",
"test_new_api_modified_line"."create_date" AS "create_date", "test_new_api_modified_line"."write_uid" AS "write_uid", "test_new_api_modified_line"."parent_id",
"test_new_api_modified_line"."write_date" AS "write_date" "test_new_api_modified_line"."create_uid",
FROM "test_new_api_modified_line" "test_new_api_modified_line"."create_date"
WHERE "test_new_api_modified_line".id IN %s FROM "test_new_api_modified_line"
"""] * 2, flush=False): WHERE ("test_new_api_modified_line"."id" IN %s)
""", """
SELECT "test_new_api_modified_line"."id",
"test_new_api_modified_line"."parent_id"
FROM "test_new_api_modified_line"
WHERE ("test_new_api_modified_line"."id" IN %s)
"""], flush=False):
# Two requests: # Two requests:
# - one for fetch modified_line_a_child_child data (invalidate just before) # - one for fetch modified_line_a_child_child data (invalidate just before)
# - one because modified_line_a_child.parent_id (invalidate just before because we invalidate inverse in `_invalidate_cache`, # - one because modified_line_a_child.parent_id (invalidate just before because we invalidate inverse in `_invalidate_cache`,
@@ -174,53 +174,53 @@ class PropertiesCase(TransactionCase):
expected_queries = [ expected_queries = [
# read the properties field value # read the properties field value
''' SELECT "test_new_api_message"."id" AS "id", ''' SELECT "test_new_api_message"."id",
"test_new_api_message"."attributes" AS "attributes" "test_new_api_message"."attributes"
FROM "test_new_api_message" FROM "test_new_api_message"
WHERE "test_new_api_message".id IN %s WHERE ("test_new_api_message"."id" IN %s)
''', ''',
''' SELECT "test_new_api_message"."id" AS "id", ''' SELECT "test_new_api_message"."id",
"test_new_api_message"."discussion" AS "discussion", "test_new_api_message"."discussion",
"test_new_api_message"."body" AS "body", "test_new_api_message"."body",
"test_new_api_message"."author" AS "author", "test_new_api_message"."author",
"test_new_api_message"."name" AS "name", "test_new_api_message"."name",
"test_new_api_message"."important" AS "important", "test_new_api_message"."important",
"test_new_api_message"."label"->>'en_US' AS "label", "test_new_api_message"."label"->>'en_US',
"test_new_api_message"."priority" AS "priority", "test_new_api_message"."priority",
"test_new_api_message"."create_uid" AS "create_uid", "test_new_api_message"."create_uid",
"test_new_api_message"."create_date" AS "create_date", "test_new_api_message"."create_date",
"test_new_api_message"."write_uid" AS "write_uid", "test_new_api_message"."write_uid",
"test_new_api_message"."write_date" AS "write_date" "test_new_api_message"."write_date"
FROM "test_new_api_message" FROM "test_new_api_message"
WHERE "test_new_api_message".id IN %s WHERE ("test_new_api_message"."id" IN %s)
''', ''',
# read the definition on the definition record # read the definition on the definition record
''' SELECT "test_new_api_discussion"."id" AS "id", ''' SELECT "test_new_api_discussion"."id",
"test_new_api_discussion"."name" AS "name", "test_new_api_discussion"."name",
"test_new_api_discussion"."moderator" AS "moderator", "test_new_api_discussion"."moderator",
"test_new_api_discussion"."message_concat" AS "message_concat", "test_new_api_discussion"."message_concat",
"test_new_api_discussion"."history" AS "history", "test_new_api_discussion"."history",
"test_new_api_discussion"."attributes_definition" AS "attributes_definition", "test_new_api_discussion"."attributes_definition",
"test_new_api_discussion"."create_uid" AS "create_uid", "test_new_api_discussion"."create_uid",
"test_new_api_discussion"."create_date" AS "create_date", "test_new_api_discussion"."create_date",
"test_new_api_discussion"."write_uid" AS "write_uid", "test_new_api_discussion"."write_uid",
"test_new_api_discussion"."write_date" AS "write_date" "test_new_api_discussion"."write_date"
FROM "test_new_api_discussion" FROM "test_new_api_discussion"
WHERE "test_new_api_discussion".id IN %s WHERE ("test_new_api_discussion"."id" IN %s)
''', ''',
# check the many2one existence # check the many2one existence
''' SELECT "test_new_api_partner"."id" ''' SELECT "test_new_api_partner"."id"
FROM "test_new_api_partner" FROM "test_new_api_partner"
WHERE "test_new_api_partner".id IN %s WHERE "test_new_api_partner".id IN %s
''', ''',
''' SELECT "test_new_api_partner"."id" AS "id", ''' SELECT "test_new_api_partner"."id",
"test_new_api_partner"."name" AS "name", "test_new_api_partner"."name",
"test_new_api_partner"."create_uid" AS "create_uid", "test_new_api_partner"."create_uid",
"test_new_api_partner"."create_date" AS "create_date", "test_new_api_partner"."create_date",
"test_new_api_partner"."write_uid" AS "write_uid", "test_new_api_partner"."write_uid",
"test_new_api_partner"."write_date" AS "write_date" "test_new_api_partner"."write_date"
FROM "test_new_api_partner" FROM "test_new_api_partner"
WHERE "test_new_api_partner".id IN %s WHERE ("test_new_api_partner"."id" IN %s)
''', ''',
] ]
@@ -79,7 +79,7 @@ class TestPerformance(SavepointCaseWithUserDemo):
records.write({'line_ids': [Command.create({})]}) records.write({'line_ids': [Command.create({})]})
self.env.invalidate_all() self.env.invalidate_all()
with self.assertQueryCount(2): with self.assertQueryCount(1):
records.line_ids records.line_ids
@users('__system__', 'demo') @users('__system__', 'demo')
@@ -115,6 +115,51 @@ class TestPerformance(SavepointCaseWithUserDemo):
for record in records: for record in records:
self.assertEqual(record.with_context(key=3).value_ctx, 3) self.assertEqual(record.with_context(key=3).value_ctx, 3)
def test_fetch(self):
""" Fetch only when necessary. """
records = self.env['test_performance.base'].search([])
self.assertEqual(len(records), 5)
with self.assertQueryCount(1):
# not in cache yet
records.fetch(['name', 'partner_id'])
with self.assertQueryCount(0):
# already in cache
records.mapped('name')
records.mapped('partner_id')
with self.assertQueryCount(1):
# this one shouldn't have been fetched
records.mapped('value')
with self.assertQueryCount(0):
# 'name' and 'value' are already in cache
records.fetch(['name', 'value'])
@warmup
def test_search_fetch(self):
""" Search and fetch all at once. """
records = self.env['test_performance.base'].search([])
self.assertEqual(len(records), 5)
with self.assertQueryCount(2):
self.env.invalidate_all()
for record in records.search([]):
record.partner_id
# search() can do everything in a single query!
with self.assertQueryCount(1):
self.env.invalidate_all()
for record in records.search_fetch([], ['partner_id']):
record.partner_id
# the case where you don't fetch the right field
with self.assertQueryCount(2):
self.env.invalidate_all()
for record in records.search_fetch([], ['value_pc']):
record.partner_id
@warmup @warmup
def test_search_read(self): def test_search_read(self):
""" Search and fetch all at once. """ """ Search and fetch all at once. """
@@ -122,18 +167,18 @@ class TestPerformance(SavepointCaseWithUserDemo):
records = Model.search([]) records = Model.search([])
self.assertEqual(len(records), 5) self.assertEqual(len(records), 5)
# one query for search, one query for read, one query for display_name # one query for search and read, one query for display_name
expected = records.read(['partner_id', 'value_pc']) expected = records.read(['partner_id', 'value_pc'])
with self.assertQueryCount(3): with self.assertQueryCount(2):
self.env.invalidate_all() self.env.invalidate_all()
self.assertEqual( self.assertEqual(
Model.search_read([], ['partner_id', 'value_pc']), Model.search_read([], ['partner_id', 'value_pc']),
expected, expected,
) )
# one query for search, one query for read # one query for search and read
expected = records.read(['partner_id', 'value_pc'], load=False) expected = records.read(['partner_id', 'value_pc'], load=False)
with self.assertQueryCount(2): with self.assertQueryCount(1):
self.env.invalidate_all() self.env.invalidate_all()
self.assertEqual( self.assertEqual(
Model.search_read([], ['partner_id', 'value_pc'], load=False), Model.search_read([], ['partner_id', 'value_pc'], load=False),
@@ -260,7 +305,7 @@ class TestPerformance(SavepointCaseWithUserDemo):
lines = rec1.line_ids lines = rec1.line_ids
# set N lines in rec2: O(1) queries # set N lines in rec2: O(1) queries
with self.assertQueryCount(8): with self.assertQueryCount(7):
self.env.invalidate_all() self.env.invalidate_all()
rec2.write({'line_ids': [Command.set(lines[0].ids)]}) rec2.write({'line_ids': [Command.set(lines[0].ids)]})
self.assertEqual(rec1.line_ids, lines[1:]) self.assertEqual(rec1.line_ids, lines[1:])
@@ -498,18 +543,18 @@ class TestPerformance(SavepointCaseWithUserDemo):
# fetching 'name' prefetches all fields on all records # fetching 'name' prefetches all fields on all records
queries = [ queries = [
''' SELECT "test_performance_base"."id" AS "id", ''' SELECT "test_performance_base"."id",
"test_performance_base"."name" AS "name", "test_performance_base"."name",
"test_performance_base"."value" AS "value", "test_performance_base"."value",
"test_performance_base"."value_pc" AS "value_pc", "test_performance_base"."value_pc",
"test_performance_base"."partner_id" AS "partner_id", "test_performance_base"."partner_id",
"test_performance_base"."total" AS "total", "test_performance_base"."total",
"test_performance_base"."create_uid" AS "create_uid", "test_performance_base"."create_uid",
"test_performance_base"."create_date" AS "create_date", "test_performance_base"."create_date",
"test_performance_base"."write_uid" AS "write_uid", "test_performance_base"."write_uid",
"test_performance_base"."write_date" AS "write_date" "test_performance_base"."write_date"
FROM "test_performance_base" FROM "test_performance_base"
WHERE "test_performance_base".id IN %s WHERE ("test_performance_base"."id" IN %s)
''', ''',
] ]
with self.assertQueries(queries, flush=False): with self.assertQueries(queries, flush=False):
@@ -648,7 +693,7 @@ class TestMapped(TransactionCase):
self.env.invalidate_all() self.env.invalidate_all()
# expected same performance as recs.line_ids.mapped('value') # expected same performance as recs.line_ids.mapped('value')
with self.assertQueryCount(3): with self.assertQueryCount(2):
for rec in recs: for rec in recs:
rec.line_ids.mapped('value') rec.line_ids.mapped('value')
+7 -9
View File
@@ -4293,20 +4293,18 @@ class One2many(_RelationalMulti):
comodel = records.env[self.comodel_name].with_context(**context) comodel = records.env[self.comodel_name].with_context(**context)
inverse = self.inverse_name inverse = self.inverse_name
inverse_field = comodel._fields[inverse] inverse_field = comodel._fields[inverse]
domain = self.get_domain_list(records) + [(inverse, 'in', records.ids)]
lines = comodel.search(domain)
if len(records) == 1: # optimization: fetch the inverse and active fields with search()
# optimization: all lines have the same value for 'inverse_field', domain = self.get_domain_list(records) + [(inverse, 'in', records.ids)]
# so we don't need to fetch it from database field_names = [inverse]
records.env.cache.insert_missing(records, self, [lines._ids]) if comodel._active_name:
records.env.cache.insert_missing(lines, inverse_field, itertools.repeat(records.id)) field_names.append(comodel._active_name)
return lines = comodel.search_fetch(domain, field_names)
# group lines by inverse field (without prefetching other fields) # group lines by inverse field (without prefetching other fields)
get_id = (lambda rec: rec.id) if inverse_field.type == 'many2one' else int get_id = (lambda rec: rec.id) if inverse_field.type == 'many2one' else int
group = defaultdict(list) group = defaultdict(list)
for line in lines.with_context(prefetch_fields=False): for line in lines:
# line[inverse] may be a record or an integer # line[inverse] may be a record or an integer
group[get_id(line[inverse])].append(line.id) group[get_id(line[inverse])].append(line.id)
+187 -136
View File
@@ -168,6 +168,8 @@ class MetaModel(api.Meta):
if '__init__' in attrs and len(inspect.signature(attrs['__init__']).parameters) != 4: if '__init__' in attrs and len(inspect.signature(attrs['__init__']).parameters) != 4:
_logger.warning("The method %s.__init__ doesn't match the new signature in module %s", name, attrs.get('__module__')) _logger.warning("The method %s.__init__ doesn't match the new signature in module %s", name, attrs.get('__module__'))
if callable(attrs.get('_read')):
warnings.warn(f"{self.__module__}.{self.__name__}: method BaseModel._read() has been replaced by BaseModel._fetch_query()")
if not attrs.get('_register', True): if not attrs.get('_register', True):
return return
@@ -1468,6 +1470,9 @@ class BaseModel(metaclass=MetaModel):
:param domain: :ref:`A search domain <reference/orm/domains>`. Use an empty :param domain: :ref:`A search domain <reference/orm/domains>`. Use an empty
list to match all records. list to match all records.
:param limit: maximum number of record to count (upperbound) (default: all) :param limit: maximum number of record to count (upperbound) (default: all)
This is a high-level method, which should not be overridden. Its actual
implementation is done by method :meth:`_search`.
""" """
query = self._search(domain, limit=limit) query = self._search(domain, limit=limit)
return len(query) return len(query)
@@ -1477,7 +1482,7 @@ class BaseModel(metaclass=MetaModel):
def search(self, domain, offset=0, limit=None, order=None): def search(self, domain, offset=0, limit=None, order=None):
""" search(domain[, offset=0][, limit=None][, order=None]) """ search(domain[, offset=0][, limit=None][, order=None])
Searches for records based on the ``domain`` Search for the records that satisfy the given ``domain``
:ref:`search domain <reference/orm/domains>`. :ref:`search domain <reference/orm/domains>`.
:param domain: :ref:`A search domain <reference/orm/domains>`. Use an empty :param domain: :ref:`A search domain <reference/orm/domains>`. Use an empty
@@ -1487,9 +1492,57 @@ class BaseModel(metaclass=MetaModel):
:param str order: sort string :param str order: sort string
:returns: at most ``limit`` records matching the search criteria :returns: at most ``limit`` records matching the search criteria
:raise AccessError: if user is not allowed to access requested information :raise AccessError: if user is not allowed to access requested information
This is a high-level method, which should not be overridden. Its actual
implementation is done by method :meth:`_search`.
""" """
return self.search_fetch(domain, [], offset=offset, limit=limit, order=order)
@api.model
@api.returns('self')
def search_fetch(self, domain, field_names, offset=0, limit=None, order=None):
""" search_fetch(domain, field_names[, offset=0][, limit=None][, order=None])
Search for the records that satisfy the given ``domain``
:ref:`search domain <reference/orm/domains>`, and fetch the given fields
to the cache. This method is like a combination of methods :meth:`search`
and :meth:`fetch`, but it performs both tasks with a minimal number of
SQL queries.
:param domain: :ref:`A search domain <reference/orm/domains>`. Use an empty
list to match all records.
:param field_names: a collection of field names to fetch
:param int offset: number of results to ignore (default: none)
:param int limit: maximum number of records to return (default: all)
:param str order: sort string
:returns: at most ``limit`` records matching the search criteria
:raise AccessError: if user is not allowed to access requested information
"""
# first determine a query that satisfies the domain and access rules
query = self._search(domain, offset=offset, limit=limit, order=order or self._order) query = self._search(domain, offset=offset, limit=limit, order=order or self._order)
return self.browse(query)
if query.is_empty():
# optimization: don't execute the query at all
return self.browse()
# determine fields to fetch
fields_to_fetch = OrderedSet()
if field_names:
field_names = self.check_field_access_rights('read', field_names)
for field_name in field_names:
field = self._fields.get(field_name)
if not field:
raise ValueError(f"Invalid field {field_name!r} on model {self._name!r}")
if field.store:
fields_to_fetch.add(field)
elif field.compute:
# optimization: fetch direct field dependencies
for dotname in self.pool.field_depends[field]:
dep = self._fields[dotname.split('.', 1)[0]]
if dep.prefetch is True and (not dep.groups or self.user_has_groups(dep.groups)):
fields_to_fetch.add(dep)
return self._fetch_query(query, fields_to_fetch)
# #
# display_name, name_get, name_create, name_search # display_name, name_get, name_create, name_search
@@ -2935,8 +2988,8 @@ class BaseModel(metaclass=MetaModel):
def read(self, fields=None, load='_classic_read'): def read(self, fields=None, load='_classic_read'):
""" read([fields]) """ read([fields])
Reads the requested fields for the records in ``self``, low-level/RPC Read the requested fields for the records in ``self``, and return their
method. values as a list of dicts.
:param list fields: field names to return (default is all fields) :param list fields: field names to return (default is all fields)
:param str load: loading mode, currently the only option is to set to :param str load: loading mode, currently the only option is to set to
@@ -2946,25 +2999,13 @@ class BaseModel(metaclass=MetaModel):
:rtype: list :rtype: list
:raise AccessError: if user is not allowed to access requested information :raise AccessError: if user is not allowed to access requested information
:raise ValueError: if a requested field does not exist :raise ValueError: if a requested field does not exist
This is a high-level method that is not supposed to be overridden. In
order to modify how fields are read from database, see methods
:meth:`_fetch_query` and :meth:`_read_format`.
""" """
fields = self.check_field_access_rights('read', fields) fields = self.check_field_access_rights('read', fields)
self.fetch(fields)
# fetch stored fields from the database to the cache
stored_fields = OrderedSet()
for name in fields:
field = self._fields.get(name)
if not field:
raise ValueError("Invalid field %r on model %r" % (name, self._name))
if field.store:
stored_fields.add(name)
elif field.compute:
# optimization: prefetch direct field dependencies
for dotname in self.pool.field_depends[field]:
f = self._fields[dotname.split('.')[0]]
if f.prefetch is True and (not f.groups or self.user_has_groups(f.groups)):
stored_fields.add(f.name)
self._read(stored_fields)
return self._read_format(fnames=fields, load=load) return self._read_format(fnames=fields, load=load)
def update_field_translations(self, field_name, translations): def update_field_translations(self, field_name, translations):
@@ -3100,7 +3141,8 @@ class BaseModel(metaclass=MetaModel):
"""Returns a list of dictionaries mapping field names to their values, """Returns a list of dictionaries mapping field names to their values,
with one dictionary per record that exists. with one dictionary per record that exists.
The output format is similar to the one expected from the `read` method. The output format is the one expected from the `read` method, which uses
this method as its implementation for formatting values.
The current method is different from `read` because it retrieves its The current method is different from `read` because it retrieves its
values from the cache without doing a query when it is avoidable. values from the cache without doing a query when it is avoidable.
@@ -3140,103 +3182,127 @@ class BaseModel(metaclass=MetaModel):
fnames.append(field.name) fnames.append(field.name)
else: else:
fnames = [field.name] fnames = [field.name]
self._read(fnames) self.fetch(fnames)
def _read(self, field_names): def fetch(self, field_names):
""" Read the given fields of the records in ``self`` from the database, """ Make sure the given fields are in memory for the records in ``self``,
and store them in cache. Skip fields that are not stored. by fetching what is necessary from the database. Non-stored fields are
mostly ignored, except for their stored dependencies. This method should
be called to optimize code.
:param field_names: list of field names to read This method is implemented thanks to methods :meth:`_search` and
:meth:`_fetch_query`, and should not be overridden.
""" """
if not self: if not self or not field_names:
return return
self.check_access_rights('read')
# determine columns fields and those with their own read() method # determine fields to fetch
column_fields = [] fields_to_fetch = OrderedSet()
other_fields = [] cache = self.env.cache
translated_field_names = [] field_names = self.check_field_access_rights('read', field_names)
for name in field_names: for field_name in field_names:
if name == 'id': field = self._fields.get(field_name)
continue
field = self._fields.get(name)
if not field: if not field:
_logger.warning("%s._read() with unknown field %r", self._name, name) raise ValueError(f"Invalid field {field_name!r} on model {self._name!r}")
if not any(cache.get_missing_ids(self, field)):
continue continue
if field.base_field.store and field.base_field.column_type: if field.store:
column_fields.append(field) fields_to_fetch.add(field)
elif field.store and not field.column_type: elif field.compute:
# non-column fields: for the sake of simplicity, we ignore inherited fields # optimization: fetch direct field dependencies
other_fields.append(field) for dotname in self.pool.field_depends[field]:
if field.store and field.translate: dep = self._fields[dotname.split('.', 1)[0]]
translated_field_names.append(field.name) if dep.prefetch is True and (not dep.groups or self.user_has_groups(dep.groups)):
fields_to_fetch.add(dep)
if field.type == 'properties': if not fields_to_fetch:
# force calling fields.read for properties field because # there is nothing to fetch, but we expect an error anyway in case
# we want to read all relational properties in batch # self is not accessible
# (and check their existence in batch as well) self.check_access_rights('read')
other_fields.append(field)
if column_fields:
cr, context = self.env.cr, self.env.context
# If a read() follows a write(), we must flush the updates that have
# an impact on checking security rules, as they are injected into
# the query. However, we don't need to flush the fields to fetch,
# as explained below when putting values in cache.
# Since only one language translation is fetched from database,
# we must flush these translated fields before read
# E.g. in database, the {'en_US': 'English'},
# write record.with_context(lang='en_US').name = 'English2'
# then record.with_context(lang='fr_FR').name => cache miss => _read
# 'English2'should is flushed before query as it is the fallback of empty 'fr_FR'
if translated_field_names:
self.flush_recordset(translated_field_names)
self._flush_search([], order='id')
# make a query object for selecting ids, and apply security rules to it
query = Query(cr, self._table, self._table_query)
self._apply_ir_rules(query, 'read')
# the query may involve several tables: we need fully-qualified names
def qualify(field):
qname = self._inherits_join_calc(self._table, field.name, query)
if field.type == 'binary' and (
context.get('bin_size') or context.get('bin_size_' + field.name)):
# PG 9.2 introduces conflicting pg_size_pretty(numeric) -> need ::cast
qname = f'pg_size_pretty(length({qname})::bigint)'
return f'{qname} AS "{field.name}"'
# selected fields are: 'id' followed by column_fields
qual_names = [qualify(field) for field in [self._fields['id']] + column_fields]
# determine the actual query to execute (last parameter is added below)
query.add_where(f'"{self._table}".id IN %s')
query_str, params = query.select(*qual_names)
result = []
for sub_ids in cr.split_for_in_conditions(self.ids):
cr.execute(query_str, params + [sub_ids])
result += cr.fetchall()
else:
try: try:
self.check_access_rule('read') self.check_access_rule('read')
except MissingError: except MissingError:
# Method _read() should never raise a MissingError, but method # Method fetch() should never raise a MissingError, but method
# check_access_rule() can, because it must read fields on self.
# So we restrict 'self' to existing records (to avoid an extra
# exists() at the end of the method).
self.exists().check_access_rule('read')
return
# first determine a query that satisfies the domain and access rules
if any(field.column_type for field in fields_to_fetch):
query = self.with_context(active_test=False)._search([('id', 'in', self.ids)])
else:
self.check_access_rights('read')
try:
self.check_access_rule('read')
except MissingError:
# Method fetch() should never raise a MissingError, but method
# check_access_rule() can, because it must read fields on self. # check_access_rule() can, because it must read fields on self.
# So we restrict 'self' to existing records (to avoid an extra # So we restrict 'self' to existing records (to avoid an extra
# exists() at the end of the method). # exists() at the end of the method).
self = self.exists() self = self.exists()
self.check_access_rule('read') self.check_access_rule('read')
query = self._as_query(ordered=False)
result = [(id_,) for id_ in self.ids] # fetch the fields
fetched = self._fetch_query(query, fields_to_fetch)
fetched = self.browse() # possibly raise exception for the records that could not be read
if result: if fetched != self:
# result = [(id1, a1, b1), (id2, a2, b2), ...] forbidden = (self - fetched).exists()
if forbidden:
raise self.env['ir.rule']._make_access_error('read', forbidden)
def _fetch_query(self, query, fields):
""" Fetch the given fields (iterable of :class:`Field` instances) from
the given query, put them in cache, and return the fetched records.
This method may be overridden to change what fields to actually fetch,
or to change the values that are put in cache.
"""
# determine columns fields and those with their own read() method
column_fields = OrderedSet()
other_fields = OrderedSet()
for field in fields:
if field.name == 'id':
continue
assert field.store
(column_fields if field.column_type else other_fields).add(field)
if field.type == 'properties':
# force calling fields.read for properties field in order to
# read all relational properties in batch
other_fields.add(field)
# necessary to retrieve the en_US value of fields without a translation
translated_field_names = [field.name for field in column_fields if field.translate]
if translated_field_names:
self.flush_model(translated_field_names)
context = self.env.context
if column_fields:
# the query may involve several tables: we need fully-qualified names
select_terms = [f'"{self._table}"."id"']
for field in column_fields:
qname = self._inherits_join_calc(self._table, field.name, query)
if field.type == 'binary' and (
context.get('bin_size') or context.get('bin_size_' + field.name)):
# PG 9.2 introduces conflicting pg_size_pretty(numeric) -> need ::cast
qname = f'pg_size_pretty(length({qname})::bigint)'
select_terms.append(qname)
# select the given columns from the rows in the query
query_str, params = query.select(*select_terms)
self.env.cr.execute(query_str, params)
rows = self.env.cr.fetchall()
if not rows:
return self.browse()
# rows = [(id1, a1, b1), (id2, a2, b2), ...]
# column_values = [(id1, id2, ...), (a1, a2, ...), (b1, b2, ...)] # column_values = [(id1, id2, ...), (a1, a2, ...), (b1, b2, ...)]
column_values = zip(*result) column_values = zip(*rows)
ids = next(column_values) ids = next(column_values)
fetched = self.browse(ids) fetched = self.browse(ids)
@@ -3248,25 +3314,15 @@ class BaseModel(metaclass=MetaModel):
# store values in cache, but without overwriting # store values in cache, but without overwriting
self.env.cache.insert_missing(fetched, field, values) self.env.cache.insert_missing(fetched, field, values)
# process non-column fields else:
fetched = self.browse(query)
# process non-column fields
if fetched:
for field in other_fields: for field in other_fields:
field.read(fetched) field.read(fetched)
# possibly raise exception for the records that could not be read return fetched
missing = self - fetched
if missing:
extras = fetched - self
if extras:
raise AccessError(_(
"Database fetch misses ids (%(missing)s) and has extra ids (%(extra)s),"
" may be caused by a type incoherence in a previous request",
missing=missing._ids,
extra=extras._ids,
))
# mark non-existing records in missing
forbidden = missing.exists()
if forbidden:
raise self.env['ir.rule']._make_access_error('read', forbidden)
def get_metadata(self): def get_metadata(self):
"""Return some metadata about the given records. """Return some metadata about the given records.
@@ -4632,6 +4688,12 @@ class BaseModel(metaclass=MetaModel):
:param access_rights_uid: optional user ID to use when checking access rights :param access_rights_uid: optional user ID to use when checking access rights
(not for ir.rules, this is only for ir.model.access) (not for ir.rules, this is only for ir.model.access)
:return: a :class:`Query` object that represents the matching records :return: a :class:`Query` object that represents the matching records
This method may be overridden to modify the domain being searched, or to
do some post-filtering of the resulting query object. Be careful with
the latter option, though, as it might hurt performance. Indeed, by
default the returned query object is not actually executed, and it can
be injected as a value in a domain in order to generate sub-queries.
""" """
model = self.with_user(access_rights_uid) if access_rights_uid else self model = self.with_user(access_rights_uid) if access_rights_uid else self
model.check_access_rights('read') model.check_access_rights('read')
@@ -4937,7 +4999,7 @@ class BaseModel(metaclass=MetaModel):
@api.model @api.model
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None, **read_kwargs): def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None, **read_kwargs):
"""Perform a :meth:`search` followed by a :meth:`read`. """ Perform a :meth:`search_fetch` followed by a :meth:`_read_format`.
:param domain: Search domain, see ``args`` parameter in :meth:`search`. :param domain: Search domain, see ``args`` parameter in :meth:`search`.
Defaults to an empty domain that will match all records. Defaults to an empty domain that will match all records.
@@ -4955,30 +5017,19 @@ class BaseModel(metaclass=MetaModel):
:return: List of dictionaries containing the asked fields. :return: List of dictionaries containing the asked fields.
:rtype: list(dict). :rtype: list(dict).
""" """
records = self.search(domain or [], offset=offset, limit=limit, order=order) fields = self.check_field_access_rights('read', fields)
if not records: records = self.search_fetch(domain or [], fields, offset=offset, limit=limit, order=order)
return []
if fields and fields == ['id']: # Method _read_format() ignores 'active_test', but it would forward it
# shortcut read if we only want the ids # to any downstream search call(e.g. for x2m or computed fields), and
return [{'id': record.id} for record in records] # this is not the desired behavior. The flag was presumably only meant
# for the main search().
# read() ignores active_test, but it would forward it to any downstream search call
# (e.g. for x2m or function fields), and this is not the desired behavior, the flag
# was presumably only meant for the main search().
# TODO: Move this to read() directly?
if 'active_test' in self._context: if 'active_test' in self._context:
context = dict(self._context) context = dict(self._context)
del context['active_test'] del context['active_test']
records = records.with_context(context) records = records.with_context(context)
result = records.read(fields, **read_kwargs) return records._read_format(fnames=fields, **read_kwargs)
if len(result) <= 1:
return result
# reorder read
index = {vals['id']: vals for vals in result}
return [index[record.id] for record in records if record.id in index]
def toggle_active(self): def toggle_active(self):
"Inverses the value of :attr:`active` on the records in ``self``." "Inverses the value of :attr:`active` on the records in ``self``."
@@ -6321,7 +6372,7 @@ class BaseModel(metaclass=MetaModel):
fnames = [subname fnames = [subname
for subname in subnames for subname in subnames
if lines._fields[subname].base_field.store] if lines._fields[subname].base_field.store]
lines._read(fnames) lines.fetch(fnames)
# copy the cache of lines to their corresponding new records; # copy the cache of lines to their corresponding new records;
# this avoids computing computed stored fields on new_lines # this avoids computing computed stored fields on new_lines
new_lines = lines.browse(map(NewId, line_ids)) new_lines = lines.browse(map(NewId, line_ids))
+4
View File
@@ -193,6 +193,10 @@ class Query(object):
) )
return query_str, params return query_str, params
def is_empty(self):
""" Return whether the query is known to return nothing. """
return self._ids == ()
def get_sql(self): def get_sql(self):
""" Returns (query_from, query_where, query_params). """ """ Returns (query_from, query_where, query_params). """
tables = [_from_table(table, alias) for alias, table in self._tables.items()] tables = [_from_table(table, alias) for alias, table in self._tables.items()]