details:   https://code.tryton.org/tryton/commit/6a0e22e0f45e
branch:    default
user:      Nicolas Évrard <[email protected]>
date:      Tue Dec 02 00:12:08 2025 +0100
description:
        Add support for materializing ModelSQL based on a table query
diffstat:

 trytond/CHANGELOG                              |   1 +
 trytond/doc/topics/configuration.rst           |  21 +++++++
 trytond/trytond/backend/database.py            |  13 ++++
 trytond/trytond/backend/postgresql/database.py |  40 ++++++++++++++
 trytond/trytond/backend/postgresql/table.py    |  31 ++++++++--
 trytond/trytond/backend/sqlite/table.py        |   4 +
 trytond/trytond/backend/table.py               |   4 +
 trytond/trytond/ir/action.py                   |   8 ++
 trytond/trytond/ir/cron.py                     |   1 +
 trytond/trytond/ir/model.py                    |  22 +++++++-
 trytond/trytond/ir/model.xml                   |  13 ++++
 trytond/trytond/ir/tryton.cfg                  |   1 +
 trytond/trytond/model/modelsql.py              |  73 ++++++++++++++++++++-----
 trytond/trytond/modules/__init__.py            |   8 ++
 trytond/trytond/tests/modelsql.py              |  33 +++++++++++
 trytond/trytond/tests/test_modelsql.py         |  25 ++++++++
 16 files changed, 275 insertions(+), 23 deletions(-)

diffs (602 lines):

diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/CHANGELOG
--- a/trytond/CHANGELOG Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/CHANGELOG Tue Dec 02 00:12:08 2025 +0100
@@ -1,3 +1,4 @@
+* Add support for materializing ModelSQL based on a table query
 * Add an option to trytond-console to run a script file
 * Store only immutable structure in MemoryCache
 * Replace ``clean_days`` with ``log_size`` in the cron configuration
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/doc/topics/configuration.rst
--- a/trytond/doc/topics/configuration.rst      Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/doc/topics/configuration.rst      Tue Dec 02 00:12:08 2025 +0100
@@ -516,6 +516,27 @@
     account.invoice.line = acc_inv_line
     account.invoice.tax = acc_inv_tax
 
+.. _config-table_query_materialized:
+
+table_query_materialized
+------------------------
+
+This section allow to define which :class:`~trytond.model.ModelSQL` with
+:meth:`~trytond.model.ModelSQL.table_query` must be materialized and at which
+interval they must be refreshed.
+
+The interval value is expressed in seconds.
+
+For example::
+
+    [table_query_materialized]
+    account.account.party = 1440
+
+.. note::
+
+   Only the :meth:`~trytond.model.ModelSQL.table_query` that does not depend on
+   the :attr:`~trytond.transaction.Transaction.context`.
+
 .. _config-ssl:
 
 ssl
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/backend/database.py
--- a/trytond/trytond/backend/database.py       Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/backend/database.py       Tue Dec 02 00:12:08 2025 +0100
@@ -185,6 +185,19 @@
     def has_channel(self):
         return False
 
+    @classmethod
+    def has_materialized_views(cls):
+        return False
+
+    def create_materialized_view(self, connection, view_name, query):
+        raise NotImplementedError
+
+    def drop_materialized_view(self, connection):
+        raise NotImplementedError
+
+    def refresh_materialized_view(self, connection, view_name):
+        raise NotImplementedError
+
     def sql_type(self, type_):
         pass
 
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e 
trytond/trytond/backend/postgresql/database.py
--- a/trytond/trytond/backend/postgresql/database.py    Thu Oct 16 16:07:52 
2025 +0200
+++ b/trytond/trytond/backend/postgresql/database.py    Tue Dec 02 00:12:08 
2025 +0100
@@ -535,6 +535,16 @@
             if cursor.rowcount:
                 return schema
 
