[facet] closes #1806931: new facet type, based on arbitrary rql path
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Tue, 05 Jul 2011 10:54:52 +0200
changeset 7618 5395007c415c
parent 7617 be5f68f9314e
child 7619 0d0344fd5231
[facet] closes #1806931: new facet type, based on arbitrary rql path
web/facet.py
web/test/unittest_facet.py
--- a/web/facet.py	Tue Jul 05 10:52:34 2011 +0200
+++ b/web/facet.py	Tue Jul 05 10:54:52 2011 +0200
@@ -30,6 +30,7 @@
 .. autoclass:: cubicweb.web.facet.RelationAttributeFacet
 .. autoclass:: cubicweb.web.facet.HasRelationFacet
 .. autoclass:: cubicweb.web.facet.AttributeFacet
+.. autoclass:: cubicweb.web.facet.RQLPathFacet
 .. autoclass:: cubicweb.web.facet.RangeFacet
 .. autoclass:: cubicweb.web.facet.DateRangeFacet
 
@@ -150,8 +151,8 @@
     """
     newvar = _add_rtype_relation(select, filtered_variable, rtype, role)[0]
     if select_target_entity:
-        if select.groupby:
-            select.add_group_var(newvar)
+        # if select.groupby: XXX we remove groupby now
+        #     select.add_group_var(newvar)
         select.add_selected(newvar)
     # add is restriction if necessary
     if filtered_variable.stinfo['typerel'] is None:
@@ -300,10 +301,6 @@
     select.add_restriction(newrel)
     return newvar, newrel
 
-def _add_eid_restr(rel, restrvar, value):
-    rrel = nodes.make_constant_restriction(restrvar, 'eid', value, 'Int')
-    rel.parent.replace(rel, nodes.And(rel, rrel))
-
 def _remove_relation(select, rel, var):
     """remove a constraint relation from the syntax tree"""
     # remove the relation
@@ -331,6 +328,13 @@
         term = nodes.SortTerm(sortfunc, sortasc)
         select.add_sort_term(term)
 
+def _get_var(select, varname, varmap):
+    try:
+        return varmap[varname]
+    except KeyError:
+        varmap[varname] = var = select.make_variable()
+        return var
+
 
 _prepare_vocabulary_rqlst = deprecated('[3.13] renamed prepare_vocabulary_select')(
     prepare_vocabulary_select)
@@ -404,6 +408,21 @@
     def __repr__(self):
         return '<%s>' % self.__class__.__name__
 
+    def get_widget(self):
+        """Return the widget instance to use to display this facet, or None if
+        the facet can't do anything valuable (only one value in the vocabulary
+        for instance).
+        """
+        raise NotImplementedError
+
+    def add_rql_restrictions(self):
+        """When some facet criteria has been updated, this method is called to
+        add restriction for this facet into the rql syntax tree. It should get
+        back its value in form parameters, and modify the syntax tree
+        (`self.select`) accordingly.
+        """
+        raise NotImplementedError
+
     @property
     def operator(self):
         """Return the operator (AND or OR) to use for this facet when multiple
@@ -421,21 +440,6 @@
         except Unauthorized:
             return []
 
-    def get_widget(self):
-        """Return the widget instance to use to display this facet, or None if
-        the facet can't do anything valuable (only one value in the vocabulary
-        for instance).
-        """
-        raise NotImplementedError
-
-    def add_rql_restrictions(self):
-        """When some facet criteria has been updated, this method is called to
-        add restriction for this facet into the rql syntax tree. It should get
-        back its value in form parameters, and modify the syntax tree
-        (`self.select`) accordingly.
-        """
-        raise NotImplementedError
-
     @property
     def wdgclass(self):
         raise NotImplementedError
@@ -515,7 +519,7 @@
     set. By default, `i18nable` will be set according to the schema, but you can
     force its value by setting it has a class attribute.
 
-    You can filter out target entity types by specifying `target_type`
+    You can filter out target entity types by specifying `target_type`.
 
     By default, vocabulary will be displayed sorted on `target_attr` value in an
     ascending way. You can control sorting with:
@@ -561,8 +565,14 @@
     # class attributes to configure the relation facet
     rtype = None
     role = 'subject'
