[ui] provide an autocomplete RQL bar (closes #2439846)
authorAdrien Di Mascio <Adrien.DiMascio@logilab.fr>
Tue, 24 Jul 2012 18:30:36 +0200
changeset 8505 dcd9bc1d1bca
parent 8496 e4d71fc0b701
child 8509 e83b4a64ba29
[ui] provide an autocomplete RQL bar (closes #2439846)
doc/book/en/devweb/index.rst
doc/book/en/devweb/searchbar.rst
web/test/unittest_magicsearch.py
web/views/basecomponents.py
web/views/magicsearch.py
--- a/doc/book/en/devweb/index.rst	Tue Jul 24 17:51:00 2012 +0200
+++ b/doc/book/en/devweb/index.rst	Tue Jul 24 18:30:36 2012 +0200
@@ -10,6 +10,7 @@
    publisher
    controllers
    request
+   searchbar
    views/index
    rtags
    ajax
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/book/en/devweb/searchbar.rst	Tue Jul 24 18:30:36 2012 +0200
@@ -0,0 +1,41 @@
+.. _searchbar:
+
+RQL search bar
+--------------
+
+The RQL search bar is a visual component, hidden by default, the tiny *search*
+input being enough for common use cases.
+
+An autocompletion helper is provided to help you type valid queries, both
+in terms of syntax and in terms of schema validity.
+
+.. autoclass:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder
+
+
+How search is performed
++++++++++++++++++++++++
+
+You can use the *rql search bar* to either type RQL queries, plain text queries
+or standard shortcuts such as *<EntityType>* or *<EntityType> <attrname> <value>*.
+
+Ultimately, all queries are translated to rql since it's the only
+language understood on the server (data) side. To transform the user
+query into RQL, CubicWeb uses the so-called *magicsearch component*,
+defined in :mod:`cubicweb.web.views.magicsearch`, which in turn
+delegates to a number of query preprocessor that are responsible of
+interpreting the user query and generating corresponding RQL.
+
+The code of the main processor loop is easy to understand:
+
+.. sourcecode:: python
+
+  for proc in self.processors:
+      try:
+          return proc.process_query(uquery, req)
+      except (RQLSyntaxError, BadRQLQuery):
+          pass
+
+The idea is simple: for each query processor, try to translate the
+query. If it fails, try with the next processor, if it succeeds,
+we're done and the RQL query will be executed.
+
--- a/web/test/unittest_magicsearch.py	Tue Jul 24 17:51:00 2012 +0200
+++ b/web/test/unittest_magicsearch.py	Tue Jul 24 18:30:36 2012 +0200
@@ -230,5 +230,114 @@
         self.assertEqual(rset.rql, 'Any X ORDERBY FTIRANK(X) DESC WHERE X has_text %(text)s')
         self.assertEqual(rset.args, {'text': u'utilisateur Smith'})
 
+
+class RQLSuggestionsBuilderTC(CubicWebTC):
+    def suggestions(self, rql):
+        req = self.request()
+        rbs = self.vreg['components'].select('rql.suggestions', req)
+        rbs.attr_value_limit = 10 # limit to 10 to ease vocabulry tests
+        return rbs.build_suggestions(rql)
+
+    def test_no_restrictions_rql(self):
+        self.assertListEqual([], self.suggestions(''))
+        self.assertListEqual([], self.suggestions('An'))
+        self.assertListEqual([], self.suggestions('Any X'))
+        self.assertListEqual([], self.suggestions('Any X, Y'))
+
+    def test_invalid_rql(self):
+        self.assertListEqual([], self.suggestions('blabla'))
+        self.assertListEqual([], self.suggestions('Any X WHERE foo, bar'))
+
+    def test_is_rql(self):
+        self.assertListEqual(['Any X WHERE X is %s' % eschema
+                              for eschema in sorted(self.vreg.schema.entities())
+                              if not eschema.final],
+                             self.suggestions('Any X WHERE X is'))
+
+        self.assertListEqual(['Any X WHERE X is Personne', 'Any X WHERE X is Project'],
+                             self.suggestions('Any X WHERE X is P'))
+
+        self.assertListEqual(['Any X WHERE X is Personne, Y is Personne',
+                              'Any X WHERE X is Personne, Y is Project'],
+                             self.suggestions('Any X WHERE X is Personne, Y is P'))
+
+
+    def test_relations_rql(self):
+        self.assertListEqual(['Any X WHERE X is Personne, X ass A',
+                              'Any X WHERE X is Personne, X datenaiss A',
+                              'Any X WHERE X is Personne, X description A',
+                              'Any X WHERE X is Personne, X fax A',
+                              'Any X WHERE X is Personne, X nom A',
+                              'Any X WHERE X is Personne, X prenom A',
+                              'Any X WHERE X is Personne, X promo A',
+                              'Any X WHERE X is Personne, X salary A',
+                              'Any X WHERE X is Personne, X sexe A',
+                              'Any X WHERE X is Personne, X tel A',
+                              'Any X WHERE X is Personne, X test A',
+                              'Any X WHERE X is Personne, X titre A',
+                              'Any X WHERE X is Personne, X travaille A',
+                              'Any X WHERE X is Personne, X web A',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X '))
+        self.assertListEqual(['Any X WHERE X is Personne, X tel A',
+                              'Any X WHERE X is Personne, X test A',
+                              'Any X WHERE X is Personne, X titre A',
+                              'Any X WHERE X is Personne, X travaille A',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X t'))
+        # try completion on selected
+        self.assertListEqual(['Any X WHERE X is Personne, Y is Societe, X tel A',
+                              'Any X WHERE X is Personne, Y is Societe, X test A',
+                              'Any X WHERE X is Personne, Y is Societe, X titre A',
+                              'Any X WHERE X is Personne, Y is Societe, X travaille Y',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, Y is Societe, X t'))
+        # invalid relation should not break
+        self.assertListEqual([],
+                             self.suggestions('Any X WHERE X is Personne, X asdasd'))
+
+    def test_attribute_vocabulary_rql(self):
+        self.assertListEqual(['Any X WHERE X is Personne, X promo "bon"',
+                              'Any X WHERE X is Personne, X promo "pasbon"',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X promo "'))
+        self.assertListEqual(['Any X WHERE X is Personne, X promo "pasbon"',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X promo "p'))
+        # "bon" should be considered complete, hence no suggestion
+        self.assertListEqual([],
+                             self.suggestions('Any X WHERE X is Personne, X promo "bon"'))
+        # no valid vocabulary starts with "po"
+        self.assertListEqual([],
+                             self.suggestions('Any X WHERE X is Personne, X promo "po'))
+
+    def test_attribute_value_rql(self):
+        # suggestions should contain any possible value for
+        # a given attribute (limited to 10)
+        req = self.request()
+        for i in xrange(15):
+            req.create_entity('Personne', nom=u'n%s' % i, prenom=u'p%s' % i)
+        self.assertListEqual(['Any X WHERE X is Personne, X nom "n0"',
+                              'Any X WHERE X is Personne, X nom "n1"',
+                              'Any X WHERE X is Personne, X nom "n2"',
+                              'Any X WHERE X is Personne, X nom "n3"',
+                              'Any X WHERE X is Personne, X nom "n4"',
+                              'Any X WHERE X is Personne, X nom "n5"',
+                              'Any X WHERE X is Personne, X nom "n6"',
+                              'Any X WHERE X is Personne, X nom "n7"',
+                              'Any X WHERE X is Personne, X nom "n8"',
+                              'Any X WHERE X is Personne, X nom "n9"',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X nom "'))
+        self.assertListEqual(['Any X WHERE X is Personne, X nom "n1"',
+                              'Any X WHERE X is Personne, X nom "n10"',
+                              'Any X WHERE X is Personne, X nom "n11"',
+                              'Any X WHERE X is Personne, X nom "n12"',
+                              'Any X WHERE X is Personne, X nom "n13"',
+                              'Any X WHERE X is Personne, X nom "n14"',
+                              ],
+                             self.suggestions('Any X WHERE X is Personne, X nom "n1'))
+
+
 if __name__ == '__main__':
     unittest_main()
--- a/web/views/basecomponents.py	Tue Jul 24 17:51:00 2012 +0200
+++ b/web/views/basecomponents.py	Tue Jul 24 18:30:36 2012 +0200
@@ -59,6 +59,14 @@
         # display multilines query as one line
         rql = rset is not None and rset.printable_rql(encoded=False) or req.form.get('rql', '')
         rql = rql.replace(u"\n", u" ")
+        rql_suggestion_comp = self._cw.vreg['components'].select_or_none('rql.suggestions', self._cw)
+        if rql_suggestion_comp is not None:
+            # enable autocomplete feature only if the rql
+            # suggestions builder is available
+            self._cw.add_css('jquery.ui.css')
+            self._cw.add_js(('cubicweb.ajax.js', 'jquery.ui.js'))
+            self._cw.add_onload('$("#rql").autocomplete({source: "%s"});'
+                                % (req.build_url('json', fname='rql_suggest')))
         self.w(u'''<div id="rqlinput" class="%s"><form action="%s"><fieldset>
 <input type="text" id="rql" name="rql" value="%s"  title="%s" tabindex="%s" accesskey="q" class="searchField" />
 ''' % (not self.cw_propval('visible') and 'hidden' or '',
--- a/web/views/magicsearch.py	Tue Jul 24 17:51:00 2012 +0200
+++ b/web/views/magicsearch.py	Tue Jul 24 18:30:36 2012 +0200
@@ -15,19 +15,23 @@
 #
 # 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"""
+"""a query processor to handle quick search shortcuts for cubicweb
+"""
 
 __docformat__ = "restructuredtext en"
 
 import re
 from logging import getLogger
-from warnings import warn
+
+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, typed_eid
 from cubicweb.view import Component
+from cubicweb.web.views.ajaxcontroller import ajaxfunc
 
 LOGGER = getLogger('cubicweb.magicsearch')
 
@@ -408,3 +412,247 @@
             # 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).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, 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 = rqlvar_maker(defined=select.defined_vars).next()
+        # 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'] = '%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 []