diff --git a/base_write_diff/README.rst b/base_write_diff/README.rst new file mode 100644 index 00000000000..a4d544f0638 --- /dev/null +++ b/base_write_diff/README.rst @@ -0,0 +1,191 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Base - Write Diff +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:71745a37619c24019b67f8558eb6c7be1b974ad0031b6ce40892e7eec305bcb3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/19.0/base_write_diff + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-19-0/server-tools-19-0-base_write_diff + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows filtering values to update on records according to +whether they are actually different from the records' current values. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Summary** + +This module allows you to update records by filtering out fields whose +values are going to be left unchanged by ``BaseModel.write()``; for +example, let's assume you have: + +.. code:: python + + >>> self + sale.order.line(1,) + >>> self.price_unit + 10.00 + +If you use ``self.write({"price_unit": 10.00})`` or +``self.price_unit = 10.00``, Odoo may end up executing unnecessary +operations, like triggering the update on the field, recompute computed +fields that depend on ``price_unit``, and so on, even if the value is +actually unchanged. + +By using this module, you can prevent all of that. + +You can use this module in 3 different ways. All of them require you to +add this module as a dependency of your module. + +**1 - Context key ``"write_use_diff_values"``** + +By adding ``write_use_diff_values=True`` to the context when updating a +field value, the ``BaseModel.write()`` patch will take care of filtering +out the fields' values that are the same as the record's current ones. + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +Example: + +.. code:: python + + from odoo import models + + + class ProductTemplate(models.Model): + _inherit = "product.template" + + def write(self, vals): + # Update only fields that are actually different + self = self.with_context(write_use_diff_values=True) + return super().write(vals) + + + class ProductProduct(models.Model): + _inherit = "product.product" + + def update_code_if_necessary(self, code: str): + # Update ``default_code`` only if different from the current value + self.with_context(write_use_diff_values=True).default_code = code + +**2 - Method ``BaseModel.write_diff()``** + +It is the same as calling ``write()``, but it automatically enables the +``"write_use_diff_values"`` context flag: ``self.write_diff(vals)`` is a +shortcut for +``self.with_context(write_use_diff_values=True).write(vals)`` + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +**3 - Method ``BaseModel._get_write_diff_values(vals)``** + +This method accepts a write-like ``dict`` as param, and returns a new +``dict`` made of the fields who will actually update the record's +values. This allows for a more flexible and customizable behavior than +the context key usage, because: + +- you'll be able to filter out specific fields, instead of filtering out + all the fields whose values won't be changed after the update; +- you'll be able to execute the filtering on specific models, instead of + executing it on all the models involved in the stack of ``write()`` + calls from the first usage of the context key down to the base method + ``BaseModel.write()``. + +Example: + +.. code:: python + + from collections import defaultdict + + from odoo import api, models + from odoo.tools.misc import frozendict + + + class ProductProduct(models.Model): + _inherit = "product.product" + + def write(self, vals): + # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()`` + # override will clear the whole registry cache if either 'active' or + # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary: + # remove them unless it's necessary to update them + fnames = {"active", "product_template_attribute_value_ids"} + if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}: + groups = defaultdict(lambda: self.browse()) + for prod in self: + groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod + for diff_vals, prods in groups.items(): + if res_vals := (vals | dict(diff_vals)): + super(ProductProduct, prods).write(res_vals) + return True + return super().write(vals) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Silvio Gregorini + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_write_diff/__init__.py b/base_write_diff/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/base_write_diff/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_write_diff/__manifest__.py b/base_write_diff/__manifest__.py new file mode 100644 index 00000000000..716b3bb271c --- /dev/null +++ b/base_write_diff/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base - Write Diff", + "summary": "Prevents updates on fields whose values won't change anyway", + "version": "19.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Hidden", + "website": "https://github.com/OCA/server-tools", + "installable": True, + "depends": ["base", "web"], +} diff --git a/base_write_diff/i18n/base_write_diff.pot b/base_write_diff/i18n/base_write_diff.pot new file mode 100644 index 00000000000..55c54821814 --- /dev/null +++ b/base_write_diff/i18n/base_write_diff.pot @@ -0,0 +1,19 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_write_diff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_write_diff +#: model:ir.model,name:base_write_diff.model_base +msgid "Base" +msgstr "" diff --git a/base_write_diff/i18n/it.po b/base_write_diff/i18n/it.po new file mode 100644 index 00000000000..a47e0f9430d --- /dev/null +++ b/base_write_diff/i18n/it.po @@ -0,0 +1,22 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_write_diff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-12-09 12:43+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: base_write_diff +#: model:ir.model,name:base_write_diff.model_base +msgid "Base" +msgstr "Base" diff --git a/base_write_diff/models/__init__.py b/base_write_diff/models/__init__.py new file mode 100644 index 00000000000..0e44449338c --- /dev/null +++ b/base_write_diff/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/base_write_diff/models/base.py b/base_write_diff/models/base.py new file mode 100644 index 00000000000..7a4e927126e --- /dev/null +++ b/base_write_diff/models/base.py @@ -0,0 +1,117 @@ +# Copyright 2025 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import Command, models +from odoo.tools.misc import frozendict + +from odoo.addons.web.models.models import RecordSnapshot + + +class BaseModel(models.BaseModel): + _inherit = "base" + + def write(self, vals): + # OVERRIDE: when using the ``write_use_diff_values`` context key, remove values + # that won't be changed before/after the ``write()`` itself. + # If ``write()`` is called on an empty recordset or with no value, ignore + # everything and shortcut to ``super()``. + if not (self and vals and self.env.context.get("write_use_diff_values")): + return super().write(vals) + recs_by_vals = defaultdict(lambda: self.browse()) + for rec in self: + recs_by_vals[frozendict(rec._get_write_diff_values(vals))] += rec + for rec_vals, recs in recs_by_vals.items(): + if rec_vals: # Don't trigger ``write()`` if there is nothing to update + super(BaseModel, recs).write(dict(rec_vals)) + return True + + def write_diff(self, vals: dict) -> bool: + """Executes a ``write()`` only on fields that actually need to be updated""" + return self.with_context(write_use_diff_values=True).write(vals) + + def _get_write_diff_values(self, vals: dict) -> dict: + """Compares record values with the values to write + + Returns a dictionary containing only the fields that actually needs to be + updated on ``self``, filtering out those which contain a value that is the same + as the current record's field value. + For example: + >>> self.name = "A" + >>> self.code = "a" + >>> self._get_write_diff_values({"name": "A", "code": "a"}) + {} + >>> self._get_write_diff_values({"name": "B", "code": "a"}) + {"name": "B"} + >>> self._get_write_diff_values({"name": "B", "code": "b"}) + {"name": "B", "code": "b"} + """ + self.ensure_one() + diff_values = {} + + # Step 1: group fields according to whether they're multi-relational or not + x2many_fields_values, simple_fields_values = {}, {} + for fname, fvalue in vals.items(): + if self._fields[fname].type in ("one2many", "many2many"): + x2many_fields_values[fname] = fvalue + else: + simple_fields_values[fname] = fvalue + + # Step 2: prepare fields to update by checking simple fields first + if simple_fields_values: + simple_fields_specs = {f: {} for f in simple_fields_values} + snapshot0 = self._do_snapshot({}, simple_fields_specs) + snapshot1 = self._do_snapshot(simple_fields_values, simple_fields_specs) + diff_values.update(snapshot1.diff(snapshot0)) + + # Step 3: prepare fields to update by checking multi-relational fields + # For each multi-relational field, prepare a new list of values by checking + # the original commands: + # - if it's an update command, check whether something actually changes on + # the corecord by calling ``_get_write_diff_values()`` recursively + # - else, add the original command to the new list: all commands except "update" + # will modify the record-corecords relation by creating/[un]linking/deleting + # corecords + # Then, check the new list of values to decide if the field needs updating: + # - at least 1 creation/update => add the full list of commands for simplicity + # - else => check whether the new values will effectively change the + # record-corecords relationship + for fname, fvalues in x2many_fields_values.items(): + # Prepare the new list of commands/values according to the original command + new_fvalues = [] + for fvalue in fvalues: + if fvalue[0] == Command.UPDATE: + cmd, corec_id, corec_vals = fvalue + corec = self.env[self._fields[fname].comodel_name].browse(corec_id) + if corec_diff_vals := corec._get_write_diff_values(corec_vals): + new_fvalues.append((cmd, corec_id, corec_diff_vals)) + else: + new_fvalues.append(fvalue) + # Check whether we actually need to include the new list in the diff values + if any(v[0] in (Command.CREATE, Command.UPDATE) for v in new_fvalues): + diff_values[fname] = new_fvalues + else: + x2many_snapshot0 = self._do_snapshot({}, {fname: {}}) + x2many_snapshot1 = self._do_snapshot({fname: new_fvalues}, {fname: {}}) + if x2many_diff_values := x2many_snapshot1.diff(x2many_snapshot0): + diff_values.update(x2many_diff_values) + + return diff_values + + def _do_snapshot(self, vals: dict, specs: dict) -> "RecordSnapshot": + """Prepares a ``RecordSnapshot`` object with the specified params""" + self.ensure_one() + # Align ``vals`` and ``specs`` to make sure they both contain the same fields: + # - if a field in ``specs`` is missing from ``vals``, we read its current value + # from the record and convert it to a ``write()``-able format to prevent cache + # issues and inconsistencies + # - if a field in ``vals`` is missing from ``specs``, we add it with the default + # value of ``{}`` to allow ``RecordSnapshot`` to handle it properly + vals_fnames_not_in_specs = set(vals) - set(specs) + specs_fnames_not_in_vals = set(specs) - set(vals) + for fname in vals_fnames_not_in_specs: + specs[fname] = {} + for fname in specs_fnames_not_in_vals: + vals[fname] = self._fields[fname].convert_to_write(self[fname], self) + return RecordSnapshot(self.new(values=vals, origin=self), fields_spec=specs) diff --git a/base_write_diff/pyproject.toml b/base_write_diff/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/base_write_diff/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_write_diff/readme/CONTRIBUTORS.md b/base_write_diff/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..82b9a91c10e --- /dev/null +++ b/base_write_diff/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Silvio Gregorini \<\> diff --git a/base_write_diff/readme/DESCRIPTION.md b/base_write_diff/readme/DESCRIPTION.md new file mode 100644 index 00000000000..141e6771fc2 --- /dev/null +++ b/base_write_diff/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows filtering values to update on records according to whether they are +actually different from the records' current values. diff --git a/base_write_diff/readme/USAGE.md b/base_write_diff/readme/USAGE.md new file mode 100644 index 00000000000..f2ff146cd8e --- /dev/null +++ b/base_write_diff/readme/USAGE.md @@ -0,0 +1,101 @@ +**Summary** + +This module allows you to update records by filtering out fields whose values are going +to be left unchanged by ``BaseModel.write()``; for example, let's assume you have: + +```python +>>> self +sale.order.line(1,) +>>> self.price_unit +10.00 +``` + +If you use ``self.write({"price_unit": 10.00})`` or ``self.price_unit = 10.00``, Odoo +may end up executing unnecessary operations, like triggering the update on the field, +recompute computed fields that depend on ``price_unit``, and so on, even if the value +is actually unchanged. + +By using this module, you can prevent all of that. + +You can use this module in 3 different ways. All of them require you to add this module +as a dependency of your module. + +**1 - Context key ``"write_use_diff_values"``** + +By adding ``write_use_diff_values=True`` to the context when updating a field value, + the ``BaseModel.write()`` patch will take care of filtering out the fields' values + that are the same as the record's current ones. + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +Example: + +```python +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def write(self, vals): + # Update only fields that are actually different + self = self.with_context(write_use_diff_values=True) + return super().write(vals) + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def update_code_if_necessary(self, code: str): + # Update ``default_code`` only if different from the current value + self.with_context(write_use_diff_values=True).default_code = code +``` + +**2 - Method ``BaseModel.write_diff()``** + +It is the same as calling ``write()``, but it automatically enables the + ``"write_use_diff_values"`` context flag: ``self.write_diff(vals)`` is a shortcut for + ``self.with_context(write_use_diff_values=True).write(vals)`` + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +**3 - Method ``BaseModel._get_write_diff_values(vals)``** + +This method accepts a write-like ``dict`` as param, and returns a new ``dict`` made of + the fields who will actually update the record's values. This allows for a more + flexible and customizable behavior than the context key usage, because: + +- you'll be able to filter out specific fields, instead of filtering out all the fields + whose values won't be changed after the update; +- you'll be able to execute the filtering on specific models, instead of executing it + on all the models involved in the stack of ``write()`` calls from the first usage of + the context key down to the base method ``BaseModel.write()``. + +Example: + +```python +from collections import defaultdict + +from odoo import api, models +from odoo.tools.misc import frozendict + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def write(self, vals): + # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()`` + # override will clear the whole registry cache if either 'active' or + # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary: + # remove them unless it's necessary to update them + fnames = {"active", "product_template_attribute_value_ids"} + if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}: + groups = defaultdict(lambda: self.browse()) + for prod in self: + groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod + for diff_vals, prods in groups.items(): + if res_vals := (vals | dict(diff_vals)): + super(ProductProduct, prods).write(res_vals) + return True + return super().write(vals) +``` diff --git a/base_write_diff/static/description/icon.png b/base_write_diff/static/description/icon.png new file mode 100644 index 00000000000..1dcc49c24f3 Binary files /dev/null and b/base_write_diff/static/description/icon.png differ diff --git a/base_write_diff/static/description/index.html b/base_write_diff/static/description/index.html new file mode 100644 index 00000000000..a40f7a17420 --- /dev/null +++ b/base_write_diff/static/description/index.html @@ -0,0 +1,524 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Base - Write Diff

