changeset 03ec47b65b78 in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset&node=03ec47b65b78
description:
Add full text and similarity search
issue10023
review320601007
diffstat:
CHANGELOG | 1 +
doc/ref/models/fields.rst | 51 ++++++-
doc/ref/tools/misc.rst | 10 +
trytond/backend/database.py | 25 +++
trytond/backend/postgresql/database.py | 171 +++++++++++++++++++++-
trytond/backend/sqlite/database.py | 1 +
trytond/ir/lang.py | 3 +
trytond/model/fields/__init__.py | 9 +-
trytond/model/fields/char.py | 103 +++++++++++++-
trytond/model/fields/field.py | 34 ++-
trytond/model/fields/text.py | 83 +++++++++++
trytond/tests/field_text.py | 7 +
trytond/tests/test_field_char.py | 83 ++++++----
trytond/tests/test_field_dict.py | 6 +-
trytond/tests/test_field_text.py | 242 +++++++++++++++++++++++++++++---
trytond/tests/test_tools.py | 20 ++-
trytond/tests/test_tryton.py | 36 ++++
trytond/tools/__init__.py | 5 +-
trytond/tools/misc.py | 14 +
19 files changed, 806 insertions(+), 98 deletions(-)
diffs (1302 lines):
diff -r 130d98839af1 -r 03ec47b65b78 CHANGELOG
--- a/CHANGELOG Sun Mar 21 16:09:13 2021 +0100
+++ b/CHANGELOG Sun Mar 21 16:21:05 2021 +0100
@@ -1,3 +1,4 @@
+* Add full text and similarity search and FullText field
* Add URL type to icon
* Use instance method to get next sequence value
* Store the sequence type directly on sequence
diff -r 130d98839af1 -r 03ec47b65b78 doc/ref/models/fields.rst
--- a/doc/ref/models/fields.rst Sun Mar 21 16:09:13 2021 +0100
+++ b/doc/ref/models/fields.rst Sun Mar 21 16:21:05 2021 +0100
@@ -268,7 +268,15 @@
A single line string field.
-:class:`Char` has two extra optional arguments:
+Search by similarity is used for the ``ilike`` operator and
+:meth:`~trytond.tools.is_full_text` value if the backend supports it.
+The similarity threshold is defined for the context key ``<model name>.<field
+name>.search_similarity`` (default value is ``0.3``).
+
+The field is ordered using the similarity with the context value from the key
+``<model name>.<field name>.order`` if it is set.
+
+:class:`Char` has some extra optional arguments:
.. attribute:: Char.size
@@ -301,6 +309,26 @@
The database backend must supports unaccented search.
+.. attribute:: Char.search_full_text
+
+ If this attribute is set to True, ``ilike`` searches with an
+ :meth:`~trytond.tools.is_full_text` value use the full text search of the
+ backend.
+ The default value is False.
+
+ The context can be used to force the full text search behaviour.
+ This is done using the key ``<model name>.<field name>.search_full_text``.
+ If True, the full text search is used no matter what the value.
+ If False, no full text search is peformed.
+
+ The full text ranking value is added to the similarity if the
+ ``search_full_text`` is True.
+
+.. note::
+
+ The database backend must support full text search otherwise ``ilike`` is
+ always used.
+
Text
----
@@ -308,7 +336,7 @@
A multi line string field.
-:class:`Text` has two extra optional arguments:
+:class:`Text` has four extra optional arguments:
.. attribute:: Text.size
@@ -318,6 +346,25 @@
Same as :attr:`Char.translate`
+.. attribute:: Text.search_unaccented
+
+ Same as :attr:`Char.search_unaccented`
+
+.. attribute:: Text.search_full_text
+
+ Same as :attr:`Char.search_full_text`
+ The default value is True.
+
+FullText
+--------
+
+.. class:: FullText(\**options)
+
+An internal field to store a list of parsed strings ordered by weights.
+The field is ordered using the full text ranking with the context value from
+the key `<model name>.<field name>.order`` if it is set.
+
+
Float
-----
diff -r 130d98839af1 -r 03ec47b65b78 doc/ref/tools/misc.rst
--- a/doc/ref/tools/misc.rst Sun Mar 21 16:09:13 2021 +0100
+++ b/doc/ref/tools/misc.rst Sun Mar 21 16:21:05 2021 +0100
@@ -9,6 +9,16 @@
Resolve a dotted name to a global object.
+.. method:: unescape_wildcard(string[, wildcards[, escape]])
+
+Return the string without the wild card escapes.
+
+.. method:: is_full_text(value[, escape])
+
+Determine if the value can be used as full text search.
+This is the case when the value starts and ends with a ``%`` or does not
+contain any wild cards.
+
.. method:: sql_pairing(x, y)
Return an SQL expression that pairs SQL integers x and y.
diff -r 130d98839af1 -r 03ec47b65b78 trytond/backend/database.py
--- a/trytond/backend/database.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/backend/database.py Sun Mar 21 16:21:05 2021 +0100
@@ -181,6 +181,31 @@
"Return the expression to use for unaccentuated columns"
return value
+ def has_similarity(self):
+ "Return if database supports similarity"
+ return False
+
+ def similarity(self, column, value):
+ raise NotImplementedError
+
+ def has_search_full_text(self):
+ "Return if database supports full text search"
+ return False
+
+ def format_full_text(self, *documents, language=None):
+ return '\n'.join(documents)
+
+ def format_full_text_query(self, query, language=None):
+ raise NotImplementedError
+
+ def search_full_text(self, document, query):
+ "Return the clause expression for searching document against query"
+ raise NotImplementedError
+
+ def rank_full_text(self, document, query, normalize=None):
+ "Return the expression that ranks query on document"
+ raise NotImplementedError
+
@classmethod
def has_sequence(cls):
"Return if database supports sequence querying and assignation"
diff -r 130d98839af1 -r 03ec47b65b78 trytond/backend/postgresql/database.py
--- a/trytond/backend/postgresql/database.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/backend/postgresql/database.py Sun Mar 21 16:21:05 2021 +0100
@@ -8,6 +8,7 @@
import json
from datetime import datetime
from decimal import Decimal
+from itertools import chain, repeat
from threading import RLock
try:
@@ -31,9 +32,10 @@
from psycopg2 import ProgrammingError
from psycopg2.extras import register_default_json, register_default_jsonb
-from sql import Flavor, Cast, For
+from sql import Flavor, Cast, For, Table
+from sql.conditionals import Coalesce
from sql.functions import Function
-from sql.operators import BinaryOperator
+from sql.operators import BinaryOperator, Concat
from trytond.backend.database import DatabaseInterface, SQLType
from trytond.config import config, parse_uri
@@ -80,6 +82,55 @@
_function = 'unaccent'
+class Similarity(Function):
+ __slots__ = ()
+ _function = 'similarity'
+
+
+class Match(BinaryOperator):
+ __slots__ = ()
+ _operator = '@@'
+
+
+class ToTsvector(Function):
+ __slots__ = ()
+ _function = 'to_tsvector'
+
+
+class Setweight(Function):
+ __slots__ = ()
+ _function = 'setweight'
+
+
+class TsQuery(Function):
+ __slots__ = ()
+
+
+class ToTsQuery(TsQuery):
+ __slots__ = ()
+ _function = 'to_tsquery'
+
+
+class PlainToTsQuery(TsQuery):
+ __slots__ = ()
+ _function = 'plainto_tsquery'
+
+
+class PhraseToTsQuery(TsQuery):
+ __slots__ = ()
+ _function = 'phraseto_tsquery'
+
+
+class WebsearchToTsQuery(TsQuery):
+ __slots__ = ()
+ _function = 'websearch_to_tsquery'
+
+
+class TsRank(Function):
+ __slots__ = ()
+ _function = 'ts_rank'
+
+
class AdvisoryLock(Function):
_function = 'pg_advisory_xact_lock'
@@ -141,7 +192,8 @@
_current_user = None
_has_returning = None
_has_select_for_skip_locked = None
- _has_unaccent = {}
+ _has_proc = defaultdict(dict)
+ _search_full_text_languages = defaultdict(dict)
flavor = Flavor(ilike=True)
TYPES_MAPPING = {
@@ -151,6 +203,7 @@
'BLOB': SQLType('BYTEA', 'BYTEA'),
'DATETIME': SQLType('TIMESTAMP', 'TIMESTAMP(0)'),
'TIMESTAMP': SQLType('TIMESTAMP', 'TIMESTAMP(6)'),
+ 'FULLTEXT': SQLType('TSVECTOR', 'TSVECTOR'),
}
def __new__(cls, name=_default_name):
@@ -468,21 +521,117 @@
def has_sequence(cls):
return True
- def has_unaccent(self):
- if self.name in self._has_unaccent:
- return self._has_unaccent[self.name]
+ def has_proc(self, name):
+ if name in self._has_proc[self.name]:
+ return self._has_proc[self.name][name]
connection = self.get_connection()
- unaccent = False
+ result = False
try:
cursor = connection.cursor()
cursor.execute(
"SELECT 1 FROM pg_proc WHERE proname=%s",
- (Unaccent._function,))
- unaccent = bool(cursor.rowcount)
+ (name,))
+ result = bool(cursor.rowcount)
finally:
self.put_connection(connection)
- self._has_unaccent[self.name] = unaccent
- return unaccent
+ self._has_proc[self.name][name] = result
+ return result
+
+ def has_unaccent(self):
+ return self.has_proc(Unaccent._function)
+
+ def has_similarity(self):
+ return self.has_proc(Similarity._function)
+
+ def similarity(self, column, value):
+ return Similarity(column, value)
+
+ def has_search_full_text(self):
+ return True
+
+ def _search_full_text_language(self, language):
+ languages = self._search_full_text_languages[self.name]
+ if language not in languages:
+ lang = Table('ir_lang')
+ connection = self.get_connection()
+ try:
+ cursor = connection.cursor()
+ cursor.execute(*lang.select(
+ Coalesce(lang.pg_text_search, 'simple'),
+ where=lang.code == language,
+ limit=1))
+ config_name, = cursor.fetchone()
+ finally:
+ self.put_connection(connection)
+ languages[language] = config_name
+ else:
+ config_name = languages[language]
+ return config_name
+
+ def format_full_text(self, *documents, language=None):
+ size = max(len(documents) // 4, 1)
+ if len(documents) > 1:
+ weights = chain(
+ ['A'] * size, ['B'] * size, ['C'] * size, repeat('D'))
+ else:
+ weights = [None]
+ expression = None
+ if language:
+ config_name = self._search_full_text_language(language)
+ else:
+ config_name = None
+ for document, weight in zip(documents, weights):
+ if not document:
+ continue
+ if config_name:
+ ts_vector = ToTsvector(config_name, document)
+ else:
+ ts_vector = ToTsvector('simple', document)
+ if weight:
+ ts_vector = Setweight(ts_vector, weight)
+ if expression is None:
+ expression = ts_vector
+ else:
+ expression = Concat(expression, ts_vector)
+ return expression
+
+ def format_full_text_query(self, query, language=None):
+ connection = self.get_connection()
+ try:
+ version = self.get_version(connection)
+ finally:
+ self.put_connection(connection)
+ if version >= (11, 0):
+ ToTsQuery = WebsearchToTsQuery
+ else:
+ ToTsQuery = PlainToTsQuery
+ if language:
+ config_name = self._search_full_text_language(language)
+ if not isinstance(query, TsQuery):
+ query = ToTsQuery(config_name, query)
+ else:
+ if not isinstance(query, TsQuery):
+ query = ToTsQuery(query)
+ return query
+
+ def search_full_text(self, document, query):
+ return Match(document, query)
+
+ def rank_full_text(self, document, query, normalize=None):
+ # TODO: weights and cover density
+ norm_int = 0
+ if normalize:
+ values = {
+ 'document log': 1,
+ 'document': 2,
+ 'mean': 4,
+ 'word': 8,
+ 'word log': 16,
+ 'rank': 32,
+ }
+ for norm in normalize:
+ norm_int |= values.get(norm, 0)
+ return TsRank(document, query, norm_int)
def sql_type(self, type_):
if type_ in self.TYPES_MAPPING:
diff -r 130d98839af1 -r 03ec47b65b78 trytond/backend/sqlite/database.py
--- a/trytond/backend/sqlite/database.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/backend/sqlite/database.py Sun Mar 21 16:21:05 2021 +0100
@@ -321,6 +321,7 @@
'DATETIME': SQLType('TIMESTAMP', 'TIMESTAMP'),
'BIGINT': SQLType('INTEGER', 'INTEGER'),
'BOOL': SQLType('BOOLEAN', 'BOOLEAN'),
+ 'FULLTEXT': SQLType('TEXT', 'TEXT'),
}
def __new__(cls, name=_default_name):
diff -r 130d98839af1 -r 03ec47b65b78 trytond/ir/lang.py
--- a/trytond/ir/lang.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/ir/lang.py Sun Mar 21 16:21:05 2021 +0100
@@ -73,6 +73,9 @@
p_sep_by_space = fields.Boolean('Positive Separate by Space')
n_sep_by_space = fields.Boolean('Negative Separate by Space')
+ pg_text_search = fields.Char(
+ "PostgreSQL Text Search Configuration", readonly=True)
+
_lang_cache = Cache('ir.lang')
_code_cache = Cache('ir.lang.code', context=False)
diff -r 130d98839af1 -r 03ec47b65b78 trytond/model/fields/__init__.py
--- a/trytond/model/fields/__init__.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/model/fields/__init__.py Sun Mar 21 16:21:05 2021 +0100
@@ -7,7 +7,7 @@
from .boolean import Boolean
from .integer import Integer, BigInteger
from .char import Char
-from .text import Text
+from .text import Text, FullText
from .float import Float
from .numeric import Numeric
from .date import Date, Timestamp, DateTime, Time, TimeDelta
@@ -25,6 +25,7 @@
__all__ = [
depends, with_inactive_records, SQL_OPERATORS, on_change_result,
get_eval_fields, states_validate, domain_validate, context_validate, Field,
- Boolean, Integer, BigInteger, Char, Text, Float, Numeric, Date, Timestamp,
- DateTime, Time, TimeDelta, Binary, Selection, Reference, Many2One,
- One2Many, Many2Many, Function, MultiValue, One2One, Dict, MultiSelection]
+ Boolean, Integer, BigInteger, Char, Text, FullText, Float, Numeric, Date,
+ Timestamp, DateTime, Time, TimeDelta, Binary, Selection, Reference,
+ Many2One, One2Many, Many2Many, Function, MultiValue, One2One, Dict,
+ MultiSelection]
diff -r 130d98839af1 -r 03ec47b65b78 trytond/model/fields/char.py
--- a/trytond/model/fields/char.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/model/fields/char.py Sun Mar 21 16:21:05 2021 +0100
@@ -2,7 +2,10 @@
# this repository contains the full copyright notices and license terms.
import warnings
+from sql.operators import Not
+
from trytond.rpc import RPC
+from trytond.tools import unescape_wildcard, is_full_text
from trytond.transaction import Transaction
from .field import Field, FieldTranslate, size_validate
@@ -14,6 +17,8 @@
_type = 'char'
_py_type = str
forbidden_chars = '\t\n\r\x0b\x0c'
+ search_unaccented = True
+ search_full_text = False
def __init__(self, string='', size=None, help='', required=False,
readonly=False, domain=None, states=None, translate=False,
@@ -37,7 +42,6 @@
self.translate = translate
self.__size = None
self.size = size
- self.search_unaccented = True
__init__.__doc__ += Field.__init__.__doc__
def _get_size(self):
@@ -75,6 +79,103 @@
value = database.unaccent(value)
return value
+ def convert_domain(self, domain, tables, Model):
+ transaction = Transaction()
+ context = transaction.context
+ database = transaction.database
+ expression = super().convert_domain(domain, tables, Model)
+ name, operator, value = domain
+ if operator.endswith('ilike'):
+ table, _ = tables[None]
+ if self.translate:
+ language = transaction.language
+ model, join, column = self._get_translation_column(
+ Model, name)
+ else:
+ language = None
+ column = self.sql_column(table)
+ column = self._domain_column(operator, column)
+
+ if database.has_similarity() and is_full_text(value):
+ threshold = context.get(
+ '%s.%s.search_similarity' % (Model.__name__, name),
+ context.get('search_similarity', 0.3))
+ sim_value = unescape_wildcard(value)
+ sim_value = self._domain_value(operator, sim_value)
+ expression = (
+ database.similarity(column, sim_value) >= threshold)
+ if operator.startswith('not'):
+ expression = Not(expression)
+
+ key = '%s.%s.search_full_text' % (Model.__name__, name)
+ if ((self.search_full_text or context.get(key))
+ and context.get(key, True)
+ and database.has_search_full_text()):
+ if context.get(key) or is_full_text(value):
+ fts_column = database.format_full_text(
+ column, language=language)
+ fts_value = value
+ if key not in context:
+ fts_value = unescape_wildcard(fts_value)
+ fts_value = self._domain_value(operator, fts_value)
+ fts_value = database.format_full_text_query(
+ fts_value, language=language)
+ fts = database.search_full_text(fts_column, fts_value)
+ if operator.startswith('not'):
+ fts = Not(fts)
+ if self.translate:
+ fts = table.id.in_(
+ join.select(model.id, where=fts))
+ if database.has_similarity() and is_full_text(value):
+ if operator.startswith('not'):
+ expression |= fts
+ else:
+ expression &= fts
+ else:
+ expression = fts
+ return expression
+
+ def convert_order(self, name, tables, Model):
+ method = getattr(Model, 'order_%s' % name, None)
+ if method:
+ return method(tables)
+ transaction = Transaction()
+ context = transaction.context
+ database = transaction.database
+ key = '%s.%s.order' % (Model.__name__, name)
+ value = context.get(key)
+ order = super().convert_order(name, tables, Model)
+ if value:
+ expression = None
+ table, _ = tables[None]
+ if self.translate:
+ language = transaction.language
+ column = self._get_translation_order(tables, Model, name)
+ else:
+ language = None
+ column = self.sql_column(table)
+ column = self._domain_column('ilike', column)
+ if database.has_similarity():
+ sim_value = unescape_wildcard(value)
+ sim_value = self._domain_value('ilike', sim_value)
+ expression = database.similarity(column, sim_value)
+ key = '%s.%s.search_full_text' % (Model.__name__, name)
+ if ((self.search_full_text or context.get(key))
+ and database.has_search_full_text()):
+ column = database.format_full_text(column, language=language)
+ value = self._domain_value('ilike', value)
+ value = database.format_full_text_query(
+ value, language=language)
+ rank = database.rank_full_text(
+ column, value, normalize=['rank'])
+ if expression:
+ expression += rank
+ else:
+ expression = rank
+ if expression:
+ order = [expression]
+ return order
+
def definition(self, model, language):
definition = super().definition(model, language)
definition['autocomplete'] = list(self.autocomplete)
diff -r 130d98839af1 -r 03ec47b65b78 trytond/model/fields/field.py
--- a/trytond/model/fields/field.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/model/fields/field.py Sun Mar 21 16:21:05 2021 +0100
@@ -544,18 +544,14 @@
return query, from_.join(query, 'LEFT',
condition=(query.res_id == res_id))
- def convert_domain(self, domain, tables, Model):
+ def _get_translation_column(self, Model, name):
from trytond.ir.lang import get_parent_language
pool = Pool()
Translation = pool.get('ir.translation')
IrModel = pool.get('ir.model')
- if not self.translate:
- return super(FieldTranslate, self).convert_domain(
- domain, tables, Model)
table = join = Model.__table__()
model = IrModel.__table__()
- name, operator, value = domain
language = Transaction().language
column = None
while language:
@@ -564,7 +560,16 @@
Model, name, translation, model, table, join, language)
column = Coalesce(NullIf(column, ''), translation.value)
language = get_parent_language(language)
- column = Coalesce(NullIf(column, ''), self.sql_column(table))
+ return table, join, column
+
+ def convert_domain(self, domain, tables, Model):
+ if not self.translate:
+ return super(FieldTranslate, self).convert_domain(
+ domain, tables, Model)
+ table, _ = tables[None]
+ name, operator, value = domain
+ model, join, column = self._get_translation_column(Model, name)
+ column = Coalesce(NullIf(column, ''), self.sql_column(model))
column = self._domain_column(operator, column)
Operator = SQL_OPERATORS[operator]
assert name == self.name
@@ -574,20 +579,14 @@
elif isinstance(where, operators.NotIn) and not where.right:
where = Literal(True)
where = self._domain_add_null(column, operator, value, where)
- return tables[None][0].id.in_(join.select(table.id, where=where))
+ return table.id.in_(join.select(model.id, where=where))
- def convert_order(self, name, tables, Model):
+ def _get_translation_order(self, tables, Model, name):
from trytond.ir.lang import get_parent_language
pool = Pool()
Translation = pool.get('ir.translation')
IrModel = pool.get('ir.model')
- if not self.translate:
- return super(FieldTranslate, self).convert_order(name, tables,
- Model)
- assert name == self.name
-
table, _ = tables[None]
-
join = table
language = Transaction().language
column = None
@@ -616,7 +615,14 @@
translation, _ = tables[key]['translation'][None]
column = Coalesce(NullIf(column, ''), translation.value)
language = get_parent_language(language)
+ return column
+ def convert_order(self, name, tables, Model):
+ if not self.translate:
+ return super().convert_order(name, tables, Model)
+ assert name == self.name
+ table, _ = tables[None]
+ column = self._get_translation_order(tables, Model, name)
return [Coalesce(NullIf(column, ''), self.sql_column(table))]
def definition(self, model, language):
diff -r 130d98839af1 -r 03ec47b65b78 trytond/model/fields/text.py
--- a/trytond/model/fields/text.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/model/fields/text.py Sun Mar 21 16:21:05 2021 +0100
@@ -1,5 +1,11 @@
# 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 Query, Expression, Null, Literal
+from sql.operators import ILike, Not, In, NotIn
+
+from trytond.transaction import Transaction
+
+from .field import Field, SQL_OPERATORS
from .char import Char
@@ -10,3 +16,80 @@
_type = 'text'
_sql_type = 'TEXT'
forbidden_chars = ''
+ search_full_text = True
+
+
+class FullText(Field):
+ '''
+ Define a full text field.
+ '''
+ _type = 'full_text'
+ _sql_type = 'FULLTEXT'
+ _py_type = str
+
+ def sql_format(self, value):
+ if isinstance(value, (Query, Expression)):
+ return value
+ if not value:
+ return Null
+ if isinstance(value, (int, float)):
+ return value
+ transaction = Transaction()
+ database = transaction.database
+ if isinstance(value, str):
+ value = [value]
+ return database.format_full_text(*value, language=transaction.language)
+
+ def _rank_column(self, column, name, Model):
+ transaction = Transaction()
+ context = transaction.context
+ database = transaction.database
+ key = '%s.%s.order' % (Model.__name__, name)
+ value = context.get(key)
+ if value and database.has_search_full_text():
+ value = database.format_full_text_query(
+ value, language=transaction.language)
+ column = database.rank_full_text(column, value, normalize=['rank'])
+ else:
+ column = Null
+ return column
+
+ def convert_domain(self, domain, tables, Model):
+ transaction = Transaction()
+ database = transaction.database
+ table, _ = tables[None]
+ name, operator, value = domain
+ assert name == self.name
+ column = self.sql_column(table)
+ column = self._domain_column(operator, column)
+ if operator.endswith('like'):
+ if database.has_search_full_text():
+ value = database.format_full_text_query(
+ value, language=transaction.language)
+ expression = database.search_full_text(column, value)
+ else:
+ expression = Literal(True)
+ for v in value.split():
+ expression &= ILike(column, '%' + v + '%')
+ if operator.startswith('not'):
+ expression = Not(expression)
+ else:
+ Operator = SQL_OPERATORS[operator]
+ column = self._rank_column(column, name, Model)
+ expression = Operator(column, self._domain_value(operator, value))
+ if isinstance(expression, In) and not expression.right:
+ expression = Literal(False)
+ elif isinstance(expression, NotIn) and not expression.right:
+ expression = Literal(True)
+ expression = self._domain_add_null(
+ column, operator, value, expression)
+ return expression
+
+ def convert_order(self, name, tables, Model):
+ method = getattr(Model, 'order_%s' % name, None)
+ if method:
+ return method(tables)
+ table, _ = tables[None]
+ column = self.sql_column(table)
+ column = self._domain_column('ilike', column)
+ return [self._rank_column(column, name, Model)]
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/field_text.py
--- a/trytond/tests/field_text.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/field_text.py Sun Mar 21 16:21:05 2021 +0100
@@ -44,6 +44,12 @@
required=False, translate=True)
+class FullText(ModelSQL):
+ "FullText"
+ __name__ = 'test.text_full'
+ full_text = fields.FullText("Full Text")
+
+
def register(module):
Pool.register(
Text,
@@ -51,4 +57,5 @@
TextRequired,
TextSize,
TextTranslate,
+ FullText,
module=module, type_='model')
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/test_field_char.py
--- a/trytond/tests/test_field_char.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/test_field_char.py Sun Mar 21 16:21:05 2021 +0100
@@ -10,6 +10,7 @@
RequiredValidationError, ForbiddenCharValidationError)
from trytond.pool import Pool
from trytond.tests.test_tryton import activate_module, with_transaction
+from trytond.tests.test_tryton import ExtensionTestCase
from trytond.transaction import Transaction
@@ -494,44 +495,11 @@
self.assertEqual(char.char, "bar")
-class UnaccentedTestCase(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls._activate_extension()
-
- @classmethod
- def tearDownClass(cls):
- super().tearDownClass()
- cls._deactivate_extension()
-
- @classmethod
- @with_transaction()
- def _activate_extension(cls):
- connection = Transaction().connection
- cursor = connection.cursor()
- cursor.execute('CREATE EXTENSION "unaccent"')
- connection.commit()
- cls._clear_unaccent_cache()
-
- @classmethod
- @with_transaction()
- def _deactivate_extension(cls):
- connection = Transaction().connection
- cursor = connection.cursor()
- cursor.execute('DROP EXTENSION "unaccent"')
- connection.commit()
- cls._clear_unaccent_cache()
-
- @classmethod
- def _clear_unaccent_cache(cls):
- backend.Database._has_unaccent.clear()
-
-
@unittest.skipUnless(backend.name == 'postgresql',
"unaccent works only on postgresql")
-class FieldCharUnaccentedTestCase(UnaccentedTestCase):
+class FieldCharUnaccentedTestCase(ExtensionTestCase):
"Test Field Char with unaccented searches"
+ extension = 'unaccent'
@classmethod
def setUpClass(cls):
@@ -607,10 +575,55 @@
self.assertListEqual(chars_ecole, [char])
[email protected](backend.name == 'postgresql',
+ "similarity works only on postgresql")
+class FieldCharSimilarityTestCase(ExtensionTestCase):
+ "Test Field Char with similarity searches"
+ extension = 'pg_trgm'
+
+ @classmethod
+ def setUpClass(cls):
+ activate_module('tests')
+ super().setUpClass()
+
+ @with_transaction()
+ def test_search(self):
+ "Test search"
+ pool = Pool()
+ Model = pool.get('test.char')
+ record1, record2 = Model.create([{
+ 'char': "word",
+ }, {
+ 'char': "Foo",
+ }])
+
+ self.assertListEqual(Model.search([
+ ('char', 'ilike', 'two words'),
+ ]), [record1])
+
+ @with_transaction()
+ def test_order(self):
+ "Test order"
+ pool = Pool()
+ Model = pool.get('test.char')
+ record1, record2 = Model.create([{
+ 'char': "word",
+ }, {
+ 'char': "Foo",
+ }])
+ with Transaction().set_context({
+ '%s.char.order' % Model.__name__: 'foo bar',
+ }):
+ self.assertListEqual(Model.search([
+ ], order=[('char', 'DESC')]),
+ [record2, record1])
+
+
def suite():
suite_ = unittest.TestSuite()
loader = unittest.TestLoader()
suite_.addTests(loader.loadTestsFromTestCase(FieldCharTestCase))
suite_.addTests(loader.loadTestsFromTestCase(FieldCharTranslatedTestCase))
suite_.addTests(loader.loadTestsFromTestCase(FieldCharUnaccentedTestCase))
+ suite_.addTests(loader.loadTestsFromTestCase(FieldCharSimilarityTestCase))
return suite_
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/test_field_dict.py
--- a/trytond/tests/test_field_dict.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/test_field_dict.py Sun Mar 21 16:21:05 2021 +0100
@@ -7,10 +7,9 @@
from trytond.model.exceptions import RequiredValidationError
from trytond.pool import Pool
from trytond.tests.test_tryton import activate_module, with_transaction
+from trytond.tests.test_tryton import ExtensionTestCase
from trytond.transaction import Transaction
-from .test_field_char import UnaccentedTestCase
-
class FieldDictTestCase(unittest.TestCase):
"Test Field Dict"
@@ -890,8 +889,9 @@
@unittest.skipUnless(backend.name == 'postgresql',
"unaccent works only on postgresql")
-class FieldDictUnaccentedTestCase(UnaccentedTestCase):
+class FieldDictUnaccentedTestCase(ExtensionTestCase):
"Test Field Dict with unaccented searched"
+ extension = 'unaccent'
@classmethod
def setUpClass(cls):
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/test_field_text.py
--- a/trytond/tests/test_field_text.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/test_field_text.py Sun Mar 21 16:21:05 2021 +0100
@@ -3,10 +3,12 @@
# this repository contains the full copyright notices and license terms.
import unittest
+from trytond import backend
from trytond.model.exceptions import (
RequiredValidationError, SizeValidationError)
from trytond.pool import Pool
from trytond.tests.test_tryton import activate_module, with_transaction
+from trytond.transaction import Transaction
class CommonTestCaseMixin:
@@ -244,25 +246,73 @@
text, = Text.create([{
'text': "Bar",
}])
-
- texts_bar = Text.search([
- ('text', 'ilike', "bar"),
- ])
- texts_b = Text.search([
- ('text', 'ilike', "b%"),
- ])
- texts_foo = Text.search([
- ('text', 'ilike', "foo"),
- ])
- texts_f = Text.search([
- ('text', 'ilike', "f%"),
- ])
+ with Transaction().set_context({
+ '%s.text.search_full_text' % Text.__name__: False,
+ }):
+ texts_bar = Text.search([
+ ('text', 'ilike', "bar"),
+ ])
+ texts_b = Text.search([
+ ('text', 'ilike', "b%"),
+ ])
+ texts_foo = Text.search([
+ ('text', 'ilike', "foo"),
+ ])
+ texts_f = Text.search([
+ ('text', 'ilike', "f%"),
+ ])
self.assertListEqual(texts_bar, [text])
self.assertListEqual(texts_b, [text])
self.assertListEqual(texts_foo, [])
self.assertListEqual(texts_f, [])
+ @unittest.skipIf(backend.name == 'sqlite',
+ "SQLite does not have full text search")
+ @with_transaction()
+ def test_search_full_text(self):
+ "Test search full text"
+ Text = self.Text()
+ fat_cat, = Text.create([{
+ 'text': "a fat cat sat on a mat and ate a fat rat",
+ }])
+ sad_cat, = Text.create([{
+ 'text': "a sad cat sat",
+ }])
+
+ for value, result in [
+ ("Fat rat", [fat_cat]),
+ ('"sad cat" or "fat rat"', [fat_cat, sad_cat]),
+ ]:
+ with self.subTest(value=value):
+ self.assertListEqual(Text.search([
+ ('text', 'ilike', value),
+ ]), result)
+
+ @unittest.skipIf(backend.name == 'sqlite',
+ "SQLite does not have full text search")
+ @with_transaction()
+ def test_search_full_text_order(self):
+ "Test order search full text"
+ Text = self.Text()
+ sad_cat, = Text.create([{
+ 'text': "a sad cat sat",
+ }])
+ dog, = Text.create([{
+ 'text': "a dog",
+ }])
+ fat_cat, = Text.create([{
+ 'text': "a fat cat sat on a mat and ate a fat rat",
+ }])
+
+ with Transaction().set_context({
+ '%s.text.order' % Text.__name__: (
+ '"sad cat" or "fat rat"'),
+ }):
+ self.assertListEqual(
+ Text.search([], order=[('text', 'DESC')]),
+ [fat_cat, sad_cat, dog])
+
@with_transaction()
def test_search_not_like(self):
"Test search text not like"
@@ -297,18 +347,21 @@
'text': "Bar",
}])
- texts_bar = Text.search([
- ('text', 'not ilike', "bar"),
- ])
- texts_b = Text.search([
- ('text', 'not ilike', "b%"),
- ])
- texts_foo = Text.search([
- ('text', 'not ilike', "foo"),
- ])
- texts_f = Text.search([
- ('text', 'not ilike', "f%"),
- ])
+ with Transaction().set_context({
+ '%s.text.search_full_text' % Text.__name__: False,
+ }):
+ texts_bar = Text.search([
+ ('text', 'not ilike', "bar"),
+ ])
+ texts_b = Text.search([
+ ('text', 'not ilike', "b%"),
+ ])
+ texts_foo = Text.search([
+ ('text', 'not ilike', "foo"),
+ ])
+ texts_f = Text.search([
+ ('text', 'not ilike', "f%"),
+ ])
self.assertListEqual(texts_bar, [])
self.assertListEqual(texts_b, [])
@@ -458,9 +511,148 @@
return Pool().get('test.text_translate')
+class FieldFullTextTestCase(unittest.TestCase):
+ "Test Field FullText"
+
+ @classmethod
+ def setUpClass(cls):
+ activate_module('tests')
+
+ @with_transaction()
+ def test_create(self):
+ "Test create full text"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ record, = Model.create([{
+ 'full_text': "a fat cat sat on a mat and ate a fat rat",
+ }])
+
+ self.assertTrue(record.full_text)
+
+ @with_transaction()
+ def test_create_list(self):
+ "Test create full text with a list"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ record, = Model.create([{
+ 'full_text': [
+ "The Fat Cats",
+ "a fat cat sat on a mat and ate a fat rat",
+ ],
+ }])
+
+ self.assertTrue(record.full_text)
+
+ @with_transaction()
+ def test_search(self):
+ "Test search full text"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ fat_cat, sad_cat = Model.create([{
+ 'full_text': "a fat cat sat on a mat and ate a fat rat",
+ }, {
+ 'full_text': "a sad cat sat",
+ }])
+
+ for clause, result, neg_result in [
+ ('fat', [fat_cat], [sad_cat]),
+ ('cat', [fat_cat, sad_cat], []),
+ ]:
+ with self.subTest(clause=clause):
+ self.assertListEqual(Model.search([
+ ('full_text', 'ilike', clause),
+ ]), result)
+ self.assertListEqual(Model.search([
+ ('full_text', 'not ilike', clause),
+ ]), neg_result)
+
+ @unittest.skipIf(backend.name == 'sqlite',
+ "SQLite does not have full text search")
+ @with_transaction()
+ def test_search_full_text(self):
+ "Test search full text"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ fat_cat, sad_cat = Model.create([{
+ 'full_text': "a fat cat sat on a mat and ate a fat rat",
+ }, {
+ 'full_text': "a sad cat sat",
+ }])
+
+ for clause, result in [
+ ("Fat rat", [fat_cat]),
+ ('"sad cat" or "fat rat"', [fat_cat, sad_cat]),
+ ]:
+ with self.subTest(clause=clause):
+ self.assertListEqual(Model.search([
+ ('full_text', 'ilike', clause),
+ ]), result)
+
+ @unittest.skipIf(backend.name == 'sqlite',
+ "SQLite does not have full text search")
+ @with_transaction()
+ def test_search_full_text_filter(self):
+ "Test search full text"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ fat_cat, sad_cat = Model.create([{
+ 'full_text': "a fat cat sat on a mat and ate a fat rat",
+ }, {
+ 'full_text': "a sad cat sat",
+ }])
+
+ for clause, result in [
+ ("Fat rat", [fat_cat]),
+ ('"sad cat" or "fat rat"', [fat_cat, sad_cat]),
+ ]:
+ with self.subTest(clause=clause):
+ with Transaction().set_context({
+ '%s.full_text.order' % Model.__name__: clause,
+ }):
+ self.assertListEqual(Model.search([
+ ('full_text', '>', 0.01),
+ ]), result)
+
+ @unittest.skipIf(backend.name == 'sqlite',
+ "SQLite does not have full text search")
+ @with_transaction()
+ def test_search_full_text_order(self):
+ "Test order full text"
+ pool = Pool()
+ Model = pool.get('test.text_full')
+
+ sad_cat, fat_cat, dog = Model.create([{
+ 'full_text': [
+ None,
+ "a fat cat sat on a mat and ate a fat rat",
+ ],
+ }, {
+ 'full_text': [
+ "The Fat Cats",
+ "a fat cat sat on a mat and ate a fat rat",
+ ]
+ }, {
+ 'full_text': "a dog",
+ }])
+
+ with Transaction().set_context({
+ '%s.full_text.order' % Model.__name__: (
+ '"sad cat" or "fat rat"'),
+ }):
+ self.assertListEqual(
+ Model.search([], order=[('full_text', 'DESC')]),
+ [fat_cat, sad_cat, dog])
+
+
def suite():
suite_ = unittest.TestSuite()
loader = unittest.TestLoader()
suite_.addTests(loader.loadTestsFromTestCase(FieldTextTestCase))
suite_.addTests(loader.loadTestsFromTestCase(FieldTextTranslatedTestCase))
+ suite_.addTests(loader.loadTestsFromTestCase(FieldFullTextTestCase))
return suite_
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/test_tools.py
--- a/trytond/tests/test_tools.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/test_tools.py Sun Mar 21 16:21:05 2021 +0100
@@ -13,7 +13,7 @@
from trytond.tools import (
reduce_ids, reduce_domain, decimal_, is_instance_method, file_open,
strip_wildcard, lstrip_wildcard, rstrip_wildcard, slugify, sortable_values,
- escape_wildcard, firstline)
+ escape_wildcard, unescape_wildcard, is_full_text, firstline)
from trytond.tools.string_ import StringPartitioned, LazyString
from trytond.tools.domain_inversion import (
domain_inversion, parse, simplify, merge, concat, unique_value,
@@ -201,6 +201,24 @@
escape_wildcard('foo%bar_baz\\'),
'foo\\%bar\\_baz\\\\')
+ def test_unescape_wildcard(self):
+ "Test unescape_wildcard"
+ self.assertEqual(
+ unescape_wildcard('foo\\%bar\\_baz\\\\'),
+ 'foo%bar_baz\\')
+
+ def test_is_full_text(self):
+ "Test is_full_text"
+ for value, result in [
+ ('foo', True),
+ ('%foo bar%', True),
+ ('foo%', False),
+ ('foo_bar', False),
+ ('foo\\_bar', True),
+ ]:
+ with self.subTest(value=value):
+ self.assertEqual(is_full_text(value), result)
+
def test_slugify(self):
"Test slugify"
self.assertEqual(slugify('unicode ♥ is ☢'), 'unicode-is')
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tests/test_tryton.py
--- a/trytond/tests/test_tryton.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tests/test_tryton.py Sun Mar 21 16:21:05 2021 +0100
@@ -842,6 +842,42 @@
Module.update_list()
+class ExtensionTestCase(unittest.TestCase):
+ extension = None
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls._activate_extension()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ cls._deactivate_extension()
+
+ @classmethod
+ @with_transaction()
+ def _activate_extension(cls):
+ connection = Transaction().connection
+ cursor = connection.cursor()
+ cursor.execute('CREATE EXTENSION "%s"' % cls.extension)
+ connection.commit()
+ cls._clear_cache()
+
+ @classmethod
+ @with_transaction()
+ def _deactivate_extension(cls):
+ connection = Transaction().connection
+ cursor = connection.cursor()
+ cursor.execute('DROP EXTENSION "%s"' % cls.extension)
+ connection.commit()
+ cls._clear_cache()
+
+ @classmethod
+ def _clear_cache(cls):
+ backend.Database._has_proc.clear()
+
+
def drop_db(name=DB_NAME):
if db_exist(name):
database = backend.Database(name)
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tools/__init__.py
--- a/trytond/tools/__init__.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tools/__init__.py Sun Mar 21 16:21:05 2021 +0100
@@ -9,14 +9,15 @@
file_open, get_smtp_server, reduce_ids, reduce_domain,
grouped_slice, is_instance_method, resolve, strip_wildcard,
lstrip_wildcard, rstrip_wildcard, slugify, sortable_values,
- escape_wildcard, sql_pairing, firstline)
+ escape_wildcard, unescape_wildcard, is_full_text, sql_pairing, firstline)
from .decimal_ import decistmt
__all__ = ['file_open', 'get_smtp_server', 'reduce_ids',
'reduce_domain', 'grouped_slice', 'is_instance_method', 'resolve',
'strip_wildcard', 'lstrip_wildcard', 'rstrip_wildcard', 'slugify',
'decistmt', 'ClassProperty', 'cursor_dict', 'cached_property',
- 'sortable_values', 'escape_wildcard', 'sql_pairing', 'firstline']
+ 'sortable_values', 'escape_wildcard', 'unescape_wildcard', 'is_full_text',
+ 'sql_pairing', 'firstline']
class ClassProperty(property):
diff -r 130d98839af1 -r 03ec47b65b78 trytond/tools/misc.py
--- a/trytond/tools/misc.py Sun Mar 21 16:09:13 2021 +0100
+++ b/trytond/tools/misc.py Sun Mar 21 16:21:05 2021 +0100
@@ -221,6 +221,20 @@
return string
+def unescape_wildcard(string, wildcards='%_', escape='\\'):
+ for wildcard in wildcards + escape:
+ string = string.replace(escape + wildcard, wildcard)
+ return string
+
+
+def is_full_text(value, escape='\\'):
+ escaped = value.strip('%')
+ escaped = escaped.replace(escape + '%', '').replace(escape + '_', '')
+ if '%' in escaped or '_' in escaped:
+ return False
+ return value.startswith('%') == value.endswith('%')
+
+
_slugify_strip_re = re.compile(r'[^\w\s-]')
_slugify_hyphenate_re = re.compile(r'[-\s]+')