cubicweb/web/views/magicsearch.py
changeset 11057 0b59724cb3f2
parent 10889 310344d6cc6c
child 11767 432f87a63057
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cubicweb/web/views/magicsearch.py	Sat Jan 16 13:48:51 2016 +0100
@@ -0,0 +1,660 @@
+# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
+#
+# This file is part of CubicWeb.
+#
+# CubicWeb is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
+"""a query processor to handle quick search shortcuts for cubicweb
+"""
+
+__docformat__ = "restructuredtext en"
+
+import re
+from logging import getLogger
+
+from six import text_type
+
+from yams.interfaces import IVocabularyConstraint
+
+from rql import RQLSyntaxError, BadRQLQuery, parse
+from rql.utils import rqlvar_maker
+from rql.nodes import Relation
+
+from cubicweb import Unauthorized
+from cubicweb.view import Component
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
+
+LOGGER = getLogger('cubicweb.magicsearch')
+
+def _get_approriate_translation(translations_found, eschema):
+    """return the first (should be the only one) possible translation according
+    to the given entity type
+    """
+    # get the list of all attributes / relations for this kind of entity
+    existing_relations = set(eschema.subject_relations())
+    consistent_translations = translations_found & existing_relations
+    if len(consistent_translations) == 0:
+        return None
+    return consistent_translations.pop()
+
+
+def translate_rql_tree(rqlst, translations, schema):
+    """Try to translate each relation in the RQL syntax tree
+
+    :type rqlst: `rql.stmts.Statement`
+    :param rqlst: the RQL syntax tree
+
+    :type translations: dict
+    :param translations: the reverted l10n dict
+
+    :type schema: `cubicweb.schema.Schema`
+    :param schema: the instance's schema
+    """
+    # var_types is used as a map : var_name / var_type
+    vartypes = {}
+    # ambiguous_nodes is used as a map : relation_node / (var_name, available_translations)
+    ambiguous_nodes = {}
+    # For each relation node, check if it's a localized relation name
+    # If it's a localized name, then use the original relation name, else
+    # keep the existing relation name
+    for relation in rqlst.get_nodes(Relation):
+        rtype = relation.r_type
+        lhs, rhs = relation.get_variable_parts()
+        if rtype == 'is':
+            try:
+                etype = translations[rhs.value]
+                rhs.value = etype
+            except KeyError:
+                # If no translation found, leave the entity type as is
+                etype = rhs.value
+            # Memorize variable's type
+            vartypes[lhs.name] = etype
+        else:
+            try:
+                translation_set = translations[rtype]
+            except KeyError:
+                pass # If no translation found, leave the relation type as is
+            else:
+                # Only one possible translation, no ambiguity
+                if len(translation_set) == 1:
+                    relation.r_type = next(iter(translations[rtype]))
+                # More than 1 possible translation => resolve it later
+                else:
+                    ambiguous_nodes[relation] = (lhs.name, translation_set)
+    if ambiguous_nodes:
+        resolve_ambiguities(vartypes, ambiguous_nodes, schema)
+
+
+def resolve_ambiguities(var_types, ambiguous_nodes, schema):
+    """Tries to resolve remaining ambiguities for translation
+    /!\ An ambiguity is when two different string can be localized with
+        the same string
+    A simple example:
+      - 'name' in a company context will be localized as 'nom' in French
+      - but ... 'surname' will also be localized as 'nom'
+
+    :type var_types: dict
+    :param var_types: a map : var_name / var_type
+
+    :type ambiguous_nodes: dict
+    :param ambiguous_nodes: a map : relation_node / (var_name, available_translations)
+
+    :type schema: `cubicweb.schema.Schema`
+    :param schema: the instance's schema
+    """
+    # Now, try to resolve ambiguous translations
+    for relation, (var_name, translations_found) in ambiguous_nodes.items():
+        try:
+            vartype = var_types[var_name]
+        except KeyError:
+            continue
+        # Get schema for this entity type
+        eschema = schema.eschema(vartype)
+        rtype = _get_approriate_translation(translations_found, eschema)
+        if rtype is None:
+            continue
+        relation.r_type = rtype
+
+
+
+QUOTED_SRE = re.compile(r'(.*?)(["\'])(.+?)\2')
+
+TRANSLATION_MAPS = {}
+def trmap(config, schema, lang):
+    try:
+        return TRANSLATION_MAPS[lang]
+    except KeyError:
+        assert lang in config.translations, '%s %s' % (lang, config.translations)
+        tr, ctxtr = config.translations[lang]
+        langmap = {}
+        for etype in schema.entities():
+            etype = str(etype)
+            langmap[tr(etype).capitalize()] = etype
+            langmap[etype.capitalize()] = etype
+        for rtype in schema.relations():
+            rtype = str(rtype)
+            langmap.setdefault(tr(rtype).lower(), set()).add(rtype)
+            langmap.setdefault(rtype, set()).add(rtype)
+        TRANSLATION_MAPS[lang] = langmap
+        return langmap
+
+
+class BaseQueryProcessor(Component):
+    __abstract__ = True
+    __regid__ = 'magicsearch_processor'
+    # set something if you want explicit component search facility for the
+    # component
+    name = None
+
+    def process_query(self, uquery):
+        args = self.preprocess_query(uquery)
+        try:
+            return self._cw.execute(*args)
+        finally:
+            # rollback necessary to avoid leaving the connection in a bad state
+            self._cw.cnx.rollback()
+
+    def preprocess_query(self, uquery):
+        raise NotImplementedError()
+
+
+
+
+class DoNotPreprocess(BaseQueryProcessor):
+    """this one returns the raw query and should be placed in first position
+    of the chain
+    """
+    name = 'rql'
+    priority = 0
+    def preprocess_query(self, uquery):
+        return uquery,
+
+
+class QueryTranslator(BaseQueryProcessor):
+    """ parses through rql and translates into schema language entity names
+    and attributes
+    """
+    priority = 2
+    def preprocess_query(self, uquery):
+        rqlst = parse(uquery, print_errors=False)
+        schema = self._cw.vreg.schema
+        # rql syntax tree will be modified in place if necessary
+        translate_rql_tree(rqlst, trmap(self._cw.vreg.config, schema, self._cw.lang),
+                           schema)
+        return rqlst.as_string(),
+
+
+class QSPreProcessor(BaseQueryProcessor):
+    """Quick search preprocessor
+
+    preprocessing query in shortcut form to their RQL form
+    """
+    priority = 4
+
+    def preprocess_query(self, uquery):
+        """try to get rql from a unicode query string"""
+        args = None
+        try:
+            # Process as if there was a quoted part
+            args = self._quoted_words_query(uquery)
+        ## No quoted part
+        except BadRQLQuery:
+            words = uquery.split()
+            if len(words) == 1:
+                args = self._one_word_query(*words)
+            elif len(words) == 2:
+                args = self._two_words_query(*words)
+            elif len(words) == 3:
+                args = self._three_words_query(*words)
+            else:
+                raise
+        return args
+
+    def _get_entity_type(self, word):
+        """check if the given word is matching an entity type, return it if
+        it's the case or raise BadRQLQuery if not
+        """
+        etype = word.capitalize()
+        try:
+            return trmap(self._cw.vreg.config, self._cw.vreg.schema, self._cw.lang)[etype]
+        except KeyError:
+            raise BadRQLQuery('%s is not a valid entity name' % etype)
+
+    def _get_attribute_name(self, word, eschema):
+        """check if the given word is matching an attribute of the given entity type,
+        return it normalized if found or return it untransformed else
+        """
+        """Returns the attributes's name as stored in the DB"""
+        # Need to convert from unicode to string (could be whatever)
+        rtype = word.lower()
+        # Find the entity name as stored in the DB
+        translations = trmap(self._cw.vreg.config, self._cw.vreg.schema, self._cw.lang)
+        try:
+            translations = translations[rtype]
+        except KeyError:
+            raise BadRQLQuery('%s is not a valid attribute for %s entity type'
+                              % (word, eschema))
+        rtype = _get_approriate_translation(translations, eschema)
+        if rtype is None:
+            raise BadRQLQuery('%s is not a valid attribute for %s entity type'
+                              % (word, eschema))
+        return rtype
+
+    def _one_word_query(self, word):
+        """Specific process for one word query (case (1) of preprocess_rql)
+        """
+        # if this is an integer, then directly go to eid
+        try:
+            eid = int(word)
+            return 'Any X WHERE X eid %(x)s', {'x': eid}, 'x'
+        except ValueError:
+            etype = self._get_entity_type(word)
+            return '%s %s' % (etype, etype[0]),
+
+    def _complete_rql(self, searchstr, etype, rtype=None, var=None, searchattr=None):
+        searchop = ''
+        if '%' in searchstr:
+            if rtype:
+                possible_etypes = self._cw.vreg.schema.rschema(rtype).objects(etype)
+            else:
+                possible_etypes = [self._cw.vreg.schema.eschema(etype)]
+            if searchattr or len(possible_etypes) == 1:
+                searchattr = searchattr or possible_etypes[0].main_attribute()
+                searchop = 'LIKE '
+        searchattr = searchattr or 'has_text'
+        if var is None:
+            var = etype[0]
+        return '%s %s %s%%(text)s' % (var, searchattr, searchop)
+
+    def _two_words_query(self, word1, word2):
+        """Specific process for two words query (case (2) of preprocess_rql)
+        """
+        etype = self._get_entity_type(word1)
+        # this is a valid RQL query : ("Person X", or "Person TMP1")
+        if len(word2) == 1 and word2.isupper():
+            return '%s %s' % (etype, word2),
+        # else, suppose it's a shortcut like : Person Smith
+        restriction = self._complete_rql(word2, etype)
+        if ' has_text ' in restriction:
+            rql = '%s %s ORDERBY FTIRANK(%s) DESC WHERE %s' % (
+                etype, etype[0], etype[0], restriction)
+        else:
+            rql = '%s %s WHERE %s' % (
+                etype, etype[0], restriction)
+        return rql, {'text': word2}
+
+    def _three_words_query(self, word1, word2, word3):
+        """Specific process for three words query (case (3) of preprocess_rql)
+        """
+        etype = self._get_entity_type(word1)
+        eschema = self._cw.vreg.schema.eschema(etype)
+        rtype = self._get_attribute_name(word2, eschema)
+        # expand shortcut if rtype is a non final relation
+        if not self._cw.vreg.schema.rschema(rtype).final:
+            return self._expand_shortcut(etype, rtype, word3)
+        if '%' in word3:
+            searchop = 'LIKE '
+        else:
+            searchop = ''
+        rql = '%s %s WHERE %s' % (etype, etype[0],
+                                  self._complete_rql(word3, etype, searchattr=rtype))
+        return rql, {'text': word3}
+
+    def _expand_shortcut(self, etype, rtype, searchstr):
+        """Expands shortcut queries on a non final relation to use has_text or
+        the main attribute (according to possible entity type) if '%' is used in the
+        search word
+
+        Transforms : 'person worksat IBM' into
+        'Personne P WHERE P worksAt C, C has_text "IBM"'
+        """
+        # check out all possilbe entity types for the relation represented
+        # by 'rtype'
+        mainvar = etype[0]
+        searchvar = mainvar  + '1'
+        restriction = self._complete_rql(searchstr, etype, rtype=rtype,
+                                         var=searchvar)
+        if ' has_text ' in restriction:
+            rql =  ('%s %s ORDERBY FTIRANK(%s) DESC '
+                    'WHERE %s %s %s, %s' % (etype, mainvar, searchvar,
+                                            mainvar, rtype, searchvar, # P worksAt C
+                                            restriction))
+        else:
+            rql =  ('%s %s WHERE %s %s %s, %s' % (etype, mainvar,
+                                            mainvar, rtype, searchvar, # P worksAt C
+                                            restriction))
+        return rql, {'text': searchstr}
+
+
+    def _quoted_words_query(self, ori_rql):
+        """Specific process when there's a "quoted" part
+        """
+        m = QUOTED_SRE.match(ori_rql)
+        # if there's no quoted part, then no special pre-processing to do
+        if m is None:
+            raise BadRQLQuery("unable to handle request %r" % ori_rql)
+        left_words = m.group(1).split()
+        quoted_part = m.group(3)
+        # Case (1) : Company "My own company"
+        if len(left_words) == 1:
+            try:
+                word1 = left_words[0]
+                return self._two_words_query(word1, quoted_part)
+            except BadRQLQuery as error:
+                raise BadRQLQuery("unable to handle request %r" % ori_rql)
+        # Case (2) : Company name "My own company";
+        elif len(left_words) == 2:
+            word1, word2 = left_words
+            return self._three_words_query(word1, word2, quoted_part)
+            # return ori_rql
+        raise BadRQLQuery("unable to handle request %r" % ori_rql)
+
+
+
+class FullTextTranslator(BaseQueryProcessor):
+    priority = 10
+    name = 'text'
+
+    def preprocess_query(self, uquery):
+        """suppose it's a plain text query"""
+        return 'Any X ORDERBY FTIRANK(X) DESC WHERE X has_text %(text)s', {'text': uquery}
+
+
+
+class MagicSearchComponent(Component):
+    __regid__  = 'magicsearch'
+    def __init__(self, req, rset=None):
+        super(MagicSearchComponent, self).__init__(req, rset=rset)
+        processors = []
+        self.by_name = {}
+        for processorcls in self._cw.vreg['components']['magicsearch_processor']:
+            # instantiation needed
+            processor = processorcls(self._cw)
+            processors.append(processor)
+            if processor.name is not None:
+                assert not processor.name in self.by_name
+                self.by_name[processor.name.lower()] = processor
+        self.processors = sorted(processors, key=lambda x: x.priority)
+
+    def process_query(self, uquery):
+        assert isinstance(uquery, text_type)
+        try:
+            procname, query = uquery.split(':', 1)
+            proc = self.by_name[procname.strip().lower()]
+            uquery = query.strip()
+        except Exception:
+            # use processor chain
+            unauthorized = None
+            for proc in self.processors:
+                try:
+                    return proc.process_query(uquery)
+                # FIXME : we don't want to catch any exception type here !
+                except (RQLSyntaxError, BadRQLQuery):
+                    pass
+                except Unauthorized as ex:
+                    unauthorized = ex
+                    continue
+                except Exception as ex:
+                    LOGGER.debug('%s: %s', ex.__class__.__name__, ex)
+                    continue
+            if unauthorized:
+                raise unauthorized
+        else:
+            # explicitly specified processor: don't try to catch the exception
+            return proc.process_query(uquery)
+        raise BadRQLQuery(self._cw._('sorry, the server is unable to handle this query'))
+
+
+
+## RQL suggestions builder ####################################################
+class RQLSuggestionsBuilder(Component):
+    """main entry point is `build_suggestions()` which takes
+    an incomplete RQL query and returns a list of suggestions to complete
+    the query.
+
+    This component is enabled by default and is used to provide autocompletion
+    in the RQL search bar. If you don't want this feature in your application,
+    just unregister it or make it unselectable.
+
+    .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.build_suggestions
+    .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.etypes_suggestion_set
+    .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.possible_etypes
+    .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.possible_relations
+    .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.vocabulary
+    """
+    __regid__ = 'rql.suggestions'
+
+    #: maximum number of results to fetch when suggesting attribute values
+    attr_value_limit = 20
+
+    def build_suggestions(self, user_rql):
+        """return a list of suggestions to complete `user_rql`
+
+        :param user_rql: an incomplete RQL query
+        """
+        req = self._cw
+        try:
+            if 'WHERE' not in user_rql: # don't try to complete if there's no restriction
+                return []
+            variables, restrictions = [part.strip() for part in user_rql.split('WHERE', 1)]
+            if ',' in restrictions:
+                restrictions, incomplete_part = restrictions.rsplit(',', 1)
+                user_rql = '%s WHERE %s' % (variables, restrictions)
+            else:
+                restrictions, incomplete_part = '', restrictions
+                user_rql = variables
+            select = parse(user_rql, print_errors=False).children[0]
+            req.vreg.rqlhelper.annotate(select)
+            req.vreg.solutions(req, select, {})
+            if restrictions:
+                return ['%s, %s' % (user_rql, suggestion)
+                        for suggestion in self.rql_build_suggestions(select, incomplete_part)]
+            else:
+                return ['%s WHERE %s' % (user_rql, suggestion)
+                        for suggestion in self.rql_build_suggestions(select, incomplete_part)]
+        except Exception as exc: # we never want to crash
+            self.debug('failed to build suggestions: %s', exc)
+            return []
+
+    ## actual completion entry points #########################################
+    def rql_build_suggestions(self, select, incomplete_part):
+        """
+        :param select: the annotated select node (rql syntax tree)
+        :param incomplete_part: the part of the rql query that needs
+                                to be completed, (e.g. ``X is Pr``, ``X re``)
+        """
+        chunks = incomplete_part.split(None, 2)
+        if not chunks: # nothing to complete
+            return []
+        if len(chunks) == 1: # `incomplete` looks like "MYVAR"
+            return self._complete_rqlvar(select, *chunks)
+        elif len(chunks) == 2: # `incomplete` looks like "MYVAR some_rel"
+            return self._complete_rqlvar_and_rtype(select, *chunks)
+        elif len(chunks) == 3: # `incomplete` looks like "MYVAR some_rel something"
+            return self._complete_relation_object(select, *chunks)
+        else: # would be anything else, hard to decide what to do here
+            return []
+
+    # _complete_* methods are considered private, at least while the API
+    # isn't stabilized.
+    def _complete_rqlvar(self, select, rql_var):
+        """return suggestions for "variable only" incomplete_part
+
+        as in :
+
+        - Any X WHERE X
+        - Any X WHERE X is Project, Y
+        - etc.
+        """
+        return ['%s %s %s' % (rql_var, rtype, dest_var)
+                for rtype, dest_var in self.possible_relations(select, rql_var)]
+
+    def _complete_rqlvar_and_rtype(self, select, rql_var, user_rtype):
+        """return suggestions for "variable + rtype" incomplete_part
+
+        as in :
+
+        - Any X WHERE X is
+        - Any X WHERE X is Person, X firstn
+        - etc.
+        """
+        # special case `user_type` == 'is', return every possible type.
+        if user_rtype == 'is':
+            return self._complete_is_relation(select, rql_var)
+        else:
+            return ['%s %s %s' % (rql_var, rtype, dest_var)
+                    for rtype, dest_var in self.possible_relations(select, rql_var)
+                    if rtype.startswith(user_rtype)]
+
+    def _complete_relation_object(self, select, rql_var, user_rtype, user_value):
+        """return suggestions for "variable + rtype + some_incomplete_value"
+
+        as in :
+
+        - Any X WHERE X is Per
+        - Any X WHERE X is Person, X firstname "
+        - Any X WHERE X is Person, X firstname "Pa
+        - etc.
+        """
+        # special case `user_type` == 'is', return every possible type.
+        if user_rtype == 'is':
+            return self._complete_is_relation(select, rql_var, user_value)
+        elif user_value:
+            if user_value[0] in ('"', "'"):
+                # if finished string, don't suggest anything
+                if len(user_value) > 1 and user_value[-1] == user_value[0]:
+                    return []
+                user_value = user_value[1:]
+                return ['%s %s "%s"' % (rql_var, user_rtype, value)
+                        for value in self.vocabulary(select, rql_var,
+                                                     user_rtype, user_value)]
+        return []
+
+    def _complete_is_relation(self, select, rql_var, prefix=''):
+        """return every possible types for rql_var
+
+        :param prefix: if specified, will only return entity types starting
+                       with the specified value.
+        """
+        return ['%s is %s' % (rql_var, etype)
+                for etype in self.possible_etypes(select, rql_var, prefix)]
+
+    def etypes_suggestion_set(self):
+        """returns the list of possible entity types to suggest
+
+        The default is to return any non-final entity type available
+        in the schema.
+
+        Can be overridden for instance if an application decides
+        to restrict this list to a meaningful set of business etypes.
+        """
+        schema = self._cw.vreg.schema
+        return set(eschema.type for eschema in schema.entities() if not eschema.final)
+
+    def possible_etypes(self, select, rql_var, prefix=''):
+        """return all possible etypes for `rql_var`
+
+        The returned list will always be a subset of meth:`etypes_suggestion_set`
+
+        :param select: the annotated select node (rql syntax tree)
+        :param rql_var: the variable name for which we want to know possible types
+        :param prefix: if specified, will only return etypes starting with it
+        """
+        available_etypes = self.etypes_suggestion_set()
+        possible_etypes = set()
+        for sol in select.solutions:
+            if rql_var in sol and sol[rql_var] in available_etypes:
+                possible_etypes.add(sol[rql_var])
+        if not possible_etypes:
+            # `Any X WHERE X is Person, Y is`
+            # -> won't have a solution, need to give all etypes
+            possible_etypes = available_etypes
+        return sorted(etype for etype in possible_etypes if etype.startswith(prefix))
+
+    def possible_relations(self, select, rql_var, include_meta=False):
+        """returns a list of couple (rtype, dest_var) for each possible
+        relations with `rql_var` as subject.
+
+        ``dest_var`` will be picked among availabel variables if types match,
+        otherwise a new one will be created.
+        """
+        schema = self._cw.vreg.schema
+        relations = set()
+        untyped_dest_var = next(rqlvar_maker(defined=select.defined_vars))
+        # for each solution
+        # 1. find each possible relation
+        # 2. for each relation:
+        #    2.1. if the relation is meta, skip it
+        #    2.2. for each possible destination type, pick up possible
+        #         variables for this type or use a new one
+        for sol in select.solutions:
+            etype = sol[rql_var]
+            sol_by_types = {}
+            for varname, var_etype in sol.items():
+                # don't push subject var to avoid "X relation X" suggestion
+                if varname != rql_var:
+                    sol_by_types.setdefault(var_etype, []).append(varname)
+            for rschema in schema[etype].subject_relations():
+                if include_meta or not rschema.meta:
+                    for dest in rschema.objects(etype):
+                        for varname in sol_by_types.get(dest.type, (untyped_dest_var,)):
+                            suggestion = (rschema.type, varname)
+                            if suggestion not in relations:
+                                relations.add(suggestion)
+        return sorted(relations)
+
+    def vocabulary(self, select, rql_var, user_rtype, rtype_incomplete_value):
+        """return acceptable vocabulary for `rql_var` + `user_rtype` in `select`
+
+        Vocabulary is either found from schema (Yams) definition or
+        directly from database.
+        """
+        schema = self._cw.vreg.schema
+        vocab = []
+        for sol in select.solutions:
+            # for each solution :
+            # - If a vocabulary constraint exists on `rql_var+user_rtype`, use it
+            #   to define possible values
+            # - Otherwise, query the database to fetch available values from
+            #   database (limiting results to `self.attr_value_limit`)
+            try:
+                eschema = schema.eschema(sol[rql_var])
+                rdef = eschema.rdef(user_rtype)
+            except KeyError: # unknown relation
+                continue
+            cstr = rdef.constraint_by_interface(IVocabularyConstraint)
+            if cstr is not None:
+                # a vocabulary is found, use it
+                vocab += [value for value in cstr.vocabulary()
+                          if value.startswith(rtype_incomplete_value)]
+            elif rdef.final:
+                # no vocab, query database to find possible value
+                vocab_rql = 'DISTINCT Any V LIMIT %s WHERE X is %s, X %s V' % (
+                    self.attr_value_limit, eschema.type, user_rtype)
+                vocab_kwargs = {}
+                if rtype_incomplete_value:
+                    vocab_rql += ', X %s LIKE %%(value)s' % user_rtype
+                    vocab_kwargs['value'] = u'%s%%' % rtype_incomplete_value
+                vocab += [value for value, in
+                          self._cw.execute(vocab_rql, vocab_kwargs)]
+        return sorted(set(vocab))
+
+
+
+@ajaxfunc(output_type='json')
+def rql_suggest(self):
+    rql_builder = self._cw.vreg['components'].select_or_none('rql.suggestions', self._cw)
+    if rql_builder:
+        return rql_builder.build_suggestions(self._cw.form['term'])
+    return []