+ +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module allows filtering values to update on records according to +whether they are actually different from the records’ current values.

+

Table of contents

+ +
+

Usage

+

Summary

+

This module allows you to update records by filtering out fields whose +values are going to be left unchanged by BaseModel.write(); for +example, let’s assume you have:

+
+>>> self
+sale.order.line(1,)
+>>> self.price_unit
+10.00
+
+

If you use self.write({"price_unit": 10.00}) or +self.price_unit = 10.00, Odoo may end up executing unnecessary +operations, like triggering the update on the field, recompute computed +fields that depend on price_unit, and so on, even if the value is +actually unchanged.

+

By using this module, you can prevent all of that.

+

You can use this module in 3 different ways. All of them require you to +add this module as a dependency of your module.

+

1 - Context key ``”write_use_diff_values”``

+

By adding write_use_diff_values=True to the context when updating a +field value, the BaseModel.write() patch will take care of filtering +out the fields’ values that are the same as the record’s current ones.

+

⚠️ Beware: the context key is propagated down to other write() calls

+

Example:

+
+from odoo import models
+
+
+class ProductTemplate(models.Model):
+    _inherit = "product.template"
+
+    def write(self, vals):
+        # Update only fields that are actually different
+        self = self.with_context(write_use_diff_values=True)
+        return super().write(vals)
+
+
+class ProductProduct(models.Model):
+    _inherit = "product.product"
+
+    def update_code_if_necessary(self, code: str):
+        # Update ``default_code`` only if different from the current value
+        self.with_context(write_use_diff_values=True).default_code = code
+
+