+    def get_view_schema(self, connection, view_name):
+        cursor = connection.cursor()
+        for schema in self.search_path:
+            cursor.execute('SELECT 1 '
+                'FROM pg_matviews '
+                'WHERE matviewname = %s AND schemaname = %s',
+                (view_name, schema))
+            if cursor.rowcount:
+                return schema
+
     @property
     def current_user(self):
         if self._current_user is None:
@@ -860,6 +870,36 @@
     def json_contains(self, column, json):
         return JSONContains(Cast(column, 'jsonb'), Cast(json, 'jsonb'))
 
+    @classmethod
+    def has_materialized_views(cls):
+        return True
+
+    def create_materialized_view(self, connection, view_name, query):
+        cursor = connection.cursor()
+        cursor.execute(
+            SQL(
+                "CREATE MATERIALIZED VIEW IF NOT EXISTS {} AS {} "
+                "WITH NO DATA"
+                ).format(Identifier(view_name), SQL(str(query))),
+            query.params)
+        cursor.execute(
+            SQL("CREATE UNIQUE INDEX ON {}(id)").format(Identifier(view_name)))
+
+    def drop_materialized_view(self, connection, view_name):
+        cursor = connection.cursor()
+        cursor.execute(
+            SQL("DROP MATERIALIZED VIEW {}").format(Identifier(view_name)))
+
+    def refresh_materialized_view(
+            self, connection, view_name, concurrently=True):
+        cursor = connection.cursor()
+        cursor.execute(
+            SQL("REFRESH MATERIALIZED VIEW {} {}")
+            .format(
+                SQL('CONCURRENTLY' if concurrently else ''),
+                Identifier(view_name)
+                ))
+
 
 register_type(UNICODE)
 if PYDATE:
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/backend/postgresql/table.py
--- a/trytond/trytond/backend/postgresql/table.py       Thu Oct 16 16:07:52 
2025 +0200
+++ b/trytond/trytond/backend/postgresql/table.py       Tue Dec 02 00:12:08 
2025 +0100
@@ -32,16 +32,27 @@
         cursor = transaction.connection.cursor()
 
         # Create new table if necessary
-        if not self.table_exist(self.table_name):
+        if (not (view_exists := self.view_exist(self.table_name))
+                and not self.table_exist(self.table_name)):
             cursor.execute(SQL('CREATE TABLE {} ()').format(
                     Identifier(self.table_name)))
-        self.table_schema = transaction.database.get_table_schema(
-            transaction.connection, self.table_name)
+        if view_exists:
+            self.table_schema = transaction.database.get_view_schema(
+                transaction.connection, self.table_name)
+            is_owner_query = (
+                'SELECT matviewowner = current_user FROM pg_matviews '
+                'WHERE matviewname = %s and schemaname = %s')
+        else:
+            self.table_schema = transaction.database.get_table_schema(
+                transaction.connection, self.table_name)
+            is_owner_query = (
+                'SELECT tableowner = current_user FROM pg_tables '
+                'WHERE tablename = %s AND schemaname = %s')
+        cursor.execute(is_owner_query, (self.table_name, self.table_schema))
+        self.is_owner, = cursor.fetchone()
 
-        cursor.execute('SELECT tableowner = current_user FROM pg_tables '
-            'WHERE tablename = %s AND schemaname = %s',
-            (self.table_name, self.table_schema))
-        self.is_owner, = cursor.fetchone()
+        if view_exists:
+            return
 
         if model.__doc__ and self.is_owner:
             cursor.execute(SQL('COMMENT ON TABLE {} IS %s').format(
@@ -126,6 +137,12 @@
                 transaction.connection, table_name))
 
     @classmethod
+    def view_exist(cls, view_name):
+        transaction = Transaction()
+        return bool(transaction.database.get_view_schema(
+                transaction.connection, view_name))
+
+    @classmethod
     def table_rename(cls, old_name, new_name):
         transaction = Transaction()
         cursor = transaction.connection.cursor()
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/backend/sqlite/table.py
--- a/trytond/trytond/backend/sqlite/table.py   Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/backend/sqlite/table.py   Tue Dec 02 00:12:08 2025 +0100
@@ -57,6 +57,10 @@
         return True
 
     @classmethod
