From 0adc373462c3613c0c73a993dc996eefe6b81e61 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 12 Jun 2026 12:16:51 +0200 Subject: [PATCH] [ADD] full_text_search --- .github/workflows/test.yml | 2 +- full_text_search/README.rst | 189 +++++++ full_text_search/__init__.py | 3 + full_text_search/__manifest__.py | 18 + full_text_search/fields.py | 237 ++++++++ full_text_search/hooks.py | 30 + full_text_search/models/__init__.py | 1 + full_text_search/models/base.py | 49 ++ full_text_search/readme/CONTRIBUTORS.md | 1 + full_text_search/readme/DESCRIPTION.md | 87 +++ .../static/description/index.html | 520 ++++++++++++++++++ full_text_search/static/src/js/tsvector.js | 11 + full_text_search/tests/__init__.py | 1 + full_text_search/tests/models.py | 71 +++ .../tests/test_full_text_search.py | 307 +++++++++++ full_text_search/utils.py | 19 + full_text_search/views/assets.xml | 20 + .../odoo/addons/full_text_search | 1 + setup/full_text_search/setup.py | 6 + 19 files changed, 1572 insertions(+), 1 deletion(-) create mode 100644 full_text_search/README.rst create mode 100644 full_text_search/__init__.py create mode 100644 full_text_search/__manifest__.py create mode 100644 full_text_search/fields.py create mode 100644 full_text_search/hooks.py create mode 100644 full_text_search/models/__init__.py create mode 100644 full_text_search/models/base.py create mode 100644 full_text_search/readme/CONTRIBUTORS.md create mode 100644 full_text_search/readme/DESCRIPTION.md create mode 100644 full_text_search/static/description/index.html create mode 100644 full_text_search/static/src/js/tsvector.js create mode 100644 full_text_search/tests/__init__.py create mode 100644 full_text_search/tests/models.py create mode 100644 full_text_search/tests/test_full_text_search.py create mode 100644 full_text_search/utils.py create mode 100644 full_text_search/views/assets.xml create mode 120000 setup/full_text_search/odoo/addons/full_text_search create mode 100644 setup/full_text_search/setup.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef5799fdf1c..549de9bd913 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,7 @@ jobs: makepot: "true" services: postgres: - image: postgres:9.6 + image: postgres:12 env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo diff --git a/full_text_search/README.rst b/full_text_search/README.rst new file mode 100644 index 00000000000..cf34d260654 --- /dev/null +++ b/full_text_search/README.rst @@ -0,0 +1,189 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +Full Text Search +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5374048b8bc825d4893d68c0f697227e4f6f76c1b21971351a5b223910a6cc46 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/14.0/full_text_search + :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-14-0/server-tools-14-0-full_text_search + :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=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a simple way to use full-text search functionality +in Odoo models. + +It is fully based on `PostgreSQL full-text +search `__, and +adds a new ``Searchable`` field that represents the TSVector column in +the database that will store the weighted full-text vector. It also adds +the ``full_text`` matching operator : ``@@`` to odoo domains. + +To add this functionality to a model, simply add a ``Searchable`` field +to the model with the fields you want to search on weighted by their +importance: ``A``, ``B``, ``C``, ``D`` with ``A`` being the most +important. + +.. code:: python + + from odoo import fields, models + + class YourModel(models.Model): + _name = 'your.model' + + full_text = fields.Searchable( + "Full Text", + fields={ + "name": "A", + "description": "B", + "notes": "C", + }, + dictionary="english", + ) + +And add the ``full_text`` field to the model's search view (first +position is recommended): + +.. code:: xml + + + + your.model + + + + + + + + + +You can also use the ``full_text`` operator in your own code to search +for records: + +.. code:: python + + records = self.env['your.model'].search([('full_text', '@@', 'search query')]) + +The search query uses the PostgreSQL websearch syntax with additional +prefix matching: + +- unquoted text will be ANDed (``&``) +- quoted text will be searched for with followed by operator (``<->``) +- text around the OR keyword will be ORed (``|``) +- a dash ``-`` will be treated as a negation operator (``!``) +- every term will be treated as a prefix match (``:*``) + +The results will be sorted by relevance if no other sort order is +specified. + +By default, the ``full_text`` field is computed at database level using +a generated field (PostgreSQL 12+). + +For more fine grained control, you can also use a compute to generate +the ``full_text`` field value yourself. + +The usage is as follows: + +.. code:: python + + from odoo import fields, models + + class YourModel(models.Model): + _name = 'your.model' + + full_text = fields.Searchable( + "Full Text", + fields={ + "name": "A", + "description": "B", + "notes": "C", + }, + dictionary="english", + compute="_compute_full_text", + store=True, + ) + + @api.depends('name', 'description', 'notes') + def _compute_full_text(self): + for record in self: + record.full_text = { + "name": record.name, + "description": record.description, + "notes": " ".join(record.relation_ids.mapped('name')), + } + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +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. + +.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +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/full_text_search/__init__.py b/full_text_search/__init__.py new file mode 100644 index 00000000000..371ce5d136b --- /dev/null +++ b/full_text_search/__init__.py @@ -0,0 +1,3 @@ +from .hooks import post_load +from . import fields +from . import models diff --git a/full_text_search/__manifest__.py b/full_text_search/__manifest__.py new file mode 100644 index 00000000000..bf28c1cda0f --- /dev/null +++ b/full_text_search/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Full Text Search", + "summary": "Adds full text search capabilities to Odoo", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "category": "Tools", + "website": "https://github.com/OCA/server-tools", + "author": "Akretion, Odoo Community Association (OCA)", + "depends": ["web"], + "data": ["views/assets.xml"], + "maintainers": ["paradoxxxzero"], + "installable": True, + "post_load": "post_load", +} diff --git a/full_text_search/fields.py b/full_text_search/fields.py new file mode 100644 index 00000000000..413e8d5949e --- /dev/null +++ b/full_text_search/fields.py @@ -0,0 +1,237 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import re + +from psycopg2.extensions import AsIs, QuotedString + +from odoo import fields +from odoo.fields import resolve_mro +from odoo.tools import pycompat + +_logger = logging.getLogger(__name__) + + +class Searchable(fields.Field): + type = "tsvector" + column_type = ("tsvector", "tsvector") + readonly = True + copy = False + fields = None + dictionary = "english" + + def _get_attrs(self, model, name): + attrs = super()._get_attrs(model, name) + attrs.pop("fields_add", None) + return attrs + + def _setup_attrs(self, model, name): + super()._setup_attrs(model, name) + + # Set up fields (with fields and fields_add) + values = None + for field in reversed(resolve_mro(model, name, self._can_setup_from)): + if "fields" in field.args: + fields = field.args["fields"] + if not isinstance(fields, dict) or not fields: + raise ValueError( + "%s: fields=%r must be a dict of field name/weight pairs" + % (self, fields) + ) + if values is not None and values != fields: + _logger.warning( + "%s: fields=%r overrides existing fields; use fields_add instead", + self, + fields, + ) + values = {**fields} + + if "fields_add" in field.args: + fields_add = field.args["fields_add"] + assert isinstance( + fields_add, dict + ), "%s: fields_add=%r must be a dict" % (self, fields_add) + assert ( + values is not None + ), "%s: fields_add=%r on no defined fields %r" % ( + self, + fields_add, + self.fields, + ) + + values = {**values, **fields_add} + + if values is not None: + self.fields = {key: val for key, val in values.items() if val is not None} + + available_languages = self._fetch_languages(model) + if self.dictionary not in available_languages: + _logger.warning( + f"Dictionary '{self.dictionary}' not found, falling back to 'simple'" + ) + self.dictionary = "simple" + + def _fetch_definition(self, model): + """Fetch the definition of the tsvector column from the database.""" + cr = model._cr + cr.execute( + "SELECT pg_get_expr(d.adbin, d.adrelid) " + "FROM pg_attribute a JOIN pg_attrdef d ON " + "a.attrelid = d.adrelid AND a.attnum = d.adnum " + "WHERE a.attname = %s AND d.adrelid = %s::regclass", + (self.name, model._table), + ) + result = cr.fetchone() + return result[0] if result else None + + def _fetch_languages(self, model): + """Fetch the available languages from the database.""" + cr = model._cr + cr.execute("SELECT cfgname FROM pg_ts_config") + return [row[0] for row in cr.fetchall()] + + def _create_column(self, model): + """Create the tsvector column in the database.""" + model._cr.execute( + f"ALTER TABLE {model._table} ADD COLUMN {self.name} tsvector" + + ( + f" GENERATED ALWAYS AS ({self.get_vector_def(model)}) STORED" + if not self.compute + else "" + ), + ) + + def _drop_column(self, model): + """Drop the tsvector column from the database.""" + model._cr.execute( + f"ALTER TABLE {model._table} DROP COLUMN IF EXISTS {self.name}", + ) + + def _create_index(self, model): + """Create the index on the tsvector column.""" + index_name = f"{model._table}_{self.name}_index" + model._cr.execute( + f"CREATE INDEX IF NOT EXISTS {index_name} " + f"ON {model._table} USING GIN ({self.name})", + ) + + def _should_drop_column(self, model, column): + # Drop column if it exists and has the wrong type + if column["udt_name"] != self.column_type[0]: + return True + + # Get the current vector expression + definition = self._fetch_definition(model) + if not definition: + if not self.compute: + _logger.warning( + f"Recreating column {self.name} for model {model._name} " + f"since it does not have an expression and compute is disabled" + ) + return True + return False + + if definition and self.compute: + _logger.warning( + f"Recreating column {self.name} for model {model._name} " + f"since it has an expression and compute is enabled" + ) + return True + + existing_fields = self.get_weighted_field_from_def(definition) + language = self.get_language_from_def(definition) + + if existing_fields != self.fields: + _logger.info( + f"Recreating column {self.name} for model {model._name} " + f"following field changes {existing_fields} -> {self.fields}" + ) + return True + if language != self.dictionary: + _logger.info( + f"Recreating column {self.name} for model {model._name} " + f"following language change {language} -> {self.dictionary}" + ) + return True + + def update_db_column(self, model, column): + if column: + if self._should_drop_column(model, column): + self._drop_column(model) + column = None + + # Add generated column + if not column: + self._create_column(model) + + self._create_index(model) + + def get_weighted_field_def(self, field, weight, raw=False): + """Return the weighted field definition for the tsvector column.""" + if not raw: + field = f"coalesce({field}, '')" + return ( + f"setweight(to_tsvector({self.dictionary!r}::regconfig, " # noqa:E231 + f"{field}), '{weight}')" + ) + + def get_weighted_field_from_def(self, definition): + """Return the weighted field definition from the tsvector column definition.""" + existing_fields = {} + for field, weight in re.findall( + r"setweight\(to_tsvector\('\w+'(?:::[^,]+)?, \(?COALESCE\((.+?)" + r", ''(?:::[^)]+)?\)\)?(?:::[^)]+)?\), '(\w+)'(?:::[^)]+)?", + definition, + ): + existing_fields[field] = weight + return existing_fields + + def get_language_from_def(self, definition): + """Return the language from the tsvector column definition.""" + match = re.search(r"to_tsvector\('(\w+)'", definition) + return match.group(1) if match else None + + def get_vector_def(self, model): + """Return the vector definition for the tsvector column.""" + return " || ".join( + [ + self.get_weighted_field_def(field, weight) + for field, weight in self.fields.items() + ] + ) + + def _convert_value(self, record, field_name, value): + field = record._fields.get(field_name) + if field: + value = field.convert_to_write(value, record) + value = field.convert_to_column(value, record) + # If no field found, assume dynamic string value + value = QuotedString(value) + value.encoding = "utf-8" + value = pycompat.to_text(value.getquoted()) + return value + + def _dict_to_tsvector(self, dct, record): + return AsIs( + " || ".join( + [ + self.get_weighted_field_def( + self._convert_value(record, field, value), + self.fields[field], + True, + ) + for field, value in dct.items() + if field in self.fields and value + ] + ) + ) + + def convert_to_column(self, value, record, values=None, validate=True): + if isinstance(value, dict): + return self._dict_to_tsvector(value, record) + return super().convert_to_column(value, record, values, validate) + + +fields.Searchable = Searchable diff --git a/full_text_search/hooks.py b/full_text_search/hooks.py new file mode 100644 index 00000000000..b38049adbff --- /dev/null +++ b/full_text_search/hooks.py @@ -0,0 +1,30 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from functools import wraps + +from odoo.osv import expression + +from .utils import to_tsquery + + +def patch_leaf_to_sql(original): + @wraps(original) + def _wrapper(self, leaf, model, alias): + left, operator, right = leaf + query, params = original(self, leaf, model, alias) + + if operator == "@@" and params: + field = model._fields[left] + params[0] = to_tsquery(params[0], field.dictionary) + return query, params + + return _wrapper + + +def post_load(): + # Add full text search operator + expression.TERM_OPERATORS += ("@@",) + expression.expression._expression__leaf_to_sql = patch_leaf_to_sql( + expression.expression._expression__leaf_to_sql + ) diff --git a/full_text_search/models/__init__.py b/full_text_search/models/__init__.py new file mode 100644 index 00000000000..0e44449338c --- /dev/null +++ b/full_text_search/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/full_text_search/models/base.py b/full_text_search/models/base.py new file mode 100644 index 00000000000..cd24fc5aa79 --- /dev/null +++ b/full_text_search/models/base.py @@ -0,0 +1,49 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + +from ..utils import to_tsquery + + +class Base(models.AbstractModel): + _inherit = "base" + + @api.model + def _search( + self, + args, + offset=0, + limit=None, + order=None, + count=False, + access_rights_uid=None, + ): + query = super()._search( + args, + offset=offset, + limit=limit, + order=order, + count=count, + access_rights_uid=access_rights_uid, + ) + if not order and not count: + # If no order is specified and a full text search argument is found + # order by the full text search rank + for arg in args: + if isinstance(arg, (tuple, list)) and arg[1] == "@@": + field = self._fields.get(arg[0]) + if field: + if field.inherited: + field = field.base_field + if field.store and field.column_type: + qualifield_name = self._inherits_join_calc( + self._table, arg[0], query + ) + query.order = ( + f"ts_rank_cd({qualifield_name}, " + f"{to_tsquery(arg[2], field.dictionary)}) desc, " + f"{query.order}" + ) + return query diff --git a/full_text_search/readme/CONTRIBUTORS.md b/full_text_search/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..328a37da87c --- /dev/null +++ b/full_text_search/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/full_text_search/readme/DESCRIPTION.md b/full_text_search/readme/DESCRIPTION.md new file mode 100644 index 00000000000..78b226939e8 --- /dev/null +++ b/full_text_search/readme/DESCRIPTION.md @@ -0,0 +1,87 @@ +This module provides a simple way to use full-text search functionality in Odoo models. + +It is fully based on [PostgreSQL full-text search](https://www.postgresql.org/docs/current/textsearch.html), and adds a new `Searchable` field that represents the TSVector column in the database that will store the weighted full-text vector. It also adds the `full_text` matching operator : `@@` to odoo domains. + +To add this functionality to a model, simply add a `Searchable` field to the model with the fields you want to search on weighted by their importance: `A`, `B`, `C`, `D` with `A` being the most important. + +```python +from odoo import fields, models + +class YourModel(models.Model): + _name = 'your.model' + + full_text = fields.Searchable( + "Full Text", + fields={ + "name": "A", + "description": "B", + "notes": "C", + }, + dictionary="english", + ) +``` + +And add the `full_text` field to the model's search view (first position is recommended): +```xml + + + your.model + + + + + + + + +``` + +You can also use the `full_text` operator in your own code to search for records: + +```python + records = self.env['your.model'].search([('full_text', '@@', 'search query')]) +``` + +The search query uses the PostgreSQL websearch syntax with additional prefix matching: + + - unquoted text will be ANDed (`&`) + - quoted text will be searched for with followed by operator (`<->`) + - text around the OR keyword will be ORed (`|`) + - a dash `-` will be treated as a negation operator (`!`) + - every term will be treated as a prefix match (`:*`) + +The results will be sorted by relevance if no other sort order is specified. + +By default, the `full_text` field is computed at database level using a generated field (PostgreSQL 12+). + +For more fine grained control, you can also use a compute to generate the `full_text` field value yourself. + +The usage is as follows: + +```python +from odoo import fields, models + +class YourModel(models.Model): + _name = 'your.model' + + full_text = fields.Searchable( + "Full Text", + fields={ + "name": "A", + "description": "B", + "notes": "C", + }, + dictionary="english", + compute="_compute_full_text", + store=True, + ) + +@api.depends('name', 'description', 'notes') +def _compute_full_text(self): + for record in self: + record.full_text = { + "name": record.name, + "description": record.description, + "notes": " ".join(record.relation_ids.mapped('name')), + } +``` diff --git a/full_text_search/static/description/index.html b/full_text_search/static/description/index.html new file mode 100644 index 00000000000..2a0d512b3a5 --- /dev/null +++ b/full_text_search/static/description/index.html @@ -0,0 +1,520 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + + +
+ + diff --git a/full_text_search/static/src/js/tsvector.js b/full_text_search/static/src/js/tsvector.js new file mode 100644 index 00000000000..9aab0b7fdd2 --- /dev/null +++ b/full_text_search/static/src/js/tsvector.js @@ -0,0 +1,11 @@ +/* + Copyright 2026 Akretion (http://www.akretion.com). + @author Florian Mounier + License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +*/ +odoo.define("full_text_search.tsvector", function (require) { + "use strict"; + + const field_utils = require("web.field_utils"); + field_utils.parse.tsvector = (x) => x; +}); diff --git a/full_text_search/tests/__init__.py b/full_text_search/tests/__init__.py new file mode 100644 index 00000000000..c921ee987ac --- /dev/null +++ b/full_text_search/tests/__init__.py @@ -0,0 +1 @@ +from . import test_full_text_search diff --git a/full_text_search/tests/models.py b/full_text_search/tests/models.py new file mode 100644 index 00000000000..24a4ab39dbb --- /dev/null +++ b/full_text_search/tests/models.py @@ -0,0 +1,71 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint: disable=consider-merging-classes-inherited +from odoo import api, fields, models + + +class ResPartnerBase(models.Model): + _inherit = "res.partner" + + full_text = fields.Searchable( + "Full Text", + fields={ + "name": "A", + "street": "B", + "city": "C", + }, + ) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + full_text = fields.Searchable( + fields_add={ + "email": "B", + "street": "C", + "commercial_company_name": "D", + }, + ) + + +class ResUsers(models.Model): + _inherit = "res.users" + + full_text = fields.Searchable( + "Full Text", + fields={ + "partner_info": "A", + "login": "B", + "signature": "D", + }, + dictionary="finnish", + compute="_compute_full_text", + store=True, + ) + + @api.depends( + "login", + "signature", + "partner_id.name", + "partner_id.city", + "partner_id.street", + ) + def _compute_full_text(self): + for record in self: + record.full_text = { + "login": record.login, + "signature": record.signature, + "partner_info": " ".join( + [ + info + for info in ( + record.partner_id.name, + record.partner_id.city, + record.partner_id.street, + ) + if info + ] + ), + } diff --git a/full_text_search/tests/test_full_text_search.py b/full_text_search/tests/test_full_text_search.py new file mode 100644 index 00000000000..ace50481321 --- /dev/null +++ b/full_text_search/tests/test_full_text_search.py @@ -0,0 +1,307 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo_test_helper import FakeModelLoader + +from odoo import fields +from odoo.tests import SavepointCase + + +class TestFullTextSearch(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + + from .models import ResPartner, ResPartnerBase, ResUsers + + cls.loader.update_registry((ResPartnerBase, ResPartner, ResUsers)) + + cls.partner_1 = cls.env["res.partner"].create( + { + "name": "Denis Oryoz", + "email": "denis.oryoz@example.com", + "city": "Paris", + "street": "12 Rue de la Liberté", + } + ) + cls.partner_2 = cls.env["res.partner"].create( + { + "name": "Vincent Dupont", + "email": "vincent.dupont@example.com", + "city": "Lille", + "street": "3 Rue de la Paix", + } + ) + cls.partner_3 = cls.env["res.partner"].create( + { + "name": "Vincenzo D'Agostino", + "email": "vincenzo.dagostino@example.com", + "city": "Roma", + "street": "5 Via della Repubblica", + } + ) + cls.partner_4 = cls.env["res.partner"].create( + { + "name": "Roman Sanchez", + "email": "roman.sanchez@example.com", + "city": "Marseille", + "street": "10 Rue de la Liberté", + } + ) + + cls.user_1 = cls.env["res.users"].create( + { + "login": "User yksi", + "partner_id": cls.partner_1.id, + } + ) + cls.user_2 = cls.env["res.users"].create( + { + "login": "User kaksi", + "signature": "Dolor sit amet", + "partner_id": cls.partner_2.id, + } + ) + cls.user_3 = cls.env["res.users"].create( + { + "login": "User kolme", + "partner_id": cls.partner_3.id, + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def test_fields_definition(self): + field = self.env["res.partner"]._fields["full_text"] + self.assertEqual( + field.fields, + { + "name": "A", + "email": "B", + "city": "C", + "street": "C", + "commercial_company_name": "D", + }, + ) + + def test_definition_lookup(self): + field = self.env["res.partner"]._fields["full_text"] + definition = field._fetch_definition(self.env["res.partner"]) + fields = field.get_weighted_field_from_def(definition) + self.assertEqual( + fields, + { + "name": "A", + "email": "B", + "city": "C", + "street": "C", + "commercial_company_name": "D", + }, + ) + + def test_other_definition_lookups_1(self): + field = self.env["res.partner"]._fields["full_text"] + self.assertEqual( + field.get_weighted_field_from_def( + "((((setweight(to_tsvector('english'::regconfig, " + "(COALESCE(name, ''::character varying))::text), 'A'::\"char\") " + "|| setweight(to_tsvector('english'::regconfig, " + "(COALESCE(street, ''::character varying))::text), 'C'::\"char\")) " + "|| setweight(to_tsvector('english'::regconfig, " + "(COALESCE(city, ''::character varying))::text), 'C'::\"char\")) " + "|| setweight(to_tsvector('english'::regconfig, " + "(COALESCE(email, ''::character varying))::text), 'B'::\"char\")) " + "|| setweight(to_tsvector('english'::regconfig, " + "(COALESCE(commercial_company_name, ''::character varying))::text), " + "'D'::\"char\"))" + ), + { + "name": "A", + "email": "B", + "city": "C", + "street": "C", + "commercial_company_name": "D", + }, + ) + + def test_other_definition_lookups_2(self): + field = self.env["res.partner"]._fields["full_text"] + self.assertEqual( + field.get_weighted_field_from_def( + "((((setweight(to_tsvector('basque'::regconfig, " + "(COALESCE(name, ''::text))::text), 'A'::\"char\") " + "|| setweight(to_tsvector('basque'::regconfig, " + "(COALESCE(street, ''::\"char\"))), 'C'::\"char\")) " + "|| setweight(to_tsvector('basque'::regconfig, " + "(COALESCE(city, ''::character varying))::character varying), " + "'C'::\"char\")) " + "|| setweight(to_tsvector('basque'::regconfig, " + "COALESCE(email, '')::\"char\"), 'B')) " + ), + { + "name": "A", + "email": "B", + "city": "C", + "street": "C", + }, + ) + + def test_other_definition_lookups_3(self): + field = self.env["res.partner"]._fields["full_text"] + self.assertEqual( + field.get_weighted_field_from_def(""), + {}, + ) + + def test_other_definition_lookups_4(self): + field = self.env["res.partner"]._fields["full_text"] + self.assertEqual( + field.get_weighted_field_from_def( + "setweight(to_tsvector('russian', COALESCE(name, '')), 'A')" + ), + { + "name": "A", + }, + ) + + def test_fetch_languages(self): + field = self.env["res.partner"]._fields["full_text"] + languages = field._fetch_languages(self.env["res.partner"]) + self.assertIn("simple", languages) + + def test_field_language_1(self): + field = self.env["res.partner"]._fields["full_text"] + definition = field._fetch_definition(self.env["res.partner"]) + self.assertEqual(field.dictionary, "english") + self.assertEqual(field.get_language_from_def(definition), "english") + + def test_fields_definition_generation(self): + field = fields.Searchable() + field.fields = { + "title": "A", + "label": "B", + } + field.dictionary = "yiddish" + + self.assertEqual( + field.get_vector_def(None), + "setweight(to_tsvector('yiddish'::regconfig, coalesce(title, '')), 'A') " + "|| setweight(to_tsvector('yiddish'::regconfig, coalesce(label, '')), 'B')", + ) + + def test_search_1(self): + partners = self.env["res.partner"].search([("full_text", "@@", "Denis")]) + self.assertEqual(partners, self.partner_1) + + def test_search_2(self): + partners = self.env["res.partner"].search([("full_text", "@@", "Vinc")]) + self.assertEqual(partners, self.partner_2 | self.partner_3) + + def test_search_3(self): + partners = self.env["res.partner"].search([("full_text", "@@", "Roma")]) + self.assertEqual(partners, self.partner_4 | self.partner_3) + + def test_search_4(self): + partners = self.env["res.partner"].search([("full_text", "@@", '"de la"')]) + self.assertEqual(partners, self.partner_1 | self.partner_2 | self.partner_4) + + def test_search_5(self): + partners = self.env["res.partner"].search([("full_text", "@@", '"de la" San')]) + self.assertEqual(partners, self.partner_4) + + def test_search_6(self): + partners = self.env["res.partner"].search([("full_text", "@@", "Vinc -Dupo")]) + self.assertEqual(partners, self.partner_3) + + def test_search_7(self): + partners = self.env["res.partner"].search( + [("full_text", "@@", "Marseille or Denis")] + ) + self.assertEqual(partners, self.partner_1 | self.partner_4) + + def test_search_8(self): + partners = self.env["res.partner"].search( + [("full_text", "@@", "roma")], + ) + self.assertEqual(partners, self.partner_3 | self.partner_4) + + def test_search_9(self): + partners = self.env["res.partner"].search([("full_text", "@@", "vinz'")]) + self.assertFalse(partners) + + def test_search_10(self): + partners = self.env["res.partner"].search( + [ + ( + "full_text", + "@@", + "'), ''' ', ''':*')::tsquery FROM res_partner; " + "DELETE FROM res_partner; --", + ) + ] + ) + self.assertFalse(partners) + self.assertTrue(self.env["res.partner"].search_count([]) > 0) + + def test_search_order_rank(self): + partners = self.env["res.partner"].search( + [("full_text", "@@", "vincen or deni or roma")] + ) + self.assertEqual( + partners, self.partner_1 | self.partner_2 | self.partner_3 | self.partner_4 + ) + # Partner should be ordered by rank + self.assertEqual( + partners.ids, + [ + self.partner_3.id, + self.partner_1.id, + self.partner_4.id, + self.partner_2.id, + ], + ) + + def test_search_order_default(self): + partners = self.env["res.partner"].search( + [("full_text", "@@", "vincen or deni or roma")], + order=self.env["res.partner"]._order, + ) + self.assertEqual( + partners, self.partner_1 | self.partner_2 | self.partner_3 | self.partner_4 + ) + self.assertEqual( + partners.ids, + [ + self.partner_1.id, + self.partner_4.id, + self.partner_2.id, + self.partner_3.id, + ], + ) + + def test_search_count(self): + partners_count = self.env["res.partner"].search_count( + [("full_text", "@@", "a")] + ) + self.assertEqual( + partners_count, + len(self.env["res.partner"].search([("full_text", "@@", "a")])), + ) + + def test_search_computed_1(self): + users = self.env["res.users"].search([("full_text", "@@", "kaksi")]) + self.assertEqual(users, self.user_2) + + def test_search_computed_2(self): + users = self.env["res.users"].search([("full_text", "@@", "amet")]) + self.assertEqual(users, self.user_2) + + def test_search_computed_3(self): + users = self.env["res.users"].search([("full_text", "@@", "Repubblica")]) + self.assertEqual(users, self.user_3) diff --git a/full_text_search/utils.py b/full_text_search/utils.py new file mode 100644 index 00000000000..41a22d8ba36 --- /dev/null +++ b/full_text_search/utils.py @@ -0,0 +1,19 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from psycopg2.extensions import AsIs, QuotedString + +from odoo.tools import pycompat + + +def to_tsquery(text, lang): + text = QuotedString(text) + text.encoding = "utf-8" + text = pycompat.to_text(text.getquoted()) + return AsIs( + "replace(" + f"websearch_to_tsquery({lang!r}::regconfig, " # noqa:E231 + f"{text})::text " # noqa:E231 + "|| ' ', ''' ', ''':*'" + ")::tsquery" + ) diff --git a/full_text_search/views/assets.xml b/full_text_search/views/assets.xml new file mode 100644 index 00000000000..b967066d8c3 --- /dev/null +++ b/full_text_search/views/assets.xml @@ -0,0 +1,20 @@ + + + +