# HG changeset patch # User Sylvain Thénault # Date 1309856092 -7200 # Node ID 5395007c415c6249d38b84a4b80709f1db4b80bf # Parent be5f68f9314efb3d8f04ec46deb9e7304f00bf54 [facet] closes #1806931: new facet type, based on arbitrary rql path diff -r be5f68f9314e -r 5395007c415c web/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): diff -r be5f68f9314e -r 5395007c415c web/test/unittest_facet.py --- 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