[facets] support for `no_relation` on RelationFacet
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Fri, 01 Oct 2010 16:53:35 +0200
changeset 6380 63d5dbaef999
parent 6379 3f67f7ea5632
child 6384 89d5b339ebdd
[facets] support for `no_relation` on RelationFacet e.g. to filter en entities *without* a given relation as well as with a particular relation. Proposed or not by default according to relation cardinality. Also, try to guess i18nable properly from the schema by default.
__pkginfo__.py
debian/control
web/facet.py
web/test/unittest_facet.py
--- a/__pkginfo__.py	Fri Oct 01 16:07:03 2010 +0200
+++ b/__pkginfo__.py	Fri Oct 01 16:53:35 2010 +0200
@@ -42,7 +42,7 @@
 __depends__ = {
     'logilab-common': '>= 0.51.0',
     'logilab-mtconverter': '>= 0.8.0',
-    'rql': '>= 0.26.2',
+    'rql': '>= 0.27.0',
     'yams': '>= 0.30.1',
     'docutils': '>= 0.6',
     #gettext                    # for xgettext, msgcat, etc...
--- a/debian/control	Fri Oct 01 16:07:03 2010 +0200
+++ b/debian/control	Fri Oct 01 16:53:35 2010 +0200
@@ -97,7 +97,7 @@
 Package: cubicweb-common
 Architecture: all
 XB-Python-Version: ${python:Versions}
-Depends: ${python:Depends}, graphviz, gettext, python-logilab-mtconverter (>= 0.8.0), python-logilab-common (>= 0.51.0), python-yams (>= 0.30.1), python-rql (>= 0.26.3), python-lxml
+Depends: ${python:Depends}, graphviz, gettext, python-logilab-mtconverter (>= 0.8.0), python-logilab-common (>= 0.51.0), python-yams (>= 0.30.1), python-rql (>= 0.27.0), python-lxml
 Recommends: python-simpletal (>= 4.0), python-crypto
 Conflicts: cubicweb-core
 Replaces: cubicweb-core
--- a/web/facet.py	Fri Oct 01 16:07:03 2010 +0200
+++ b/web/facet.py	Fri Oct 01 16:53:35 2010 +0200
@@ -55,7 +55,7 @@
 from logilab.common.date import datetime2ticks
 from logilab.common.compat import all
 
-from rql import parse, nodes
+from rql import parse, nodes, utils
 
 from cubicweb import Unauthorized, typed_eid
 from cubicweb.schema import display_name
@@ -165,18 +165,27 @@
         return ovar
     return None
 
+def _make_relation(rqlst, mainvar, rtype, role):
+    newvar = rqlst.make_variable()
+    if role == 'object':
+        rel = nodes.make_relation(newvar, rtype, (mainvar,), nodes.VariableRef)
+    else:
+        rel = nodes.make_relation(mainvar, rtype, (newvar,), nodes.VariableRef)
+    return newvar, rel
+
 def _add_rtype_relation(rqlst, mainvar, rtype, role):
     """add a relation relying `mainvar` to entities linked by the `rtype`
     relation (where `mainvar` has `role`)
 
     return the inserted variable for linked entities.
     """
-    newvar = rqlst.make_variable()
-    if role == 'object':
-        rqlst.add_relation(newvar, rtype, mainvar)
-    else:
-        rqlst.add_relation(mainvar, rtype, newvar)
-    return newvar
+    newvar, newrel = _make_relation(rqlst, mainvar, rtype, role)
+    rqlst.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 _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role,
                               select_target_entity=True):
