From a6bf6cf44ded4835fa46144432aebd663dcbc5ff Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Wed, 30 Nov 2022 12:16:24 +0000 Subject: [PATCH] [FIX] website, test_website: prevent fuzzy search to crash with inherits This commit ensures that when searching on a `inherits` field of a model (like `name` of `website.page`), it works properly when `pg_trgm` is activated. Indeed, `name` is a field of `website.page` record but only at the ORM level, not in SQL, due to how `inherits` works. So, when the `pg_trgm` extension is enabled, it will switch from ORM queries to raw SQL query (to use the native SQL similarity feature and not our custom python/orm one, as obviously the SQL one is better, more powerfull/accurate and faster). But this will actually make the code fail when searching on fields from a model which has `inherits` and that you search on that `inherits` model fields. Note that in 15.2, the `pg_trgm` extension is auto installed when possible thanks to [1] and [2]. [1]: https://github.com/odoo/odoo/commit/eedf37d6e286b995c47b946be1a6b66817094eff [2]: https://github.com/odoo/odoo/commit/75e6b645acdc95aece506ce0249fd0760838281c opw-3063592 closes odoo/odoo#107178 X-original-commit: 3d978caad9de2010603581687ee39a5240b74074 Signed-off-by: Romain Derie (rde) Signed-off-by: Quentin Smetz (qsm) --- addons/test_website/tests/test_fuzzy.py | 24 ++++++++ addons/website/models/website.py | 75 ++++++++++++++++++------- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/addons/test_website/tests/test_fuzzy.py b/addons/test_website/tests/test_fuzzy.py index 4e242abc248..17be95f7dc0 100644 --- a/addons/test_website/tests/test_fuzzy.py +++ b/addons/test_website/tests/test_fuzzy.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging +import psycopg2 from odoo.addons.website.controllers.main import Website from odoo.addons.website.tools import MockRequest @@ -72,3 +73,26 @@ class TestAutoComplete(TransactionCase): # There are no "X*" records self._autocomplete('XEF1000', 0, "Not found") + + def test_02_pages_search(self): + if not self.env.registry.has_trigram: + try: + self.env.cr.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + self.env.registry.has_trigram = True + except psycopg2.Error: + _logger.warning("pg_trgm extension can't be installed, which is required to run this test") + return + + with MockRequest(self.env, website=self.env['website'].browse(1)): + # This should not crash. This ensures that when searching on `name` + # field of `website.page` model, it works properly when `pg_trgm` is + # activated. + # Indeed, `name` is a field of `website.page` record but only at the + # ORM level, not in SQL, due to how `inherits` works. + self.env['website'].browse(1)._search_with_fuzzy( + 'pages', 'test', limit=5, order='name asc, website_id desc, id', options={ + 'displayDescription': False, 'displayDetail': False, + 'displayExtraDetail': False, 'displayExtraLink': False, + 'displayImage': False, 'allowFuzzy': True + } + ) diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 2ff686f7966..d43f00a2216 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1611,35 +1611,72 @@ class Website(models.Model): fields = set(fields).intersection(model._fields) unaccent = get_unaccent_wrapper(self.env.cr) - similarities = [sql.SQL("word_similarity({search}, {field})").format( - search=unaccent(sql.Placeholder('search')), - # Specific handling for website.page that inherits its arch_db and name fields - # TODO make more generic - field=unaccent(sql.SQL("{table}.{field}").format( - table=sql.Identifier((self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._table), - field=sql.Identifier(field) - )) if not (self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._fields[field].translate else - unaccent(sql.SQL("COALESCE({table}.{field}->>{lang}, {table}.{field}->>'en_US')").format( - table=sql.Identifier((self.env['ir.ui.view'] if field == 'arch_db' or (field == 'name' and 'arch_db' in fields) else model)._table), - field=sql.Identifier(field), - lang=sql.Literal(lang) - )) - ) for field in fields] + + # Specific handling for fields being actually part of another model + # through the `inherits` mechanism. + # It gets the list of fields requested to search upon and that are + # actually not part of the requested model itself but part of a + # `inherits` model: + # { + # 'name': { + # 'table': 'ir_ui_view', + # 'fname': 'view_id', + # }, + # 'url': { + # 'table': 'ir_ui_view', + # 'fname': 'view_id', + # }, + # 'another_field': { + # 'table': 'another_table', + # 'fname': 'record_id', + # }, + # } + inherits_fields = { + inherits_model_fname: { + 'table': self.env[inherits_model_name]._table, + 'fname': inherits_field_name, + } + for inherits_model_name, inherits_field_name in model._inherits.items() + for inherits_model_fname in self.env[inherits_model_name]._fields.keys() + if inherits_model_fname in fields + } + similarities = [] + for field in fields: + # Field might belong to another model (`inherits` mechanism) + table = inherits_fields[field]['table'] if field in inherits_fields else model._table + similarities.append( + sql.SQL("word_similarity({search}, {field})").format( + search=unaccent(sql.Placeholder('search')), + field=unaccent(sql.SQL("{table}.{field}").format( + table=sql.Identifier(table), + field=sql.Identifier(field) + )) if not model._fields[field].translate else + unaccent(sql.SQL("COALESCE({table}.{field}->>{lang}, {table}.{field}->>'en_US')").format( + table=sql.Identifier(table), + field=sql.Identifier(field), + lang=sql.Literal(lang) + )), + ) + ) + best_similarity = sql.SQL('GREATEST({similarities})').format( similarities=sql.SQL(', ').join(similarities) ) from_clause = sql.SQL("FROM {table}").format(table=sql.Identifier(model._table)) - # Specific handling for website.page that inherits its arch_db and name fields - # TODO make more generic - if 'arch_db' in fields: + # Specific handling for fields being actually part of another model + # through the `inherits` mechanism. + for table_to_join in { + field['table']: field['fname'] for field in inherits_fields.values() + }.items(): # Removes duplicate inherits model from_clause = sql.SQL(""" {from_clause} - LEFT JOIN {view_table} ON {table}.view_id = {view_table}.id + LEFT JOIN {inherits_table} ON {table}.{inherits_field} = {inherits_table}.id """).format( from_clause=from_clause, table=sql.Identifier(model._table), - view_table=sql.Identifier(self.env['ir.ui.view']._table), + inherits_table=sql.Identifier(table_to_join[0]), + inherits_field=sql.Identifier(table_to_join[1]), ) query = sql.SQL(""" SELECT {table}.id, {best_similarity} AS _best_similarity