From d04a5b5c8c7dc13e4e911a29d1944e90587e2883 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Fri, 29 Oct 2021 11:09:28 +0000 Subject: [PATCH] [REF] core: compute fields before database insertion. Until now, stored compute fields were computed after database insertion. This meant that required fields should not be computed, for instance, unless some hackish code was added to make it work. Another trick was to provide some default value, but this actually prevents the field for being computed after insertion. This commit provides a field parameter to specify that the field should be precomputed: adding precompute=True on the field definition force the method create() to compute its value before inserting the new record in the database. For the reason explained below, precomputing fields is not always correct, and therefore the default remains to not precompute a field. Some stored fields must be computed after insertion, for instance: * statistics fields computed with search/read_group/... * fields referencing the current record (res.partner.commercial_partner_id) * fields referencing another record that does not exist yet (think about records created by one2many fields) * fields depending on the create_date/write_date/create_uid/write_uid Those fields shouldn't be defined with precompute=True, which triggers their computation post record creation. This is why, by safety, we consider the default behavior to be precompute=False. closes odoo/odoo#80449 Signed-off-by: Raphael Collet (rco) Co-authored-by: Raphael Collet Co-authored-by: Yannick Tivisse --- .../test_new_api/models/test_new_api.py | 127 +++++++++++- .../test_new_api/security/ir.model.access.csv | 5 + .../test_new_api/tests/test_new_fields.py | 196 ++++++++++++++++++ .../test_new_api/tests/test_one2many.py | 69 ++++++ odoo/fields.py | 32 ++- odoo/models.py | 125 ++++++++--- odoo/modules/registry.py | 3 + 7 files changed, 525 insertions(+), 32 deletions(-) diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py index ddef5c09686..a00b6b23dca 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import datetime +import logging from odoo import models, fields, api, _, Command from odoo.exceptions import AccessError, ValidationError @@ -1088,8 +1088,8 @@ class ModelChildM2o(models.Model): @api.depends('parent_id.name') def _compute_sizes(self): for record in self: - record.size1 = len(self.parent_id.name) - record.size2 = len(self.parent_id.name) + record.size1 = len(record.parent_id.name) + record.size2 = len(record.parent_id.name) @api.depends('name') def _compute_cost(self): @@ -1390,3 +1390,124 @@ class TriggerRight(models.Model): def _compute_left_size(self): for record in self: record.left_size = len(record.left_ids) + + +class Precompute(models.Model): + _name = 'test_new_api.precompute' + _description = 'model with precomputed fields' + + name = fields.Char(required=True) + + # both fields are precomputed + lower = fields.Char(compute='_compute_names', store=True, precompute=True) + upper = fields.Char(compute='_compute_names', store=True, precompute=True) + + # precomputed that depends on precomputed fields + lowup = fields.Char(compute='_compute_lowup', store=True, precompute=True) + + # kind of precomputed related field traversing a many2one + partner_id = fields.Many2one('res.partner') + commercial_id = fields.Many2one('res.partner', compute='_compute_commercial_id', + store=True, precompute=True) + + # precomputed depending on one2many fields + line_ids = fields.One2many('test_new_api.precompute.line', 'parent_id') + size = fields.Integer(compute='_compute_size', store=True, precompute=True) + + @api.depends('name') + def _compute_names(self): + for record in self: + record.lower = (record.name or "").lower() + record.upper = (record.name or "").upper() + + @api.depends('lower', 'upper') + def _compute_lowup(self): + for record in self: + record.lowup = record.lower + record.upper + + @api.depends('partner_id.commercial_partner_id') + def _compute_commercial_id(self): + for record in self: + record.commercial_id = record.partner_id.commercial_partner_id + + @api.depends('line_ids.size') + def _compute_size(self): + for record in self: + record.size = sum(record.line_ids.mapped('size')) + + +class PrecomputeLine(models.Model): + _name = 'test_new_api.precompute.line' + _description = 'secondary model with precomputed fields' + + parent_id = fields.Many2one('test_new_api.precompute') + name = fields.Char(required=True) + size = fields.Integer(compute='_compute_size', store=True, precompute=True) + + @api.depends('name') + def _compute_size(self): + for line in self: + line.size = len(line.name or "") + + +class PrecomputeCombo(models.Model): + _name = 'test_new_api.precompute.combo' + _description = 'yet another model with precomputed fields' + + _logger = logging.getLogger('precompute_setter') + + name = fields.Char() + reader = fields.Char(compute='_compute_reader', precompute=True, store=True) + editer = fields.Char(compute='_compute_editer', precompute=True, store=True, readonly=False) + setter = fields.Char(compute='_compute_setter', precompute=True, inverse='_inverse_setter', store=True) + + @api.depends('name') + def _compute_reader(self): + for record in self: + record.reader = record.name + + @api.depends('name') + def _compute_editer(self): + for record in self: + record.editer = record.name + + @api.depends('name') + def _compute_setter(self): + for record in self: + record.setter = record.name + + def _inverse_setter(self): + self._logger.warning("Unexpected inverse of %s.setter", self._name, stack_info=True) + + +class PrecomputeEditable(models.Model): + _name = 'test_new_api.precompute.editable' + _description = 'yet another model with precomputed editable fields' + + foo = fields.Char() + bar = fields.Char(compute='_compute_bar', precompute=True, store=True, readonly=False) + baz = fields.Char(compute='_compute_baz', precompute=True, store=True, readonly=False) + baz2 = fields.Char(compute='_compute_baz2', precompute=True, store=True) + + @api.depends('foo') + def _compute_bar(self): + self.bar = "COMPUTED" + + @api.depends('bar') + def _compute_baz(self): + self.baz = "COMPUTED" + + @api.depends('baz') + def _compute_baz2(self): + # this field is a trick to get the value of baz if it ever is recomputed + # during the precomputation of bar + for record in self: + record.baz2 = record.baz + + +class PrecomputeRequired(models.Model): + _name = 'test_new_api.precompute.required' + _description = 'a model with precomputed required fields' + + partner_id = fields.Many2one('res.partner', required=True) + name = fields.Char(related='partner_id.name', precompute=True, store=True, required=True) diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv index 4c20f2a7413..9250f277377 100644 --- a/odoo/addons/test_new_api/security/ir.model.access.csv +++ b/odoo/addons/test_new_api/security/ir.model.access.csv @@ -79,3 +79,8 @@ access_test_new_api_model_constrained_unlinks,access_test_new_api_model_constrai access_test_new_api_trigger_left,access_test_new_api_trigger_left,model_test_new_api_trigger_left,,1,1,1,1 access_test_new_api_trigger_middle,access_test_new_api_trigger_middle,model_test_new_api_trigger_middle,,1,1,1,1 access_test_new_api_trigger_right,access_test_new_api_trigger_right,model_test_new_api_trigger_right,,1,1,1,1 +access_test_new_api_precompute,access_test_new_api_precompute,model_test_new_api_precompute,,1,0,0,0 +access_test_new_api_precompute_line,access_test_new_api_precompute_line,model_test_new_api_precompute_line,,1,0,0,0 +access_test_new_api_precompute_combo,access_test_new_api_precompute_combo,model_test_new_api_precompute_combo,,1,0,0,0 +access_test_new_api_precompute_editable,access_test_new_api_precompute_editable,model_test_new_api_precompute_editable,,1,0,0,0 +access_test_new_api_precompute_required,access_test_new_api_precompute_required,model_test_new_api_precompute_required,,1,0,0,0 diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 1223366053f..9f606aef959 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -1630,6 +1630,26 @@ class TestFields(TransactionCaseWithUserDemo): # check that this does not generate an infinite recursion new_disc._convert_to_write(new_disc._cache) + def test_40_new_convert_to_write(self): + new_disc = self.env['test_new_api.discussion'].new({ + 'name': "Stuff", + 'moderator': self.env.uid, + 'participants': [(6, 0, self.env.user.ids)], + }) + # Put the user groups in the cache of the new record + new_disc.participants.groups_id + + # Check that the groups in the cache are not returned by convert_to_write + # because no real change happened, the values are identical except that + # self.env.user.groups_id._ids = (Id1, Id2, ...) whereas + # new_disc.participants.groups_id._ids = (NewId(origin=Id1), NewId(origin=Id2), ...) + field = new_disc._fields.get("participants") + # make sure that there is no inverse field for discussions on res_users, + # as the test depends on it + self.assertFalse(new_disc.pool.field_inverses[field]) + convert = field.convert_to_write(new_disc["participants"], new_disc) + self.assertEqual(convert, [(6, 0, self.env.user.ids)]) + def test_40_new_inherited_fields(self): """ Test the behavior of new records with inherited fields. """ email = self.env['test_new_api.emailmessage'].new({'body': 'XXX'}) @@ -3411,3 +3431,179 @@ class TestWrongRelatedError(common.TransactionCase): ) with self.assertRaisesRegex(KeyError, errMsg): self.registry.setup_models(self.env.cr) + + +class TestPrecomputeModel(common.TransactionCase): + + def test_precompute_consistency(self): + Model = self.registry['test_new_api.precompute'] + self.assertEqual(Model.lower.compute, Model.upper.compute) + self.assertTrue(Model.lower.precompute) + self.assertTrue(Model.upper.precompute) + + # see what happens if not both are precompute + self.addCleanup(self.registry.reset_changes) + self.patch(Model.upper, 'precompute', False) + with self.assertLogs('odoo.modules.registry', level='WARNING'): + self.registry.setup_models(self.cr) + self.registry.field_computed + + def test_precompute_dependencies_base(self): + Model = self.registry['test_new_api.precompute'] + self.assertTrue(Model.lower.precompute) + self.assertTrue(Model.upper.precompute) + self.assertTrue(Model.lowup.precompute) + + # see what happens if precompute depends on non-precompute + self.addCleanup(self.registry.reset_changes) + self.patch(Model.lower, 'precompute', False) + self.patch(Model.upper, 'precompute', False) + with self.assertWarns(UserWarning): + self.registry.setup_models(self.cr) + self.registry.field_triggers + + def test_precompute_dependencies_many2one(self): + Model = self.registry['test_new_api.precompute'] + Partner = self.registry['res.partner'] + + # Model.commercial_id depends on partner_id.commercial_partner_id, and + # precomputation is valid when traversing many2one fields + self.assertTrue(Model.commercial_id.precompute) + self.assertFalse(Partner.commercial_partner_id.precompute) + + def test_precompute_dependencies_one2many(self): + Model = self.registry['test_new_api.precompute'] + Line = self.registry['test_new_api.precompute.line'] + self.assertTrue(Model.size.precompute) + self.assertTrue(Line.size.precompute) + + # see what happens if precompute depends on non-precompute + self.addCleanup(self.registry.reset_changes) + # ensure that Model.size.precompute is restored after setup_models() + self.patch(Model.size, 'precompute', True) + self.patch(Line.size, 'precompute', False) + with self.assertWarns(UserWarning): + self.registry.setup_models(self.cr) + self.registry.field_triggers + + +class TestPrecompute(common.TransactionCase): + + def test_precompute(self): + model = self.env['test_new_api.precompute'] + + # warmup + model.create({'name': 'Foo', 'line_ids': [Command.create({'name': 'bar'})]}) + + # the creation makes one insert query for the main record, and one for the line + with self.assertQueries([ + insert(model, 'name', 'lower', 'upper', 'lowup', 'commercial_id', 'size'), + insert(model.line_ids, 'parent_id', 'name', 'size'), + ]): + record = model.create({'name': 'Foo', 'line_ids': [Command.create({'name': 'bar'})]}) + + # check the values in the database + self.cr.execute(f'SELECT * FROM "{model._table}" WHERE id=%s', [record.id]) + [row] = self.cr.dictfetchall() + + self.assertEqual(row['name'], 'Foo') + self.assertEqual(row['lower'], 'foo') + self.assertEqual(row['upper'], 'FOO') + self.assertEqual(row['lowup'], 'fooFOO') + self.assertEqual(row['size'], 3) + + def test_precompute_combo(self): + model = self.env['test_new_api.precompute.combo'] + + # warmup + model.create({}) + QUERIES = [insert(model, 'name', 'reader', 'editer', 'setter')] + + # no value at all + with self.assertQueries(QUERIES): + record = model.create({'name': 'A'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'A') + self.assertEqual(record.setter, 'A') + + # default value + with self.assertQueries(QUERIES), self.assertLogs('precompute_setter', level='WARNING'): + defaults = dict(default_reader='X', default_editer='Y', default_setter='Z') + record = model.with_context(**defaults).create({'name': 'A'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'Y') + self.assertEqual(record.setter, 'Z') + + # explicit value + with self.assertQueries(QUERIES), self.assertLogs('precompute_setter', level='WARNING'): + record = model.create({'name': 'A', 'reader': 'X', 'editer': 'Y', 'setter': 'Z'}) + + self.assertEqual(record.reader, 'A') + self.assertEqual(record.editer, 'Y') + self.assertEqual(record.setter, 'Z') + + def test_precompute_editable(self): + model = self.env['test_new_api.precompute.editable'] + + # no value for bar, no value for baz + record = model.create({'foo': 'foo'}) + self.assertEqual(record.bar, 'COMPUTED') + self.assertEqual(record.baz, 'COMPUTED') + self.assertEqual(record.baz2, 'COMPUTED') + + # value for bar, no value for baz + record = model.create({'foo': 'foo', 'bar': 'bar'}) + self.assertEqual(record.bar, 'bar') + self.assertEqual(record.baz, 'COMPUTED') + self.assertEqual(record.baz2, 'COMPUTED') + + # no value for bar, value for baz: the computation of bar should not + # recompute baz in memory, in case a third field depends on it + record = model.create({'foo': 'foo', 'baz': 'baz'}) + self.assertEqual(record.bar, 'COMPUTED') + self.assertEqual(record.baz, 'baz') + self.assertEqual(record.baz2, 'baz') + + # value for bar, value for baz + record = model.create({'foo': 'foo', 'bar': 'bar', 'baz': 'baz'}) + self.assertEqual(record.bar, 'bar') + self.assertEqual(record.baz, 'baz') + self.assertEqual(record.baz2, 'baz') + + def test_precompute_required(self): + model = self.env['test_new_api.precompute.required'] + + field = type(model).name + self.assertTrue(field.related) + self.assertTrue(field.store) + self.assertTrue(field.required) + + partner = self.env['res.partner'].create({'name': 'Foo'}) + + # this will crash if field is not precomputed + record = model.create({'partner_id': partner.id}) + self.assertEqual(record.name, 'Foo') + + # check the queries being made + QUERIES = [insert(model, 'partner_id', 'name')] + with self.assertQueries(QUERIES): + record = model.create({'partner_id': partner.id}) + + def test_precompute_batch(self): + model = self.env['test_new_api.precompute.required'] + + partners = self.env['res.partner'].create([ + {'name': name} + for name in "Foo Bar Baz".split() + ]) + + # warmup + model.create({'partner_id': partners[0].id}) + model.flush() + model.invalidate_cache() + + # check the number of queries: 1 SELECT + 3 INSERT + with self.assertQueryCount(4): + model.create([{'partner_id': pid} for pid in partners.ids]) diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py index 44e91961db0..4d323e16f9d 100644 --- a/odoo/addons/test_new_api/tests/test_one2many.py +++ b/odoo/addons/test_new_api/tests/test_one2many.py @@ -283,3 +283,72 @@ class One2manyCase(TransactionCase): order.write({ 'line_ids': [Command.link(line0.id), Command.link(line1.id)], }) + + def test_new_real_interactions(self): + """ Test and specify the interactions between new and real records. + Through m2o and o2m, with real/unreal records on both sides, the behavior + varies greatly. At least, the behavior will be clearly consistent and any + change will have to adapt the current test. + """ + ############## + # REAL - NEW # + ############## + parent = self.env['test_new_api.model_parent_m2o'].create({'name': 'parentB'}) + new_child = self.env['test_new_api.model_child_m2o'].new({'name': 'B', 'parent_id': parent.id}) + + # wanted behavior: when creating a new with a real parent id, the child + # isn't present in the parent childs until true creation + self.assertFalse(parent.child_ids) + self.assertEqual(new_child.parent_id, parent) + + # current (wanted?) behavior: when adding a new record to a real record + # o2m, the record is created, but not linked to the new in cache + # REAL.O2M += NEW RECORD + parent.child_ids += new_child + self.assertTrue(parent.child_ids) + self.assertNotEqual(parent.child_ids, new_child) + + ############# + # NEW - NEW # + ############# + # wanted behavior: linking new records to new records is totally fine + new_parent = self.env['test_new_api.model_parent_m2o'].new({ + "name": 'parentC3PO', + "child_ids": [(0, 0, {"name": "C3"})], + }) + self.assertEqual(new_parent, new_parent.child_ids.parent_id) + self.assertFalse(new_parent.id) + self.assertTrue(new_parent.child_ids) + self.assertFalse(new_parent.child_ids.ids) + + new_child = self.env['test_new_api.model_child_m2o'].new({ + 'name': 'PO', + }) + new_parent.child_ids += new_child + self.assertIn(new_child, new_parent.child_ids) + self.assertEqual(len(new_parent.child_ids), 2) + self.assertListEqual(new_parent.child_ids.mapped('name'), ['C3', 'PO']) + + new_child2 = self.env['test_new_api.model_child_m2o'].new({ + 'name': 'R2D2', + 'parent_id': new_parent.id, + }) + self.assertIn(new_child2, new_parent.child_ids) + self.assertEqual(len(new_parent.child_ids), 3) + self.assertListEqual(new_parent.child_ids.mapped('name'), ['C3', 'PO', 'R2D2']) + + ############################### + # NEW TO REAL CONVERSION TEST # + ############################### + + # A bit out of scope, but was interesting to check everything was + # working fine on the way. + name = type(new_parent).name + child_ids = type(new_parent).child_ids + parent = self.env['test_new_api.model_parent_m2o'].create({ + 'name': name.convert_to_write(new_parent.name, new_parent), + 'child_ids': child_ids.convert_to_write(new_parent.child_ids, new_parent), + }) + self.assertEqual(len(parent.child_ids), 3) + self.assertEqual(parent, parent.child_ids.parent_id) + self.assertEqual(parent.child_ids.mapped('name'), ['C3', 'PO', 'R2D2']) diff --git a/odoo/fields.py b/odoo/fields.py index 74e9d71cbc9..c0f76a53cba 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -187,6 +187,18 @@ class Field(MetaField('DummyField', (object,), {})): .. seealso:: :ref:`Advanced Fields/Compute fields ` + :param bool precompute: whether the field should be computed before record insertion + in database. Should be used to specify manually some fields as precompute=True + when the field can be computed before record insertion. + (e.g. avoid statistics fields based on search/read_group), many2one + linking to the previous record, ... (default: `False`) + + .. warning:: + + Precompute only happens if no explicit value and no default value is + provided to create(). This means that a default value disables the + precomputation, even if the field is specified as precompute=True. + :param bool compute_sudo: whether the field should be recomputed as superuser to bypass access rights (by default ``True`` for stored fields, ``False`` for non stored fields) @@ -236,6 +248,7 @@ class Field(MetaField('DummyField', (object,), {})): recursive = False # whether self depends on itself compute = None # compute(recs) computes field on recs compute_sudo = False # whether field should be recomputed as superuser + precompute = False # whether field has to be computed before creation inverse = None # inverse(recs) inverses field on recs search = None # search(recs, operator, value) searches on self related = None # sequence of field names, for related fields @@ -244,7 +257,7 @@ class Field(MetaField('DummyField', (object,), {})): string = None # field label help = None # field tooltip - invisible = False # whether the field is invisible + invisible = False # whether the field is invisible readonly = False # whether the field is readonly required = False # whether the field is required states = None # set readonly and required depending on state @@ -384,6 +397,13 @@ class Field(MetaField('DummyField', (object,), {})): attrs['compute_sudo'] = attrs.get('compute_sudo', attrs.get('related_sudo', True)) attrs['copy'] = attrs.get('copy', False) attrs['readonly'] = attrs.get('readonly', True) + if attrs.get('precompute'): + if not attrs.get('compute') and not attrs.get('related'): + warnings.warn(f"precompute attribute doesn't make any sense on non computed field {self}") + attrs['precompute'] = False + elif not attrs.get('store'): + warnings.warn(f"precompute attribute has no impact on non stored field {self}") + attrs['precompute'] = False if attrs.get('company_dependent'): # by default, company-dependent fields are not stored, not computed # in superuser mode and not copied @@ -701,6 +721,7 @@ class Field(MetaField('DummyField', (object,), {})): for dotnames in registry.field_depends[self]: field_seq = [] model_name = self.model_name + check_precompute = self.precompute for index, fname in enumerate(dotnames.split('.')): Model = registry[model_name] @@ -720,6 +741,12 @@ class Field(MetaField('DummyField', (object,), {})): self.recursive = True warnings.warn(f"Field {self} should be declared with recursive=True") + # precomputed fields can depend on non-precomputed ones, as long + # as they are reachable through at least one many2one field + if check_precompute and field.store and field.compute and not field.precompute: + warnings.warn(f"Field {self} cannot be precomputed as it depends on non-precomputed field {field}") + self.precompute = False + field_seq.append(field) # do not make self trigger itself: for instance, a one2many @@ -732,6 +759,9 @@ class Field(MetaField('DummyField', (object,), {})): for inv_field in Model.pool.field_inverses[field]: yield tuple(field_seq) + (inv_field,) + if check_precompute and field.type == 'many2one': + check_precompute = False + model_name = field.comodel_name ############################################################################ diff --git a/odoo/models.py b/odoo/models.py index 289e9daaf68..c94cb0deee5 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2840,17 +2840,17 @@ class BaseModel(metaclass=MetaModel): continue # don't update custom fields new = field.update_db(self, columns) if new and field.compute: - fields_to_compute.append(field.name) + fields_to_compute.append(field) if fields_to_compute: - @self.pool.post_init - def mark_fields_to_compute(): - recs = self.with_context(active_test=False).search([], order='id') - if not recs: - return - for field in fields_to_compute: - _logger.info("Storing computed values of %s.%s", recs._name, field) - self.env.add_to_compute(recs._fields[field], recs) + # mark existing records for computation now, so that computed + # required fields are flushed before the NOT NULL constraint is + # added to the database + cr.execute('SELECT id FROM "{}"'.format(self._table)) + records = self.browse(row[0] for row in cr.fetchall()) + for field in fields_to_compute: + _logger.info("Prepare computation of %s", field) + self.env.add_to_compute(field, records) if self._auto: self._add_sql_constraints() @@ -4000,28 +4000,14 @@ Fields: self = self.browse() self.check_access_rights('create') - bad_names = {'id', 'parent_path'} - if self._log_access: - # the superuser can set log_access fields while loading registry - if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): - bad_names.update(LOG_ACCESS_COLUMNS) + vals_list = self._prepare_create_values(vals_list) # classify fields for each record data_list = [] inversed_fields = set() for vals in vals_list: - # add missing defaults - vals = self._add_missing_default_values(vals) - - # set magic fields - for name in bad_names: - vals.pop(name, None) - if self._log_access: - vals.setdefault('create_uid', self.env.uid) - vals.setdefault('create_date', self.env.cr.now()) - vals.setdefault('write_uid', self.env.uid) - vals.setdefault('write_date', self.env.cr.now()) + precomputed = vals.pop('__precomputed__', ()) # distribute fields into sets for various purposes data = {} @@ -4047,11 +4033,12 @@ Fields: stored[key] = val if field.inherited: inherited[field.related_field.model_name][key] = val - elif field.inverse: + elif field.inverse and field not in precomputed: inversed[key] = val inversed_fields.add(field) - # protect non-readonly computed fields against (re)computation - if field.compute and not field.readonly: + # protect editable computed fields and precomputed fields + # against (re)computation + if field.compute and (not field.readonly or field.precompute): protected.update(self.pool.field_computed.get(field, [field])) data_list.append(data) @@ -4118,6 +4105,88 @@ Fields: records._check_company() return records + def _prepare_create_values(self, vals_list): + """ Clean up and complete the given create values, and return a list of + new vals containing: + + * default values, + * discarded forbidden values (magic fields), + * precomputed fields. + + :param list vals_list: List of create values + :returns: new list of completed create values + :rtype: dict + """ + bad_names = ['id', 'parent_path'] + if self._log_access: + # the superuser can set log_access fields while loading registry + if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): + bad_names.extend(LOG_ACCESS_COLUMNS) + + # also discard precomputed readonly fields (to force their computation) + bad_names.extend( + fname + for fname, field in self._fields.items() + if field.precompute and field.readonly + ) + + result_vals_list = [] + for vals in vals_list: + # add default values + vals = self._add_missing_default_values(vals) + + # add magic fields + for fname in bad_names: + vals.pop(fname, None) + if self._log_access: + vals.setdefault('create_uid', self.env.uid) + vals.setdefault('create_date', self.env.cr.now()) + vals.setdefault('write_uid', self.env.uid) + vals.setdefault('write_date', self.env.cr.now()) + + result_vals_list.append(vals) + + # add precomputed fields + self._add_precomputed_values(result_vals_list) + + return result_vals_list + + def _add_precomputed_values(self, vals_list): + """ Add missing precomputed fields to ``vals_list`` values. + Only applies for precompute=True fields. + + :param dict vals_list: list(dict) of create values + """ + precomputable = { + fname: field + for fname, field in self._fields.items() + if field.precompute + } + if not precomputable: + return + + # determine which vals must be completed + vals_list_todo = [ + vals + for vals in vals_list + if any(fname not in vals for fname in precomputable) + ] + if not vals_list_todo: + return + + # create new records for the vals that must be completed + records = self.browse().concat(*(self.new(vals) for vals in vals_list_todo)) + + for record, vals in zip(records, vals_list_todo): + vals['__precomputed__'] = precomputed = set() + for fname, field in precomputable.items(): + if fname not in vals: + # computed stored fields with a column + # have to be computed before create + # s.t. required and constraints can be applied on those fields. + vals[fname] = field.convert_to_write(record[fname], self) + precomputed.add(field) + @api.model def _create(self, data_list): """ Create records from the stored field values in ``data_list``. """ diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index 948cd7973d5..01f3b52cef8 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -321,6 +321,9 @@ class Registry(Mapping): if len({field.compute_sudo for field in fields}) > 1: _logger.warning("%s: inconsistent 'compute_sudo' for computed fields: %s", model_name, ", ".join(field.name for field in fields)) + if len({field.precompute for field in fields}) > 1: + _logger.warning("%s: inconsistent 'precompute' for computed fields: %s", + model_name, ", ".join(field.name for field in fields)) return computed @lazy_property