web/views/magicsearch.py
changeset 8505 dcd9bc1d1bca
parent 7990 a673d1d9a738
child 8510 e2913c9880a0
equal deleted inserted replaced
8496:e4d71fc0b701 8505:dcd9bc1d1bca
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
    14 # details.
    14 # details.
    15 #
    15 #
    16 # You should have received a copy of the GNU Lesser General Public License along
    16 # You should have received a copy of the GNU Lesser General Public License along
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
    18 """a query processor to handle quick search shortcuts for cubicweb"""
    18 """a query processor to handle quick search shortcuts for cubicweb
       
    19 """
    19 
    20 
    20 __docformat__ = "restructuredtext en"
    21 __docformat__ = "restructuredtext en"
    21 
    22 
    22 import re
    23 import re
    23 from logging import getLogger
    24 from logging import getLogger
    24 from warnings import warn
    25 
       
    26 from yams.interfaces import IVocabularyConstraint
    25 
    27 
    26 from rql import RQLSyntaxError, BadRQLQuery, parse
    28 from rql import RQLSyntaxError, BadRQLQuery, parse
       
    29 from rql.utils import rqlvar_maker
    27 from rql.nodes import Relation
    30 from rql.nodes import Relation
    28 
    31 
    29 from cubicweb import Unauthorized, typed_eid
    32 from cubicweb import Unauthorized, typed_eid
    30 from cubicweb.view import Component
    33 from cubicweb.view import Component
       
    34 from cubicweb.web.views.ajaxcontroller import ajaxfunc
    31 
    35 
    32 LOGGER = getLogger('cubicweb.magicsearch')
    36 LOGGER = getLogger('cubicweb.magicsearch')
    33 
    37 
    34 def _get_approriate_translation(translations_found, eschema):
    38 def _get_approriate_translation(translations_found, eschema):
    35     """return the first (should be the only one) possible translation according
    39     """return the first (should be the only one) possible translation according
   406                 raise unauthorized
   410                 raise unauthorized
   407         else:
   411         else:
   408             # explicitly specified processor: don't try to catch the exception
   412             # explicitly specified processor: don't try to catch the exception
   409             return proc.process_query(uquery)
   413             return proc.process_query(uquery)
   410         raise BadRQLQuery(self._cw._('sorry, the server is unable to handle this query'))
   414         raise BadRQLQuery(self._cw._('sorry, the server is unable to handle this query'))
       
   415 
       
   416 
       
   417 
       
   418 ## RQL suggestions builder ####################################################
       
   419 class RQLSuggestionsBuilder(Component):
       
   420     """main entry point is `build_suggestions()` which takes
       
   421     an incomplete RQL query and returns a list of suggestions to complete
       
   422     the query.
       
   423 
       
   424     This component is enabled by default and is used to provide autocompletion
       
   425     in the RQL search bar. If you don't want this feature in your application,
       
   426     just unregister it or make it unselectable.
       
   427 
       
   428     .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.build_suggestions
       
   429     .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.etypes_suggestion_set
       
   430     .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.possible_etypes
       
   431     .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.possible_relations
       
   432     .. automethod:: cubicweb.web.views.magicsearch.RQLSuggestionsBuilder.vocabulary
       
   433     """
       
   434     __regid__ = 'rql.suggestions'
       
   435 
       
   436     #: maximum number of results to fetch when suggesting attribute values
       
   437     attr_value_limit = 20
       
   438 
       
   439     def build_suggestions(self, user_rql):
       
   440         """return a list of suggestions to complete `user_rql`
       
   441 
       
   442         :param user_rql: an incomplete RQL query
       
   443         """
       
   444         req = self._cw
       
   445         try:
       
   446             if 'WHERE' not in user_rql: # don't try to complete if there's no restriction
       
   447                 return []
       
   448             variables, restrictions = [part.strip() for part in user_rql.split('WHERE', 1)]
       
   449             if ',' in restrictions:
       
   450                 restrictions, incomplete_part = restrictions.rsplit(',', 1)
       
   451                 user_rql = '%s WHERE %s' % (variables, restrictions)
       
   452             else:
       
   453                 restrictions, incomplete_part = '', restrictions
       
   454                 user_rql = variables
       
   455             select = parse(user_rql).children[0]
       
   456             req.vreg.rqlhelper.annotate(select)
       
   457             req.vreg.solutions(req, select, {})
       
   458             if restrictions:
       
   459                 return ['%s, %s' % (user_rql, suggestion)
       
   460                         for suggestion in self.rql_build_suggestions(select, incomplete_part)]
       
   461             else:
       
   462                 return ['%s WHERE %s' % (user_rql, suggestion)
       
   463                         for suggestion in self.rql_build_suggestions(select, incomplete_part)]
       
   464         except Exception, exc: # we never want to crash
       
   465             self.debug('failed to build suggestions: %s', exc)
       
   466             return []
       
   467 
       
   468     ## actual completion entry points #########################################
       
   469     def rql_build_suggestions(self, select, incomplete_part):
       
   470         """
       
   471         :param select: the annotated select node (rql syntax tree)
       
   472         :param incomplete_part: the part of the rql query that needs
       
   473                                 to be completed, (e.g. ``X is Pr``, ``X re``)
       
   474         """
       
   475         chunks = incomplete_part.split(None, 2)
       
   476         if not chunks: # nothing to complete
       
   477             return []
       
   478         if len(chunks) == 1: # `incomplete` looks like "MYVAR"
       
   479             return self._complete_rqlvar(select, *chunks)
       
   480         elif len(chunks) == 2: # `incomplete` looks like "MYVAR some_rel"
       
   481             return self._complete_rqlvar_and_rtype(select, *chunks)
       
   482         elif len(chunks) == 3: # `incomplete` looks like "MYVAR some_rel something"
       
   483             return self._complete_relation_object(select, *chunks)
       
   484         else: # would be anything else, hard to decide what to do here
       
   485             return []
       
   486 
       
   487     # _complete_* methods are considered private, at least while the API
       
   488     # isn't stabilized.
       
   489     def _complete_rqlvar(self, select, rql_var):
       
   490         """return suggestions for "variable only" incomplete_part
       
   491 
       
   492         as in :
       
   493 
       
   494         - Any X WHERE X
       
   495         - Any X WHERE X is Project, Y
       
   496         - etc.
       
   497         """
       
   498         return ['%s %s %s' % (rql_var, rtype, dest_var)
       
   499                 for rtype, dest_var in self.possible_relations(select, rql_var)]
       
   500 
       
   501     def _complete_rqlvar_and_rtype(self, select, rql_var, user_rtype):
       
   502         """return suggestions for "variable + rtype" incomplete_part
       
   503 
       
   504         as in :
       
   505 
       
   506         - Any X WHERE X is
       
   507         - Any X WHERE X is Person, X firstn
       
   508         - etc.
       
   509         """
       
   510         # special case `user_type` == 'is', return every possible type.
       
   511         if user_rtype == 'is':
       
   512             return self._complete_is_relation(select, rql_var)
       
   513         else:
       
   514             return ['%s %s %s' % (rql_var, rtype, dest_var)
       
   515                     for rtype, dest_var in self.possible_relations(select, rql_var)
       
   516                     if rtype.startswith(user_rtype)]
       
   517 
       
   518     def _complete_relation_object(self, select, rql_var, user_rtype, user_value):
       
   519         """return suggestions for "variable + rtype + some_incomplete_value"
       
   520 
       
   521         as in :
       
   522 
       
   523         - Any X WHERE X is Per
       
   524         - Any X WHERE X is Person, X firstname "
       
   525         - Any X WHERE X is Person, X firstname "Pa
       
   526         - etc.
       
   527         """
       
   528         # special case `user_type` == 'is', return every possible type.
       
   529         if user_rtype == 'is':
       
   530             return self._complete_is_relation(select, rql_var, user_value)
       
   531         elif user_value:
       
   532             if user_value[0] in ('"', "'"):
       
   533                 # if finished string, don't suggest anything
       
   534                 if len(user_value) > 1 and user_value[-1] == user_value[0]:
       
   535                     return []
       
   536                 user_value = user_value[1:]
       
   537                 return ['%s %s "%s"' % (rql_var, user_rtype, value)
       
   538                         for value in self.vocabulary(select, rql_var,
       
   539                                                      user_rtype, user_value)]
       
   540         return []
       
   541 
       
   542     def _complete_is_relation(self, select, rql_var, prefix=''):
       
   543         """return every possible types for rql_var
       
   544 
       
   545         :param prefix: if specified, will only return entity types starting
       
   546                        with the specified value.
       
   547         """
       
   548         return ['%s is %s' % (rql_var, etype)
       
   549                 for etype in self.possible_etypes(select, rql_var, prefix)]
       
   550 
       
   551     def etypes_suggestion_set(self):
       
   552         """returns the list of possible entity types to suggest
       
   553 
       
   554         The default is to return any non-final entity type available
       
   555         in the schema.
       
   556 
       
   557         Can be overridden for instance if an application decides
       
   558         to restrict this list to a meaningful set of business etypes.
       
   559         """
       
   560         schema = self._cw.vreg.schema
       
   561         return set(eschema.type for eschema in schema.entities() if not eschema.final)
       
   562 
       
   563     def possible_etypes(self, select, rql_var, prefix=''):
       
   564         """return all possible etypes for `rql_var`
       
   565 
       
   566         The returned list will always be a subset of meth:`etypes_suggestion_set`
       
   567 
       
   568         :param select: the annotated select node (rql syntax tree)
       
   569         :param rql_var: the variable name for which we want to know possible types
       
   570         :param prefix: if specified, will only return etypes starting with it
       
   571         """
       
   572         available_etypes = self.etypes_suggestion_set()
       
   573         possible_etypes = set()
       
   574         for sol in select.solutions:
       
   575             if rql_var in sol and sol[rql_var] in available_etypes:
       
   576                 possible_etypes.add(sol[rql_var])
       
   577         if not possible_etypes:
       
   578             # `Any X WHERE X is Person, Y is`
       
   579             # -> won't have a solution, need to give all etypes
       
   580             possible_etypes = available_etypes
       
   581         return sorted(etype for etype in possible_etypes if etype.startswith(prefix))
       
   582 
       
   583     def possible_relations(self, select, rql_var, include_meta=False):
       
   584         """returns a list of couple (rtype, dest_var) for each possible
       
   585         relations with `rql_var` as subject.
       
   586 
       
   587         ``dest_var`` will be picked among availabel variables if types match,
       
   588         otherwise a new one will be created.
       
   589         """
       
   590         schema = self._cw.vreg.schema
       
   591         relations = set()
       
   592         untyped_dest_var = rqlvar_maker(defined=select.defined_vars).next()
       
   593         # for each solution
       
   594         # 1. find each possible relation
       
   595         # 2. for each relation:
       
   596         #    2.1. if the relation is meta, skip it
       
   597         #    2.2. for each possible destination type, pick up possible
       
   598         #         variables for this type or use a new one
       
   599         for sol in select.solutions:
       
   600             etype = sol[rql_var]
       
   601             sol_by_types = {}
       
   602             for varname, var_etype in sol.items():
       
   603                 # don't push subject var to avoid "X relation X" suggestion
       
   604                 if varname != rql_var:
       
   605                     sol_by_types.setdefault(var_etype, []).append(varname)
       
   606             for rschema in schema[etype].subject_relations():
       
   607                 if include_meta or not rschema.meta:
       
   608                     for dest in rschema.objects(etype):
       
   609                         for varname in sol_by_types.get(dest.type, (untyped_dest_var,)):
       
   610                             suggestion = (rschema.type, varname)
       
   611                             if suggestion not in relations:
       
   612                                 relations.add(suggestion)
       
   613         return sorted(relations)
       
   614 
       
   615     def vocabulary(self, select, rql_var, user_rtype, rtype_incomplete_value):
       
   616         """return acceptable vocabulary for `rql_var` + `user_rtype` in `select`
       
   617 
       
   618         Vocabulary is either found from schema (Yams) definition or
       
   619         directly from database.
       
   620         """
       
   621         schema = self._cw.vreg.schema
       
   622         vocab = []
       
   623         for sol in select.solutions:
       
   624             # for each solution :
       
   625             # - If a vocabulary constraint exists on `rql_var+user_rtype`, use it
       
   626             #   to define possible values
       
   627             # - Otherwise, query the database to fetch available values from
       
   628             #   database (limiting results to `self.attr_value_limit`)
       
   629             try:
       
   630                 eschema = schema.eschema(sol[rql_var])
       
   631                 rdef = eschema.rdef(user_rtype)
       
   632             except KeyError: # unknown relation
       
   633                 continue
       
   634             cstr = rdef.constraint_by_interface(IVocabularyConstraint)
       
   635             if cstr is not None:
       
   636                 # a vocabulary is found, use it
       
   637                 vocab += [value for value in cstr.vocabulary()
       
   638                           if value.startswith(rtype_incomplete_value)]
       
   639             elif rdef.final:
       
   640                 # no vocab, query database to find possible value
       
   641                 vocab_rql = 'DISTINCT Any V LIMIT %s WHERE X is %s, X %s V' % (
       
   642                     self.attr_value_limit, eschema.type, user_rtype)
       
   643                 vocab_kwargs = {}
       
   644                 if rtype_incomplete_value:
       
   645                     vocab_rql += ', X %s LIKE %%(value)s' % user_rtype
       
   646                     vocab_kwargs['value'] = '%s%%' % rtype_incomplete_value
       
   647                 vocab += [value for value, in
       
   648                           self._cw.execute(vocab_rql, vocab_kwargs)]
       
   649         return sorted(set(vocab))
       
   650 
       
   651 
       
   652 
       
   653 @ajaxfunc(output_type='json')
       
   654 def rql_suggest(self):
       
   655     rql_builder = self._cw.vreg['components'].select_or_none('rql.suggestions', self._cw)
       
   656     if rql_builder:
       
   657         return rql_builder.build_suggestions(self._cw.form['term'])
       
   658     return []