2 - Method ``BaseModel.write_diff()``

+

It is the same as calling write(), but it automatically enables the +"write_use_diff_values" context flag: self.write_diff(vals) is a +shortcut for +self.with_context(write_use_diff_values=True).write(vals)

+

⚠️ Beware: the context key is propagated down to other write() calls

+

3 - Method ``BaseModel._get_write_diff_values(vals)``

+

This method accepts a write-like dict as param, and returns a new +dict made of the fields who will actually update the record’s +values. This allows for a more flexible and customizable behavior than +the context key usage, because:

+
    +
  • you’ll be able to filter out specific fields, instead of filtering out +all the fields whose values won’t be changed after the update;
  • +
  • you’ll be able to execute the filtering on specific models, instead of +executing it on all the models involved in the stack of write() +calls from the first usage of the context key down to the base method +BaseModel.write().
  • +
+

Example:

+
+from collections import defaultdict
+
+from odoo import api, models
+from odoo.tools.misc import frozendict
+
+
+class ProductProduct(models.Model):
+    _inherit = "product.product"
+
+    def write(self, vals):
+        # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()``
+        # override will clear the whole registry cache if either 'active' or
+        # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary:
+        # remove them unless it's necessary to update them
+        fnames = {"active", "product_template_attribute_value_ids"}
+        if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}:
+            groups = defaultdict(lambda: self.browse())
+            for prod in self:
+                groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod
+            for diff_vals, prods in groups.items():
+                if res_vals := (vals | dict(diff_vals)):
+                    super(ProductProduct, prods).write(res_vals)
+            return True
+        return super().write(vals)
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/base_write_diff/tests/__init__.py b/base_write_diff/tests/__init__.py new file mode 100644 index 00000000000..aa320247467 --- /dev/null +++ b/base_write_diff/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_write_diff diff --git a/base_write_diff/tests/test_base_write_diff.py b/base_write_diff/tests/test_base_write_diff.py new file mode 100644 index 00000000000..183681b96df --- /dev/null +++ b/base_write_diff/tests/test_base_write_diff.py @@ -0,0 +1,419 @@ +# Copyright 2025 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from logging import getLogger + +from odoo import api, fields, models +from odoo.orm.model_classes import add_to_registry +from odoo.tests import TransactionCase +from odoo.tools.misc import mute_logger + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT + + +class TestRecordDiffCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Setup env + cls.env = cls.env["base"].with_context(**DISABLED_MAIL_CONTEXT).env + + # ``_register_hook()`` is usually called at the end of the test process, but + # we need to be able to test it here + cls.env["base"]._register_hook() + + # Load test model + class BWDTestModel(models.Model): + _name = "bwd.test.model" + _description = "Base Write Diff - Test Model" + _test_logger = getLogger("bwd.test.model.log") + + # To test a non-relational field + name = fields.Char() + # To test single-relational fields + m2o_id = fields.Many2one("bwd.test.model") + # To test multi-relational fields + o2m_ids = fields.One2many("bwd.test.model", inverse_name="m2o_id") + m2m_ids = fields.Many2many("bwd.test.model", "test_rel", "id_1", "id_2") + # To test computed fields + # ``perimeter``: computed, stored field that depends on stored fields + # ``area``: computed, non-stored field that depends on stored fields + # ``volume``: computed, non-stored field that depends on non-stored fields + length = fields.Integer() # pylint: disable=attribute-deprecated + width = fields.Integer() + height = fields.Integer() + perimeter = fields.Integer(compute="_compute_perimeter", store=True) + area = fields.Integer(compute="_compute_area", store=False) + volume = fields.Integer(compute="_compute_volume", store=False) + + @api.depends("length", "width") + def _compute_perimeter(self): + self._test_logger.warning("Computing perimeter") + for rec in self: + rec.perimeter = 2 * (rec.length + rec.width) + + @api.depends("length", "width") + def _compute_area(self): + self._test_logger.warning("Computing area") + for rec in self: + rec.area = rec.length * rec.width + + @api.depends("area", "height") + def _compute_volume(self): + self._test_logger.warning("Computing volume") + for rec in self: + rec.volume = rec.area * rec.height + + add_to_registry(cls.registry, BWDTestModel) + cls.registry._setup_models__(cls.env.cr, ["bwd.test.model"]) + cls.registry.init_models( + cls.env.cr, ["bwd.test.model"], {"models_to_check": True} + ) + cls.addClassCleanup(cls.registry.__delitem__, "bwd.test.model") + + def _create_records(self, count=1): + records = self.env["bwd.test.model"].create([{} for _ in range(1, count + 1)]) + for rec in records: + rec.name = f"Record {rec.id}" + return records + + +class TestRecordDiff(TestRecordDiffCommon): + @mute_logger("bwd.test.model.log") + def test_00_get_write_diff_values_simple(self): + """Test ``_get_write_diff_values()`` on fields that are not multi-relational""" + record = self._create_records() + # Try to write the same value on non-relational field + # => ``_get_write_diff_values()`` returns an empty dict + vals = {"name": record.name} + self.assertEqual(record._get_write_diff_values(vals), {}) + # Try to write another value on non-relational field + # => ``_get_write_diff_values()`` returns the same dict + vals = {"name": record.name + " something else"} + self.assertEqual(record._get_write_diff_values(vals), vals) + # Try to write the same value on M2O field + # => ``_get_write_diff_values()`` returns an empty dict + vals = {"m2o_id": record.m2o_id.id} + self.assertEqual(record._get_write_diff_values(vals), {}) + # Try to write another value on M2O field + # => ``_get_write_diff_values()`` returns the same dict + vals = {"m2o_id": self._create_records().id} + self.assertEqual(record._get_write_diff_values(vals), vals) + + @mute_logger("bwd.test.model.log") + def test_10_get_write_diff_values_x2many_command_create(self): + """Test ``_get_write_diff_values()`` on fields.Command.create() + + ``_get_write_diff_values()`` always returns the original dict, even after the + corecords are actually created (because the values will create a new, different + corecord if used on ``write()`` again) + """ + record = self._create_records() + vals = { + "o2m_ids": [fields.Command.create({"name": "O2M Co-record"})], + "m2m_ids": [fields.Command.create({"name": "M2M Co-record"})], + } + self.assertEqual(record._get_write_diff_values(vals), vals) + record.write(vals) # Do the real update => the diff is not empty anyway + self.assertEqual(record._get_write_diff_values(vals), vals) + + @mute_logger("bwd.test.model.log") + def test_11_get_write_diff_values_x2many_command_update(self): + """Test ``_get_write_diff_values()`` on fields.Command.update() + + ``_get_write_diff_values()`` returns only the subset of IDs/values that should + be updated + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to update 1 corecord on each X2M field + vals = { + "o2m_ids": [ + fields.Command.update(o2m_corecords[0].id, {"name": "O2M Corec"}), + fields.Command.update( + o2m_corecords[1].id, {"name": o2m_corecords[1].name} + ), + ], + "m2m_ids": [ + fields.Command.update( + m2m_corecords[0].id, {"name": m2m_corecords[0].name} + ), + fields.Command.update(m2m_corecords[1].id, {"name": "M2M Corec"}), + ], + } + # The diff should include only the IDs we want to update, and the fields we are + # actually different on them + self.assertEqual( + record._get_write_diff_values(vals), + { + "o2m_ids": [ + fields.Command.update(o2m_corecords[0].id, {"name": "O2M Corec"}) + ], + "m2m_ids": [ + fields.Command.update(m2m_corecords[1].id, {"name": "M2M Corec"}) + ], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_12_get_write_diff_values_x2many_command_delete(self): + """Test ``_get_write_diff_values()`` on fields.Command.delete() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to delete 1 corecord in each X2M field + vals = { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.delete(m2m_corecords[1].id)], + } + # The diff should include only the IDs we want to delete + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_13_get_write_diff_values_x2many_command_unlink(self): + """Test ``_get_write_diff_values()`` on fields.Command.unlink() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to unlink 1 corecord in each X2M field + vals = { + "o2m_ids": [fields.Command.unlink(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + } + # The diff should include only the IDs we want to unlink + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_14_get_write_diff_values_x2many_command_link(self): + """Test ``_get_write_diff_values()`` on fields.Command.link() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + linked + """ + record = self._create_records() + # Create 2 corecords + o2m_corecords = self._create_records(2) + m2m_corecords = self._create_records(2) + # Assign 1 corecord to each X2M field + record.write( + { + "o2m_ids": [fields.Command.set(o2m_corecords[0].ids)], + "m2m_ids": [fields.Command.set(m2m_corecords[1].ids)], + } + ) + # Set vals to link all corecords on each X2M field + vals = { + "o2m_ids": [fields.Command.link(i) for i in o2m_corecords.ids], + "m2m_ids": [fields.Command.link(i) for i in m2m_corecords.ids], + } + # The diff should include only the IDs we want to link that are not already + # linked + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo will update the commands to include the {"id": corecord.id} in them + { + "o2m_ids": [ + ( + fields.Command.LINK, + o2m_corecords[1].id, + {"id": o2m_corecords[1].id}, + ) + ], + "m2m_ids": [ + ( + fields.Command.LINK, + m2m_corecords[0].id, + {"id": m2m_corecords[0].id}, + ) + ], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_15_get_write_diff_values_x2many_command_clear(self): + """Test ``_get_write_diff_values()`` on fields.Command.clear() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to clear each X2M field + vals = { + "o2m_ids": [fields.Command.clear()], + "m2m_ids": [fields.Command.clear()], + } + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(i) for i in o2m_corecords.ids], + "m2m_ids": [fields.Command.unlink(i) for i in m2m_corecords.ids], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_16_get_write_diff_values_x2many_command_set(self): + """Test ``_get_write_diff_values()`` on fields.Command.set() + + ``_get_write_diff_values()`` behavior depends on various cases + """ + record = self._create_records() + # Create 3 corecords for each X2M field + o2m_corecords = self._create_records(3) + m2m_corecords = self._create_records(3) + + # Case 1: + # - X2M fields contain no corecords + # - we want to assign them some corecords + # => ``_get_write_diff_values()`` should return a ``fields.Command.link()`` + # command for each corecord to add + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords.ids)], + "m2m_ids": [fields.Command.set(m2m_corecords.ids)], + }, + ), + # Odoo will update the commands to "link", and it will add the + # {"id": corecord.id} in them + { + "o2m_ids": [ + (fields.Command.LINK, i, {"id": i}) for i in o2m_corecords.ids + ], + "m2m_ids": [ + (fields.Command.LINK, i, {"id": i}) for i in m2m_corecords.ids + ], + }, + ) + + # Case 2: + # - X2M fields contain some corecords + # - we want to replace them with different corecords + # => ``_get_write_diff_values()`` should return a + # ``fields.Command.[delete|unlink]()`` command for each corecord to remove, + # and a ``fields.Command.link()`` command for each corecord to add + record.o2m_ids = o2m_corecords[:1] + record.m2m_ids = m2m_corecords[:2] + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords[1:].ids)], + "m2m_ids": [fields.Command.set(m2m_corecords[2:].ids)], + }, + ), + # Odoo will update the commands to "unlink", "delete" and "link" (with the + # {"id": corecord.id} in the "link" ones) + { + "o2m_ids": [ + (fields.Command.DELETE, i, 0) for i in o2m_corecords[:1].ids + ] + + [(fields.Command.LINK, i, {"id": i}) for i in o2m_corecords[1:].ids], + "m2m_ids": [ + (fields.Command.UNLINK, i, 0) for i in m2m_corecords[:2].ids + ] + + [(fields.Command.LINK, i, {"id": i}) for i in m2m_corecords[2:].ids], + }, + ) + + # Case 3: + # - X2M fields contain some corecords + # - we want to reassign the same corecords + # => ``_get_write_diff_values()`` should return nothing + record.o2m_ids = o2m_corecords + record.m2m_ids = m2m_corecords + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords.ids)], + "m2m_ids": [fields.Command.set(m2m_corecords.ids)], + }, + ), + {}, + ) + + # Case 4: + # - X2M fields contain some corecords + # - we want to remove all corecords + # => ``_get_write_diff_values()`` should return a + # ``fields.Command.[delete|unlink]()`` command for each linked corecord + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set([])], + "m2m_ids": [fields.Command.set([])], + }, + ), + # Odoo will update the commands to "unlink" and "delete" + { + "o2m_ids": [(fields.Command.DELETE, i, 0) for i in o2m_corecords.ids], + "m2m_ids": [(fields.Command.UNLINK, i, 0) for i in m2m_corecords.ids], + }, + ) + + # pylint: disable=W0104 + def test_20_write_diff_computed_fields(self): + """Checks cache behavior for computed fields when diff-writing their deps""" + # Prepare the record, its fields values and the cache + record = self._create_records() + vals = {"length": 5, "width": 3, "height": 2} + record.write(vals) + fnames = ("perimeter", "area", "volume") + for fname in fnames: + with mute_logger("bwd.test.model.log"): + record[fname] # Dummy read: set fields in cache + + # Use ``write`` w/ the same values: Odoo will need to recompute the computed + # fields values as soon as they're read + record.write(vals) + for fname in fnames: + with self.assertLogs("bwd.test.model.log", level="WARNING"): + record[fname] # Dummy read: check the compute method is triggered + + # Use ``write_diff`` w/ the same values: Odoo won't need to recompute the + # computed fields values + record.write_diff(vals) + for fname in fnames: + with self.assertNoLogs("bwd.test.model.log", level="WARNING"): + record[fname] # Dummy read: check the compute method is not triggered