@@ -187,7 +196,7 @@
     * add the new variable to GROUPBY clause if necessary
     * add the new variable to the selection
     """
-    newvar = _add_rtype_relation(rqlst, mainvar, rtype, role)
+    newvar = _add_rtype_relation(rqlst, mainvar, rtype, role)[0]
     if select_target_entity:
         if rqlst.groupby:
             rqlst.add_group_var(newvar)
@@ -240,7 +249,6 @@
     _cleanup_rqlst(rqlst, mainvar)
     var = _prepare_vocabulary_rqlst(rqlst, mainvar, rtype, role,
                                     select_target_entity)
-    # not found, create one
     attrvar = rqlst.make_variable()
     rqlst.add_relation(var, attrname, attrvar)
     # if query is grouped, we have to add the attribute variable
@@ -449,6 +457,10 @@
 
     The relation is defined by the `rtype` and `role` attributes.
 
+    The `no_relation` boolean flag tells if a special 'no relation' value should be
+    added (allowing to filter on entities which *do not* have the relation set).
+    Default is computed according the relation's cardinality.
+
     The values displayed for related entities will be:
 
     * result of calling their `label_vid` view if specified
@@ -456,7 +468,8 @@
     * else their eid (you usually want something nicer...)
 
     When no `label_vid` is set, you will get translated value if `i18nable` is
-    set.
+    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`
 
@@ -506,8 +519,6 @@
     role = 'subject'
     target_attr = 'eid'
     target_type = None
-    # should value be internationalized (XXX could be guessed from the schema)
-    i18nable = True
     # 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
@@ -520,6 +531,23 @@
     _select_target_entity = True
 
     title = property(rtype_facet_title)
+    no_relation_label = '<no relation>'
+
+    @property
+    def i18nable(self):
+        """should label be internationalized"""
+        if self.target_type:
+            eschema = self._cw.vreg.schema.eschema(self.target_type)
+        elif self.role == 'subject':
+            eschema = self._cw.vreg.schema.rschema(self.rtype).objects()[0]
+        else:
+            eschema = self._cw.vreg.schema.rschema(self.rtype).subjects()[0]
+        return eschema.rdef(self.target_attr).internationalizable
+
+    @property
+    def no_relation(self):
+        return (not self._cw.vreg.schema.rschema(self.rtype).final
+                and self._search_card('?*'))
 
     @property
     def rql_sort(self):
@@ -529,6 +557,7 @@
         """
         return self.sortfunc is not None or (self.label_vid is None
                                              and not self.i18nable)
+
     def vocabulary(self):
         """return vocabulary for this facet, eg a list of 2-uple (label, value)
         """
@@ -555,7 +584,10 @@
             rqlst.recover()
         # don't call rset_vocabulary on empty result set, it may be an empty
         # *list* (see rqlexec implementation)
-        return rset and self.rset_vocabulary(rset)
+        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
@@ -572,12 +604,14 @@
                 insert_attr_select_relation(
                     rqlst, self.filtered_variable, self.rtype, self.role, self.target_attr,
                     select_target_entity=False)
-            return [str(x) for x, in self.rqlexec(rqlst.as_string())]
+            values = [str(x) for x, in self.rqlexec(rqlst.as_string())]
         except:
-            import traceback
-            traceback.print_exc()
+            self.exception('while computing values for %s', self)
         finally:
             rqlst.recover()
+        if self._include_no_relation():
+            values.append('')
+        return values
 
     def rset_vocabulary(self, rset):
         if self.i18nable:
@@ -585,56 +619,103 @@
         else:
             _ = unicode
         if self.rql_sort:
-            return [(_(label), eid) for eid, label in rset]
-        if self.label_vid is None:
-            assert self.i18nable
             values = [(_(label), eid) for eid, label in rset]
         else:
-            values = [(entity.view(self.label_vid), entity.eid)
-                      for entity in rset.entities()]
-        values = sorted(values)
-        if self.sortasc:
-            return values
-        return reversed(values)
+            if self.label_vid is None:
+                values = [(_(label), eid) for eid, label in rset]
+            else:
+                values = [(entity.view(self.label_vid), entity.eid)
+                          for entity in rset.entities()]
+            values = sorted(values)
+            if not self.sortasc:
+                values = list(reversed(values))
+        return values
+
+    def support_and(self):
+        return self._search_card('+*')
+
+    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
+        mainvar = self.filtered_variable
+        restrvar, rel = _add_rtype_relation(self.rqlst, mainvar, self.rtype,
+                                            self.role)
+        if isinstance(value, basestring):
+            # only one value selected
+            if value:
+                self.rqlst.add_eid_restriction(restrvar, value)
+            else:
+                rel.parent.replace(rel, nodes.Not(rel))
+        elif self.operator == 'OR':
+            # set_distinct only if rtype cardinality is > 1
+            if self.support_and():
+                self.rqlst.set_distinct(True)
+            # multiple ORed values: using IN is fine
+            if '' in value:
+                value.remove('')
+                self._add_not_rel_restr(rel)
+            _add_eid_restr(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())
+            while value:
+                restrvar, rtrel = _make_relation(self.rqlst, mainvar,
+                                                 self.rtype, self.role)
+                _add_eid_restr(rel, restrvar, value.pop())
 
     @cached
-    def support_and(self):
+    def _search_card(self, cards):
+        for rdef in self._iter_rdefs():
+            if rdef.role_cardinality(self.role) in cards:
+                return True
+        return False
+
+    def _iter_rdefs(self):
         rschema = self._cw.vreg.schema.rschema(self.rtype)
         # XXX when called via ajax, no rset to compute possible types
         possibletypes = self.cw_rset and self.cw_rset.column_types(0)
         for rdef in rschema.rdefs.itervalues():
             if possibletypes is not None:
                 if self.role == 'subject':