+    target_type = None
     target_attr = 'eid'
-    target_type = None
+    # for subclasses parametrization, should not change if you want a
+    # RelationFacet
+    target_attr_type = 'Int'
+    restr_attr = 'eid'
+    restr_attr_type = 'Int'
+    comparator = '=' # could be '<', '<=', '>', '>='
     # set this to a stored procedure name if you want to sort on the result of
     # this function's result instead of direct value
     sortfunc = None
@@ -600,7 +610,7 @@
                 select.add_type_restriction(var, self.target_type)
             try:
                 rset = self.rqlexec(select.as_string(), self.cw_rset.args)
-            except:
+            except Exception:
                 self.exception('error while getting vocabulary for %s, rql: %s',
                                self, select.as_string())
                 return ()
@@ -626,10 +636,10 @@
                                          self.role, select_target_entity=True)
             else:
                 insert_attr_select_relation(
-                    select, self.filtered_variable, self.rtype, self.role, self.target_attr,
-                    select_target_entity=False)
+                    select, self.filtered_variable, self.rtype, self.role,
+                    self.target_attr, select_target_entity=False)
             values = [unicode(x) for x, in self.rqlexec(select.as_string())]
-        except:
+        except Exception:
             self.exception('while computing values for %s', self)
             return []
         finally:
@@ -708,10 +718,14 @@
         return support
 
     def value_restriction(self, restrvar, rel, value):
+        if self.restr_attr != 'eid':
+            self.select.set_distinct(True)
         if isinstance(value, basestring):
             # only one value selected
             if value:
-                self.select.add_eid_restriction(restrvar, value)
+                self.select.add_constant_restriction(
+                    restrvar, self.restr_attr, value,
+                    self.restr_attr_type)
             else:
                 rel.parent.replace(rel, nodes.Not(rel))
         elif self.operator == 'OR':
@@ -722,17 +736,23 @@
             if '' in value:
                 value.remove('')
                 self._add_not_rel_restr(rel)
-            _add_eid_restr(rel, restrvar, value)
+            self._and_restriction(rel, restrvar, value)
         else:
             # multiple values with AND operator
             if '' in value:
                 value.remove('')
                 self._add_not_rel_restr(rel)
-            _add_eid_restr(rel, restrvar, value.pop())
+            self._and_restriction(rel, restrvar, value.pop())
             while value:
                 restrvar, rtrel = _make_relation(self.select, filtered_variable,
                                                  self.rtype, self.role)
-                _add_eid_restr(rel, restrvar, value.pop())
+                self._and_restriction(rel, restrvar, value.pop())
+
+    def _and_restriction(self, rel, restrvar, value):
+        rrel = nodes.make_constant_restriction(restrvar, self.restr_attr,
+                                               value, self.restr_attr_type)
+        rel.parent.replace(rel, nodes.And(rel, rrel))
+
 
     @cached
     def _search_card(self, cards):
@@ -787,7 +807,7 @@
             self.filtered_variable.name, subj, self.rtype, obj, restrictions)
         try:
             return bool(self.rqlexec(rql, self.cw_rset and self.cw_rset.args))
-        except:
+        except Exception:
             # catch exception on executing rql, work-around #1356884 until a
             # proper fix
             self.exception('cant handle rql generated by %s', self)
@@ -806,7 +826,7 @@
 
     * `label_vid` doesn't make sense here
 
-    * you should specify the attribute type using `attrtype` if it's not a
+    * you should specify the attribute type using `target_attr_type` if it's not a
       String
 
     * you can specify a comparison operator using `comparator`
