diff --git a/full_text_search/README.rst b/full_text_search/README.rst new file mode 100644 index 00000000000..27a33d6ba69 --- /dev/null +++ b/full_text_search/README.rst @@ -0,0 +1,185 @@ +================ +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/licence-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/18.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-18-0/server-tools-18-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=18.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..d4a567087fb --- /dev/null +++ b/full_text_search/__manifest__.py @@ -0,0 +1,23 @@ +# 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": "18.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": [], + "maintainers": ["paradoxxxzero"], + "installable": True, + "assets": { + "web.assets_backend": [ + "full_text_search/static/src/**/*", + ], + }, + "post_load": "post_load", +} diff --git a/full_text_search/fields.py b/full_text_search/fields.py new file mode 100644 index 00000000000..498599fdf97 --- /dev/null +++ b/full_text_search/fields.py @@ -0,0 +1,239 @@ +# 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 + +_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_class, name): + attrs = super()._get_attrs(model_class, name) + attrs.pop("fields_add", None) + return attrs + + def _setup_attrs(self, model_class, name): + rv = super()._setup_attrs(model_class, name) + if not self._base_fields: + return + + # Set up fields (with fields and fields_add) + values = None + for field in self._base_fields: + if "fields" in field._args__: + fields = field._args__["fields"] + if not isinstance(fields, dict) or not fields: + raise ValueError( + f"{self}: fields={fields} must be a dict of field " + "name/weight pairs" + ) + 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 + ), f"{self}: fields_add={fields_add} must be a dict" + assert values is not None, ( + f"{self}: fields_add={fields_add} on no defined " + f"fields {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} + + return rv + + 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): + 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" + + 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 = value.getquoted().decode("utf-8") + + 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..e70d34a4c42 --- /dev/null +++ b/full_text_search/hooks.py @@ -0,0 +1,12 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.osv import expression +from odoo.tools.sql import SQL + + +def post_load(): + # Add full text search operator + expression.TERM_OPERATORS += ("@@",) + expression.SQL_OPERATORS.update({"@@": 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..97347ad8869 --- /dev/null +++ b/full_text_search/models/base.py @@ -0,0 +1,52 @@ +# 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 odoo.tools.sql import SQL + +from ..utils import to_tsquery + + +class Base(models.AbstractModel): + _inherit = "base" + + def _condition_to_sql(self, alias, fname, operator, value, query): + if operator == "@@": + field = self._fields[fname] + value = to_tsquery(value, field.dictionary) + + return super()._condition_to_sql(alias, fname, operator, value, query) + + @api.model + def _search(self, domain, offset=0, limit=None, order=None): + query = super()._search( + domain, + offset=offset, + limit=limit, + order=order, + ) + + if (not order or order == self._order) and query.order: + # If no order is specified and a full text search argument is found + # order by the full text search rank + for leaf in domain: + if isinstance(leaf, tuple | list) and leaf[1] == "@@": + field_name, _, value = leaf + field = self._fields.get(field_name) + if field: + if field.inherited: + field = field.base_field + if field.store and field.column_type: + alias = self._table + sql_field = self._field_to_sql(alias, field_name, query) + query.order = SQL(", ").join( + [ + SQL( + "ts_rank_cd(%(sql_field)s, %(tsquery)s) desc", + sql_field=sql_field, + tsquery=to_tsquery(value, field.dictionary), + ), + query.order, + ] + ) + return query diff --git a/full_text_search/pyproject.toml b/full_text_search/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/full_text_search/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" 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..68c7790818c --- /dev/null +++ b/full_text_search/static/description/index.html @@ -0,0 +1,514 @@ + + + + + +Full Text Search + + + + + + diff --git a/full_text_search/static/src/views/fields/tsvector.esm.js b/full_text_search/static/src/views/fields/tsvector.esm.js new file mode 100644 index 00000000000..003198e9919 --- /dev/null +++ b/full_text_search/static/src/views/fields/tsvector.esm.js @@ -0,0 +1,8 @@ +/* + Copyright 2026 Akretion (http://www.akretion.com). + @author Florian Mounier + License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +*/ +import {registry} from "@web/core/registry"; + +registry.category("parsers").add("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..133c0a514e3 --- /dev/null +++ b/full_text_search/tests/models.py @@ -0,0 +1,69 @@ +# 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( + 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( + 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..0ba70caa1e2 --- /dev/null +++ b/full_text_search/tests/test_full_text_search.py @@ -0,0 +1,317 @@ +# 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 TransactionCase + + +class TestFullTextSearch(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + ) + ) + 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() + + @classmethod + def check_attrs(cls): + pass + + 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_regular(self): + partners = self.env["res.partner"].search( + [("full_text", "@@", "vincen or deni or roma")], + order="name desc", + ) + self.assertEqual( + partners, self.partner_1 | self.partner_2 | self.partner_3 | self.partner_4 + ) + self.assertEqual( + partners.ids, + [ + self.partner_3.id, + self.partner_2.id, + self.partner_4.id, + self.partner_1.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..3c0a07595d2 --- /dev/null +++ b/full_text_search/utils.py @@ -0,0 +1,14 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tools.sql import SQL + + +def to_tsquery(text, lang): + return SQL( + "replace(websearch_to_tsquery(%(lang)s::regconfig, " + "%(text)s)::text || ' ', ''' ', ''':*')::tsquery", + lang=lang, + text=text, + )