-                    if not rdef.subject in possibletypes:
+                    if rdef.subject not in possibletypes:
                         continue
-                elif not rdef.object in possibletypes:
+                elif rdef.object not in possibletypes:
                     continue
-            if rdef.role_cardinality(self.role) in '+*':
-                return True
-        return False
+            if self.target_type is not None:
+                if self.role == 'subject':
+                    if rdef.object != self.target_type:
+                        continue
+                elif rdef.subject != self.target_type:
+                    continue
+            yield rdef
 
-    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
-        mainvar = self.filtered_variable
-        restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)
-        if isinstance(value, basestring):
-            # only one value selected
-            self.rqlst.add_eid_restriction(restrvar, value)
-        elif self.operator == 'OR':
-            # set_distinct only if rtype cardinality is > 1
-            if self.support_and():
-                self.rqlst.set_distinct(True)
-            # multiple ORed values: using IN is fine
-            self.rqlst.add_eid_restriction(restrvar, value)
+    def _include_no_relation(self):
+        if not self.no_relation:
+            return False
+        if self._cw.vreg.schema.rschema(self.rtype).final:
+            return False
+        if self.role == 'object':
+            subj = utils.rqlvar_maker(defined=self.rqlst.defined_vars,
+                                      aliases=self.rqlst.aliases).next()
+            obj = self.filtered_variable.name
         else:
-            # multiple values with AND operator
-            self.rqlst.add_eid_restriction(restrvar, value.pop())
-            while value:
-                restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)
-                self.rqlst.add_eid_restriction(restrvar, value.pop())
+            subj = self.filtered_variable.name
+            obj = utils.rqlvar_maker(defined=self.rqlst.defined_vars,
+                                     aliases=self.rqlst.aliases).next()
+        rql = 'Any %s LIMIT 1 WHERE NOT %s %s %s, %s' % (
+            self.filtered_variable.name, subj, self.rtype, obj,
+            self.rqlst.where.as_string())
+        return bool(self.rqlexec(rql, self.cw_rset and self.cw_rset.args))
+
+    def _add_not_rel_restr(self, rel):
+        nrrel = nodes.Not(_make_relation(self.rqlst, self.filtered_variable,
+                                         self.rtype, self.role)[1])
+        rel.parent.replace(rel, nodes.Or(nrrel, rel))
 
 
 class RelationAttributeFacet(RelationFacet):
@@ -699,20 +780,25 @@
         if not value:
             return
         mainvar = self.filtered_variable
-        restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)
+        restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype,
+                                       self.role)[0]
         self.rqlst.set_distinct(True)
         if isinstance(value, basestring) or self.operator == 'OR':
             # only one value selected or multiple ORed values: using IN is fine
-            self.rqlst.add_constant_restriction(restrvar, self.target_attr, value,
-                                                self.attrtype, self.comparator)
+            self.rqlst.add_constant_restriction(
+                restrvar, self.target_attr, value,
+                self.attrtype, self.comparator)
         else:
             # multiple values with AND operator
-            self.rqlst.add_constant_restriction(restrvar, self.target_attr, value.pop(),
-                                                self.attrtype, self.comparator)
+            self.rqlst.add_constant_restriction(
+                restrvar, self.target_attr, value.pop(),
+                self.attrtype, self.comparator)
             while value:
-                restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype, self.role)
-                self.rqlst.add_constant_restriction(restrvar, self.target_attr, value.pop(),
-                                                    self.attrtype, self.comparator)
+                restrvar = _add_rtype_relation(self.rqlst, mainvar, self.rtype,
+                                               self.role)[0]
+                self.rqlst.add_constant_restriction(
+                    restrvar, self.target_attr, value.pop(),
+                    self.attrtype, self.comparator)
 
 
 class AttributeFacet(RelationAttributeFacet):
@@ -749,6 +835,16 @@
 
     _select_target_entity = True
 
+    @property
+    def i18nable(self):
+        """should label be internationalized"""
+        for rdef in self._iter_rdefs():
+            # no 'internationalizable' property for rdef whose object is not a
+            # String
+            if not getattr(rdef, 'internationalizable', False):
+                return False
+        return True
+
     def vocabulary(self):
         """return vocabulary for this facet, eg a list of 2-uple (label, value)
         """
--- a/web/test/unittest_facet.py	Fri Oct 01 16:07:03 2010 +0200
+++ b/web/test/unittest_facet.py	Fri Oct 01 16:53:35 2010 +0200
@@ -14,26 +14,33 @@
         self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
         return req, rset, rqlst, mainvar
 