@@ -839,10 +859,18 @@
     """
     _select_target_entity = False
     # attribute type
-    attrtype = 'String'
+    target_attr_type = 'String'
     # type of comparison: default is an exact match on the attribute value
     comparator = '=' # could be '<', '<=', '>', '>='
 
+    @property
+    def restr_attr(self):
+        return self.target_attr
+
+    @property
+    def restr_attr_type(self):
+        return self.target_attr_type
+
     def rset_vocabulary(self, rset):
         if self.i18nable:
             _ = self._cw._
@@ -855,32 +883,6 @@
             return sorted(values)
         return reversed(sorted(values))
 
-    def add_rql_restrictions(self):
-        """add restriction for this facet into the rql syntax tree"""
-        value = self._cw.form.get(self.__regid__)
-        if not value:
-            return
-        filtered_variable = self.filtered_variable
-        restrvar = _add_rtype_relation(self.select, filtered_variable, self.rtype,
-                                       self.role)[0]
-        self.select.set_distinct(True)
-        if isinstance(value, basestring) or self.operator == 'OR':
-            # only one value selected or multiple ORed values: using IN is fine
-            self.select.add_constant_restriction(
-                restrvar, self.target_attr, value,
-                self.attrtype, self.comparator)
-        else:
-            # multiple values with AND operator
-            self.select.add_constant_restriction(
-                restrvar, self.target_attr, value.pop(),
-                self.attrtype, self.comparator)
-            while value:
-                restrvar = _add_rtype_relation(self.select, filtered_variable, self.rtype,
-                                               self.role)[0]
-                self.select.add_constant_restriction(
-                    restrvar, self.target_attr, value.pop(),
-                    self.attrtype, self.comparator)
-
 
 class AttributeFacet(RelationAttributeFacet):
     """Base facet to filter some entities according one of their attribute.
@@ -939,7 +941,7 @@
             _set_orderby(select, newvar, self.sortasc, self.sortfunc)
             try:
                 rset = self.rqlexec(select.as_string(), self.cw_rset.args)
-            except:
+            except Exception:
                 self.exception('error while getting vocabulary for %s, rql: %s',
                                self, select.as_string())
                 return ()
