details:   https://code.tryton.org/tryton/commit/49748579c23a
branch:    default
user:      Cédric Krier <[email protected]>
date:      Fri Jan 30 18:06:45 2026 +0100
description:
        Support Function field without getter but SQL expression

        Closes #4369
diffstat:

 trytond/CHANGELOG                            |    1 +
 trytond/doc/ref/fields.rst                   |    2 +-
 trytond/trytond/ir/message.xml               |    3 +
 trytond/trytond/model/fields/function.py     |   21 +++-
 trytond/trytond/model/modelsql.py            |   14 +-
 trytond/trytond/model/modelstorage.py        |    1 +
 trytond/trytond/tests/field_function.py      |   62 ++++++++++++
 trytond/trytond/tests/test_field_function.py |  138 +++++++++++++++++++++++++++
 trytond/trytond/tests/test_tryton.py         |    6 +
 9 files changed, 238 insertions(+), 10 deletions(-)

diffs (396 lines):

diff -r 350b077d44c0 -r 49748579c23a trytond/CHANGELOG
--- a/trytond/CHANGELOG Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/CHANGELOG Fri Jan 30 18:06:45 2026 +0100
@@ -1,3 +1,4 @@
+* Support Function field without getter but SQL expression
 * Add support for column_<field name> method
 * Use tables and Model as arguments for sql_column of the Field
 * Add support for basic authentication for user application
diff -r 350b077d44c0 -r 49748579c23a trytond/doc/ref/fields.rst
--- a/trytond/doc/ref/fields.rst        Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/doc/ref/fields.rst        Fri Jan 30 18:06:45 2026 +0100
@@ -1081,7 +1081,7 @@
 Function
 --------
 
-.. class:: Function(field, getter[, setter[, searcher[, getter_with_context]]])
+.. class:: Function(field, [getter[, setter[, searcher[, 
getter_with_context]]]])
 
    A function field can emulate any other given :class:`field <Field>`.
 
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/ir/message.xml
--- a/trytond/trytond/ir/message.xml    Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/ir/message.xml    Fri Jan 30 18:06:45 2026 +0100
@@ -270,6 +270,9 @@
         <record model="ir.message" id="msg_search_function_missing">
             <field name="text">Missing search function for field "%(field)s" 
in "%(model)s".</field>
         </record>
+        <record model="ir.message" id="msg_order_function_missing">
+            <field name="text">Missing order function for field "%(field)s" in 
"%(model)s".</field>
+        </record>
         <record model="ir.message" id="msg_setter_function_missing">
             <field name="text">Missing setter function for field "%(field)s" 
in "%(model)s".</field>
         </record>
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/model/fields/function.py
--- a/trytond/trytond/model/fields/function.py  Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/model/fields/function.py  Fri Jan 30 18:06:45 2026 +0100
@@ -30,7 +30,7 @@
 
 class Function(Field):
 
