diff --git a/orm_forward_compatibility/README.rst b/orm_forward_compatibility/README.rst new file mode 100644 index 00000000000..95b9bda7d96 --- /dev/null +++ b/orm_forward_compatibility/README.rst @@ -0,0 +1,112 @@ +========================= +ORM Forward Compatibility +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:69068e4f7be049656b46a19592cfb15d2468bddb3e38f8522adbb63adf8ba386 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/orm_forward_compatibility + :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-18-0/server-tools-18-0-orm_forward_compatibility + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module backports a subset of the Odoo 19+ ORM API onto Odoo 18.0. +Backporting a module to 18.0 then requires fewer adaptations. + +Backported so far: + +- ``Domain``, the domain object introduced in Odoo 19. +- The typed ``ir.config_parameter`` getters ``get_str``, ``get_int``, + ``get_float`` and ``get_bool``, which return a typed value or a + default instead of ``False``. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Domain** + +Replace the Odoo 19 import ``from odoo.fields import Domain`` and leave +the rest untouched: + +.. code:: python + + from odoo.addons.orm_forward_compatibility import Domain + + domain = Domain("partner_id", "=", partner.id) & Domain([("state", "=", "done")]) + +Not supported yet: + +- Relative-date literals +- custom SQL domains +- ``any!`` / ``not any!`` operators + +**Typed ir.config_parameter getters** + +.. code:: python + + limit = self.env["ir.config_parameter"].sudo().get_int("my_module.limit", 20) + +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 +------------ + +- `Camptocamp `__: + + - David Gallay + +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/orm_forward_compatibility/__init__.py b/orm_forward_compatibility/__init__.py new file mode 100644 index 00000000000..c7e7d94de9b --- /dev/null +++ b/orm_forward_compatibility/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from . import models +from .domain import Domain diff --git a/orm_forward_compatibility/__manifest__.py b/orm_forward_compatibility/__manifest__.py new file mode 100644 index 00000000000..48666fa8ae6 --- /dev/null +++ b/orm_forward_compatibility/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +{ + "name": "ORM Forward Compatibility", + "version": "18.0.1.0.0", + "development_status": "Beta", + "summary": "Backport newer-version ORM helpers (Domain) onto Odoo 18.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "license": "LGPL-3", + "category": "Hidden/Dependency", + "depends": ["base"], + "installable": True, +} diff --git a/orm_forward_compatibility/domain.py b/orm_forward_compatibility/domain.py new file mode 100644 index 00000000000..8245d391f6e --- /dev/null +++ b/orm_forward_compatibility/domain.py @@ -0,0 +1,78 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +"""Minimal ``Domain`` shim backporting the Odoo 19+ ``odoo.fields.Domain`` API. + +Odoo 19 replaced list-domains by a ``Domain`` AST object (``odoo/orm/domains.py``, +~2000 lines). Backported modules written against 19/20 import it as +``from odoo.fields import Domain``. Rather than porting the whole engine, this +shim reimplements *only* the surface those modules use, delegating to 18's +``odoo.osv.expression``. + +Because ``Domain`` subclasses ``list`` and normalises itself to a plain 18 +list-domain, a shim instance can be passed straight to ``search``/``_search``. + +Supported surface (extend as new call sites appear): +- ``Domain([('a', '=', 1), ...])`` and ``Domain('a', '=', 1)`` constructors +- ``&`` / ``|`` / ``~`` operators +- ``Domain.AND(iterable)`` / ``Domain.OR(iterable)`` +- ``Domain.TRUE`` / ``Domain.FALSE`` +- ``.optimize_full(model)`` -> validates against the model, returns self + +NOT supported (keep such call sites hand-adapted on 18): +- relative-date literals in leaves (e.g. ``('date', '<', '-1d')``) +- custom SQL domains, ``any!``/``not any!`` internal operators +""" + +from odoo.osv import expression + + +class Domain(list): + def __init__(self, *args): + if len(args) == 3: + domain = [tuple(args)] + elif len(args) == 1: + arg = args[0] + if isinstance(arg, Domain): + domain = list(arg) + elif arg is True or arg == []: + domain = list(expression.TRUE_DOMAIN) + elif arg is False: + domain = list(expression.FALSE_DOMAIN) + elif isinstance(arg, list | tuple): + domain = expression.normalize_domain(list(arg)) + else: + raise TypeError(f"Domain() invalid argument type: {arg!r}") + else: + raise TypeError(f"Domain() invalid arguments: {args!r}") + super().__init__(domain) + + def __and__(self, other): + return Domain(expression.AND([list(self), list(Domain(other))])) + + __rand__ = __and__ + + def __or__(self, other): + return Domain(expression.OR([list(self), list(Domain(other))])) + + __ror__ = __or__ + + def __invert__(self): + return Domain(["!"] + list(self)) + + @staticmethod + def AND(items): + return Domain(expression.AND([list(Domain(item)) for item in items])) + + @staticmethod + def OR(items): + return Domain(expression.OR([list(Domain(item)) for item in items])) + + def optimize_full(self, model): + """Validate the domain against ``model`` (raises on unknown fields).""" + model._where_calc(list(self)) + return self + + +# v19/v20 call sites reference ``Domain.TRUE`` / ``Domain.FALSE`` as attributes. +Domain.TRUE = Domain(True) +Domain.FALSE = Domain(False) diff --git a/orm_forward_compatibility/models/__init__.py b/orm_forward_compatibility/models/__init__.py new file mode 100644 index 00000000000..438a9130bec --- /dev/null +++ b/orm_forward_compatibility/models/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from . import ir_config_parameter diff --git a/orm_forward_compatibility/models/ir_config_parameter.py b/orm_forward_compatibility/models/ir_config_parameter.py new file mode 100644 index 00000000000..6b77887caa7 --- /dev/null +++ b/orm_forward_compatibility/models/ir_config_parameter.py @@ -0,0 +1,34 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from odoo import models +from odoo.tools import str2bool + + +class IrConfigParameter(models.Model): + _inherit = "ir.config_parameter" + + def get_str(self, key, default=""): + value = self.get_param(key) + return default if value is False else str(value) + + def get_int(self, key, default=0): + value = self.get_param(key) + if value is False: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + def get_float(self, key, default=0.0): + value = self.get_param(key) + if value is False: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + def get_bool(self, key, default=False): + value = self.get_param(key) + return default if value is False else str2bool(value, default) diff --git a/orm_forward_compatibility/pyproject.toml b/orm_forward_compatibility/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/orm_forward_compatibility/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/orm_forward_compatibility/readme/CONTRIBUTORS.md b/orm_forward_compatibility/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..8a35f06b530 --- /dev/null +++ b/orm_forward_compatibility/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Camptocamp](https://www.camptocamp.com): + - David Gallay \<\> diff --git a/orm_forward_compatibility/readme/DESCRIPTION.md b/orm_forward_compatibility/readme/DESCRIPTION.md new file mode 100644 index 00000000000..222b2265800 --- /dev/null +++ b/orm_forward_compatibility/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module backports a subset of the Odoo 19+ ORM API onto Odoo 18.0. +Backporting a module to 18.0 then requires fewer adaptations. + +Backported so far: + +- `Domain`, the domain object introduced in Odoo 19. +- The typed `ir.config_parameter` getters `get_str`, `get_int`, `get_float` and + `get_bool`, which return a typed value or a default instead of `False`. diff --git a/orm_forward_compatibility/readme/USAGE.md b/orm_forward_compatibility/readme/USAGE.md new file mode 100644 index 00000000000..96f8d847ba2 --- /dev/null +++ b/orm_forward_compatibility/readme/USAGE.md @@ -0,0 +1,20 @@ +**Domain** + +Replace the Odoo 19 import `from odoo.fields import Domain` and leave the rest untouched: + +``` python +from odoo.addons.orm_forward_compatibility import Domain + +domain = Domain("partner_id", "=", partner.id) & Domain([("state", "=", "done")]) +``` + +Not supported yet: +- Relative-date literals +- custom SQL domains +- `any!` / `not any!` operators + +**Typed ir.config_parameter getters** + +``` python +limit = self.env["ir.config_parameter"].sudo().get_int("my_module.limit", 20) +``` diff --git a/orm_forward_compatibility/static/description/index.html b/orm_forward_compatibility/static/description/index.html new file mode 100644 index 00000000000..2deb3415df1 --- /dev/null +++ b/orm_forward_compatibility/static/description/index.html @@ -0,0 +1,456 @@ + + + + + +ORM Forward Compatibility + + + +
+

ORM Forward Compatibility

+ + +

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

+

This module backports a subset of the Odoo 19+ ORM API onto Odoo 18.0. +Backporting a module to 18.0 then requires fewer adaptations.

+

Backported so far:

+
    +
  • Domain, the domain object introduced in Odoo 19.
  • +
  • The typed ir.config_parameter getters get_str, get_int, +get_float and get_bool, which return a typed value or a +default instead of False.
  • +
+

Table of contents

+ +
+

Usage

+

Domain

+

Replace the Odoo 19 import from odoo.fields import Domain and leave +the rest untouched:

+
+from odoo.addons.orm_forward_compatibility import Domain
+
+domain = Domain("partner_id", "=", partner.id) & Domain([("state", "=", "done")])
+
+

Not supported yet:

+
    +
  • Relative-date literals
  • +
  • custom SQL domains
  • +
  • any! / not any! operators
  • +
+

Typed ir.config_parameter getters

+
+limit = self.env["ir.config_parameter"].sudo().get_int("my_module.limit", 20)
+
+
+
+

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
  • +
+
+ +
+

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/orm_forward_compatibility/tests/__init__.py b/orm_forward_compatibility/tests/__init__.py new file mode 100644 index 00000000000..55bb02b9b41 --- /dev/null +++ b/orm_forward_compatibility/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from . import test_domain +from . import test_ir_config_parameter diff --git a/orm_forward_compatibility/tests/test_domain.py b/orm_forward_compatibility/tests/test_domain.py new file mode 100644 index 00000000000..df319a15e87 --- /dev/null +++ b/orm_forward_compatibility/tests/test_domain.py @@ -0,0 +1,126 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from odoo.tests.common import TransactionCase + +from odoo.addons.orm_forward_compatibility import Domain + + +class TestDomain(TransactionCase): + """The shim is only useful if a ``Domain`` selects the same records as the + equivalent 18.0 list-domain, so every test searches with both and compares. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["res.partner"] + cls.alice = cls.partner_model.create({"name": "Alice", "ref": "shim-a"}) + cls.bob = cls.partner_model.create({"name": "Bob", "ref": "shim-b"}) + cls.carol = cls.partner_model.create({"name": "Carol", "ref": "shim-c"}) + cls.partners = cls.alice + cls.bob + cls.carol + # Every search is scoped to the 3 partners above, so unrelated records + # already in the database cannot make an assertion pass or fail. + cls.scope = [("id", "in", cls.partners.ids)] + + def _search(self, domain): + return self.partner_model.search(self.scope + list(domain)) + + def test_constructor_from_leaf_arguments(self): + self.assertEqual(Domain("name", "=", "Alice"), [("name", "=", "Alice")]) + self.assertEqual(self._search(Domain("name", "=", "Alice")), self.alice) + + def test_constructor_from_list(self): + domain = Domain([("name", "=", "Alice"), ("ref", "=", "shim-a")]) + # A list of leaves is implicitly AND-ed, as in 18.0. + self.assertEqual(domain, ["&", ("name", "=", "Alice"), ("ref", "=", "shim-a")]) + self.assertEqual(self._search(domain), self.alice) + + def test_constructor_from_tuple(self): + domain = Domain((("name", "=", "Bob"),)) + self.assertEqual(self._search(domain), self.bob) + + def test_constructor_from_domain_copies(self): + source = Domain("name", "=", "Alice") + copy = Domain(source) + self.assertEqual(copy, source) + self.assertIsNot(copy, source) + + def test_constructor_true_and_false(self): + self.assertEqual(self._search(Domain(True)), self.partners) + self.assertEqual(self._search(Domain([])), self.partners) + self.assertFalse(self._search(Domain(False))) + + def test_true_and_false_attributes(self): + self.assertEqual(self._search(Domain.TRUE), self.partners) + self.assertFalse(self._search(Domain.FALSE)) + + def test_constructor_rejects_unsupported_argument(self): + with self.assertRaises(TypeError): + Domain("name") + with self.assertRaises(TypeError): + Domain("name", "=") + + def test_and_operator(self): + domain = Domain("name", "=", "Alice") & Domain("ref", "=", "shim-a") + self.assertEqual(self._search(domain), self.alice) + # Both leaves must match: mismatched ref selects nothing. + mismatch = Domain("name", "=", "Alice") & Domain("ref", "=", "shim-b") + self.assertFalse(self._search(mismatch)) + + def test_and_operator_with_plain_list(self): + # Chained with a plain 18.0 list-domain, on either side. + right = Domain("name", "=", "Alice") & [("ref", "=", "shim-a")] + left = [("ref", "=", "shim-a")] & Domain("name", "=", "Alice") + self.assertEqual(self._search(right), self.alice) + self.assertEqual(self._search(left), self.alice) + + def test_or_operator(self): + domain = Domain("name", "=", "Alice") | Domain("name", "=", "Bob") + self.assertEqual(self._search(domain), self.alice + self.bob) + + def test_or_operator_with_plain_list(self): + right = Domain("name", "=", "Alice") | [("name", "=", "Bob")] + left = [("name", "=", "Bob")] | Domain("name", "=", "Alice") + self.assertEqual(self._search(right), self.alice + self.bob) + self.assertEqual(self._search(left), self.alice + self.bob) + + def test_invert_operator(self): + domain = ~Domain("name", "=", "Alice") + self.assertEqual(self._search(domain), self.bob + self.carol) + + def test_and_static_method(self): + domain = Domain.AND( + [ + Domain("name", "!=", "Alice"), + [("name", "!=", "Bob")], + ] + ) + self.assertEqual(self._search(domain), self.carol) + + def test_and_static_method_on_empty_iterable(self): + self.assertEqual(self._search(Domain.AND([])), self.partners) + + def test_or_static_method(self): + domain = Domain.OR( + [ + Domain("name", "=", "Alice"), + [("name", "=", "Carol")], + ] + ) + self.assertEqual(self._search(domain), self.alice + self.carol) + + def test_optimize_full_returns_self(self): + domain = Domain("name", "=", "Alice") + self.assertIs(domain.optimize_full(self.partner_model), domain) + + def test_optimize_full_rejects_unknown_field(self): + with self.assertRaises(ValueError): + Domain("no_such_field", "=", 1).optimize_full(self.partner_model) + + def test_is_accepted_as_a_plain_domain(self): + # The point of subclassing list: no conversion at the call site. + self.assertIsInstance(Domain("name", "=", "Alice"), list) + self.assertEqual( + self.partner_model.search_count(self.scope + Domain("name", "=", "Alice")), + 1, + ) diff --git a/orm_forward_compatibility/tests/test_ir_config_parameter.py b/orm_forward_compatibility/tests/test_ir_config_parameter.py new file mode 100644 index 00000000000..022e9ee033f --- /dev/null +++ b/orm_forward_compatibility/tests/test_ir_config_parameter.py @@ -0,0 +1,75 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html). +from odoo.tests.common import TransactionCase + +KEY = "orm_forward_compatibility.test_key" +MISSING_KEY = "orm_forward_compatibility.missing_key" + + +class TestIrConfigParameter(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.params = cls.env["ir.config_parameter"].sudo() + + def _set(self, value): + self.params.set_param(KEY, value) + + def test_get_str(self): + self._set("hello") + self.assertEqual(self.params.get_str(KEY), "hello") + + def test_get_str_casts_to_str(self): + self._set(42) + self.assertEqual(self.params.get_str(KEY), "42") + + def test_get_str_missing_key(self): + self.assertEqual(self.params.get_str(MISSING_KEY), "") + self.assertEqual(self.params.get_str(MISSING_KEY, "fallback"), "fallback") + + def test_get_int(self): + self._set("5") + self.assertEqual(self.params.get_int(KEY), 5) + + def test_get_int_missing_key(self): + self.assertEqual(self.params.get_int(MISSING_KEY), 0) + self.assertEqual(self.params.get_int(MISSING_KEY, 20), 20) + + def test_get_int_unparsable_value_falls_back(self): + # A hand-edited parameter must not break the caller. + self._set("not-a-number") + self.assertEqual(self.params.get_int(KEY, 20), 20) + + def test_get_float(self): + self._set("1.5") + self.assertEqual(self.params.get_float(KEY), 1.5) + + def test_get_float_missing_key(self): + self.assertEqual(self.params.get_float(MISSING_KEY), 0.0) + self.assertEqual(self.params.get_float(MISSING_KEY, 2.5), 2.5) + + def test_get_float_unparsable_value_falls_back(self): + self._set("not-a-number") + self.assertEqual(self.params.get_float(KEY, 2.5), 2.5) + + def test_get_bool(self): + for value, expected in [ + ("1", True), + ("True", True), + ("yes", True), + ("0", False), + ("False", False), + ("off", False), + ]: + with self.subTest(value=value): + self._set(value) + self.assertIs(self.params.get_bool(KEY), expected) + + def test_get_bool_missing_key(self): + self.assertIs(self.params.get_bool(MISSING_KEY), False) + self.assertIs(self.params.get_bool(MISSING_KEY, True), True) + + def test_get_bool_unparsable_value_falls_back(self): + self._set("maybe") + self.assertIs(self.params.get_bool(KEY, True), True) + self.assertIs(self.params.get_bool(KEY, False), False)