@@ -956,7 +958,181 @@
             return
         filtered_variable = self.filtered_variable
         self.select.add_constant_restriction(filtered_variable, self.rtype, value,
-                                            self.attrtype, self.comparator)
+                                            self.target_attr_type, self.comparator)
+
+
+class RQLPathFacet(RelationFacet):
+    """Base facet to filter some entities according to an arbitrary rql
+    path. Path should be specified as a list of 3-uples or triplet string, where
+    'X' represent the filtered variable. You should specify using
+    `filter_variable` the snippet variable that will be used to filter out
+    results. You may also specify a `label_variable`. If you want to filter on
+    an attribute value, you usually don't want to specify the later since it's
+    the same as the filter variable, though you may have to specify the attribute
+    type using `restr_attr_type` if there are some type ambiguity in the schema
+    for the attribute.
+
+    Using this facet, we can rewrite facets we defined previously:
+
+    .. sourcecode:: python
+
+      class AgencyFacet(RQLPathFacet):
+          __regid__ = 'agency'
+          # this facet should only be selected when visualizing offices
+          __select__ = RelationFacet.__select__ & is_instance('Office')
+          # this facet is a filter on the 'Agency' entities linked to the office
+          # through the 'proposed_by' relation, where the office is the subject
+          # of the relation
+          path = ['X has_address O', 'O name N']
+          filter_variable = 'O'
+          label_variable = 'N'
+
+      class PostalCodeFacet(RQLPathFacet):
+          __regid__ = 'postalcode'
+          # this facet should only be selected when visualizing offices
+          __select__ = RelationAttributeFacet.__select__ & is_instance('Office')
+          # this facet is a filter on the PostalAddress entities linked to the
+          # office through the 'has_address' relation, where the office is the
+          # subject of the relation
+          path = ['X has_address O', 'O postal_code PC']
+          filter_variable = 'PC'
+
+    Though some features, such as 'no value' or automatic internationalization,
+    won't work. This facet class is designed to be used for cases where
+    :class:`RelationFacet` or :class:`RelationAttributeFacet` can't do the trick
+    (e.g when you want to filter on entities where are not directly linked to
+     the filtered entities).
+    """
+    # must be specified
+    path = None
+    filter_variable = None
+    # may be specified
+    label_variable = None
+    # usually guessed, but may be explicitly specified
+    restr_attr = None
+    restr_attr_type = None
+
+    # XXX disabled features
+    i18nable = False
+    no_relation = False
+    support_and = False
+
+    def __init__(self, *args, **kwargs):
+        super(RQLPathFacet, self).__init__(*args, **kwargs)
+        assert self.path and isinstance(self.path, (list, tuple)), \
+               'path should be a list of 3-uples, not %s' % self.path
+        for part in self.path:
+            if isinstance(part, basestring):
+                part = part.split()
+            assert len(part) == 3, \
+                   'path should be a list of 3-uples, not %s' % part
+
+    def __repr__(self):
+        return '<%s %s>' % (self.__class__.__name__,
+                            ','.join(str(p) for p in self.path))
+
+    def vocabulary(self):
+        """return vocabulary for this facet, eg a list of 2-uple (label, value)
+        """
+        select = self.select
+        select.save_state()
+        if self.rql_sort:
+            sort = self.sortasc
+        else:
+            sort = None # will be sorted on label
+        try:
+            cleanup_select(select, self.filtered_variable)
+            varmap, restrvar = self.add_path_to_select()
+            select.append_selected(nodes.VariableRef(restrvar))
+            if self.label_variable:
+                attrvar = varmap[self.label_variable]
+            else:
+                attrvar = restrvar
+            select.append_selected(nodes.VariableRef(attrvar))
+            if sort is not None:
+                _set_orderby(select, attrvar, sort, self.sortfunc)
+            try:
+                rset = self.rqlexec(select.as_string(), self.cw_rset.args)
+            except Exception:
+                self.exception('error while getting vocabulary for %s, rql: %s',
+                               self, select.as_string())
+                return ()
+        finally:
+            select.recover()
+        # don't call rset_vocabulary on empty result set, it may be an empty
+        # *list* (see rqlexec implementation)
+        values = rset and self.rset_vocabulary(rset) or []
+        if self._include_no_relation():
+            values.insert(0, (self._cw._(self.no_relation_label), ''))
+        return values
+
+    def possible_values(self):
+        """return a list of possible values (as string since it's used to
+        compare to a form value in javascript) for this facet
+        """
+        select = self.select
+        select.save_state()
+        try:
+            cleanup_select(select, self.filtered_variable)
+            varmap, restrvar = self.add_path_to_select(skiplabel=True)
+            select.append_selected(nodes.VariableRef(restrvar))
+            values = [unicode(x) for x, in self.rqlexec(select.as_string())]
+        except Exception:
+            self.exception('while computing values for %s', self)
+            return []
+        finally:
+            select.recover()
+        if self._include_no_relation():
+            values.append('')
+        return values
+
+    def add_rql_restrictions(self):
+        """add restriction for this facet into the rql syntax tree"""
+        value = self._cw.form.get(self.__regid__)
+        if value is None:
+            return
+        varmap, restrvar = self.add_path_to_select(
+            skiplabel=True, skipattrfilter=True)
+        self.value_restriction(restrvar, None, value)
+
+    def add_path_to_select(self, skiplabel=False, skipattrfilter=False):
+        varmap = {'X': self.filtered_variable}
+        actual_filter_variable = None
+        for part in self.path:
+            if isinstance(part, basestring):
+                part = part.split()
+            subject, rtype, object = part
+            if skiplabel and object == self.label_variable:
+                continue
+            if object == self.filter_variable:
+                rschema = self._cw.vreg.schema.rschema(rtype)
+                if rschema.final:
+                    # filter variable is an attribute variable
+                    if self.restr_attr is None:
+                        self.restr_attr = rtype
+                    if self.restr_attr_type is None:
+                        attrtypes = set(obj for subj,obj in rschema.rdefs)
+                        if len(attrtypes) > 1:
+                            raise Exception('ambigous attribute %s, specify attrtype on %s'
+                                            % (rtype, self.__class__))
+                        self.restr_attr_type = iter(attrtypes).next()
+                    if skipattrfilter:
+                        actual_filter_variable = subject
+                        continue
+            subjvar = _get_var(self.select, subject, varmap)
+            objvar = _get_var(self.select, object, varmap)
+            rel = nodes.make_relation(subjvar, rtype, (objvar,),
+                                      nodes.VariableRef)
+            self.select.add_restriction(rel)
+        if self.restr_attr is None:
+            self.restr_attr = 'eid'
+        if self.restr_attr_type is None:
+            self.restr_attr_type = 'Int'
+        if actual_filter_variable:
+            restrvar = varmap[actual_filter_variable]
+        else:
+            restrvar = varmap[self.filter_variable]
+        return varmap, restrvar
 
 
 class RangeFacet(AttributeFacet):
