diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/entity.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/entity.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,1403 @@ +# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""Base class for entity objects manipulated in clients""" + +__docformat__ = "restructuredtext en" + +from warnings import warn +from functools import partial + +from six import text_type, string_types, integer_types +from six.moves import range + +from logilab.common.decorators import cached +from logilab.common.deprecation import deprecated +from logilab.common.registry import yes +from logilab.mtconverter import TransformData, xml_escape + +from rql.utils import rqlvar_maker +from rql.stmts import Select +from rql.nodes import (Not, VariableRef, Constant, make_relation, + Relation as RqlRelation) + +from cubicweb import Unauthorized, neg_role +from cubicweb.utils import support_args +from cubicweb.rset import ResultSet +from cubicweb.appobject import AppObject +from cubicweb.schema import (RQLVocabularyConstraint, RQLConstraint, + GeneratedConstraint) +from cubicweb.rqlrewrite import RQLRewriter + +from cubicweb.uilib import soup2xhtml +from cubicweb.mttransforms import ENGINE + +_marker = object() + +def greater_card(rschema, subjtypes, objtypes, index): + for subjtype in subjtypes: + for objtype in objtypes: + card = rschema.rdef(subjtype, objtype).cardinality[index] + if card in '+*': + return card + return '1' + +def can_use_rest_path(value): + """return True if value can be used at the end of a Rest URL path""" + if value is None: + return False + value = text_type(value) + # the check for ?, /, & are to prevent problems when running + # behind Apache mod_proxy + if value == u'' or u'?' in value or u'/' in value or u'&' in value: + return False + return True + +def rel_vars(rel): + return ((isinstance(rel.children[0], VariableRef) + and rel.children[0].variable or None), + (isinstance(rel.children[1].children[0], VariableRef) + and rel.children[1].children[0].variable or None) + ) + +def rel_matches(rel, rtype, role, varname, operator='='): + if rel.r_type == rtype and rel.children[1].operator == operator: + same_role_var_idx = 0 if role == 'subject' else 1 + variables = rel_vars(rel) + if variables[same_role_var_idx].name == varname: + return variables[1 - same_role_var_idx] + +def build_cstr_with_linkto_infos(cstr, args, searchedvar, evar, + lt_infos, eidvars): + """restrict vocabulary as much as possible in entity creation, + based on infos provided by __linkto form param. + + Example based on following schema: + + class works_in(RelationDefinition): + subject = 'CWUser' + object = 'Lab' + cardinality = '1*' + constraints = [RQLConstraint('S in_group G, O welcomes G')] + + class welcomes(RelationDefinition): + subject = 'Lab' + object = 'CWGroup' + + If you create a CWUser in the "scientists" CWGroup you can show + only the labs that welcome them using : + + lt_infos = {('in_group', 'subject'): 321} + + You get following restriction : 'O welcomes G, G eid 321' + + """ + st = cstr.snippet_rqlst.copy() + # replace relations in ST by eid infos from linkto where possible + for (info_rtype, info_role), eids in lt_infos.items(): + eid = eids[0] # NOTE: we currently assume a pruned lt_info with only 1 eid + for rel in st.iget_nodes(RqlRelation): + targetvar = rel_matches(rel, info_rtype, info_role, evar.name) + if targetvar is not None: + if targetvar.name in eidvars: + rel.parent.remove(rel) + else: + eidrel = make_relation( + targetvar, 'eid', (targetvar.name, 'Substitute'), + Constant) + rel.parent.replace(rel, eidrel) + args[targetvar.name] = eid + eidvars.add(targetvar.name) + # if modified ST still contains evar references we must discard the + # constraint, otherwise evar is unknown in the final rql query which can + # lead to a SQL table cartesian product and multiple occurences of solutions + evarname = evar.name + for rel in st.iget_nodes(RqlRelation): + for variable in rel_vars(rel): + if variable and evarname == variable.name: + return + # else insert snippets into the global tree + return GeneratedConstraint(st, cstr.mainvars - set(evarname)) + +def pruned_lt_info(eschema, lt_infos): + pruned = {} + for (lt_rtype, lt_role), eids in lt_infos.items(): + # we can only use lt_infos describing relation with a cardinality + # of value 1 towards the linked entity + if not len(eids) == 1: + continue + lt_card = eschema.rdef(lt_rtype, lt_role).cardinality[ + 0 if lt_role == 'subject' else 1] + if lt_card not in '?1': + continue + pruned[(lt_rtype, lt_role)] = eids + return pruned + + +class Entity(AppObject): + """an entity instance has e_schema automagically set on + the class and instances has access to their issuing cursor. + + A property is set for each attribute and relation on each entity's type + class. Becare that among attributes, 'eid' is *NEITHER* stored in the + dict containment (which acts as a cache for other attributes dynamically + fetched) + + :type e_schema: `cubicweb.schema.EntitySchema` + :ivar e_schema: the entity's schema + + :type rest_attr: str + :cvar rest_attr: indicates which attribute should be used to build REST urls + If `None` is specified (the default), the first unique attribute will + be used ('eid' if none found) + + :type cw_skip_copy_for: list + :cvar cw_skip_copy_for: a list of couples (rtype, role) for each relation + that should be skipped when copying this kind of entity. Note that some + relations such as composite relations or relations that have '?1' as + object cardinality are always skipped. + """ + __registry__ = 'etypes' + __select__ = yes() + + # class attributes that must be set in class definition + rest_attr = None + fetch_attrs = None + skip_copy_for = () # bw compat (< 3.14), use cw_skip_copy_for instead + cw_skip_copy_for = [('in_state', 'subject')] + # class attributes set automatically at registration time + e_schema = None + + @classmethod + def __initialize__(cls, schema): + """initialize a specific entity class by adding descriptors to access + entity type's attributes and relations + """ + etype = cls.__regid__ + assert etype != 'Any', etype + cls.e_schema = eschema = schema.eschema(etype) + for rschema, _ in eschema.attribute_definitions(): + if rschema.type == 'eid': + continue + setattr(cls, rschema.type, Attribute(rschema.type)) + mixins = [] + for rschema, _, role in eschema.relation_definitions(): + if role == 'subject': + attr = rschema.type + else: + attr = 'reverse_%s' % rschema.type + setattr(cls, attr, Relation(rschema, role)) + + fetch_attrs = ('modification_date',) + + @classmethod + def cw_fetch_order(cls, select, attr, var): + """This class method may be used to control sort order when multiple + entities of this type are fetched through ORM methods. Its arguments + are: + + * `select`, the RQL syntax tree + + * `attr`, the attribute being watched + + * `var`, the variable through which this attribute's value may be + accessed in the query + + When you want to do some sorting on the given attribute, you should + modify the syntax tree accordingly. For instance: + + .. sourcecode:: python + + from rql import nodes + + class Version(AnyEntity): + __regid__ = 'Version' + + fetch_attrs = ('num', 'description', 'in_state') + + @classmethod + def cw_fetch_order(cls, select, attr, var): + if attr == 'num': + func = nodes.Function('version_sort_value') + func.append(nodes.variable_ref(var)) + sterm = nodes.SortTerm(func, asc=False) + select.add_sort_term(sterm) + + The default implementation call + :meth:`~cubicweb.entity.Entity.cw_fetch_unrelated_order` + """ + cls.cw_fetch_unrelated_order(select, attr, var) + + @classmethod + def cw_fetch_unrelated_order(cls, select, attr, var): + """This class method may be used to control sort order when multiple entities of + this type are fetched to use in edition (e.g. propose them to create a + new relation on an edited entity). + + See :meth:`~cubicweb.entity.Entity.cw_fetch_unrelated_order` for a + description of its arguments and usage. + + By default entities will be listed on their modification date descending, + i.e. you'll get entities recently modified first. + """ + if attr == 'modification_date': + select.add_sort_var(var, asc=False) + + @classmethod + def fetch_rql(cls, user, restriction=None, fetchattrs=None, mainvar='X', + settype=True, ordermethod='fetch_order'): + st = cls.fetch_rqlst(user, mainvar=mainvar, fetchattrs=fetchattrs, + settype=settype, ordermethod=ordermethod) + rql = st.as_string() + if restriction: + # cannot use RQLRewriter API to insert 'X rtype %(x)s' restriction + warn('[3.14] fetch_rql: use of `restriction` parameter is ' + 'deprecated, please use fetch_rqlst and supply a syntax' + 'tree with your restriction instead', DeprecationWarning) + insert = ' WHERE ' + ','.join(restriction) + if ' WHERE ' in rql: + select, where = rql.split(' WHERE ', 1) + rql = select + insert + ',' + where + else: + rql += insert + return rql + + @classmethod + def fetch_rqlst(cls, user, select=None, mainvar='X', fetchattrs=None, + settype=True, ordermethod='fetch_order'): + if select is None: + select = Select() + mainvar = select.get_variable(mainvar) + select.add_selected(mainvar) + elif isinstance(mainvar, string_types): + assert mainvar in select.defined_vars + mainvar = select.get_variable(mainvar) + # eases string -> syntax tree test transition: please remove once stable + select._varmaker = rqlvar_maker(defined=select.defined_vars, + aliases=select.aliases, index=26) + if settype: + rel = select.add_type_restriction(mainvar, cls.__regid__) + # should use 'is_instance_of' instead of 'is' so we retrieve + # subclasses instances as well + rel.r_type = 'is_instance_of' + if fetchattrs is None: + fetchattrs = cls.fetch_attrs + cls._fetch_restrictions(mainvar, select, fetchattrs, user, ordermethod) + return select + + @classmethod + def _fetch_ambiguous_rtypes(cls, select, var, fetchattrs, subjtypes, schema): + """find rtypes in `fetchattrs` that relate different subject etypes + taken from (`subjtypes`) to different target etypes; these so called + "ambiguous" relations, are added directly to the `select` syntax tree + selection but removed from `fetchattrs` to avoid the fetch recursion + because we have to choose only one targettype for the recursion and + adding its own fetch attrs to the selection -when we recurse- would + filter out the other possible target types from the result set + """ + for attr in fetchattrs.copy(): + rschema = schema.rschema(attr) + if rschema.final: + continue + ttypes = None + for subjtype in subjtypes: + cur_ttypes = set(rschema.objects(subjtype)) + if ttypes is None: + ttypes = cur_ttypes + elif cur_ttypes != ttypes: + # we found an ambiguous relation: remove it from fetchattrs + fetchattrs.remove(attr) + # ... and add it to the selection + targetvar = select.make_variable() + select.add_selected(targetvar) + rel = make_relation(var, attr, (targetvar,), VariableRef) + select.add_restriction(rel) + break + + @classmethod + def _fetch_restrictions(cls, mainvar, select, fetchattrs, + user, ordermethod='fetch_order', visited=None): + eschema = cls.e_schema + if visited is None: + visited = set((eschema.type,)) + elif eschema.type in visited: + # avoid infinite recursion + return + else: + visited.add(eschema.type) + _fetchattrs = [] + for attr in sorted(fetchattrs): + try: + rschema = eschema.subjrels[attr] + except KeyError: + cls.warning('skipping fetch_attr %s defined in %s (not found in schema)', + attr, cls.__regid__) + continue + # XXX takefirst=True to remove warning triggered by ambiguous inlined relations + rdef = eschema.rdef(attr, takefirst=True) + if not user.matching_groups(rdef.get_groups('read')): + continue + if rschema.final or rdef.cardinality[0] in '?1': + var = select.make_variable() + select.add_selected(var) + rel = make_relation(mainvar, attr, (var,), VariableRef) + select.add_restriction(rel) + else: + cls.warning('bad relation %s specified in fetch attrs for %s', + attr, cls) + continue + if not rschema.final: + # XXX we need outer join in case the relation is not mandatory + # (card == '?') *or if the entity is being added*, since in + # that case the relation may still be missing. As we miss this + # later information here, systematically add it. + rel.change_optional('right') + targettypes = rschema.objects(eschema.type) + vreg = user._cw.vreg # XXX user._cw.vreg iiiirk + etypecls = vreg['etypes'].etype_class(targettypes[0]) + if len(targettypes) > 1: + # find fetch_attrs common to all destination types + fetchattrs = vreg['etypes'].fetch_attrs(targettypes) + # ... and handle ambiguous relations + cls._fetch_ambiguous_rtypes(select, var, fetchattrs, + targettypes, vreg.schema) + else: + fetchattrs = etypecls.fetch_attrs + etypecls._fetch_restrictions(var, select, fetchattrs, + user, None, visited=visited) + if ordermethod is not None: + try: + cmeth = getattr(cls, ordermethod) + warn('[3.14] %s %s class method should be renamed to cw_%s' + % (cls.__regid__, ordermethod, ordermethod), + DeprecationWarning) + except AttributeError: + cmeth = getattr(cls, 'cw_' + ordermethod) + if support_args(cmeth, 'select'): + cmeth(select, attr, var) + else: + warn('[3.14] %s should now take (select, attr, var) and ' + 'modify the syntax tree when desired instead of ' + 'returning something' % cmeth, DeprecationWarning) + orderterm = cmeth(attr, var.name) + if orderterm is not None: + try: + var, order = orderterm.split() + except ValueError: + if '(' in orderterm: + cls.error('ignore %s until %s is upgraded', + orderterm, cmeth) + orderterm = None + elif not ' ' in orderterm.strip(): + var = orderterm + order = 'ASC' + if orderterm is not None: + select.add_sort_var(select.get_variable(var), + order=='ASC') + + @classmethod + @cached + def cw_rest_attr_info(cls): + """this class method return an attribute name to be used in URL for + entities of this type and a boolean flag telling if its value should be + checked for uniqness. + + The attribute returned is, in order of priority: + + * class's `rest_attr` class attribute + * an attribute defined as unique in the class'schema + * 'eid' + """ + mainattr, needcheck = 'eid', True + if cls.rest_attr: + mainattr = cls.rest_attr + needcheck = not cls.e_schema.has_unique_values(mainattr) + else: + for rschema in cls.e_schema.subject_relations(): + if (rschema.final + and rschema not in ('eid', 'cwuri') + and cls.e_schema.has_unique_values(rschema) + and cls.e_schema.rdef(rschema.type).cardinality[0] == '1'): + mainattr = str(rschema) + needcheck = False + break + if mainattr == 'eid': + needcheck = False + return mainattr, needcheck + + @classmethod + def _cw_build_entity_query(cls, kwargs): + relations = [] + restrictions = set() + pendingrels = [] + eschema = cls.e_schema + qargs = {} + attrcache = {} + for attr, value in kwargs.items(): + if attr.startswith('reverse_'): + attr = attr[len('reverse_'):] + role = 'object' + else: + role = 'subject' + assert eschema.has_relation(attr, role), '%s %s not found on %s' % (attr, role, eschema) + rschema = eschema.subjrels[attr] if role == 'subject' else eschema.objrels[attr] + if not rschema.final and isinstance(value, (tuple, list, set, frozenset)): + if len(value) == 0: + continue # avoid crash with empty IN clause + elif len(value) == 1: + value = next(iter(value)) + else: + # prepare IN clause + pendingrels.append( (attr, role, value) ) + continue + if rschema.final: # attribute + relations.append('X %s %%(%s)s' % (attr, attr)) + attrcache[attr] = value + elif value is None: + pendingrels.append( (attr, role, value) ) + else: + rvar = attr.upper() + if role == 'object': + relations.append('%s %s X' % (rvar, attr)) + else: + relations.append('X %s %s' % (attr, rvar)) + restriction = '%s eid %%(%s)s' % (rvar, attr) + if not restriction in restrictions: + restrictions.add(restriction) + if hasattr(value, 'eid'): + value = value.eid + qargs[attr] = value + rql = u'' + if relations: + rql += ', '.join(relations) + if restrictions: + rql += ' WHERE %s' % ', '.join(restrictions) + return rql, qargs, pendingrels, attrcache + + @classmethod + def _cw_handle_pending_relations(cls, eid, pendingrels, execute): + for attr, role, values in pendingrels: + if role == 'object': + restr = 'Y %s X' % attr + else: + restr = 'X %s Y' % attr + if values is None: + execute('DELETE %s WHERE X eid %%(x)s' % restr, {'x': eid}) + continue + execute('SET %s WHERE X eid %%(x)s, Y eid IN (%s)' % ( + restr, ','.join(str(getattr(r, 'eid', r)) for r in values)), + {'x': eid}, build_descr=False) + + @classmethod + def cw_instantiate(cls, execute, **kwargs): + """add a new entity of this given type + + Example (in a shell session): + + >>> companycls = vreg['etypes'].etype_class('Company') + >>> personcls = vreg['etypes'].etype_class('Person') + >>> c = companycls.cw_instantiate(session.execute, name=u'Logilab') + >>> p = personcls.cw_instantiate(session.execute, firstname=u'John', lastname=u'Doe', + ... works_for=c) + + You can also set relations where the entity has 'object' role by + prefixing the relation name by 'reverse_'. Also, relation values may be + an entity or eid, a list of entities or eids. + """ + rql, qargs, pendingrels, attrcache = cls._cw_build_entity_query(kwargs) + if rql: + rql = 'INSERT %s X: %s' % (cls.__regid__, rql) + else: + rql = 'INSERT %s X' % (cls.__regid__) + try: + created = execute(rql, qargs).get_entity(0, 0) + except IndexError: + raise Exception('could not create a %r with %r (%r)' % + (cls.__regid__, rql, qargs)) + created._cw_update_attr_cache(attrcache) + cls._cw_handle_pending_relations(created.eid, pendingrels, execute) + return created + + def __init__(self, req, rset=None, row=None, col=0): + AppObject.__init__(self, req, rset=rset, row=row, col=col) + self._cw_related_cache = {} + self._cw_adapters_cache = {} + if rset is not None: + self.eid = rset[row][col] + else: + self.eid = None + self._cw_is_saved = True + self.cw_attr_cache = {} + + def __repr__(self): + return '' % ( + self.e_schema, self.eid, list(self.cw_attr_cache), id(self)) + + def __lt__(self, other): + raise NotImplementedError('comparison not implemented for %s' % self.__class__) + + def __eq__(self, other): + if isinstance(self.eid, integer_types): + return self.eid == other.eid + return self is other + + def __hash__(self): + if isinstance(self.eid, integer_types): + return self.eid + return super(Entity, self).__hash__() + + def _cw_update_attr_cache(self, attrcache): + trdata = self._cw.transaction_data + uncached_attrs = trdata.get('%s.storage-special-process-attrs' % self.eid, set()) + uncached_attrs.update(trdata.get('%s.dont-cache-attrs' % self.eid, set())) + for attr in uncached_attrs: + attrcache.pop(attr, None) + self.cw_attr_cache.pop(attr, None) + self.cw_attr_cache.update(attrcache) + + def _cw_dont_cache_attribute(self, attr, repo_side=False): + """Called when some attribute has been transformed by a *storage*, + hence the original value should not be cached **by anyone**. + + For example we have a special "fs_importing" mode in BFSS + where a file path is given as attribute value and stored as is + in the data base. Later access to the attribute will provide + the content of the file at the specified path. We do not want + the "filepath" value to be cached. + + """ + trdata = self._cw.transaction_data + trdata.setdefault('%s.dont-cache-attrs' % self.eid, set()).add(attr) + if repo_side: + trdata.setdefault('%s.storage-special-process-attrs' % self.eid, set()).add(attr) + + def __json_encode__(self): + """custom json dumps hook to dump the entity's eid + which is not part of dict structure itself + """ + dumpable = self.cw_attr_cache.copy() + dumpable['eid'] = self.eid + return dumpable + + def cw_adapt_to(self, interface): + """return an adapter the entity to the given interface name. + + return None if it can not be adapted. + """ + cache = self._cw_adapters_cache + try: + return cache[interface] + except KeyError: + adapter = self._cw.vreg['adapters'].select_or_none( + interface, self._cw, entity=self) + cache[interface] = adapter + return adapter + + def has_eid(self): # XXX cw_has_eid + """return True if the entity has an attributed eid (False + meaning that the entity has to be created + """ + try: + int(self.eid) + return True + except (ValueError, TypeError): + return False + + def cw_is_saved(self): + """during entity creation, there is some time during which the entity + has an eid attributed though it's not saved (eg during + 'before_add_entity' hooks). You can use this method to ensure the entity + has an eid *and* is saved in its source. + """ + return self.has_eid() and self._cw_is_saved + + @cached + def cw_metainformation(self): + metas = self._cw.entity_metas(self.eid) + metas['source'] = self._cw.source_defs()[metas['source']] + return metas + + def cw_check_perm(self, action): + self.e_schema.check_perm(self._cw, action, eid=self.eid) + + def cw_has_perm(self, action): + return self.e_schema.has_perm(self._cw, action, eid=self.eid) + + def view(self, __vid, __registry='views', w=None, initargs=None, **kwargs): # XXX cw_view + """shortcut to apply a view on this entity""" + if initargs is None: + initargs = kwargs + else: + initargs.update(kwargs) + view = self._cw.vreg[__registry].select(__vid, self._cw, rset=self.cw_rset, + row=self.cw_row, col=self.cw_col, + **initargs) + return view.render(row=self.cw_row, col=self.cw_col, w=w, **kwargs) + + def absolute_url(self, *args, **kwargs): # XXX cw_url + """return an absolute url to view this entity""" + # use *args since we don't want first argument to be "anonymous" to + # avoid potential clash with kwargs + if args: + assert len(args) == 1, 'only 0 or 1 non-named-argument expected' + method = args[0] + else: + method = None + # in linksearch mode, we don't want external urls else selecting + # the object for use in the relation is tricky + # XXX search_state is web specific + use_ext_id = False + if 'base_url' not in kwargs and \ + getattr(self._cw, 'search_state', ('normal',))[0] == 'normal': + sourcemeta = self.cw_metainformation()['source'] + if sourcemeta.get('use-cwuri-as-url'): + return self.cwuri # XXX consider kwargs? + if sourcemeta.get('base-url'): + kwargs['base_url'] = sourcemeta['base-url'] + use_ext_id = True + if method in (None, 'view'): + kwargs['_restpath'] = self.rest_path(use_ext_id) + else: + kwargs['rql'] = 'Any X WHERE X eid %s' % self.eid + return self._cw.build_url(method, **kwargs) + + def rest_path(self, use_ext_eid=False): # XXX cw_rest_path + """returns a REST-like (relative) path for this entity""" + mainattr, needcheck = self.cw_rest_attr_info() + etype = str(self.e_schema) + path = etype.lower() + fallback = False + if mainattr != 'eid': + value = getattr(self, mainattr) + if not can_use_rest_path(value): + mainattr = 'eid' + path = None + elif needcheck: + # make sure url is not ambiguous + try: + nbresults = self.__unique + except AttributeError: + rql = 'Any COUNT(X) WHERE X is %s, X %s %%(value)s' % ( + etype, mainattr) + nbresults = self.__unique = self._cw.execute(rql, {'value' : value})[0][0] + if nbresults != 1: # ambiguity? + mainattr = 'eid' + path = None + if mainattr == 'eid': + if use_ext_eid: + value = self.cw_metainformation()['extid'] + else: + value = self.eid + if path is None: + # fallback url: / url is used as cw entities uri, + # prefer it to //eid/ + return text_type(value) + return u'%s/%s' % (path, self._cw.url_quote(value)) + + def cw_attr_metadata(self, attr, metadata): + """return a metadata for an attribute (None if unspecified)""" + value = getattr(self, '%s_%s' % (attr, metadata), None) + if value is None and metadata == 'encoding': + value = self._cw.vreg.property_value('ui.encoding') + return value + + def printable_value(self, attr, value=_marker, attrtype=None, + format='text/html', displaytime=True): # XXX cw_printable_value + """return a displayable value (i.e. unicode string) which may contains + html tags + """ + attr = str(attr) + if value is _marker: + value = getattr(self, attr) + if isinstance(value, string_types): + value = value.strip() + if value is None or value == '': # don't use "not", 0 is an acceptable value + return u'' + if attrtype is None: + attrtype = self.e_schema.destination(attr) + props = self.e_schema.rdef(attr) + if attrtype == 'String': + # internalinalized *and* formatted string such as schema + # description... + if props.internationalizable: + value = self._cw._(value) + attrformat = self.cw_attr_metadata(attr, 'format') + if attrformat: + return self._cw_mtc_transform(value, attrformat, format, + self._cw.encoding) + elif attrtype == 'Bytes': + attrformat = self.cw_attr_metadata(attr, 'format') + if attrformat: + encoding = self.cw_attr_metadata(attr, 'encoding') + return self._cw_mtc_transform(value.getvalue(), attrformat, format, + encoding) + return u'' + value = self._cw.printable_value(attrtype, value, props, + displaytime=displaytime) + if format == 'text/html': + value = xml_escape(value) + return value + + def _cw_mtc_transform(self, data, format, target_format, encoding, + _engine=ENGINE): + trdata = TransformData(data, format, encoding, appobject=self) + data = _engine.convert(trdata, target_format).decode() + if target_format == 'text/html': + data = soup2xhtml(data, self._cw.encoding) + return data + + # entity cloning ########################################################## + + def copy_relations(self, ceid): # XXX cw_copy_relations + """copy relations of the object with the given eid on this + object (this method is called on the newly created copy, and + ceid designates the original entity). + + By default meta and composite relations are skipped. + Overrides this if you want another behaviour + """ + assert self.has_eid() + execute = self._cw.execute + skip_copy_for = {'subject': set(), 'object': set()} + for rtype in self.skip_copy_for: + skip_copy_for['subject'].add(rtype) + warn('[3.14] skip_copy_for on entity classes (%s) is deprecated, ' + 'use cw_skip_for instead with list of couples (rtype, role)' % self.cw_etype, + DeprecationWarning) + for rtype, role in self.cw_skip_copy_for: + assert role in ('subject', 'object'), role + skip_copy_for[role].add(rtype) + for rschema in self.e_schema.subject_relations(): + if rschema.type in skip_copy_for['subject']: + continue + if rschema.final or rschema.meta: + continue + # skip already defined relations + if getattr(self, rschema.type): + continue + # XXX takefirst=True to remove warning triggered by ambiguous relations + rdef = self.e_schema.rdef(rschema, takefirst=True) + # skip composite relation + if rdef.composite: + continue + # skip relation with card in ?1 else we either change the copied + # object (inlined relation) or inserting some inconsistency + if rdef.cardinality[1] in '?1': + continue + rql = 'SET X %s V WHERE X eid %%(x)s, Y eid %%(y)s, Y %s V' % ( + rschema.type, rschema.type) + execute(rql, {'x': self.eid, 'y': ceid}) + self.cw_clear_relation_cache(rschema.type, 'subject') + for rschema in self.e_schema.object_relations(): + if rschema.meta: + continue + # skip already defined relations + if self.related(rschema.type, 'object'): + continue + if rschema.type in skip_copy_for['object']: + continue + # XXX takefirst=True to remove warning triggered by ambiguous relations + rdef = self.e_schema.rdef(rschema, 'object', takefirst=True) + # skip composite relation + if rdef.composite: + continue + # skip relation with card in ?1 else we either change the copied + # object (inlined relation) or inserting some inconsistency + if rdef.cardinality[0] in '?1': + continue + rql = 'SET V %s X WHERE X eid %%(x)s, Y eid %%(y)s, V %s Y' % ( + rschema.type, rschema.type) + execute(rql, {'x': self.eid, 'y': ceid}) + self.cw_clear_relation_cache(rschema.type, 'object') + + # data fetching methods ################################################### + + @cached + def as_rset(self): # XXX .cw_as_rset + """returns a resultset containing `self` information""" + rset = ResultSet([(self.eid,)], 'Any X WHERE X eid %(x)s', + {'x': self.eid}, [(self.cw_etype,)]) + rset.req = self._cw + return rset + + def _cw_to_complete_relations(self): + """by default complete final relations to when calling .complete()""" + for rschema in self.e_schema.subject_relations(): + if rschema.final: + continue + targets = rschema.objects(self.e_schema) + if rschema.inlined: + matching_groups = self._cw.user.matching_groups + if all(matching_groups(e.get_groups('read')) and + rschema.rdef(self.e_schema, e).get_groups('read') + for e in targets): + yield rschema, 'subject' + + def _cw_to_complete_attributes(self, skip_bytes=True, skip_pwd=True): + for rschema, attrschema in self.e_schema.attribute_definitions(): + # skip binary data by default + if skip_bytes and attrschema.type == 'Bytes': + continue + attr = rschema.type + if attr == 'eid': + continue + # password retrieval is blocked at the repository server level + rdef = rschema.rdef(self.e_schema, attrschema) + if not self._cw.user.matching_groups(rdef.get_groups('read')) \ + or (attrschema.type == 'Password' and skip_pwd): + self.cw_attr_cache[attr] = None + continue + yield attr + + _cw_completed = False + def complete(self, attributes=None, skip_bytes=True, skip_pwd=True): # XXX cw_complete + """complete this entity by adding missing attributes (i.e. query the + repository to fill the entity) + + :type skip_bytes: bool + :param skip_bytes: + if true, attribute of type Bytes won't be considered + """ + assert self.has_eid() + if self._cw_completed: + return + if attributes is None: + self._cw_completed = True + varmaker = rqlvar_maker() + V = next(varmaker) + rql = ['WHERE %s eid %%(x)s' % V] + selected = [] + for attr in (attributes or self._cw_to_complete_attributes(skip_bytes, skip_pwd)): + # if attribute already in entity, nothing to do + if attr in self.cw_attr_cache: + continue + # case where attribute must be completed, but is not yet in entity + var = next(varmaker) + rql.append('%s %s %s' % (V, attr, var)) + selected.append((attr, var)) + # +1 since this doesn't include the main variable + lastattr = len(selected) + 1 + # don't fetch extra relation if attributes specified or of the entity is + # coming from an external source (may lead to error) + if attributes is None and self.cw_metainformation()['source']['uri'] == 'system': + # fetch additional relations (restricted to 0..1 relations) + for rschema, role in self._cw_to_complete_relations(): + rtype = rschema.type + if self.cw_relation_cached(rtype, role): + continue + # at this point we suppose that: + # * this is a inlined relation + # * entity (self) is the subject + # * user has read perm on the relation and on the target entity + assert rschema.inlined + assert role == 'subject' + var = next(varmaker) + # keep outer join anyway, we don't want .complete to crash on + # missing mandatory relation (see #1058267) + rql.append('%s %s %s?' % (V, rtype, var)) + selected.append(((rtype, role), var)) + if selected: + # select V, we need it as the left most selected variable + # if some outer join are included to fetch inlined relations + rql = 'Any %s,%s %s' % (V, ','.join(var for attr, var in selected), + ','.join(rql)) + try: + rset = self._cw.execute(rql, {'x': self.eid}, build_descr=False)[0] + except IndexError: + raise Exception('unable to fetch attributes for entity with eid %s' + % self.eid) + # handle attributes + for i in range(1, lastattr): + self.cw_attr_cache[str(selected[i-1][0])] = rset[i] + # handle relations + for i in range(lastattr, len(rset)): + rtype, role = selected[i-1][0] + value = rset[i] + if value is None: + rrset = ResultSet([], rql, {'x': self.eid}) + rrset.req = self._cw + else: + rrset = self._cw.eid_rset(value) + self.cw_set_relation_cache(rtype, role, rrset) + + def cw_attr_value(self, name): + """get value for the attribute relation , query the repository + to get the value if necessary. + + :type name: str + :param name: name of the attribute to get + """ + try: + return self.cw_attr_cache[name] + except KeyError: + if not self.cw_is_saved(): + return None + rql = "Any A WHERE X eid %%(x)s, X %s A" % name + try: + rset = self._cw.execute(rql, {'x': self.eid}) + except Unauthorized: + self.cw_attr_cache[name] = value = None + else: + assert rset.rowcount <= 1, (self, rql, rset.rowcount) + try: + self.cw_attr_cache[name] = value = rset.rows[0][0] + except IndexError: + # probably a multisource error + self.critical("can't get value for attribute %s of entity with eid %s", + name, self.eid) + if self.e_schema.destination(name) == 'String': + self.cw_attr_cache[name] = value = self._cw._('unaccessible') + else: + self.cw_attr_cache[name] = value = None + return value + + def related(self, rtype, role='subject', limit=None, entities=False, # XXX .cw_related + safe=False, targettypes=None): + """returns a resultset of related entities + + :param rtype: + the name of the relation, aka relation type + :param role: + the role played by 'self' in the relation ('subject' or 'object') + :param limit: + resultset's maximum size + :param entities: + if True, the entites are returned; if False, a result set is returned + :param safe: + if True, an empty rset/list of entities will be returned in case of + :exc:`Unauthorized`, else (the default), the exception is propagated + :param targettypes: + a tuple of target entity types to restrict the query + """ + rtype = str(rtype) + # Caching restricted/limited results is best avoided. + cacheable = limit is None and targettypes is None + if cacheable: + cache_key = '%s_%s' % (rtype, role) + if cache_key in self._cw_related_cache: + return self._cw_related_cache[cache_key][entities] + if not self.has_eid(): + if entities: + return [] + return self._cw.empty_rset() + rql = self.cw_related_rql(rtype, role, limit=limit, targettypes=targettypes) + try: + rset = self._cw.execute(rql, {'x': self.eid}) + except Unauthorized: + if not safe: + raise + rset = self._cw.empty_rset() + if entities: + if cacheable: + self.cw_set_relation_cache(rtype, role, rset) + return self.related(rtype, role, entities=entities) + return list(rset.entities()) + else: + return rset + + def cw_related_rql(self, rtype, role='subject', targettypes=None, limit=None): + vreg = self._cw.vreg + rschema = vreg.schema[rtype] + select = Select() + mainvar, evar = select.get_variable('X'), select.get_variable('E') + select.add_selected(mainvar) + if limit is not None: + select.set_limit(limit) + select.add_eid_restriction(evar, 'x', 'Substitute') + if role == 'subject': + rel = make_relation(evar, rtype, (mainvar,), VariableRef) + select.add_restriction(rel) + if targettypes is None: + targettypes = rschema.objects(self.e_schema) + else: + select.add_constant_restriction(mainvar, 'is', + targettypes, 'etype') + gcard = greater_card(rschema, (self.e_schema,), targettypes, 0) + else: + rel = make_relation(mainvar, rtype, (evar,), VariableRef) + select.add_restriction(rel) + if targettypes is None: + targettypes = rschema.subjects(self.e_schema) + else: + select.add_constant_restriction(mainvar, 'is', targettypes, + 'etype') + gcard = greater_card(rschema, targettypes, (self.e_schema,), 1) + etypecls = vreg['etypes'].etype_class(targettypes[0]) + if len(targettypes) > 1: + fetchattrs = vreg['etypes'].fetch_attrs(targettypes) + self._fetch_ambiguous_rtypes(select, mainvar, fetchattrs, + targettypes, vreg.schema) + else: + fetchattrs = etypecls.fetch_attrs + etypecls.fetch_rqlst(self._cw.user, select, mainvar, fetchattrs, + settype=False) + # optimisation: remove ORDERBY if cardinality is 1 or ? (though + # greater_card return 1 for those both cases) + if gcard == '1': + select.remove_sort_terms() + elif not select.orderby: + # if modification_date is already retrieved, we use it instead + # of adding another variable for sorting. This should not be + # problematic, but it is with sqlserver, see ticket #694445 + for rel in select.where.get_nodes(RqlRelation): + if (rel.r_type == 'modification_date' + and rel.children[0].variable == mainvar + and rel.children[1].operator == '='): + var = rel.children[1].children[0].variable + select.add_sort_var(var, asc=False) + break + else: + mdvar = select.make_variable() + rel = make_relation(mainvar, 'modification_date', + (mdvar,), VariableRef) + select.add_restriction(rel) + select.add_sort_var(mdvar, asc=False) + return select.as_string() + + # generic vocabulary methods ############################################## + + def cw_linkable_rql(self, rtype, targettype, role, ordermethod=None, + vocabconstraints=True, lt_infos={}, limit=None): + """build a rql to fetch targettype entities either related or unrelated + to this entity using (rtype, role) relation. + + Consider relation permissions so that returned entities may be actually + linked by `rtype`. + + `lt_infos` are supplementary informations, usually coming from __linkto + parameter, that can help further restricting the results in case current + entity is not yet created. It is a dict describing entities the current + entity will be linked to, which keys are (rtype, role) tuples and values + are a list of eids. + """ + return self._cw_compute_linkable_rql(rtype, targettype, role, ordermethod=None, + vocabconstraints=vocabconstraints, + lt_infos=lt_infos, limit=limit, + unrelated_only=False) + + def cw_unrelated_rql(self, rtype, targettype, role, ordermethod=None, + vocabconstraints=True, lt_infos={}, limit=None): + """build a rql to fetch `targettype` entities unrelated to this entity + using (rtype, role) relation. + + Consider relation permissions so that returned entities may be actually + linked by `rtype`. + + `lt_infos` are supplementary informations, usually coming from __linkto + parameter, that can help further restricting the results in case current + entity is not yet created. It is a dict describing entities the current + entity will be linked to, which keys are (rtype, role) tuples and values + are a list of eids. + """ + return self._cw_compute_linkable_rql(rtype, targettype, role, ordermethod=None, + vocabconstraints=vocabconstraints, + lt_infos=lt_infos, limit=limit, + unrelated_only=True) + + def _cw_compute_linkable_rql(self, rtype, targettype, role, ordermethod=None, + vocabconstraints=True, lt_infos={}, limit=None, + unrelated_only=False): + """build a rql to fetch `targettype` entities that may be related to + this entity using the (rtype, role) relation. + + By default (unrelated_only=False), this includes the already linked + entities as well as the unrelated ones. If `unrelated_only` is True, the + rql filters out the already related entities. + """ + ordermethod = ordermethod or 'fetch_unrelated_order' + rschema = self._cw.vreg.schema.rschema(rtype) + rdef = rschema.role_rdef(self.e_schema, targettype, role) + rewriter = RQLRewriter(self._cw) + select = Select() + # initialize some variables according to the `role` of `self` in the + # relation (variable names must respect constraints conventions): + # * variable for myself (`evar`) + # * variable for searched entities (`searchvedvar`) + if role == 'subject': + evar = subjvar = select.get_variable('S') + searchedvar = objvar = select.get_variable('O') + else: + searchedvar = subjvar = select.get_variable('S') + evar = objvar = select.get_variable('O') + select.add_selected(searchedvar) + if limit is not None: + select.set_limit(limit) + # initialize some variables according to `self` existence + if rdef.role_cardinality(neg_role(role)) in '?1': + # if cardinality in '1?', we want a target entity which isn't + # already linked using this relation + variable = select.make_variable() + if role == 'subject': + rel = make_relation(variable, rtype, (searchedvar,), VariableRef) + else: + rel = make_relation(searchedvar, rtype, (variable,), VariableRef) + select.add_restriction(Not(rel)) + elif self.has_eid() and unrelated_only: + # elif we have an eid, we don't want a target entity which is + # already linked to ourself through this relation + rel = make_relation(subjvar, rtype, (objvar,), VariableRef) + select.add_restriction(Not(rel)) + if self.has_eid(): + rel = make_relation(evar, 'eid', ('x', 'Substitute'), Constant) + select.add_restriction(rel) + args = {'x': self.eid} + if role == 'subject': + sec_check_args = {'fromeid': self.eid} + else: + sec_check_args = {'toeid': self.eid} + existant = None # instead of 'SO', improve perfs + else: + args = {} + sec_check_args = {} + existant = searchedvar.name + # undefine unused evar, or the type resolver will consider it + select.undefine_variable(evar) + # retrieve entity class for targettype to compute base rql + etypecls = self._cw.vreg['etypes'].etype_class(targettype) + etypecls.fetch_rqlst(self._cw.user, select, searchedvar, + ordermethod=ordermethod) + # from now on, we need variable type resolving + self._cw.vreg.solutions(self._cw, select, args) + # insert RQL expressions for schema constraints into the rql syntax tree + if vocabconstraints: + cstrcls = (RQLVocabularyConstraint, RQLConstraint) + else: + cstrcls = RQLConstraint + lt_infos = pruned_lt_info(self.e_schema, lt_infos or {}) + # if there are still lt_infos, use set to keep track of added eid + # relations (adding twice the same eid relation is incorrect RQL) + eidvars = set() + for cstr in rdef.constraints: + # consider constraint.mainvars to check if constraint apply + if isinstance(cstr, cstrcls) and searchedvar.name in cstr.mainvars: + if not self.has_eid(): + if lt_infos: + # we can perhaps further restrict with linkto infos using + # a custom constraint built from cstr and lt_infos + cstr = build_cstr_with_linkto_infos( + cstr, args, searchedvar, evar, lt_infos, eidvars) + if cstr is None: + continue # could not build constraint -> discard + elif evar.name in cstr.mainvars: + continue + # compute a varmap suitable to RQLRewriter.rewrite argument + varmap = dict((v, v) for v in (searchedvar.name, evar.name) + if v in select.defined_vars and v in cstr.mainvars) + # rewrite constraint by constraint since we want a AND between + # expressions. + rewriter.rewrite(select, [(varmap, (cstr,))], args, existant) + # insert security RQL expressions granting the permission to 'add' the + # relation into the rql syntax tree, if necessary + rqlexprs = rdef.get_rqlexprs('add') + if not self.has_eid(): + rqlexprs = [rqlexpr for rqlexpr in rqlexprs + if searchedvar.name in rqlexpr.mainvars] + if rqlexprs and not rdef.has_perm(self._cw, 'add', **sec_check_args): + # compute a varmap suitable to RQLRewriter.rewrite argument + varmap = dict((v, v) for v in (searchedvar.name, evar.name) + if v in select.defined_vars) + # rewrite all expressions at once since we want a OR between them. + rewriter.rewrite(select, [(varmap, rqlexprs)], args, existant) + # ensure we have an order defined + if not select.orderby: + select.add_sort_var(select.defined_vars[searchedvar.name]) + # we're done, turn the rql syntax tree as a string + rql = select.as_string() + return rql, args + + def unrelated(self, rtype, targettype, role='subject', limit=None, + ordermethod=None, lt_infos={}): # XXX .cw_unrelated + """return a result set of target type objects that may be related + by a given relation, with self as subject or object + """ + try: + rql, args = self.cw_unrelated_rql(rtype, targettype, role, limit=limit, + ordermethod=ordermethod, lt_infos=lt_infos) + except Unauthorized: + return self._cw.empty_rset() + return self._cw.execute(rql, args) + + # relations cache handling ################################################# + + def cw_relation_cached(self, rtype, role): + """return None if the given relation isn't already cached on the + instance, else the content of the cache (a 2-uple (rset, entities)). + """ + return self._cw_related_cache.get('%s_%s' % (rtype, role)) + + def cw_set_relation_cache(self, rtype, role, rset): + """set cached values for the given relation""" + if rset: + related = list(rset.entities(0)) + rschema = self._cw.vreg.schema.rschema(rtype) + if role == 'subject': + rcard = rschema.rdef(self.e_schema, related[0].e_schema).cardinality[1] + target = 'object' + else: + rcard = rschema.rdef(related[0].e_schema, self.e_schema).cardinality[0] + target = 'subject' + if rcard in '?1': + for rentity in related: + rentity._cw_related_cache['%s_%s' % (rtype, target)] = ( + self.as_rset(), (self,)) + else: + related = () + self._cw_related_cache['%s_%s' % (rtype, role)] = (rset, related) + + def cw_clear_relation_cache(self, rtype=None, role=None): + """clear cached values for the given relation or the entire cache if + no relation is given + """ + if rtype is None: + self._cw_related_cache.clear() + self._cw_adapters_cache.clear() + else: + assert role + self._cw_related_cache.pop('%s_%s' % (rtype, role), None) + + def cw_clear_all_caches(self): + """flush all caches on this entity. Further attributes/relations access + will triggers new database queries to get back values. + + If you use custom caches on your entity class (take care to @cached!), + you should override this method to clear them as well. + """ + # clear attributes cache + self._cw_completed = False + self.cw_attr_cache.clear() + # clear relations cache + self.cw_clear_relation_cache() + # rest path unique cache + try: + del self.__unique + except AttributeError: + pass + + # raw edition utilities ################################################### + + def cw_set(self, **kwargs): + """update this entity using given attributes / relation, working in the + same fashion as :meth:`cw_instantiate`. + + Example (in a shell session): + + >>> c = rql('Any X WHERE X is Company').get_entity(0, 0) + >>> p = rql('Any X WHERE X is Person').get_entity(0, 0) + >>> c.cw_set(name=u'Logilab') + >>> p.cw_set(firstname=u'John', lastname=u'Doe', works_for=c) + + You can also set relations where the entity has 'object' role by + prefixing the relation name by 'reverse_'. Also, relation values may be + an entity or eid, a list of entities or eids, or None (meaning that all + relations of the given type from or to this object should be deleted). + """ + assert kwargs + assert self.cw_is_saved(), "should not call set_attributes while entity "\ + "hasn't been saved yet" + rql, qargs, pendingrels, attrcache = self._cw_build_entity_query(kwargs) + if rql: + rql = 'SET ' + rql + qargs['x'] = self.eid + if ' WHERE ' in rql: + rql += ', X eid %(x)s' + else: + rql += ' WHERE X eid %(x)s' + self._cw.execute(rql, qargs) + # update current local object _after_ the rql query to avoid + # interferences between the query execution itself and the cw_edited / + # skip_security machinery + self._cw_update_attr_cache(attrcache) + self._cw_handle_pending_relations(self.eid, pendingrels, self._cw.execute) + # XXX update relation cache + + def cw_delete(self, **kwargs): + assert self.has_eid(), self.eid + self._cw.execute('DELETE %s X WHERE X eid %%(x)s' % self.e_schema, + {'x': self.eid}, **kwargs) + + # server side utilities #################################################### + + def _cw_clear_local_perm_cache(self, action): + for rqlexpr in self.e_schema.get_rqlexprs(action): + self._cw.local_perm_cache.pop((rqlexpr.eid, (('x', self.eid),)), None) + + # deprecated stuff ######################################################### + + @deprecated('[3.16] use cw_set() instead of set_attributes()') + def set_attributes(self, **kwargs): # XXX cw_set_attributes + if kwargs: + self.cw_set(**kwargs) + + @deprecated('[3.16] use cw_set() instead of set_relations()') + def set_relations(self, **kwargs): # XXX cw_set_relations + """add relations to the given object. To set a relation where this entity + is the object of the relation, use 'reverse_' as argument name. + + Values may be an entity or eid, a list of entities or eids, or None + (meaning that all relations of the given type from or to this object + should be deleted). + """ + if kwargs: + self.cw_set(**kwargs) + + @deprecated('[3.13] use entity.cw_clear_all_caches()') + def clear_all_caches(self): + return self.cw_clear_all_caches() + + +# attribute and relation descriptors ########################################## + +class Attribute(object): + """descriptor that controls schema attribute access""" + + def __init__(self, attrname): + assert attrname != 'eid' + self._attrname = attrname + + def __get__(self, eobj, eclass): + if eobj is None: + return self + return eobj.cw_attr_value(self._attrname) + + @deprecated('[3.10] assign to entity.cw_attr_cache[attr] or entity.cw_edited[attr]') + def __set__(self, eobj, value): + if hasattr(eobj, 'cw_edited') and not eobj.cw_edited.saved: + eobj.cw_edited[self._attrname] = value + else: + eobj.cw_attr_cache[self._attrname] = value + + +class Relation(object): + """descriptor that controls schema relation access""" + + def __init__(self, rschema, role): + self._rtype = rschema.type + self._role = role + + def __get__(self, eobj, eclass): + if eobj is None: + raise AttributeError('%s can only be accessed from instances' + % self._rtype) + return eobj.related(self._rtype, self._role, entities=True) + + def __set__(self, eobj, value): + raise NotImplementedError + + +from logging import getLogger +from cubicweb import set_log_methods +set_log_methods(Entity, getLogger('cubicweb.entity'))