+    def view_exist(cls, view_name):
+        return False
+
+    @classmethod
     def table_rename(cls, old_name, new_name):
         cursor = Transaction().connection.cursor()
         if (cls.table_exist(old_name)
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/backend/table.py
--- a/trytond/trytond/backend/table.py  Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/backend/table.py  Tue Dec 02 00:12:08 2025 +0100
@@ -46,6 +46,10 @@
         raise NotImplementedError
 
     @classmethod
+    def view_exist(cls, view_name):
+        raise NotImplementedError
+
+    @classmethod
     def table_rename(cls, old_name, new_name):
         raise NotImplementedError
 
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/ir/action.py
--- a/trytond/trytond/ir/action.py      Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/ir/action.py      Tue Dec 02 00:12:08 2025 +0100
@@ -271,6 +271,7 @@
         Action = pool.get('ir.action')
         Menu = pool.get('ir.ui.menu')
         ModelAccess = pool.get('ir.model.access')
+        ModelData = pool.get('ir.model.data')
         User = pool.get('res.user')
         groups = User.get_groups()
         key = (Transaction().language, groups, keyword, tuple(value))
@@ -279,6 +280,7 @@
             return keywords
         keywords = []
         model, record_id = value
+        Model = pool.get(model)
 
         clause = [
             ('keyword', '=', keyword),
@@ -301,6 +303,12 @@
         for action_keyword in action_keywords:
             type_ = action_keyword.action.type
             types[type_].append(action_keyword.action.id)
+        if (keyword == 'form_action'
+                and issubclass(Model, ModelSQL)
+                and Model._table_query_materialized()):
+            refresh_action = ModelData.get_id(
+                'ir', 'wizard_model_refresh_materialized')
+            types['ir.action.wizard'].append(refresh_action)
         for type_, action_ids in types.items():
             for value in Action.get_action_values(type_, action_ids):
                 if (type_ == 'ir.action.act_window'
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/ir/cron.py
--- a/trytond/trytond/ir/cron.py        Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/ir/cron.py        Tue Dec 02 00:12:08 2025 +0100
@@ -94,6 +94,7 @@
             ('ir.queue|clean', "Clean Task Queue"),
             ('ir.error|clean', "Clean Errors"),
             ('ir.cron.log|clean', "Clean Cron Logs"),
+            ('ir.model|refresh_materialized', "Refresh Materialized Models"),
             ], "Method", required=True, states=_states)
 
     logs = fields.One2Many('ir.cron.log', 'cron', "Logs", readonly=True)
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/ir/model.py
--- a/trytond/trytond/ir/model.py       Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/ir/model.py       Tue Dec 02 00:12:08 2025 +0100
@@ -27,7 +27,8 @@
 from trytond.tools import cursor_dict, grouped_slice, is_instance_method
 from trytond.tools.string_ import StringMatcher
 from trytond.transaction import Transaction, without_check_access
-from trytond.wizard import Button, StateAction, StateView, Wizard
+from trytond.wizard import (
+    Button, StateAction, StateTransition, StateView, Wizard)
 
 from .resource import ResourceAccessMixin
 
@@ -242,6 +243,13 @@
     def get_name(cls, model):
         return cls.get_names().get(model, model)
 
+    @classmethod
+    def refresh_materialized(cls):
+        pool = Pool()
+        for _, Model in pool.iterobject():
+            if hasattr(Model, '_table_query_refresh'):
+                Model._table_query_refresh()
+
 
 class ModelField(
         fields.fmany2one(
@@ -1724,3 +1732,15 @@
                         f'"{record.name}--{to}"',
                         arrowhead='normal')
                 subgraph.add_edge(edge)
+
+
+class RefreshMaterialized(Wizard):
+    __name__ = "ir.model.refresh_materialized"
+    start = StateTransition()
+
+    def transition_start(self):
+        pool = Pool()
+        Model = pool.get(Transaction().context['active_model'])
+        if hasattr(Model, '_table_query_refresh'):
+            Model._table_query_refresh(force=True)
+        return 'end'
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/ir/model.xml
--- a/trytond/trytond/ir/model.xml      Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/ir/model.xml      Tue Dec 02 00:12:08 2025 +0100
@@ -329,5 +329,18 @@
             <field name="perm_create" eval="False"/>
             <field name="perm_delete" eval="False"/>
         </record>
+
+        <record model="ir.action.wizard" 
id="wizard_model_refresh_materialized">
+            <field name="name">Refresh Data</field>
+            <field name="wiz_name">ir.model.refresh_materialized</field>
+        </record>
+    </data>
+
+    <data noupdate="1">
+        <record model="ir.cron" id="cron_model_refresh_materialized">
+            <field name="method">ir.model|refresh_materialized</field>
+            <field name="interval_number" eval="1"/>
+            <field name="interval_type">hours</field>
+        </record>
     </data>
 </tryton>
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/ir/tryton.cfg
--- a/trytond/trytond/ir/tryton.cfg     Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/ir/tryton.cfg     Tue Dec 02 00:12:08 2025 +0100
@@ -121,6 +121,7 @@
     translation.TranslationReport
     ui.view.ShowView
     model.PrintModelGraph
+    model.RefreshMaterialized
     module.ModuleConfigWizard
     module.ModuleActivateUpgrade
     module.ModuleConfig
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/model/modelsql.py
--- a/trytond/trytond/model/modelsql.py Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/model/modelsql.py Tue Dec 02 00:12:08 2025 +0100
@@ -269,7 +269,7 @@
 def no_table_query(func):
     @wraps(func)
     def wrapper(cls, *args, **kwargs):
-        if callable(cls.table_query):
+        if cls._is_table_query():
             raise NotImplementedError("On table_query")
         return func(cls, *args, **kwargs)
     return wrapper
@@ -335,11 +335,13 @@
         cls._sql_constraints = []
         cls._sql_indexes = set()
         cls._history_sql_indexes = set()
-        if not callable(cls.table_query):
+        if (not cls._is_table_query()
+                or cls._table_query_materialized()):
             table = cls.__table__()
-            cls._sql_constraints.append(
-                ('id_positive', Check(table, table.id >= 0),
-                    'ir.msg_id_positive'))
+            if not cls._is_table_query():
+                cls._sql_constraints.append(
+                    ('id_positive', Check(table, table.id >= 0),
+                        'ir.msg_id_positive'))
             rec_name_field = getattr(cls, cls._rec_name, None)
             if (isinstance(rec_name_field, fields.Field)
                     and not hasattr(rec_name_field, 'set')):
@@ -428,7 +430,7 @@
 
     @classmethod
     def __table__(cls):
-        if callable(cls.table_query):
+        if cls._is_table_query() and not cls._table_query_materialized():
             return cls.table_query()
         else:
             return Table(cls._table)
@@ -445,10 +447,20 @@
 
     @classmethod
     def __register__(cls, module_name):
-        cursor = Transaction().connection.cursor()
+        transaction = Transaction()
+        cursor = transaction.connection.cursor()
         super().__register__(module_name)
 
-        if callable(cls.table_query):
+        if cls._is_table_query():
+            if transaction.database.has_materialized_views():
+                if backend.TableHandler.view_exist(cls._table):
+                    transaction.database.drop_materialized_view(
+                        transaction.connection, cls._table)
+                if cls._table_query_materialized():
+                    query = cls.table_query()
+                    query.columns += (CurrentTimestamp().as_('_refreshed'),)
+                    transaction.database.create_materialized_view(
+                        transaction.connection, cls._table, query)
             return
 
         pool = Pool()
@@ -567,7 +579,8 @@
                 if j != index and index < j:
                     return False
             return True
-        if not callable(cls.table_query):
+        if (not cls._is_table_query()
+                or cls._table_query_materialized()):
             table_h = cls.__table_handler__()
             indexes = filter(no_subset, cls._sql_indexes)
             table_h.set_indexes(indexes, concurrently=concurrently)
@@ -586,6 +599,36 @@
                 history_table.add_column(field_name, field._sql_type)
 
     @classmethod
+    def _is_table_query(cls):
+        return callable(cls.table_query)
+
+    @classmethod
+    def _table_query_materialized(cls):
+        if cls._is_table_query():
+            return config.getint(
+                'table_query_materialized', cls.__name__, default=0)
+
+    @classmethod
+    def _table_query_refresh(cls, concurrently=True, force=False):
+        table = cls.__table__()
+        transaction = Transaction()
+        if not transaction.database.has_materialized_views():
+            return
+        cursor = transaction.connection.cursor()
+        if not (interval := cls._table_query_materialized()):
+            return
+        if not force:
+            cursor.execute(*table.select(
+                    CurrentTimestamp() - table._refreshed, limit=1))
+            if row := cursor.fetchone():
+                delta, = row
+                interval = datetime.timedelta(seconds=interval)
+                if delta < interval:
+                    return
+        transaction.database.refresh_materialized_view(
+            transaction.connection, cls._table, concurrently=concurrently)
+
+    @classmethod
     @without_check_access
     def __raise_integrity_error(
             cls, exception, values, field_names=None, transaction=None):
@@ -1126,7 +1169,7 @@
         history_limit = None
         if (cls._history
                 and transaction.context.get('_datetime')
-                and not callable(cls.table_query)):
+                and not cls._is_table_query()):
             in_max = 1
             table = cls.__table_history__()
             column = Coalesce(table.write_date, table.create_date)
@@ -1138,7 +1181,7 @@
         for f in all_fields:
             field = cls._fields.get(f)
             if field and field.sql_type():
-                if f in _TABLE_QUERY_COLUMNS and callable(cls.table_query):
+                if f in _TABLE_QUERY_COLUMNS and cls._is_table_query():
                     column = _TABLE_QUERY_COLUMNS[f]
                 else:
                     column = field.sql_column(table)
@@ -1146,7 +1189,7 @@
                 if backend.name == 'sqlite':
                     columns[f].output_name += ' [%s]' % field.sql_type().base
             elif f in {'_write', '_delete'}:
-                if not callable(cls.table_query):
+                if not cls._is_table_query():
                     rule_domain = Rule.domain_get(
                         cls.__name__, mode=f.lstrip('_'))
                     # No need to compute rule domain if it is the same as the
@@ -1168,7 +1211,7 @@
                         columns[f] = rule_expression.as_(f)
                     else:
                         columns[f] = Literal(True).as_(f)
-            elif f == '_timestamp' and not callable(cls.table_query):
+            elif f == '_timestamp' and not cls._is_table_query():
                 sql_type = fields.Char('timestamp').sql_type().base
                 columns[f] = Extract(
                     'EPOCH', Coalesce(table.write_date, table.create_date)
@@ -1641,7 +1684,7 @@
         if (mode == 'read'
                 and cls._history
                 and transaction.context.get('_datetime')
-                and not callable(cls.table_query)):
+                and not cls._is_table_query()):
             in_max = 1
             table = cls.__table_history__()
             column = Coalesce(table.write_date, table.create_date)
@@ -1824,7 +1867,7 @@
             columns.append(Column(table, '__id').as_('__id'))
 
         if eager:
-            table_query = callable(cls.table_query)
+            table_query = cls._is_table_query()
             columns += [f.sql_column(table).as_(n)
                 for n, f in sorted(cls._fields.items())
                 if not hasattr(f, 'get')
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/modules/__init__.py
--- a/trytond/trytond/modules/__init__.py       Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/modules/__init__.py       Tue Dec 02 00:12:08 2025 +0100
@@ -181,6 +181,7 @@
     modules_todo = []
     models_to_update_history = set()
     models_with_indexes = set()
+    models_to_refresh = set()
 
     # Load also parent languages
     lang = set(lang)
@@ -257,6 +258,9 @@
                 for model in classes['model']:
                     if hasattr(model, '_history'):
                         models_to_update_history.add(model.__name__)
+                    if (hasattr(model, '_table_query_materialized')
+                            and model._table_query_materialized()):
+                        models_to_refresh.add(model.__name__)
                     if hasattr(model, '_update_sql_indexes'):
                         models_with_indexes.add(model.__name__)
 
@@ -327,6 +331,10 @@
         if update:
             cursor.execute(*ir_configuration.update(
                     [ir_configuration.series], [__series__]))
+            for model_name in models_to_refresh:
+                model = pool.get(model_name)
+                logger.info('refresh %s', model.__name__)
+                model._table_query_refresh(concurrently=False, force=True)
             if indexes or indexes is None:
                 create_indexes(concurrently=False)
             else:
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/tests/modelsql.py
--- a/trytond/trytond/tests/modelsql.py Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/tests/modelsql.py Tue Dec 02 00:12:08 2025 +0100
@@ -1,6 +1,7 @@
 # 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 sql.aggregate import Count, Min
 from sql.operators import Equal
 
 from trytond.model import (
@@ -267,6 +268,36 @@
     __name__ = 'test.modelsql.lock'
 
 
+class ModelMaterializedViewData(ModelSQL):
+    "Model to provide test data to the materialized views"
+    __name__ = 'test.modelsql.materialized.data'
+    name = fields.Char("Name")
+
+
+class ModelMaterializedView(ModelSQL):
+    "Model to test the materialized views"
+    __name__ = 'test.modelsql.materialized'
+
+    name = fields.Char("Name")
+    count = fields.Integer("Count")
+
+    @classmethod
+    def table_query(cls):
+        pool = Pool()
+        Model = pool.get('test.modelsql.materialized.data')
+        model = Model.__table__()
+
+        return model.select(
+            Min(model.id).as_('id'),
+            model.name.as_('name'),
+            Count(model.id).as_('count'),
+            group_by=[model.name])
+
+    @classmethod
+    def _table_query_materialized(cls):
+        return 1
+
+
 def register(module):
     Pool.register(
         ModelSQLRead,
@@ -297,4 +328,6 @@
         ModelUnique,
         ModelExclude,
         ModelLock,
+        ModelMaterializedViewData,
+        ModelMaterializedView,
         module=module, type_='model')
diff -r 93ccf0b37bb2 -r 6a0e22e0f45e trytond/trytond/tests/test_modelsql.py
--- a/trytond/trytond/tests/test_modelsql.py    Thu Oct 16 16:07:52 2025 +0200
+++ b/trytond/trytond/tests/test_modelsql.py    Tue Dec 02 00:12:08 2025 +0100
@@ -1406,6 +1406,31 @@
                     ['OR', ('c', '=', 3), ('d.e', '=', 4)]]
                 ])
 
+    @unittest.skipUnless(
+        backend.Database.has_materialized_views(),
+        "Database backend does not support materialized views")
+    @with_transaction()
+    def test_table_query_refresh(self):
+        "Test refresh materialized table query"
+        pool = Pool()
+        Data = pool.get('test.modelsql.materialized.data')
+        MaterializedView = pool.get('test.modelsql.materialized')
+
+        Data.create([{
+                    'name': "Foo",
+                    }, {
+                    'name': "Foo",
+                    }, {
+                    'name': "Bar",
+                    }])
+        self.assertEqual(
+            MaterializedView.search([]),
+            [])
+
+        MaterializedView._table_query_refresh()
+        stats = [(s.name, s.count) for s in MaterializedView.search([])]
+        self.assertEqual(stats, [("Foo", 2), ("Bar", 1)])
+
 
 class TranslationTestCase(TestCase):
     default_language = 'fr'

Reply via email to