@@ -988,7 +1164,7 @@
 
     .. _jquery: http://www.jqueryui.com/
     """
-    attrtype = 'Float' # only numerical types are supported
+    target_attr_type = 'Float' # only numerical types are supported
     needs_update = False # not supported actually
 
     @property
@@ -1033,7 +1209,7 @@
         self.select.add_constant_restriction(self.filtered_variable,
                                              self.rtype,
                                              self.formatvalue(value),
-                                             self.attrtype, operator)
+                                             self.target_attr_type, operator)
 
 
 
@@ -1045,7 +1221,7 @@
 
     .. image:: ../images/facet_date_range.png
     """
-    attrtype = 'Date' # only date types are supported
+    target_attr_type = 'Date' # only date types are supported
 
     @property
     def wdgclass(self):
--- a/web/test/unittest_facet.py	Tue Jul 05 10:52:34 2011 +0200
+++ b/web/test/unittest_facet.py	Tue Jul 05 10:54:52 2011 +0200
@@ -45,7 +45,7 @@
         # selection is cluttered because rqlst has been prepared for facet (it
         # is not in real life)
         self.assertEqual(f.select.as_string(),
-                          'DISTINCT Any  WHERE X is CWUser, X in_group D, D eid %s' % guests)
+                         'DISTINCT Any  WHERE X is CWUser, X in_group D, D eid %s' % guests)
 
     def test_relation_optional_rel(self):
         req = self.request()
@@ -176,6 +176,52 @@
         self.assertEqual(f.select.as_string(),
                           "DISTINCT Any  WHERE X is CWUser, X login 'admin'")
 
+    def test_rql_path_eid(self):
+        req, rset, rqlst, filtered_variable = self.prepare_rqlst()
+        facet.RQLPathFacet.path = [('X created_by U'), ('U owned_by O'), ('O login OL')]
+        f = facet.RQLPathFacet(req, rset=rset,
+                               select=rqlst.children[0],
+                               filtered_variable=filtered_variable)
+        f.filter_variable = 'O'
+        f.label_variable = 'OL'
+
+        self.assertEqual(f.vocabulary(), [(u'admin', self.user().eid),])
+        # ensure rqlst is left unmodified
+        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        #rqlst = rset.syntax_tree()
+        self.assertEqual(f.possible_values(),
+                          [str(self.user().eid),])
+        # ensure rqlst is left unmodified
+        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        req.form[f.__regid__] = '1'
+        f.add_rql_restrictions()
+        # selection is cluttered because rqlst has been prepared for facet (it
+        # is not in real life)
+        self.assertEqual(f.select.as_string(),
+                         "DISTINCT Any  WHERE X is CWUser, X created_by F, F owned_by G, G eid 1")
+
+    def test_rql_path_attr(self):
+        req, rset, rqlst, filtered_variable = self.prepare_rqlst()
+        facet.RQLPathFacet.path = [('X created_by U'), ('U owned_by O'), ('O login OL')]
+        f = facet.RQLPathFacet(req, rset=rset,
+                               select=rqlst.children[0],
+                               filtered_variable=filtered_variable)
+        f.filter_variable = 'OL'
+
+        self.assertEqual(f.vocabulary(), [(u'admin', 'admin'),])
+        # ensure rqlst is left unmodified
+        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        #rqlst = rset.syntax_tree()
+        self.assertEqual(f.possible_values(), ['admin',])
+        # ensure rqlst is left unmodified
+        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        req.form[f.__regid__] = 'admin'
+        f.add_rql_restrictions()
+        # selection is cluttered because rqlst has been prepared for facet (it
+        # is not in real life)
+        self.assertEqual(f.select.as_string(),
+                         "DISTINCT Any  WHERE X is CWUser, X created_by G, G owned_by H, H login 'admin'")
+
 
 if __name__ == '__main__':
     from logilab.common.testlib import unittest_main