-    def __init__(self, field, getter, setter=None, searcher=None,
+    def __init__(self, field, getter=None, setter=None, searcher=None,
             getter_with_context=True, loading='lazy'):
         '''
         :param field: The field of the function.
@@ -88,19 +88,27 @@
         return self._field.sql_format(value)
 
     def sql_type(self):
+        if not self.getter:
+            return self._field.sql_type()
         return None
 
     @domain_method
     def convert_domain(self, domain, tables, Model):
         if self.searcher:
             return getattr(Model, self.searcher)(self.name, domain)
+        elif not self.getter:
+            return self._field.convert_domain(domain, tables, Model)
         raise NotImplementedError(gettext(
                 'ir.msg_search_function_missing',
                 **Model.__names__(self.name)))
 
     @order_method
     def convert_order(self, name, tables, Model):
-        raise NotImplementedError
+        if not self.getter:
+            return self._field.convert_order(name, tables, Model)
+        raise NotImplementedError(gettext(
+                'ir.msg_order_function_missing',
+                **Model.__names__(self.name)))
 
     @getter_context
     @without_check_access
@@ -110,6 +118,15 @@
         If the function has ``names`` in the function definition then
         it will call it with a list of name.
         '''
+        if not self.getter:
+            def get_values(name):
+                return {r['id']: r[name] for r in values}
+            if isinstance(name, list):
+                names = name
+                return {name: get_values(name) for name in names}
+            else:
+                return get_values(name)
+
         method = getattr(Model, self.getter)
         instance_method = is_instance_method(Model, self.getter)
         multiple = self.getter_multiple(method)
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/model/modelsql.py
--- a/trytond/trytond/model/modelsql.py Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/model/modelsql.py Fri Jan 30 18:06:45 2026 +0100
@@ -493,7 +493,7 @@
             if field_name == 'id':
                 continue
             sql_type = field.sql_type()
-            if not sql_type:
+            if not sql_type or isinstance(field, fields.Function):
                 continue
 
             if field_name in cls._defaults:
@@ -610,7 +610,7 @@
         if cls._history:
             history_table = cls.__table_handler__(history=True)
             for field_name, field in cls._fields.items():
-                if not field.sql_type():
+                if not field.sql_type() or isinstance(field, fields.Function):
                     continue
                 history_table.add_column(field_name, field._sql_type)
 
@@ -782,15 +782,15 @@
         columns = []
         hcolumns = []
         if not deleted:
-            fields = cls._fields
+            fields_ = cls._fields
         else:
-            fields = {
+            fields_ = {
                 'id': cls.id,
                 'write_uid': cls.write_uid,
                 'write_date': cls.write_date,
                 }
-        for fname, field in sorted(fields.items()):
-            if not field.sql_type():
+        for fname, field in sorted(fields_.items()):
+            if not field.sql_type() or isinstance(field, fields.Function):
                 continue
             columns.append(Column(table, fname))
             hcolumns.append(Column(history, fname))
@@ -834,7 +834,7 @@
         hcolumns = []
         history_columns = []
         fnames = sorted(n for n, f in cls._fields.items()
-            if f.sql_type())
+            if f.sql_type() and not isinstance(f, fields.Function))
         id_idx = fnames.index('id')
         for fname in fnames:
             columns.append(Column(table, fname))
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/model/modelstorage.py
--- a/trytond/trytond/model/modelstorage.py     Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/model/modelstorage.py     Fri Jan 30 18:06:45 2026 +0100
@@ -1885,6 +1885,7 @@
         multiple_getter = None
         if (field.loading == 'lazy'
                 and isinstance(field, fields.Function)
+                and field.getter
                 and field.getter_multiple(
                     getattr(self.__class__, field.getter))):
             multiple_getter = field.getter
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/tests/field_function.py
--- a/trytond/trytond/tests/field_function.py   Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/tests/field_function.py   Fri Jan 30 18:06:45 2026 +0100
@@ -1,6 +1,8 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
 
+from sql import Literal
+
 from trytond.model import ModelSQL, ModelStorage, fields
 from trytond.pool import Pool
 from trytond.transaction import Transaction
@@ -107,6 +109,63 @@
         return index
 
 
+class FunctionNoGetter(ModelSQL):
+    __name__ = 'test.function.no_getter'
+
+    value = fields.Integer("Value")
+    value_inc = fields.Function(fields.Integer("Value Inc"))
+
+    @classmethod
+    def column_value_inc(cls, tables):
+        table, _ = tables[None]
+        return table.value + Literal(1)
+
+
+class FunctionNoGetterRelation(ModelSQL):
+    __name__ = 'test.function.no_getter.relation'
+
+    target = fields.Many2One(
+        'test.function.no_getter.target',
+        "Target")
+    target_name = fields.Function(fields.Char("Target Name"))
+    target_target = fields.Function(
+        fields.Many2One('test.function.no_getter.target', "Target Target"))
+
+    @classmethod
+    def column_target_name(cls, tables):
+        pool = Pool()
+        Target = pool.get('test.function.no_getter.target')
+        table, _ = tables[None]
+        if 'target' not in tables:
+            target = Target.__table__()
+            tables['target'] = {
+                None: (target, table.target == target.id),
+                }
+        else:
+            target, _ = tables['target'][None]
+        return target.name
+
+    @classmethod
+    def column_target_target(cls, tables):
+        pool = Pool()
+        Target = pool.get('test.function.no_getter.target')
+        table, _ = tables[None]
+        if 'target' not in tables:
+            target = Target.__table__()
+            tables['target'] = {
+                None: (target, table.target == target.id),
+                }
+        else:
+            target, _ = tables['target'][None]
+        return target.id
+
+
+class FunctionNoGetterTarget(ModelSQL):
+    __name__ = 'test.function.no_getter.target'
+
+    name = fields.Char("Name")
+
+
 def register(module):
     Pool.register(
         FunctionDefinition,
@@ -115,4 +174,7 @@
         FunctonGetter,
         FunctionGetterContext,
         FunctionGetterLocalCache,
+        FunctionNoGetter,
+        FunctionNoGetterRelation,
+        FunctionNoGetterTarget,
         module=module, type_='model')
diff -r 350b077d44c0 -r 49748579c23a 
trytond/trytond/tests/test_field_function.py
--- a/trytond/trytond/tests/test_field_function.py      Thu Jan 29 15:24:10 
2026 +0100
+++ b/trytond/trytond/tests/test_field_function.py      Fri Jan 30 18:06:45 
2026 +0100
@@ -125,3 +125,141 @@
             Model.read([record.id], ['function1', 'function2'])
 
             self.assertEqual(getter.call_count, 1)
+
+    @with_transaction()
+    def test_no_getter(self):
+        "Test no getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter')
+
+        record = Model(value=42)
+        record.save()
+
+        self.assertEqual(record.value_inc, 43)
+
+    @with_transaction()
+    def test_no_getter_no_column(self):
+        "Test no column without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter')
+
+        table_handler = Model.__table_handler__()
+
+        self.assertFalse(table_handler.column_exist('value_inc'))
+
+    @with_transaction()
+    def test_no_getter_search(self):
+        "Test search without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter')
+
+        record = Model(value=42)
+        record.save()
+
+        result = Model.search([('value_inc', '=', 43)])
+        self.assertEqual(result, [record])
+
+    @with_transaction()
+    def test_no_getter_order(self):
+        "Test order without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter')
+
+        Model.create([{'value': i} for i in range(10)])
+
+        asc = Model.search([], order=[('value_inc', 'ASC')])
+        asc = [r.value_inc for r in asc]
+        desc = Model.search([], order=[('value_inc', 'DESC')])
+        desc = [r.value_inc for r in desc]
+
+        self.assertEqual(asc, sorted(asc))
+        self.assertEqual(desc, sorted(asc, reverse=True))
+
+    @with_transaction()
+    def test_no_getter_relation(self):
+        "Test no getter with relation"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter.relation')
+        Target = pool.get('test.function.no_getter.target')
+
+        target = Target(name="Test")
+        target.save()
+        record = Model(target=target)
+        record.save()
+
+        self.assertEqual(record.target_name, "Test")
+        self.assertEqual(record.target_target, target)
+
+    @with_transaction()
+    def test_no_getter_search_relation(self):
+        "Test search on relation without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter.relation')
+        Target = pool.get('test.function.no_getter.target')
+
+        target = Target(name="Test")
+        target.save()
+        record = Model(target=target)
+        record.save()
+
+        result = Model.search([('target_name', '=', "Test")])
+
+        self.assertEqual(result, [record])
+
+    @with_transaction()
+    def test_no_getter_search_relation_dotted(self):
+        "Test search on dotted relation without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter.relation')
+        Target = pool.get('test.function.no_getter.target')
+
+        target = Target(name="Test")
+        target.save()
+        record = Model(target=target)
+        record.save()
+
+        result = Model.search([('target_target.name', '=', "Test")])
+
+        self.assertEqual(result, [record])
+
+    @with_transaction()
+    def test_no_getter_order_relation(self):
+        "Test order on relation without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter.relation')
+        Target = pool.get('test.function.no_getter.target')
+
+        for i in range(10):
+            target = Target(name=str(i))
+            target.save()
+            record = Model(target=target)
+            record.save()
+
+        asc = Model.search([], order=[('target_name', 'ASC')])
+        asc = [r.target_name for r in asc]
+        desc = Model.search([], order=[('target_name', 'DESC')])
+        desc = [r.target_name for r in desc]
+
+        self.assertEqual(asc, sorted(asc))
+        self.assertEqual(desc, sorted(asc, reverse=True))
+
+    @with_transaction()
+    def test_no_getter_order_relation_dotted(self):
+        "Test order on dotted relation without getter"
+        pool = Pool()
+        Model = pool.get('test.function.no_getter.relation')
+        Target = pool.get('test.function.no_getter.target')
+
+        for i in range(10):
+            target = Target(name=str(i))
+            target.save()
+            record = Model(target=target)
+            record.save()
+
+        asc = Model.search([], order=[('target_target.name', 'ASC')])
+        asc = [r.target_target.name for r in asc]
+        desc = Model.search([], order=[('target_target.name', 'DESC')])
+        desc = [r.target_target.name for r in desc]
+
+        self.assertEqual(asc, sorted(asc))
+        self.assertEqual(desc, sorted(asc, reverse=True))
diff -r 350b077d44c0 -r 49748579c23a trytond/trytond/tests/test_tryton.py
--- a/trytond/trytond/tests/test_tryton.py      Thu Jan 29 15:24:10 2026 +0100
+++ b/trytond/trytond/tests/test_tryton.py      Fri Jan 30 18:06:45 2026 +0100
@@ -972,6 +972,12 @@
                             'field': field_name,
                             'type': field._type,
                             })
+                if not field.getter and isinstance(model, ModelSQL):
+                    func_name = f'column_{field_name}'
+                    self.assertTrue(
+                        getattr(model, func_name, None),
+                        msg=f"Missing method {func_name!r} "
+                        f"on model {mname!r} for field {field_name!r}")
                 for func_name in [field.getter, field.setter, field.searcher]:
                     if not func_name:
                         continue

Reply via email to