-    def test_relation_simple(self):
+    def _in_group_facet(self, cls=facet.RelationFacet, no_relation=False):
         req, rset, rqlst, mainvar = self.prepare_rqlst()
-        f = facet.RelationFacet(req, rset=rset,
-                                rqlst=rqlst.children[0],
-                                filtered_variable=mainvar)
+        cls.no_relation = no_relation
+        f = cls(req, rset=rset, rqlst=rqlst.children[0],
+                filtered_variable=mainvar)
+        f.__regid__ = 'in_group'
         f.rtype = 'in_group'
         f.role = 'subject'
         f.target_attr = 'name'
         guests, managers = [eid for eid, in self.execute('CWGroup G ORDERBY GN '
                                                          'WHERE G name GN, G name IN ("guests", "managers")')]
+        groups = [eid for eid, in self.execute('CWGroup G ORDERBY GN '
+                                               'WHERE G name GN, G name IN ("guests", "managers")')]
+        return f, groups
+
+    def test_relation_simple(self):
+        f, (guests, managers) = self._in_group_facet()
         self.assertEqual(f.vocabulary(),
-                          [(u'guests', guests), (u'managers', managers)])
+                      [(u'guests', guests), (u'managers', managers)])
         # ensure rqlst is left unmodified
-        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        self.assertEqual(f.rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
         #rqlst = rset.syntax_tree()
         self.assertEqual(f.possible_values(),
                           [str(guests), str(managers)])
         # ensure rqlst is left unmodified
-        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
-        req.form[f.__regid__] = str(guests)
+        self.assertEqual(f.rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        f._cw.form[f.__regid__] = str(guests)
         f.add_rql_restrictions()
         # selection is cluttered because rqlst has been prepared for facet (it
         # is not in real life)
@@ -72,25 +79,47 @@
         self.assertEqual(f.rqlst.as_string(),
                           'DISTINCT Any  GROUPBY X WHERE X in_group G?, G name GN, NOT G name "users", X in_group D, D eid %s' % guests)
 
+    def test_relation_no_relation_1(self):
+        f, (guests, managers) = self._in_group_facet(no_relation=True)
+        self.assertEqual(f.vocabulary(),
+                          [(u'guests', guests), (u'managers', managers)])
+        self.assertEqual(f.possible_values(),
+                          [str(guests), str(managers)])
+        f._cw.create_entity('CWUser', login=u'hop', upassword='toto')
+        self.assertEqual(f.vocabulary(),
+                          [(u'no relation', ''), (u'guests', guests), (u'managers', managers)])
+        self.assertEqual(f.possible_values(),
+                          [str(guests), str(managers)])
+        f._cw.form[f.__regid__] = ''
+        f.add_rql_restrictions()
+        self.assertEqual(f.rqlst.as_string(),
+                          'DISTINCT Any  WHERE X is CWUser, NOT X in_group G')
+
+    def test_relation_no_relation_2(self):
+        f, (guests, managers) = self._in_group_facet(no_relation=True)
+        f._cw.form[f.__regid__] = ['', guests]
+        f.rqlst.save_state()
+        f.add_rql_restrictions()
+        self.assertEqual(f.rqlst.as_string(),
+                          'DISTINCT Any  WHERE X is CWUser, (NOT X in_group B) OR (X in_group A, A eid %s)' % guests)
+        f.rqlst.recover()
+        self.assertEqual(f.rqlst.as_string(),
+                          'DISTINCT Any  WHERE X is CWUser')
+
+
 
     def test_relationattribute(self):
-        req, rset, rqlst, mainvar = self.prepare_rqlst()
-        f = facet.RelationAttributeFacet(req, rset=rset,
-                                         rqlst=rqlst.children[0],
-                                         filtered_variable=mainvar)
-        f.rtype = 'in_group'
-        f.role = 'subject'
-        f.target_attr = 'name'
+        f, (guests, managers) = self._in_group_facet(cls=facet.RelationAttributeFacet)
         self.assertEqual(f.vocabulary(),
                           [(u'guests', u'guests'), (u'managers', u'managers')])
         # ensure rqlst is left unmodified
-        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        self.assertEqual(f.rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
         #rqlst = rset.syntax_tree()
         self.assertEqual(f.possible_values(),
                           ['guests', 'managers'])
         # ensure rqlst is left unmodified
-        self.assertEqual(rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
-        req.form[f.__regid__] = 'guests'
+        self.assertEqual(f.rqlst.as_string(), 'DISTINCT Any  WHERE X is CWUser')
+        f._cw.form[f.__regid__] = 'guests'
         f.add_rql_restrictions()
         # selection is cluttered because rqlst has been prepared for facet (it
         # is not in real life)