# HG changeset patch # User Adrien Di Mascio # Date 1343147436 -7200 # Node ID dcd9bc1d1bcac01d98cf9129a5a5898abc538dd0 # Parent e4d71fc0b701dbf78ba4bd28e529d47e587ccabe [ui] provide an autocomplete RQL bar (closes #2439846) diff -r e4d71fc0b701 -r dcd9bc1d1bca doc/book/en/devweb/index.rst --- 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 diff -r e4d71fc0b701 -r dcd9bc1d1bca doc/book/en/devweb/searchbar.rst --- /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 ** or * *. + +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. + diff -r e4d71fc0b701 -r dcd9bc1d1bca web/test/unittest_magicsearch.py --- 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() diff -r e4d71fc0b701 -r dcd9bc1d1bca web/views/basecomponents.py --- 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'''
''' % (not self.cw_propval('visible') and 'hidden' or '', diff -r e4d71fc0b701 -r dcd9bc1d1bca web/views/magicsearch.py --- 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 . -"""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 []