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