summaryrefslogtreecommitdiff
path: root/odoo/fields.py
diff options
context:
space:
mode:
authorRaphael Collet <rco@odoo.com>2019-08-14 07:35:49 +0000
committerDenis Ledoux <dle@odoo.com>2019-08-20 12:43:59 +0000
commit9920f20e4c7753bc17bea71dea3a90f7de687196 (patch)
tree6a3d0258c10a8ccb1d85949ddafdeb070de78121 /odoo/fields.py
parente28d3aa4d3851d72deba08c236072d002f83216a (diff)
[IMP] models: ORM speedup
This branch is the combination of several optimizations in the ORM: * store field values once in the cache: the cache reflects more faithfully the database, only fields that explicitly depend on the context have an extra indirection in the cache; * delay recomputations by default: use method `recompute` to explicitly flush out pending recomputations; * delay updates in method `write`: updates are stored in a data structure that can be flushed efficiently to the database with method `flush` (which also flush out recomputations); * make method `modified` take advantage of inverse fields to inverse dependencies; * filter records by evaluating a domain on records in Python; * a computed field with `readonly=False` behaves like a normal field with an onchange method; * computed fields are computed in superuser mode by default. Work done by Toufik Ben Jaa, Raphael Collet, Denis Ledoux and Fabien Pinckaers. closes odoo/odoo#35659 Signed-off-by: Denis Ledoux <beledouxdenis@users.noreply.github.com>
Diffstat (limited to 'odoo/fields.py')
-rw-r--r--odoo/fields.py1532
1 files changed, 997 insertions, 535 deletions
diff --git a/odoo/fields.py b/odoo/fields.py
index daa28dff6e1..e8c1d4bbb84 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -41,9 +41,6 @@ _schema = logging.getLogger(__name__[:-7] + '.schema')
Default = object() # default value for __init__() methods
-def copy_cache(records, env):
- """ Recursively copy the cache of ``records`` to the environment ``env``. """
- env.cache.copy(records, env)
def first(records):
""" Return the first record in ``records``, with the same prefetching. """
@@ -157,8 +154,7 @@ class Field(MetaField('DummyField', (object,), {})):
default ``False`` on computed fields)
:param compute_sudo: whether the field should be recomputed as superuser
- to bypass access rights (boolean, by default ``False``)
- Note that this has no effects on non-stored computed fields
+ to bypass access rights (boolean, by default ``True``)
The methods given for ``compute``, ``inverse`` and ``search`` are model
methods. Their signature is shown in the following example::
@@ -291,13 +287,13 @@ class Field(MetaField('DummyField', (object,), {})):
'manual': False, # whether the field is a custom field
'copy': True, # whether the field is copied over by BaseModel.copy()
'depends': None, # collection of field dependencies
+ 'depends_context': None, # collection of context key dependencies
'recursive': False, # whether self depends on itself
'compute': None, # compute(recs) computes field on recs
- 'compute_sudo': False, # whether field should be recomputed as admin
+ 'compute_sudo': True, # whether field should be recomputed as superuser
'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
- 'related_sudo': True, # whether related fields should be read as admin
'company_dependent': False, # whether ``self`` is company-dependent (property field)
'default': None, # default(recs) returns the default value
@@ -314,7 +310,6 @@ class Field(MetaField('DummyField', (object,), {})):
'group_operator': None, # operator for aggregating values
'group_expand': None, # name of method to expand groups in read_group()
'prefetch': True, # whether the field is prefetched
- 'context_dependent': False, # whether the field's value depends on context
}
def __init__(self, string=Default, **kwargs):
@@ -416,7 +411,6 @@ class Field(MetaField('DummyField', (object,), {})):
attrs['store'] = attrs.get('store', False)
attrs['copy'] = attrs.get('copy', False)
attrs['readonly'] = attrs.get('readonly', not attrs.get('inverse'))
- attrs['context_dependent'] = attrs.get('context_dependent', True)
if attrs.get('related'):
# by default, related fields are not stored and not copied
attrs['store'] = attrs.get('store', False)
@@ -431,12 +425,14 @@ class Field(MetaField('DummyField', (object,), {})):
if not attrs.get('readonly'):
attrs['inverse'] = self._inverse_company_dependent
attrs['search'] = self._search_company_dependent
- attrs['context_dependent'] = attrs.get('context_dependent', True)
+ attrs['depends_context'] = attrs.get('depends_context', ()) + ('force_company',)
if attrs.get('translate'):
# by default, translatable fields are context-dependent
- attrs['context_dependent'] = attrs.get('context_dependent', True)
+ attrs['depends_context'] = attrs.get('depends_context', ()) + ('lang',)
if 'depends' in attrs:
attrs['depends'] = tuple(attrs['depends'])
+ if 'related_sudo' in attrs:
+ attrs['compute_sudo'] = attrs['related_sudo']
return attrs
@@ -498,6 +494,9 @@ class Field(MetaField('DummyField', (object,), {})):
deps = getattr(func, '_depends', ())
return deps(model) if callable(deps) else deps
+ def get_depends_context(func):
+ return getattr(func, '_depends_context', ())
+
if isinstance(self.compute, str):
# if the compute method has been overridden, concatenate all their _depends
self.depends = tuple(
@@ -505,8 +504,16 @@ class Field(MetaField('DummyField', (object,), {})):
for method in resolve_mro(model, self.compute, callable)
for dep in get_depends(method)
)
+ depends_context = tuple(
+ dep
+ for method in resolve_mro(model, self.compute, callable)
+ for dep in get_depends_context(method)
+ )
else:
self.depends = tuple(get_depends(self.compute))
+ depends_context = tuple(get_depends_context(self.compute))
+
+ self.depends_context = (self.depends_context or ()) + depends_context
def _setup_regular_full(self, model):
""" Setup the inverse field(s) of ``self``. """
@@ -565,6 +572,9 @@ class Field(MetaField('DummyField', (object,), {})):
if self.inherited:
self._modules.update(field._modules)
+ if field.depends_context:
+ self.depends_context = field.depends_context
+
def traverse_related(self, record):
""" Traverse the fields of the related field `self` except for the last
one, and return it as a pair `(last_record, last_field)`. """
@@ -574,11 +584,6 @@ class Field(MetaField('DummyField', (object,), {})):
def _compute_related(self, records):
""" Compute the related field ``self`` on ``records``. """
- # when related_sudo, bypass access rights checks when reading values
- others = records.sudo() if self.related_sudo else records
- # copy the cache of draft records into others' cache
- if not all(records._ids) and records.env != others.env:
- copy_cache(records - records.filtered('id'), others.env)
#
# Traverse fields one by one for all records, in order to take advantage
# of prefetching for each field access. In order to clarify the impact
@@ -605,7 +610,7 @@ class Field(MetaField('DummyField', (object,), {})):
# the case where 'bar' is a computed field that takes advantage of batch
# computation.
#
- values = list(others)
+ values = list(records)
for name in self.related[:-1]:
try:
values = [first(value[name]) for value in values]
@@ -627,9 +632,11 @@ class Field(MetaField('DummyField', (object,), {})):
# store record values, otherwise they may be lost by cache invalidation!
record_value = {record: record[self.name] for record in records}
for record in records:
- other, field = self.traverse_related(record)
- if other:
- other[field.name] = record_value[record]
+ target, field = self.traverse_related(record)
+ # update 'target' only if 'record' and 'target' are both real or
+ # both new (see `test_base_objects.py`, `test_basic`)
+ if target and bool(target.id) == bool(record.id):
+ target[field.name] = record_value[record]
def _search_related(self, records, operator, value):
""" Determine the domain to search on field ``self``. """
@@ -641,7 +648,6 @@ class Field(MetaField('DummyField', (object,), {})):
_related_help = property(attrgetter('help'))
_related_groups = property(attrgetter('groups'))
_related_group_operator = property(attrgetter('group_operator'))
- _related_context_dependent = property(attrgetter('context_dependent'))
@property
def base_field(self):
@@ -686,68 +692,59 @@ class Field(MetaField('DummyField', (object,), {})):
#
# Setup of field triggers
#
- # The triggers of ``self`` are a collection of pairs ``(field, path)`` of
- # fields that depend on ``self``. When ``self`` is modified, it invalidates
- # the cache of each ``field``, and determines the records to recompute based
- # on ``path``. See method ``modified`` below for details.
+ # The triggers of a field F is a tree that contains the fields that depend
+ # on F, together with the fields to inverse to find out which records to
+ # recompute.
#
+ # For instance, assume that G depends on F, H depends on X.F, I depends on
+ # W.X.F, and J depends on Y.F. The triggers of F will be the tree:
+ #
+ # [G]
+ # X/ \Y
+ # [H] [J]
+ # W/
+ # [I]
+ #
+ # This tree provides perfect support for the trigger mechanism:
+ # when F is # modified on records,
+ # - mark G to recompute on records,
+ # - mark H to recompute on inverse(X, records),
+ # - mark I to recompute on inverse(W, inverse(X, records)),
+ # - mark J to recompute on inverse(Y, records).
- def resolve_deps(self, model, path0=[], seen=frozenset()):
- """ Return the dependencies of ``self`` as tuples ``(model, field, path)``,
- where ``path`` is an optional list of field names.
- """
- model0 = model
- result = []
+ def setup_triggers(self, model):
+ def add_trigger(field, path):
+ """ add a trigger on field to recompute self """
+ field_model = model.env[field.model_name]
+ # trigger computations depending on one2many fields only at creation
+ nodes = [field_model._field_triggers_create.setdefault(field, {})]
+ if (field.type != 'one2many') or not field_model._field_inverses[field]:
+ nodes.append(field_model._field_triggers.setdefault(field, {}))
+ for node in nodes:
+ for f in reversed(path):
+ node = node.setdefault(f, {})
+ node.setdefault(None, []).append(self)
- # add self's own dependencies
for dotnames in self.depends:
- if dotnames == self.name:
- _logger.warning("Field %s depends on itself; please fix its decorator @api.depends().", self)
- model, path = model0, path0
+ field_model = model
+ path = [] # fields from model to field_model
for fname in dotnames.split('.'):
- field = model._fields.get(fname)
- if field is None:
- raise KeyError(_("Field %s depends on unknown field %s") % (self, dotnames))
- result.append((model, field, path))
- model = model0.env.get(field.comodel_name)
- path = None if path is None else path + [fname]
-
- # add self's model dependencies
- for mname, fnames in model0._depends.items():
- model = model0.env[mname]
- for fname in fnames:
- field = model._fields[fname]
- result.append((model, field, None))
-
- # add indirect dependencies from the dependencies found above
- seen = seen.union([self])
- for model, field, path in list(result):
- # Fields that depend on the inverse of a one2many do not explicitly
- # depend on the one2many. This avoids useless recomputations when
- # writing on the one2many without actually modifying it. Actual
- # modifications do write on the inverse, and therefore trigger the
- # expected recomputations.
- if field.type in ('one2many', 'many2many'):
- for inv_field in model._field_inverses[field]:
- inv_model = model0.env[inv_field.model_name]
- inv_path = None if path is None else path + [field.name]
- result.append((inv_model, inv_field, inv_path))
- if not field.store and field not in seen:
- result += field.resolve_deps(model, path, seen)
+ field = field_model._fields[fname]
+ # Do not make self trigger itself
+ # e.g. `fields.One2many('stock.move.line', 'move_id', domain=[('product_qty', '=', 0.0)])`
+ # will have 'move_line_nosuggest_ids.product_qty' as a dependency
+ if (field is not self) or path:
+ add_trigger(field, path)
- return result
+ if (field is self) and path:
+ self.recursive = True
- def setup_triggers(self, model):
- """ Add the necessary triggers to invalidate/recompute ``self``. """
- for model, field, path in self.resolve_deps(model):
- if self.store and not field.store:
- _logger.info("Field %s depends on non-stored field %s", self, field)
- if field is not self:
- path_str = None if path is None else ('.'.join(path) or 'id')
- model._field_triggers.add(field, (self, path_str))
- elif path:
- self.recursive = True
- model._field_triggers.add(field, (self, '.'.join(path)))
+ path.append(field)
+ if field.type in ('one2many', 'many2many'):
+ for inv_field in field_model._field_inverses[field]:
+ add_trigger(inv_field, path)
+
+ field_model = model.env.get(field.comodel_name)
############################################################################
#
@@ -833,7 +830,7 @@ class Field(MetaField('DummyField', (object,), {})):
If the value represents a recordset, it should share the prefetching of
``record``.
"""
- return value
+ return False if value is None else value
def convert_to_read(self, value, record, use_name_get=True):
""" Convert ``value`` from the record format to the format returned by
@@ -845,10 +842,12 @@ class Field(MetaField('DummyField', (object,), {})):
return False if value is None else value
def convert_to_write(self, value, record):
- """ Convert ``value`` from the record format to the format of method
+ """ Convert ``value`` from any format to the format of method
:meth:`BaseModel.write`.
"""
- return self.convert_to_read(value, record)
+ cache_value = self.convert_to_cache(value, record, validate=False)
+ record_value = self.convert_to_record(cache_value, record)
+ return self.convert_to_read(record_value, record)
def convert_to_onchange(self, value, record, names):
""" Convert ``value`` from the record format to the format returned by
@@ -928,6 +927,8 @@ class Field(MetaField('DummyField', (object,), {})):
# the column is new or it becomes required; initialize its values
if model._table_has_rows():
model._init_column(self.name)
+ # flush values before adding NOT NULL constraint
+ model.flush([self.name])
if self.required and not has_notnull:
sql.set_not_null(model._cr, model._table, self.name)
@@ -970,11 +971,32 @@ class Field(MetaField('DummyField', (object,), {})):
self.write(record, value)
def write(self, records, value):
- """ Write the value of ``self`` on ``records``.
+ """ Write the value of ``self`` on ``records``. This method must update
+ the cache and prepare database updates.
- :param value: a value in the format of method :meth:`BaseModel.write`
+ :param value: a value in any format
+ :return: the subset of `records` that have been modified
"""
- return NotImplementedError("Method write() undefined on %s" % self)
+ # discard recomputation of self on records
+ records.env.remove_to_compute(self, records)
+
+ # update the cache, and discard the records that are not modified
+ cache = records.env.cache
+ NOTHING = object()
+ cache_value = self.convert_to_cache(value, records)
+ records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value)
+ if not records:
+ return records
+ cache.update(records, self, [cache_value] * len(records))
+
+ # update towrite
+ if self.store:
+ towrite = records.env.all.towrite[self.model_name]
+ column_value = self.convert_to_column(cache_value, records)
+ for record in records.filtered('id'):
+ towrite[record.id][self.name] = column_value
+
+ return records
############################################################################
#
@@ -986,174 +1008,126 @@ class Field(MetaField('DummyField', (object,), {})):
if record is None:
return self # the field is accessed through the owner class
- if record._ids:
- # only a single record may be accessed
- record.ensure_one()
- try:
- value = record.env.cache.get(record, self)
- except KeyError:
- # cache miss, determine value and retrieve it
- if record.id:
- self.determine_value(record)
- else:
- self.determine_draft_value(record)
- value = record.env.cache.get(record, self)
- else:
+ if not record._ids:
# null record -> return the null value for this field
value = self.convert_to_cache(False, record, validate=False)
+ return self.convert_to_record(value, record)
- return self.convert_to_record(value, record)
-
- def __set__(self, record, value):
- """ set the value of field ``self`` on ``record`` """
env = record.env
- # only a single record may be updated
+ # only a single record may be accessed
record.ensure_one()
- # adapt value to the cache level
- value = self.convert_to_cache(value, record)
+ if self.compute and (record.id in env.all.tocompute.get(self, ())) \
+ and not env.is_protected(self, record):
+ # self must be computed on record
+ recs = record if self.recursive else env.records_to_compute(self)
+ try:
+ self.compute_value(recs)
+ except AccessError:
+ self.compute_value(record)
- if env.in_draft or not record.id:
- # determine dependent fields
- spec = self.modified_draft(record)
+ try:
+ value = env.cache.get(record, self)
- # set value in cache
- record.env.cache.set(record, self, value)
+ except KeyError:
+ # real record
+ if record.id and self.store:
+ recs = record._in_cache_without(self)
+ try:
+ recs._fetch_field(self)
+ except AccessError:
+ record._fetch_field(self)
+ if not env.cache.contains(record, self) and not record.exists():
+ raise MissingError("\n".join([
+ _("Record does not exist or has been deleted."),
+ _("(Record: %s, User: %s)") % (record, env.uid),
+ ]))
+ value = env.cache.get(record, self)
+
+ elif self.compute:
+ if env.is_protected(self, record):
+ value = self.convert_to_cache(False, record, validate=False)
+ env.cache.set(record, self, value)
+ else:
+ recs = record if self.recursive or not record.id else record._in_cache_without(self)
+ try:
+ self.compute_value(recs)
+ except AccessError:
+ self.compute_value(record)
+ value = env.cache.get(record, self)
- if not record.id:
- # set inverse fields on new records in the comodel
- if self.relational:
- inv_recs = record[self.name].filtered(lambda r: not r.id)
- if inv_recs:
- for invf in record._field_inverses[self]:
- invf._update(inv_recs, record)
+ elif (not record.id) and record._origin:
+ value = self.convert_to_cache(record._origin[self.name], record)
+ env.cache.set(record, self, value)
- # determine more dependent fields, and invalidate them
- if self.relational:
- spec += self.modified_draft(record)
- env.cache.invalidate(spec)
+ elif (not record.id) and self.type == 'many2one' and self.delegate:
+ # special case: parent records are new as well
+ parent = record.env[self.comodel_name].new()
+ value = self.convert_to_cache(parent, record)
+ env.cache.set(record, self, value)
- elif (self.store or self.inverse or self.inherited):
- # Write to database
- write_value = self.convert_to_write(self.convert_to_record(value, record), record)
- record.write({self.name: write_value})
+ else:
+ value = self.convert_to_cache(False, record, validate=False)
+ env.cache.set(record, self, value)
+ defaults = record.default_get([self.name])
+ if self.name in defaults:
+ # The null value above is necessary to convert x2many field values.
+ # For instance, converting [(4, id)] accesses the field's current
+ # value, then adds the given id. Without an initial value, the
+ # conversion ends up here to determine the field's value, and this
+ # generates an infinite recursion.
+ value = self.convert_to_cache(defaults[self.name], record)
+ env.cache.set(record, self, value)
- else:
- # Update the cache unless value contains a new record
- if not (self.relational and not all(value)):
- record.env.cache.set(record, self, value)
+ return self.convert_to_record(value, record)
+
+ def __set__(self, records, value):
+ """ set the value of field ``self`` on ``records`` """
+ protected_records = records & records.env.protected(self)
+ if protected_records:
+ # records being computed: no business logic, no recomputation
+ self.write(protected_records, value)
+ records -= protected_records
+
+ new_records = records.filtered(lambda record: not record.id)
+ if new_records:
+ # new records: no business logic
+ with records.env.protecting(records._field_computed.get(self, [self]), records):
+ new_records.modified([self.name], create=True)
+ self.write(new_records, value)
+ if self.relational:
+ new_records.modified([self.name])
+ records -= new_records
+
+ if records:
+ # base case: full business logic
+ write_value = self.convert_to_write(value, records)
+ records.write({self.name: write_value})
############################################################################
#
# Computation of field values
#
- def _compute_value(self, records):
- """ Invoke the compute method on ``records``. """
- # initialize the fields to their corresponding null value in cache
- fields = records._field_computed[self]
- cache = records.env.cache
- for field in fields:
- for record in records:
- cache.set(record, field, field.convert_to_cache(False, record, validate=False))
- if isinstance(self.compute, str):
- getattr(records, self.compute)()
- else:
- self.compute(records)
-
def compute_value(self, records):
""" Invoke the compute method on ``records``; the results are in cache. """
+ env = records.env
+ if self.compute_sudo:
+ records = records.sudo()
fields = records._field_computed[self]
- with records.env.do_in_draft(), records.env.protecting(fields, records):
- try:
- self._compute_value(records)
- except (AccessError, MissingError):
- # some record is forbidden or missing, retry record by record
- for record in records:
- try:
- self._compute_value(record)
- except Exception as exc:
- record.env.cache.set_failed(record, [self], exc)
- def determine_value(self, record):
- """ Determine the value of ``self`` for ``record``. """
- env = record.env
-
- if self.store:
- # this is a stored field or an old-style function field
- if self.compute:
- # this is a stored computed field, check for recomputation
- recs = record._recompute_check(self)
- if recs:
- # recompute the value (only in cache)
- if self.recursive:
- recs = record
- self.compute_value(recs)
- # HACK: if result is in the wrong cache, copy values
- if recs.env != env:
- computed = record._field_computed[self]
- for source, target in zip(recs, recs.with_env(env)):
- try:
- values = {f.name: source[f.name] for f in computed}
- target._update_cache(values, validate=False)
- except MissingError as exc:
- target._cache.set_failed(target._fields, exc)
- # the result is saved to database by BaseModel.recompute()
- return
-
- # read the field from database
- record._prefetch_field(self)
-
- elif self.compute:
- # this is either a non-stored computed field, or a stored computed
- # field in onchange mode
- if self.recursive:
- self.compute_value(record)
- else:
- recs = record._in_cache_without(self)
- self.compute_value(recs)
-
- else:
- # this is a non-stored non-computed field
- record.env.cache.set(record, self, self.convert_to_cache(False, record, validate=False))
-
- def determine_draft_value(self, record):
- """ Determine the value of ``self`` for the given draft ``record``. """
- if self.compute:
- if self.compute_sudo and not record.env.su:
- record_sudo = record.sudo()
- copy_cache(record, record_sudo.env)
- self.compute_value(record_sudo)
- record[self.name] = record_sudo[self.name]
- else:
- self.compute_value(record)
- return
+ # just in case the compute method does not assign a value
+ for field in fields:
+ env.remove_to_compute(field, records)
- origin = record._origin
- if origin:
- # retrieve value from original record
- value = self.convert_to_cache(origin[self.name], record)
- return record.env.cache.set(record, self, value)
-
- if self.type == 'many2one' and self.delegate:
- # special case: parent records are new as well
- parent = record.env[self.comodel_name].new()
- value = self.convert_to_cache(parent, record)
- return record.env.cache.set(record, self, value)
-
- null = self.convert_to_cache(False, record, validate=False)
- record.env.cache.set_special(record, self, lambda: null)
-
- defaults = record.default_get([self.name])
- if self.name in defaults:
- # The null value above is necessary to convert x2many field values.
- # For instance, converting [(4, id)] accesses the field's current
- # value, then adds the given id. Without an initial value, the
- # conversion ends up here to determine the field's value, and this
- # generates an infinite recursion.
- value = self.convert_to_cache(defaults[self.name], record)
- return record.env.cache.set(record, self, value)
+ try:
+ with records.env.protecting(fields, records):
+ records._compute_field_value(self)
+ except Exception:
+ for field in fields:
+ env.add_to_compute(field, records)
+ raise
def determine_inverse(self, records):
""" Given the value of ``self`` on ``records``, inverse the computation. """
@@ -1174,33 +1148,6 @@ class Field(MetaField('DummyField', (object,), {})):
# Notification when fields are modified
#
- def modified_draft(self, record):
- """ Same as :meth:`modified`, but in draft mode. """
- env = record.env
-
- # invalidate the fields on the records in cache that depend on
- # ``record``, except fields currently being computed
- spec = []
- for field, path in record._field_triggers[self]:
- if not field.compute:
- # Note: do not invalidate non-computed fields. Such fields may
- # require invalidation in general (like *2many fields with
- # domains) but should not be invalidated in this case, because
- # we would simply lose their values during an onchange!
- continue
-
- if path == 'id' and field.model_name == record._name:
- target = record
- else:
- target = env.cache.get_records(env[field.model_name], field)
- target = target.filtered(lambda t: not t.id)
- target -= env.protected(field)
-
- if target:
- spec.append((field, target._ids))
-
- return spec
-
class Boolean(Field):
type = 'boolean'
@@ -1229,9 +1176,12 @@ class Integer(Field):
def convert_to_cache(self, value, record, validate=True):
if isinstance(value, dict):
# special case, when an integer field is used as inverse for a one2many
- return value.get('id', False)
+ return value.get('id', None)
return int(value or 0)
+ def convert_to_record(self, value, record):
+ return value or 0
+
def convert_to_read(self, value, record, use_name_get=True):
# Integer values greater than 2^31-1 are not supported in pure XMLRPC,
# so we have to pass them as floats :-(
@@ -1305,6 +1255,9 @@ class Float(Field):
digits = self.get_digits(record.env)
return float_round(value, precision_digits=digits[1]) if digits else value
+ def convert_to_record(self, value, record):
+ return value or 0.0
+
def convert_to_export(self, value, record):
if value or value == 0.0:
return value
@@ -1375,6 +1328,9 @@ class Monetary(Field):
value = record[self.currency_field].round(value)
return value
+ def convert_to_record(self, value, record):
+ return value or 0.0
+
def convert_to_read(self, value, record, use_name_get=True):
return value
@@ -1437,6 +1393,82 @@ class _String(Field):
else:
return value
+ def write(self, records, value):
+ # discard recomputation of self on records
+ records.env.remove_to_compute(self, records)
+
+ # update the cache, and discard the records that are not modified
+ cache = records.env.cache
+ NOTHING = object()
+ cache_value = self.convert_to_cache(value, records)
+ records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value)
+ if not records:
+ return records
+ cache.update(records, self, [cache_value] * len(records))
+
+ if not self.store:
+ return records
+
+ update_column = True
+ update_trans = False
+ single_lang = len(records.env['res.lang'].get_installed()) <= 1
+ if self.translate:
+ lang = records.env.lang or 'en_US'
+ if single_lang:
+ # a single language is installed
+ update_trans = True
+ elif callable(self.translate) or lang == 'en_US':
+ # update the source and synchronize translations
+ update_column = True
+ update_trans = True
+ elif lang != 'en_US':
+ # update the translations only
+ update_column = False
+ update_trans = True
+
+ # update towrite if modifying the source
+ if update_column:
+ towrite = records.env.all.towrite[self.model_name]
+ column_value = self.convert_to_column(cache_value, records)
+ for record in records.filtered('id'):
+ towrite[record.id][self.name] = column_value
+ if self.translate is True:
+ tname = "%s,%s" % (records._name, self.name)
+ records.env['ir.translation']._set_source(tname, records.ids, value)
+
+ if update_trans:
+ if callable(self.translate):
+ # the source value of self has been updated, synchronize
+ # translated terms when possible
+ real_recs = records.filtered('id')
+ records.env['ir.translation']._sync_terms_translations(self, records)
+
+ else:
+ # update translations
+ value = self.convert_to_column(value, records)
+ real_recs = records.filtered('id')
+ source_recs = real_recs.with_context(lang='en_US')
+ source_value = first(source_recs)[self.name]
+ if not source_value:
+ source_recs[self.name] = value
+ source_value = value
+ tname = "%s,%s" % (self.model_name, self.name)
+ if single_lang:
+ records.env['ir.translation']._update_translations([dict(
+ src=source_value,
+ value=value,
+ name=tname,
+ lang=lang,
+ type='model',
+ state='translated',
+ res_id=res_id) for res_id in real_recs.ids])
+ else:
+ records.env['ir.translation']._set_ids(
+ tname, 'model', lang, real_recs.ids, value, source_value,
+ )
+
+ return records
+
class Char(_String):
""" Basic string field, can be length-limited, usually displayed as a
@@ -1492,7 +1524,7 @@ class Char(_String):
def convert_to_cache(self, value, record, validate=True):
if value is None or value is False:
- return False
+ return None
return pycompat.to_text(value)[:self.size]
@@ -1512,7 +1544,7 @@ class Text(_String):
def convert_to_cache(self, value, record, validate=True):
if value is None or value is False:
- return False
+ return None
return ustr(value)
@@ -1563,7 +1595,7 @@ class Html(_String):
def convert_to_cache(self, value, record, validate=True):
if value is None or value is False:
- return False
+ return None
if validate and self.sanitize:
return html_sanitize(
value, silent=True,
@@ -1660,10 +1692,12 @@ class Date(Field):
def convert_to_cache(self, value, record, validate=True):
if not value:
- return False
+ return None
if isinstance(value, datetime):
- raise TypeError("%s (field %s) must be string or date, not datetime." % (value, self))
- return self.from_string(value)
+ # TODO: better fix data files (crm demo data)
+ value = value.date()
+ # raise TypeError("%s (field %s) must be string or date, not datetime." % (value, self))
+ return self.to_date(value)
def convert_to_export(self, value, record):
if not value:
@@ -1747,10 +1781,9 @@ class Datetime(Field):
raise ValueError("Datetime field expects a naive datetime: %s" % value)
return value
return datetime.combine(value, time.min)
- try:
- return datetime.strptime(value[:DATETIME_LENGTH], DATETIME_FORMAT)
- except ValueError:
- return datetime.strptime(value, DATE_FORMAT)
+
+ # TODO: fix data files
+ return datetime.strptime(value, DATETIME_FORMAT[:len(value)-2])
# kept for backwards compatibility, but consider `from_string` as deprecated, will probably
# be removed after V12
@@ -1769,11 +1802,7 @@ class Datetime(Field):
return value.strftime(DATETIME_FORMAT) if value else False
def convert_to_cache(self, value, record, validate=True):
- if not value:
- return False
- if isinstance(value, date) and not isinstance(value, datetime):
- raise TypeError("%s (field %s) must be string or datetime, not date." % (value, self))
- return self.from_string(value)
+ return self.to_datetime(value)
def convert_to_export(self, value, record):
if not value:
@@ -1793,9 +1822,9 @@ _BINARY = memoryview
class Binary(Field):
type = 'binary'
_slots = {
- 'prefetch': False, # not prefetched by default
- 'context_dependent': True, # depends on context (content or size)
- 'attachment': True, # whether value is stored in attachment
+ 'prefetch': False, # not prefetched by default
+ 'depends_context': ('bin_size',), # depends on context (content or size)
+ 'attachment': True, # whether value is stored in attachment
}
@property
@@ -1831,6 +1860,10 @@ class Binary(Field):
def convert_to_cache(self, value, record, validate=True):
if isinstance(value, _BINARY):
return bytes(value)
+ if isinstance(value, str):
+ # the cache must contain bytes or memoryview, but sometimes a string
+ # is given when assigning a binary field (test `TestFileSeparator`)
+ return value.encode()
if isinstance(value, int) and \
(record._context.get('bin_size') or
record._context.get('bin_size_' + self.name)):
@@ -1838,7 +1871,12 @@ class Binary(Field):
# instead of the content. Presumably a separate request will be done
# to read the actual content, if necessary.
return human_size(value)
- return value
+ return None if value is False else value
+
+ def convert_to_record(self, value, record):
+ if isinstance(value, _BINARY):
+ return bytes(value)
+ return False if value is None else value
def read(self, records):
# values are stored in attachments, retrieve them
@@ -1877,20 +1915,35 @@ class Binary(Field):
])
def write(self, records, value):
- assert self.attachment
+ if not self.attachment:
+ return super().write(records, value)
+
+ # discard recomputation of self on records
+ records.env.remove_to_compute(self, records)
+
+ # update the cache, and discard the records that are not modified
+ cache = records.env.cache
+ NOTHING = object()
+ cache_value = self.convert_to_cache(value, records)
+ records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value)
+ if not records:
+ return records
+ cache.update(records, self, [cache_value] * len(records))
+
# retrieve the attachments that store the values, and adapt them
- atts = records.env['ir.attachment'].sudo().search([
- ('res_model', '=', self.model_name),
- ('res_field', '=', self.name),
- ('res_id', 'in', records.ids),
- ])
- with records.env.norecompute():
+ if self.store:
+ atts = records.env['ir.attachment'].sudo().search([
+ ('res_model', '=', self.model_name),
+ ('res_field', '=', self.name),
+ ('res_id', 'in', records.ids),
+ ])
if value:
# update the existing attachments
atts.write({'datas': value})
atts_records = records.browse(atts.mapped('res_id'))
# create the missing attachments
- if len(atts_records) < len(records):
+ missing = (records - atts_records).filtered('id')
+ if missing:
atts.create([{
'name': self.name,
'res_model': record._name,
@@ -1899,11 +1952,13 @@ class Binary(Field):
'type': 'binary',
'datas': value,
}
- for record in (records - atts_records)
+ for record in missing
])
else:
atts.unlink()
+ return records
+
class Image(Binary):
_slots = {
@@ -1914,12 +1969,17 @@ class Image(Binary):
def create(self, record_values):
new_record_values = []
for record, value in record_values:
- new_record_values.append((record, self._image_process(value)))
+ # strange behavior when setting related image field, when `self`
+ # does not resize the same way as its related field
+ new_value = self._image_process(value)
+ new_record_values.append((record, new_value))
+ record.env.cache.update(record, self, [value if self.related else new_value] * len(record))
super(Image, self).create(new_record_values)
def write(self, records, value):
- value = self._image_process(value)
- super(Image, self).write(records, value)
+ new_value = self._image_process(value)
+ super(Image, self).write(records, new_value)
+ records.env.cache.update(records, self, [value if self.related else new_value] * len(records))
def _image_process(self, value):
if value and (self.max_width or self.max_height):
@@ -2031,13 +2091,13 @@ class Selection(Field):
def convert_to_cache(self, value, record, validate=True):
if not validate:
- return value or False
+ return value or None
if value and self.column_type[0] == 'int4':
value = int(value)
if value in self.get_values(record.env):
return value
elif not value:
- return False
+ return None
raise ValueError("Wrong value for %s: %r" % (self, value))
def convert_to_export(self, value, record):
@@ -2061,22 +2121,26 @@ class Reference(Selection):
return Field.convert_to_column(self, value, record, values, validate)
def convert_to_cache(self, value, record, validate=True):
- # cache format: (res_model, res_id) or False
+ # cache format: str ("model,id") or None
if isinstance(value, BaseModel):
if not validate or (value._name in self.get_values(record.env) and len(value) <= 1):
- return (value._name, value.id) if value else False
+ return "%s,%s" % (value._name, value.id) if value else None
elif isinstance(value, str):
res_model, res_id = value.split(',')
- if record.env[res_model].browse(int(res_id)).exists():
- return (res_model, int(res_id))
- else:
- return False
+ if not validate or res_model in self.get_values(record.env):
+ if record.env[res_model].browse(int(res_id)).exists():
+ return value
+ else:
+ return None
elif not value:
- return False
+ return None
raise ValueError("Wrong value for %s: %r" % (self, value))
def convert_to_record(self, value, record):
- return value and record.env[value[0]].browse([value[1]])
+ if value:
+ res_model, res_id = value.split(',')
+ return record.env[res_model].browse(int(res_id))
+ return None
def convert_to_read(self, value, record, use_name_get=True):
return "%s,%s" % (value._name, value.id) if value else False
@@ -2233,31 +2297,32 @@ class Many2one(_Relational):
return value or None
def convert_to_cache(self, value, record, validate=True):
- # cache format: tuple(ids)
+ # cache format: id or None
if type(value) in IdType:
- ids = (value,)
+ id_ = value
elif isinstance(value, BaseModel):
if validate and (value._name != self.comodel_name or len(value) > 1):
raise ValueError("Wrong value for %s: %r" % (self, value))
- ids = value._ids
+ id_ = value._ids[0] if value._ids else None
elif isinstance(value, tuple):
# value is either a pair (id, name), or a tuple of ids
- ids = value[:1]
+ id_ = value[0] if value else None
elif isinstance(value, dict):
- ids = record.env[self.comodel_name].new(value)._ids
+ id_ = record.env[self.comodel_name].new(value).id
else:
- ids = ()
+ id_ = None
if self.delegate and record and not record.id:
# the parent record of a new record is a new record
- ids = tuple(it and NewId(it) for it in ids)
+ id_ = id_ and NewId(id_)
- return ids
+ return id_
def convert_to_record(self, value, record):
# use registry to avoid creating a recordset for the model
- prefetch_ids = IterableGenerator(prefetch_value_ids, record, self)
- return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
+ ids = () if value is None else (value,)
+ prefetch_ids = IterableGenerator(prefetch_many2one_ids, record, self)
+ return record.pool[self.comodel_name]._browse(record.env, ids, prefetch_ids)
def convert_to_read(self, value, record, use_name_get=True):
if use_name_get and value:
@@ -2274,7 +2339,18 @@ class Many2one(_Relational):
return value.id
def convert_to_write(self, value, record):
- return value.id
+ if type(value) in IdType:
+ return value
+ if not value:
+ return False
+ if isinstance(value, BaseModel) and value._name == self.comodel_name:
+ return value.id
+ if isinstance(value, tuple):
+ # value is either a pair (id, name), or a tuple of ids
+ return value[0] if value else False
+ if isinstance(value, dict):
+ return record.env[self.comodel_name].new(value).id
+ raise ValueError("Wrong value for %s: %r" % (self, value))
def convert_to_export(self, value, record):
return value.display_name if value else ''
@@ -2287,57 +2363,179 @@ class Many2one(_Relational):
return False
return super(Many2one, self).convert_to_onchange(value, record, names)
+ def write(self, records, value):
+ # discard recomputation of self on records
+ records.env.remove_to_compute(self, records)
-class _RelationalMultiUpdate(object):
- """ A getter to update the value of an x2many field, without reading its
- value until necessary.
- """
- __slots__ = ['record', 'field', 'value']
+ # discard the records that are not modified
+ cache = records.env.cache
+ NOTHING = object()
+ cache_value = self.convert_to_cache(value, records)
+ records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value)
+ if not records:
+ return records
- def __init__(self, record, field, value):
- self.record = record
- self.field = field
- self.value = value
+ # remove records from the cache of one2many fields of old corecords
+ self._remove_inverses(records, cache_value)
- def __call__(self):
- # determine the current field's value, and update it in cache only
- record, field, value = self.record, self.field, self.value
- cache = record.env.cache
- cache.remove(record, field)
- val = field.convert_to_cache(record[field.name] | value, record, validate=False)
- cache.set(record, field, val)
- return val
+ # update the cache of self
+ cache.update(records, self, [cache_value] * len(records))
+ # update the cache of one2many fields of new corecord
+ self._update_inverses(records, cache_value)
+
+ # update towrite
+ if self.store:
+ towrite = records.env.all.towrite[self.model_name]
+ write_value = self.convert_to_column(self.convert_to_write(cache_value, records), records)
+ for record in records.filtered('id'):
+ towrite[record.id][self.name] = write_value
+
+ return records
+
+ def _remove_inverses(self, records, value):
+ """ Remove `records` from the cached values of the inverse fields of `self`. """
+ cache = records.env.cache
+ record_ids = set(records._ids)
+ for invf in records._field_inverses[self]:
+ corecords = records.env[self.comodel_name].browse(
+ id_ for id_ in cache.get_values(records, self)
+ )
+ for corecord in corecords:
+ ids0 = cache.get(corecord, invf, None)
+ if ids0 is not None:
+ ids1 = tuple(id_ for id_ in ids0 if id_ not in record_ids)
+ cache.set(corecord, invf, ids1)
+
+ def _update_inverses(self, records, value):
+ """ Add `records` to the cached values of the inverse fields of `self`. """
+ if value is None:
+ return
+ cache = records.env.cache
+ corecord = self.convert_to_record(value, records)
+ for invf in records._field_inverses[self]:
+ valid_records = records.filtered_domain(invf.get_domain_list(corecord))
+ if not valid_records:
+ continue
+ ids0 = cache.get(corecord, invf, None)
+ # if the value for the corecord is not in cache, but this is a new
+ # record, assign it anyway, as you won't be able to fetch it from
+ # database (see `test_sale_order`)
+ if ids0 is not None or not corecord.id:
+ ids1 = tuple(unique((ids0 or ()) + valid_records._ids))
+ cache.set(corecord, invf, ids1)
+
+
+class Many2oneReference(Integer):
+ type = 'many2one_reference'
-class _RelationalMulti(_Relational):
- """ Abstract class for relational fields *2many. """
_slots = {
- 'context_dependent': True, # depends on context (active_test)
+ 'model_field': None,
}
+ def convert_to_cache(self, value, record, validate=True):
+ # cache format: id or None
+ if isinstance(value, BaseModel):
+ value = value._ids[0] if value._ids else None
+
+ return value
+
+ def _remove_inverses(self, records, value):
+ # TODO: unused
+ # remove records from the cache of one2many fields of old corecords
+ cache = records.env.cache
+ record_ids = set(records._ids)
+ model_ids = self._record_ids_per_res_model(records)
+
+ for invf in records._field_inverses[self]:
+ records = records.browse(model_ids[invf.model_name])
+ if not records:
+ continue
+ corecords = records.env[invf.model_name].browse(
+ id_ for id_ in cache.get_values(records, self)
+ )
+ for corecord in corecords:
+ ids0 = cache.get(corecord, invf, None)
+ if ids0 is not None:
+ ids1 = tuple(id_ for id_ in ids0 if id_ not in record_ids)
+ cache.set(corecord, invf, ids1)
+
+ def _update_inverses(self, records, value):
+ """ Add `records` to the cached values of the inverse fields of `self`. """
+ cache = records.env.cache
+ model_ids = self._record_ids_per_res_model(records)
+
+ for invf in records._field_inverses[self]:
+ records = records.browse(model_ids[invf.model_name])
+ if not records:
+ continue
+ corecord = records.env[invf.model_name].browse(value)
+ records = records.filtered_domain(invf.get_domain_list(corecord))
+ if not records:
+ continue
+ ids0 = cache.get(corecord, invf, None)
+ # if the value for the corecord is not in cache, but this is a new
+ # record, assign it anyway, as you won't be able to fetch it from
+ # database (see `test_sale_order`)
+ if ids0 is not None or not corecord.id:
+ ids1 = tuple(unique((ids0 or ()) + records._ids))
+ cache.set(corecord, invf, ids1)
+
+ def _record_ids_per_res_model(self, records):
+ model_ids = defaultdict(set)
+ for record in records:
+ model = record[self.model_field]
+ if not model and record._fields[self.model_field].compute:
+ # fallback when the model field is computed :-/
+ record._fields[self.model_field].compute_value(record)
+ model = record[self.model_field]
+ if not model:
+ continue
+ model_ids[model].add(record.id)
+ return model_ids
+
+
+class _RelationalMulti(_Relational):
+ """ Abstract class for relational fields *2many. """
+
def _update(self, records, value):
- """ Update the cached value of ``self`` for ``records`` with ``value``. """
+ """ Update the cached value of ``self`` for ``records`` with ``value``,
+ and return whether everything is in cache.
+ """
if not isinstance(records, BaseModel):
- # the inverse of self is a non-relational field; do not update in
- # this case, as we do not know whether the records are the ones that
- # value makes reference to (via a res_model/res_id pair)
- return
+ # the inverse of self is a non-relational field; `value` is a
+ # corecord that refers to `records` by an integer field
+ model = value.env[self.model_name]
+ domain = self.domain(model) if callable(self.domain) else self.domain
+ if not value.filtered_domain(domain):
+ return
+ records = model.browse(records)
+
cache = records.env.cache
- for record in records:
- special = cache.get_special(record, self)
- if isinstance(special, _RelationalMultiUpdate):
- # include 'value' in the existing _RelationalMultiUpdate; this
- # avoids reading the field's value (which may be large)
- special.value |= value
- elif cache.contains(record, self):
- try:
+ result = True
+ if 'active_test' in (self.depends_context or ()):
+ updates = [
+ (value.sudo().filtered('active'), records.with_context(active_test=True)),
+ (value, records.with_context(active_test=False)),
+ ]
+ else:
+ updates = [(value, records)]
+
+ for value, recs in updates:
+ if not value:
+ continue
+ for record in recs:
+ if cache.contains(record, self):
val = self.convert_to_cache(record[self.name] | value, record, validate=False)
cache.set(record, self, val)
- except Exception as exc:
- # delay the failure until the field is necessary
- cache.set_failed(record, [self], exc)
- else:
- cache.set_special(record, self, _RelationalMultiUpdate(record, self, value))
+ else:
+ result = False
+ # DEEP FUCKING HACK TO FIX SOMEDAY: recs.env is different from
+ # records.env; we must trigger modifications in the original env
+ recs.env._protected = records.env._protected
+ recs.modified([self.name])
+
+ return result
def convert_to_cache(self, value, record, validate=True):
# cache format: tuple(ids)
@@ -2394,60 +2592,49 @@ class _RelationalMulti(_Relational):
def convert_to_record(self, value, record):
# use registry to avoid creating a recordset for the model
- prefetch_ids = IterableGenerator(prefetch_value_ids, record, self)
+ prefetch_ids = IterableGenerator(prefetch_x2many_ids, record, self)
return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
def convert_to_read(self, value, record, use_name_get=True):
return value.ids
def convert_to_write(self, value, record):
- inv_names = {field.name for field in record._field_inverses[self]}
- # make result with new and existing records
- result = [(6, 0, [])]
- for record in value:
- origin = record._origin
- if not origin:
- values = record._convert_to_write({
- name: record[name]
- for name in record._cache
- if name not in inv_names
- })
- result.append((0, 0, values))
- else:
- result[0][2].append(origin.id)
- if record != origin:
+ if isinstance(value, tuple):
+ # a tuple of ids, this is the cache format
+ value = record.env[self.comodel_name].browse(value)
+
+ if isinstance(value, BaseModel) and value._name == self.comodel_name:
+ # make result with new and existing records
+ inv_names = {field.name for field in record._field_inverses[self]}
+ result = [(6, 0, [])]
+ for record in value:
+ origin = record._origin
+ if not origin:
values = record._convert_to_write({
name: record[name]
for name in record._cache
- if name not in inv_names and record[name] != origin[name]
+ if name not in inv_names
})
- if values:
- result.append((1, origin.id, values))
- return result
+ result.append((0, 0, values))
+ else:
+ result[0][2].append(origin.id)
+ if record != origin:
+ values = record._convert_to_write({
+ name: record[name]
+ for name in record._cache
+ if name not in inv_names and record[name] != origin[name]
+ })
+ if values:
+ result.append((1, origin.id, values))
+ return result
+
+ if value is False or value is None:
+ return [(5,)]
+
+ if isinstance(value, list):
+ return value
- def convert_to_onchange(self, value, record, names):
- # return the recordset value as a list of commands; the commands may
- # give all fields values, the client is responsible for figuring out
- # which fields are actually dirty
- vals = {record: {} for record in value}
- for name, subnames in names.items():
- if name == 'id':
- continue
- field = value._fields[name]
- # read all values before converting them (better prefetching)
- rec_vals = [(rec, rec[name]) for rec in value]
- for rec, val in rec_vals:
- vals[rec][name] = field.convert_to_onchange(val, rec, subnames)
-
- result = [(5,)]
- for record in value:
- if not record.id and not record._origin:
- result.append((0, record.id.ref or 0, vals[record]))
- elif vals[record]:
- result.append((1, record._origin.id, vals[record]))
- else:
- result.append((4, record._origin.id))
- return result
+ raise ValueError("Wrong value for %s: %s" % (self, value))
def convert_to_export(self, value, record):
return ','.join(name for id, name in value.name_get()) if value else ''
@@ -2455,17 +2642,6 @@ class _RelationalMulti(_Relational):
def convert_to_display_name(self, value, record):
raise NotImplementedError()
- def _compute_related(self, records):
- """ Compute the related field ``self`` on ``records``. """
- super(_RelationalMulti, self)._compute_related(records)
- if self.related_sudo:
- # determine which records in the relation are actually accessible
- line_ids = set(records[self.name]._filter_access_rules('read')._ids)
- accessible = lambda line: line.id in line_ids
- # filter values to keep the accessible records only
- for record in records:
- record[self.name] = record[self.name].filtered(accessible)
-
def _setup_regular_base(self, model):
super(_RelationalMulti, self)._setup_regular_base(model)
if isinstance(self.domain, list):
@@ -2475,6 +2651,50 @@ class _RelationalMulti(_Relational):
if isinstance(arg, (tuple, list)) and isinstance(arg[0], str)
)
+ def _setup_regular_full(self, model):
+ super(_RelationalMulti, self)._setup_regular_full(model)
+ # make self depend on 'active_test' if there is a field 'active' in the comodel
+ if 'active' in model.env[self.comodel_name] and 'active_test' not in (self.depends_context or ()):
+ self.depends_context = (self.depends_context or ()) + ('active_test',)
+
+ def create(self, record_values):
+ """ Write the value of ``self`` on the given records, which have just
+ been created.
+
+ :param record_values: a list of pairs ``(record, value)``, where
+ ``value`` is in the format of method :meth:`BaseModel.write`
+ """
+ self.write_batch(record_values, True)
+
+ def write(self, records, value):
+ # discard recomputation of self on records
+ records.env.remove_to_compute(self, records)
+ return self.write_batch([(records, value)])
+
+ def write_batch(self, records_commands_list, create=False):
+ if not records_commands_list:
+ return False
+
+ for idx, (recs, value) in enumerate(records_commands_list):
+ if isinstance(value, tuple):
+ value = [(6, 0, value)]
+ elif isinstance(value, BaseModel) and value._name == self.comodel_name:
+ value = [(6, 0, value._ids)]
+ elif value is False or value is None:
+ value = [(5,)]
+ elif isinstance(value, list) and value and not isinstance(value[0], (tuple, list)):
+ value = [(6, 0, tuple(value))]
+ if not isinstance(value, list):
+ raise ValueError("Wrong value for %s: %s" % (self, value))
+ records_commands_list[idx] = (recs, value)
+
+ record_ids = {rid for recs, cs in records_commands_list for rid in recs._ids}
+ if all(record_ids):
+ return self.write_real(records_commands_list, create)
+ else:
+ assert not any(record_ids)
+ return self.write_new(records_commands_list)
+
class One2many(_RelationalMulti):
""" One2many field; the value of such a field is the recordset of all the
@@ -2522,7 +2742,7 @@ class One2many(_RelationalMulti):
# link self to its inverse field and vice-versa
comodel = model.env[self.comodel_name]
invf = comodel._fields[self.inverse_name]
- if isinstance(invf, Many2one):
+ if isinstance(invf, (Many2one, Many2oneReference)):
# setting one2many fields only invalidates many2one inverses;
# integer inverses (res_model/res_id pairs) are not supported
model._field_inverses.add(self, invf)
@@ -2530,22 +2750,26 @@ class One2many(_RelationalMulti):
_description_relation_field = property(attrgetter('inverse_name'))
- def convert_to_onchange(self, value, record, names):
- names = names.copy()
- names.pop(self.inverse_name, None)
- return super(One2many, self).convert_to_onchange(value, record, names)
-
def update_db(self, model, columns):
if self.comodel_name in model.env:
comodel = model.env[self.comodel_name]
if self.inverse_name not in comodel._fields:
raise UserError(_("No inverse field %r found for %r") % (self.inverse_name, self.comodel_name))
+ def get_domain_list(self, records):
+ comodel = records.env.registry[self.comodel_name]
+ inverse_field = comodel._fields[self.inverse_name]
+ domain = super(One2many, self).get_domain_list(records)
+ if inverse_field.type == 'many2one_reference':
+ domain = domain + [(inverse_field.model_field, '=', records._name)]
+ return domain
+
def read(self, records):
# retrieve the lines in the comodel
comodel = records.env[self.comodel_name].with_context(**self.context)
inverse = self.inverse_name
- get_id = (lambda rec: rec.id) if comodel._fields[inverse].type == 'many2one' else int
+ inverse_field = comodel._fields[inverse]
+ get_id = (lambda rec: rec.id) if inverse_field.type == 'many2one' else int
domain = self.get_domain_list(records) + [(inverse, 'in', records.ids)]
lines = comodel.search(domain, limit=self.limit)
@@ -2560,74 +2784,176 @@ class One2many(_RelationalMulti):
for record in records:
cache.set(record, self, tuple(group[record.id]))
- def create(self, record_values):
- self._write(record_values)
-
- def write(self, records, value):
- self._write([(records, value)])
-
- def _write(self, records_commands_list):
+ def write_real(self, records_commands_list, create=False):
+ """ Update real records. """
# records_commands_list = [(records, commands), ...]
if not records_commands_list:
return
model = records_commands_list[0][0].browse()
comodel = model.env[self.comodel_name].with_context(**self.context)
- inverse = self.inverse_name
-
- to_create = [] # line vals to create
- to_delete = [] # line ids to delete
- to_relink = {} # lines to relink {line_id: record_id}
- def unlink(line_ids):
- if getattr(comodel._fields[inverse], 'ondelete', False) == 'cascade':
- to_delete.extend(line_ids)
- else:
- to_relink.update(dict.fromkeys(line_ids, False))
+ ids = {rid for recs, cs in records_commands_list for rid in recs.ids}
+ records = records_commands_list[0][0].browse(ids)
- def flush():
- if to_delete:
- comodel.browse(to_delete).unlink()
- to_delete.clear()
- if to_create:
- comodel.create(to_create)
- to_create.clear()
- if to_relink:
- # group line ids to update by record id, and update them
- groups = defaultdict(list)
- lines = comodel.browse(to_relink).sudo().with_context(prefetch_fields=False)
- for line, record_id in zip(lines, to_relink.values()):
- if int(line[inverse]) != record_id:
- groups[record_id].append(line.id)
- for record_id, line_ids in groups.items():
- comodel.browse(line_ids).write({inverse: record_id})
- to_relink.clear()
-
- with model.env.norecompute():
- for records, commands in records_commands_list:
- for act in (commands or ()):
- if act[0] == 0:
- for record in records:
- to_create.append(dict(act[2], **{inverse: record.id}))
- elif act[0] == 1:
- comodel.browse(act[1]).write(act[2])
- elif act[0] == 2:
- to_delete.append(act[1])
- elif act[0] == 3:
- unlink([act[1]])
- elif act[0] == 4:
- to_relink[act[1]] = records[-1].id
- elif act[0] in (5, 6):
+ if self.store:
+ inverse = self.inverse_name
+ to_create = [] # line vals to create
+ to_delete = [] # line ids to delete
+ to_inverse = {}
+ allow_full_delete = not create
+
+ def unlink(lines):
+ if getattr(comodel._fields[inverse], 'ondelete', False) == 'cascade':
+ to_delete.extend(lines._ids)
+ else:
+ lines[inverse] = False
+
+ def flush():
+ if to_delete:
+ # unlink() will remove the lines from the cache
+ comodel.browse(to_delete).unlink()
+ to_delete.clear()
+ if to_create:
+ # create() will add the new lines to the cache of records
+ comodel.create(to_create)
+ to_create.clear()
+ if to_inverse:
+ for record, inverse_ids in to_inverse.items():
+ comodel.browse(inverse_ids)[inverse] = record
+
+ for recs, commands in records_commands_list:
+ for command in (commands or ()):
+ if command[0] == 0:
+ for record in recs:
+ to_create.append(dict(command[2], **{inverse: record.id}))
+ allow_full_delete = False
+ elif command[0] == 1:
+ comodel.browse(command[1]).write(command[2])
+ elif command[0] == 2:
+ to_delete.append(command[1])
+ elif command[0] == 3:
+ unlink(comodel.browse(command[1]))
+ elif command[0] == 4:
+ to_inverse.setdefault(recs[-1], set()).add(command[1])
+ allow_full_delete = False
+ elif command[0] in (5, 6) :
+ # do not try to delete anything in creation mode if nothing has been created before
+ line_ids = command[2] if command[0] == 6 else []
+ if not allow_full_delete and not line_ids:
+ continue
flush()
- ids = act[2] if act[0] == 6 else []
- domain = self.get_domain_list(model) + [(inverse, 'in', records.ids)]
- if ids:
- domain = domain + [('id', 'not in', ids)]
- unlink(comodel.search(domain)._ids)
- to_relink.update(dict.fromkeys(ids, records[-1].id))
+ # assign the given lines to the last record only
+ lines = comodel.browse(line_ids)
+ domain = self.get_domain_list(model) + \
+ [(inverse, 'in', recs.ids), ('id', 'not in', lines.ids)]
+ unlink(comodel.search(domain))
+ lines[inverse] = recs[-1]
flush()
+ else:
+ cache = records.env.cache
+
+ def link(record, lines):
+ ids = record[self.name]._ids
+ cache.set(record, self, tuple(unique(ids + lines._ids)))
+
+ def unlink(lines):
+ for record in records:
+ cache.set(record, self, (record[self.name] - lines)._ids)
+
+ for recs, commands in records_commands_list:
+ for command in (commands or ()):
+ if command[0] == 0:
+ for record in recs:
+ link(record, comodel.new(command[2], ref=command[1]))
+ elif command[0] == 1:
+ comodel.browse(command[1]).write(command[2])
+ elif command[0] == 2:
+ unlink(comodel.browse(command[1]))
+ elif command[0] == 3:
+ unlink(comodel.browse(command[1]))
+ elif command[0] == 4:
+ link(recs[-1], comodel.browse(command[1]))
+ elif command[0] in (5, 6):
+ # assign the given lines to the last record only
+ cache.update(recs, self, [()] * len(recs))
+ lines = comodel.browse(command[2] if command[0] == 6 else [])
+ cache.set(recs[-1], self, lines._ids)
+
+ return records
+
+ def write_new(self, records_commands_list):
+ if not records_commands_list:
+ return
+
+ model = records_commands_list[0][0].browse()
+ cache = model.env.cache
+ comodel = model.env[self.comodel_name].with_context(**self.context)
+
+ ids = {record.id for records, _ in records_commands_list for record in records}
+ records = model.browse(ids)
+
+ def browse(ids):
+ return comodel.browse([id_ and NewId(id_) for id_ in ids])
+
+ # make sure self is in cache
+ records[self.name]
+
+ if self.store:
+ inverse = self.inverse_name
+
+ for recs, commands in records_commands_list:
+ for command in commands:
+ if command[0] == 0:
+ for record in recs:
+ line = comodel.new(command[2], ref=command[1])
+ line[inverse] = record
+ elif command[0] == 1:
+ browse([command[1]]).update(command[2])
+ elif command[0] == 2:
+ browse([command[1]])[inverse] = False
+ elif command[0] == 3:
+ browse([command[1]])[inverse] = False
+ elif command[0] == 4:
+ browse([command[1]])[inverse] = recs[-1]
+ elif command[0] in (5, 6):
+ # assign the given lines to the last record only
+ cache.update(recs, self, [()] * len(recs))
+ lines = comodel.browse(command[2] if command[0] == 6 else [])
+ cache.set(recs[-1], self, lines._ids)
+
+ else:
+ def link(record, lines):
+ ids = record[self.name]._ids
+ cache.set(record, self, tuple(unique(ids + lines._ids)))
+
+ def unlink(lines):
+ for record in records:
+ cache.set(record, self, (record[self.name] - lines)._ids)
+
+ for recs, commands in records_commands_list:
+ for command in commands:
+ if command[0] == 0:
+ for record in recs:
+ link(record, comodel.new(command[2], ref=command[1]))
+ elif command[0] == 1:
+ browse([command[1]]).update(command[2])
+ elif command[0] == 2:
+ unlink(browse([command[1]]))
+ elif command[0] == 3:
+ unlink(browse([command[1]]))
+ elif command[0] == 4:
+ link(recs[-1], browse([command[1]]))
+ elif command[0] in (5, 6):
+ # assign the given lines to the last record only
+ cache.update(recs, self, [()] * len(recs))
+ lines = comodel.browse(command[2] if command[0] == 6 else [])
+ cache.set(recs[-1], self, lines._ids)
+
+ return records
+
class Many2many(_RelationalMulti):
""" Many2many field; the value of such a field is the recordset.
@@ -2802,39 +3128,30 @@ class Many2many(_RelationalMulti):
for record in records:
cache.set(record, self, tuple(group[record.id]))
- def create(self, record_values):
- self._write(record_values, create=True)
-
- def write(self, records, value):
- self._write([(records, value)])
-
- def _write(self, records_commands_list, create=False):
+ def write_real(self, records_commands_list, create=False):
# records_commands_list = [(records, commands), ...]
if not records_commands_list:
return
- model = records_commands_list[0][0].browse()
- comodel = model.env[self.comodel_name].with_context(**self.context)
- cr = model.env.cr
-
- # determine old relation {x: ys}
- old_relation = defaultdict(set)
- if not create:
- clauses, params, tables = comodel.env['ir.rule'].domain_get(comodel._name)
- if '"%s"' % self.relation not in tables:
- tables.append('"%s"' % self.relation)
- query = """
- SELECT {rel}.{id1}, {rel}.{id2} FROM {tables}
- WHERE {rel}.{id1} IN %s AND {rel}.{id2}={table}.id AND {cond}
- """.format(
- rel=self.relation, id1=self.column1, id2=self.column2,
- table=comodel._table, tables=",".join(tables),
- cond=" AND ".join(clauses) if clauses else "1=1",
- )
- ids = {rid for recs, cs in records_commands_list for rid in recs.ids}
- cr.execute(query, [tuple(ids)] + params)
- for x, y in cr.fetchall():
- old_relation[x].add(y)
+ comodel = records_commands_list[0][0].env[self.comodel_name].with_context(**self.context)
+ cr = records_commands_list[0][0].env.cr
+
+ # determine old and new relation {x: ys}
+ set = OrderedSet
+ ids = {rid for recs, cs in records_commands_list for rid in recs.ids}
+ records = records_commands_list[0][0].browse(ids)
+
+ if self.store:
+ # Using `record[self.name]` generates 2 SQL queries when the value
+ # is not in cache: one that actually checks access rules for
+ # records, and the other one fetching the actual data. We use
+ # `self.read` instead to shortcut the first query.
+ missing_ids = list(records.env.cache.get_missing_ids(records, self))
+ if missing_ids:
+ self.read(records.browse(missing_ids))
+
+ old_relation = {record.id: set(record[self.name]._ids) for record in records}
+ new_relation = {x: set(ys) for x, ys in old_relation.items()}
# determine new relation {x: ys}
new_relation = defaultdict(set)
@@ -2857,33 +3174,31 @@ class Many2many(_RelationalMulti):
def relation_delete(ys):
# the pairs (x, y) have been cascade-deleted from relation
for ys1 in old_relation.values():
- ys1.difference_update(ys)
+ ys1 -= ys
for ys1 in new_relation.values():
- ys1.difference_update(ys)
+ ys1 -= ys
- to_create = [] # line vals to create [(ids, vals)]
+ to_create = [] # line vals to create
to_delete = [] # line ids to delete
- with model.env.norecompute():
- for records, commands in records_commands_list:
- for act in (commands or ()):
- if not isinstance(act, (list, tuple)) or not act:
- continue
- if act[0] == 0:
- to_create.append((records._ids, act[2]))
- elif act[0] == 1:
- comodel.browse(act[1]).write(act[2])
- elif act[0] == 2:
- to_delete.append(act[1])
- elif act[0] == 3:
- relation_remove(records._ids, act[1])
- elif act[0] == 4:
- relation_add(records._ids, act[1])
- elif act[0] in (5, 6):
- # new lines must no longer be linked to records
- to_create = [(set(ids) - set(records._ids), vals)
- for (ids, vals) in to_create]
- relation_set(records._ids, act[2] if act[0] == 6 else ())
+ for recs, commands in records_commands_list:
+ for command in (commands or ()):
+ if not isinstance(command, (list, tuple)) or not command:
+ continue
+ if command[0] == 0:
+ to_create.append((recs._ids, command[2]))
+ elif command[0] == 1:
+ comodel.browse(command[1]).write(command[2])
+ elif command[0] == 2:
+ to_delete.append(command[1])
+ elif command[0] == 3:
+ relation_remove(recs._ids, command[1])
+ elif command[0] == 4:
+ relation_add(recs._ids, command[1])
+ elif command[0] in (5, 6):
+ # new lines must no longer be linked to records
+ to_create = [(set(ids) - set(recs._ids), vals) for (ids, vals) in to_create]
+ relation_set(recs._ids, command[2] if command[0] == 6 else ())
if to_create:
# create lines in batch, and link them
@@ -2896,34 +3211,172 @@ class Many2many(_RelationalMulti):
comodel.browse(to_delete).unlink()
relation_delete(to_delete)
+ # update the cache of self
+ cache = records.env.cache
+ for record in records:
+ cache.set(record, self, tuple(new_relation[record.id]))
+
# process pairs to add (beware of duplicates)
pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]]
if pairs:
- query = "INSERT INTO {} ({}, {}) VALUES {} ON CONFLICT DO NOTHING".format(
- self.relation, self.column1, self.column2, ", ".join(["%s"] * len(pairs)),
- )
- cr.execute(query, pairs)
+ if self.store:
+ query = "INSERT INTO {} ({}, {}) VALUES {} ON CONFLICT DO NOTHING".format(
+ self.relation, self.column1, self.column2, ", ".join(["%s"] * len(pairs)),
+ )
+ cr.execute(query, pairs)
+
+ # update the cache of inverse fields
+ y_to_xs = defaultdict(set)
+ for x, y in pairs:
+ y_to_xs[y].add(x)
+ for invf in records._field_inverses[self]:
+ domain = invf.get_domain_list(comodel)
+ valid_ids = set(records.filtered_domain(domain)._ids)
+ if not valid_ids:
+ continue
+ for y, xs in y_to_xs.items():
+ corecord = comodel.browse(y)
+ try:
+ ids0 = cache.get(corecord, invf)
+ ids1 = tuple(set(ids0) | (xs & valid_ids))
+ cache.set(corecord, invf, ids1)
+ except KeyError:
+ pass
# process pairs to remove
pairs = [(x, y) for x, ys in old_relation.items() for y in ys - new_relation[x]]
if pairs:
- # express pairs as the union of cartesian products:
- # pairs = [(1, 11), (1, 12), (1, 13), (2, 11), (2, 12), (2, 14)]
- # -> y_to_xs = {11: {1, 2}, 12: {1, 2}, 13: {1}, 14: {2}}
- # -> xs_to_ys = {{1, 2}: {11, 12}, {2}: {14}, {1}: {13}}
y_to_xs = defaultdict(set)
for x, y in pairs:
y_to_xs[y].add(x)
- xs_to_ys = defaultdict(set)
- for y, xs in y_to_xs.items():
- xs_to_ys[frozenset(xs)].add(y)
- # delete the rows where (id1 IN xs AND id2 IN ys) OR ...
- COND = "{} IN %s AND {} IN %s".format(self.column1, self.column2)
- query = "DELETE FROM {} WHERE {}".format(
- self.relation, " OR ".join([COND] * len(xs_to_ys)),
- )
- params = [arg for xs, ys in xs_to_ys.items() for arg in [tuple(xs), tuple(ys)]]
- cr.execute(query, params)
+
+ if self.store:
+ # express pairs as the union of cartesian products:
+ # pairs = [(1, 11), (1, 12), (1, 13), (2, 11), (2, 12), (2, 14)]
+ # -> y_to_xs = {11: {1, 2}, 12: {1, 2}, 13: {1}, 14: {2}}
+ # -> xs_to_ys = {{1, 2}: {11, 12}, {2}: {14}, {1}: {13}}
+ xs_to_ys = defaultdict(set)
+ for y, xs in y_to_xs.items():
+ xs_to_ys[frozenset(xs)].add(y)
+ # delete the rows where (id1 IN xs AND id2 IN ys) OR ...
+ COND = "{} IN %s AND {} IN %s".format(self.column1, self.column2)
+ query = "DELETE FROM {} WHERE {}".format(
+ self.relation, " OR ".join([COND] * len(xs_to_ys)),
+ )
+ params = [arg for xs, ys in xs_to_ys.items() for arg in [tuple(xs), tuple(ys)]]
+ cr.execute(query, params)
+
+ # update the cache of inverse fields
+ for invf in records._field_inverses[self]:
+ for y, xs in y_to_xs.items():
+ corecord = comodel.browse(y)
+ try:
+ ids0 = cache.get(corecord, invf)
+ ids1 = tuple(id_ for id_ in ids0 if id_ not in xs)
+ cache.set(corecord, invf, ids1)
+ except KeyError:
+ pass
+
+ return records.filtered(
+ lambda record: new_relation[record.id] != old_relation[record.id]
+ )
+
+ def write_new(self, records_commands_list):
+ """ Update self on new records. """
+ if not records_commands_list:
+ return
+
+ model = records_commands_list[0][0].browse()
+ comodel = model.env[self.comodel_name].with_context(**self.context)
+ new = lambda id_: id_ and NewId(id_)
+
+ # determine old and new relation {x: ys}
+ set = OrderedSet
+ old_relation = {record.id: set(record[self.name]._ids) for records, _ in records_commands_list for record in records}
+ new_relation = {x: set(ys) for x, ys in old_relation.items()}
+ ids = set(old_relation.keys())
+
+ records = model.browse(ids)
+
+ for recs, commands in records_commands_list:
+ for command in commands:
+ if not isinstance(command, (list, tuple)) or not command:
+ continue
+ if command[0] == 0:
+ line_id = comodel.new(command[2], ref=command[1]).id
+ for line_ids in new_relation.values():
+ line_ids.add(line_id)
+ elif command[0] == 1:
+ line_id = new(command[1])
+ comodel.browse([line_id]).update(command[2])
+ elif command[0] == 2:
+ line_id = new(command[1])
+ for line_ids in new_relation.values():
+ line_ids.discard(line_id)
+ elif command[0] == 3:
+ line_id = new(command[1])
+ for line_ids in new_relation.values():
+ line_ids.discard(line_id)
+ elif command[0] == 4:
+ line_id = new(command[1])
+ for line_ids in new_relation.values():
+ line_ids.add(line_id)
+ elif command[0] in (5, 6):
+ # new lines must no longer be linked to records
+ line_ids = command[2] if command[0] == 6 else ()
+ line_ids = set(new(line_id) for line_id in line_ids)
+ for id_ in recs._ids:
+ new_relation[id_] = set(line_ids)
+
+ if new_relation == old_relation:
+ return records.browse()
+
+ # update the cache of self
+ cache = records.env.cache
+ for record in records:
+ cache.set(record, self, tuple(new_relation[record.id]))
+
+ # process pairs to add (beware of duplicates)
+ pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]]
+ if pairs:
+ # update the cache of inverse fields
+ y_to_xs = defaultdict(set)
+ for x, y in pairs:
+ y_to_xs[y].add(x)
+ for invf in records._field_inverses[self]:
+ domain = invf.get_domain_list(comodel)
+ valid_ids = set(records.filtered_domain(domain)._ids)
+ if not valid_ids:
+ continue
+ for y, xs in y_to_xs.items():
+ corecord = comodel.browse([y])
+ try:
+ ids0 = cache.get(corecord, invf)
+ ids1 = tuple(set(ids0) | (xs & valid_ids))
+ cache.set(corecord, invf, ids1)
+ except KeyError:
+ pass
+
+ # process pairs to remove
+ pairs = [(x, y) for x, ys in old_relation.items() for y in ys - new_relation[x]]
+ if pairs:
+ # update the cache of inverse fields
+ y_to_xs = defaultdict(set)
+ for x, y in pairs:
+ y_to_xs[y].add(x)
+ for invf in records._field_inverses[self]:
+ for y, xs in y_to_xs.items():
+ corecord = comodel.browse([y])
+ try:
+ ids0 = cache.get(corecord, invf)
+ ids1 = tuple(id_ for id_ in ids0 if id_ not in xs)
+ cache.set(corecord, invf, ids1)
+ except KeyError:
+ pass
+
+ return records.filtered(
+ lambda record: new_relation[record.id] != old_relation[record.id]
+ )
class Id(Field):
@@ -2957,13 +3410,22 @@ class Id(Field):
raise TypeError("field 'id' cannot be assigned")
-def prefetch_value_ids(record, field):
- """ Return an iterator over the ids of the cached values of a relational
+def prefetch_many2one_ids(record, field):
+ """ Return an iterator over the ids of the cached values of a many2one
+ field for the prefetch set of a record.
+ """
+ records = record.browse(record._prefetch_ids)
+ ids = record.env.cache.get_values(records, field)
+ return unique(id_ for id_ in ids if id_ is not None)
+
+
+def prefetch_x2many_ids(record, field):
+ """ Return an iterator over the ids of the cached values of an x2many
field for the prefetch set of a record.
"""
records = record.browse(record._prefetch_ids)
- ids_seq = record.env.cache.get_values(records, field, ())
- return unique(id_ for ids in ids_seq for id_ in ids)
+ ids_list = record.env.cache.get_values(records, field)
+ return unique(id_ for ids in ids_list for id_ in ids)
# imported here to avoid dependency cycle issues