diff -r 058bb3dc685f -r 0b59724cb3f2 server/hook.py --- a/server/hook.py Mon Jan 04 18:40:30 2016 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1024 +0,0 @@ -# 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 . -""" -Generalities ------------- - -Paraphrasing the `emacs`_ documentation, let us say that hooks are an important -mechanism for customizing an application. A hook is basically a list of -functions to be called on some well-defined occasion (this is called `running -the hook`). - -.. _`emacs`: http://www.gnu.org/software/emacs/manual/html_node/emacs/Hooks.html - -Hooks -~~~~~ - -In |cubicweb|, hooks are subclasses of the :class:`~cubicweb.server.hook.Hook` -class. They are selected over a set of pre-defined `events` (and possibly more -conditions, hooks being selectable appobjects like views and components). They -should implement a :meth:`~cubicweb.server.hook.Hook.__call__` method that will -be called when the hook is triggered. - -There are two families of events: data events (before / after any individual -update of an entity / or a relation in the repository) and server events (such -as server startup or shutdown). In a typical application, most of the hooks are -defined over data events. - -Also, some :class:`~cubicweb.server.hook.Operation` may be registered by hooks, -which will be fired when the transaction is commited or rolled back. - -The purpose of data event hooks is usually to complement the data model as -defined in the schema, which is static by nature and only provide a restricted -builtin set of dynamic constraints, with dynamic or value driven behaviours. -For instance they can serve the following purposes: - -* enforcing constraints that the static schema cannot express (spanning several - entities/relations, exotic value ranges and cardinalities, etc.) - -* implement computed attributes - -It is functionally equivalent to a `database trigger`_, except that database -triggers definition languages are not standardized, hence not portable (for -instance, PL/SQL works with Oracle and PostgreSQL but not SqlServer nor Sqlite). - -.. _`database trigger`: http://en.wikipedia.org/wiki/Database_trigger - - -.. hint:: - - It is a good practice to write unit tests for each hook. See an example in - :ref:`hook_test` - -Operations -~~~~~~~~~~ - -Operations are subclasses of the :class:`~cubicweb.server.hook.Operation` class -that may be created by hooks and scheduled to happen on `precommit`, -`postcommit` or `rollback` event (i.e. respectivly before/after a commit or -before a rollback of a transaction). - -Hooks are being fired immediately on data operations, and it is sometime -necessary to delay the actual work down to a time where we can expect all -information to be there, or when all other hooks have run (though take case -since operations may themselves trigger hooks). Also while the order of -execution of hooks is data dependant (and thus hard to predict), it is possible -to force an order on operations. - -So, for such case where you may miss some information that may be set later in -the transaction, you should instantiate an operation in the hook. - -Operations may be used to: - -* implements a validation check which needs that all relations be already set on - an entity - -* process various side effects associated with a transaction such as filesystem - udpates, mail notifications, etc. - - -Events ------- - -Hooks are mostly defined and used to handle `dataflow`_ operations. It -means as data gets in (entities added, updated, relations set or -unset), specific events are issued and the Hooks matching these events -are called. - -You can get the event that triggered a hook by accessing its `event` -attribute. - -.. _`dataflow`: http://en.wikipedia.org/wiki/Dataflow - - -Entity modification related events -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When called for one of these events, hook will have an `entity` attribute -containing the entity instance. - -- `before_add_entity`, `before_update_entity`: - - On those events, you can access the modified attributes of the entity using - the `entity.cw_edited` dictionary. The values can be modified and the old - values can be retrieved. - - If you modify the `entity.cw_edited` dictionary in the hook, that is before - the database operations take place, you will avoid the need to process a whole - new rql query and the underlying backend query (eg usually sql) will contain - the modified data. For example: - - .. sourcecode:: python - - self.entity.cw_edited['age'] = 42 - - will modify the age before it is written to the backend storage. - - Similarly, removing an attribute from `cw_edited` will cancel its - modification: - - .. sourcecode:: python - - del self.entity.cw_edited['age'] - - On a `before_update_entity` event, you can access the old and new values: - - .. sourcecode:: python - - old, new = entity.cw_edited.oldnewvalue('age') - -- `after_add_entity`, `after_update_entity` - - On those events, you can get the list of attributes that were modified using - the `entity.cw_edited` dictionary, but you can not modify it or get the old - value of an attribute. - -- `before_delete_entity`, `after_delete_entity` - - On those events, the entity has no `cw_edited` dictionary. - -.. note:: `self.entity.cw_set(age=42)` will set the `age` attribute to - 42. But to do so, it will generate a rql query that will have to be processed, - hence may trigger some hooks, etc. This could lead to infinitely looping hooks. - -Relation modification related events -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When called for one of these events, hook will have `eidfrom`, `rtype`, `eidto` -attributes containing respectively the eid of the subject entity, the relation -type and the eid of the object entity. - -* `before_add_relation`, `before_delete_relation` - - On those events, you can still get the original relation by issuing a rql query. - -* `after_add_relation`, `after_delete_relation` - -Specific selectors are shipped for these kinds of events, see in particular -:class:`~cubicweb.server.hook.match_rtype`. - -Also note that relations can be added or deleted, but not updated. - -Non data events -~~~~~~~~~~~~~~~ - -Hooks called on server start/maintenance/stop event (e.g. -`server_startup`, `server_maintenance`, `before_server_shutdown`, -`server_shutdown`) have a `repo` attribute, but *their `_cw` attribute -is None*. The `server_startup` is called on regular startup, while -`server_maintenance` is called on cubicweb-ctl upgrade or shell -commands. `server_shutdown` is called anyway but connections to the -native source is impossible; `before_server_shutdown` handles that. - -Hooks called on backup/restore event (eg `server_backup`, -`server_restore`) have a `repo` and a `timestamp` attributes, but -*their `_cw` attribute is None*. - -Hooks called on session event (eg `session_open`, `session_close`) have no -special attribute. - - -API ---- - -Hooks control -~~~~~~~~~~~~~ - -It is sometimes convenient to explicitly enable or disable some hooks. For -instance if you want to disable some integrity checking hook. This can be -controlled more finely through the `category` class attribute, which is a string -giving a category name. One can then uses the -:meth:`~cubicweb.server.session.Connection.deny_all_hooks_but` and -:meth:`~cubicweb.server.session.Connection.allow_all_hooks_but` context managers to -explicitly enable or disable some categories. - -The existing categories are: - -* ``security``, security checking hooks - -* ``worfklow``, workflow handling hooks - -* ``metadata``, hooks setting meta-data on newly created entities - -* ``notification``, email notification hooks - -* ``integrity``, data integrity checking hooks - -* ``activeintegrity``, data integrity consistency hooks, that you should **never** - want to disable - -* ``syncsession``, hooks synchronizing existing sessions - -* ``syncschema``, hooks synchronizing instance schema (including the physical database) - -* ``email``, email address handling hooks - -* ``bookmark``, bookmark entities handling hooks - - -Nothing precludes one to invent new categories and use existing mechanisms to -filter them in or out. - - -Hooks specific predicates -~~~~~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: cubicweb.server.hook.match_rtype -.. autoclass:: cubicweb.server.hook.match_rtype_sets - - -Hooks and operations classes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. autoclass:: cubicweb.server.hook.Hook -.. autoclass:: cubicweb.server.hook.Operation -.. autoclass:: cubicweb.server.hook.LateOperation -.. autoclass:: cubicweb.server.hook.DataOperationMixIn -""" -from __future__ import print_function - -__docformat__ = "restructuredtext en" - -from warnings import warn -from logging import getLogger -from itertools import chain - -from logilab.common.decorators import classproperty, cached -from logilab.common.deprecation import deprecated, class_renamed -from logilab.common.logging_ext import set_log_methods -from logilab.common.registry import (NotPredicate, OrPredicate, - objectify_predicate) - -from cubicweb import RegistryNotFound, server -from cubicweb.cwvreg import CWRegistry, CWRegistryStore -from cubicweb.predicates import ExpectedValuePredicate, is_instance -from cubicweb.appobject import AppObject - -ENTITIES_HOOKS = set(('before_add_entity', 'after_add_entity', - 'before_update_entity', 'after_update_entity', - 'before_delete_entity', 'after_delete_entity')) -RELATIONS_HOOKS = set(('before_add_relation', 'after_add_relation' , - 'before_delete_relation','after_delete_relation')) -SYSTEM_HOOKS = set(('server_backup', 'server_restore', - 'server_startup', 'server_maintenance', - 'server_shutdown', 'before_server_shutdown', - 'session_open', 'session_close')) -ALL_HOOKS = ENTITIES_HOOKS | RELATIONS_HOOKS | SYSTEM_HOOKS - -def _iter_kwargs(entities, eids_from_to, kwargs): - if not entities and not eids_from_to: - yield kwargs - elif entities: - for entity in entities: - kwargs['entity'] = entity - yield kwargs - else: - for subject, object in eids_from_to: - kwargs.update({'eidfrom': subject, 'eidto': object}) - yield kwargs - - -class HooksRegistry(CWRegistry): - - def register(self, obj, **kwargs): - obj.check_events() - super(HooksRegistry, self).register(obj, **kwargs) - - def call_hooks(self, event, cnx=None, **kwargs): - """call `event` hooks for an entity or a list of entities (passed - respectively as the `entity` or ``entities`` keyword argument). - """ - kwargs['event'] = event - if cnx is None: # True for events such as server_start - for hook in sorted(self.possible_objects(cnx, **kwargs), - key=lambda x: x.order): - hook() - else: - if 'entities' in kwargs: - assert 'entity' not in kwargs, \ - 'can\'t pass "entities" and "entity" arguments simultaneously' - assert 'eids_from_to' not in kwargs, \ - 'can\'t pass "entities" and "eids_from_to" arguments simultaneously' - entities = kwargs.pop('entities') - eids_from_to = [] - elif 'eids_from_to' in kwargs: - entities = [] - eids_from_to = kwargs.pop('eids_from_to') - else: - entities = [] - eids_from_to = [] - pruned = self.get_pruned_hooks(cnx, event, - entities, eids_from_to, kwargs) - - # by default, hooks are executed with security turned off - with cnx.security_enabled(read=False): - for _kwargs in _iter_kwargs(entities, eids_from_to, kwargs): - hooks = sorted(self.filtered_possible_objects(pruned, cnx, **_kwargs), - key=lambda x: x.order) - debug = server.DEBUG & server.DBG_HOOKS - with cnx.security_enabled(write=False): - with cnx.running_hooks_ops(): - for hook in hooks: - if debug: - print(event, _kwargs, hook) - hook() - - def get_pruned_hooks(self, cnx, event, entities, eids_from_to, kwargs): - """return a set of hooks that should not be considered by filtered_possible objects - - the idea is to make a first pass over all the hooks in the - registry and to mark put some of them in a pruned list. The - pruned hooks are the one which: - - * are disabled at the connection level - - * have a selector containing a :class:`match_rtype` or an - :class:`is_instance` predicate which does not match the rtype / etype - of the relations / entities for which we are calling the hooks. This - works because the repository calls the hooks grouped by rtype or by - etype when using the entities or eids_to_from keyword arguments - - Only hooks with a simple predicate or an AndPredicate of simple - predicates are considered for disabling. - - """ - if 'entity' in kwargs: - entities = [kwargs['entity']] - if len(entities): - look_for_selector = is_instance - etype = entities[0].__regid__ - elif 'rtype' in kwargs: - look_for_selector = match_rtype - etype = None - else: # nothing to prune, how did we get there ??? - return set() - cache_key = (event, kwargs.get('rtype'), etype) - pruned = cnx.pruned_hooks_cache.get(cache_key) - if pruned is not None: - return pruned - pruned = set() - cnx.pruned_hooks_cache[cache_key] = pruned - if look_for_selector is not None: - for id, hooks in self.items(): - for hook in hooks: - enabled_cat, main_filter = hook.filterable_selectors() - if enabled_cat is not None: - if not enabled_cat(hook, cnx): - pruned.add(hook) - continue - if main_filter is not None: - if isinstance(main_filter, match_rtype) and \ - (main_filter.frometypes is not None or \ - main_filter.toetypes is not None): - continue - first_kwargs = next(_iter_kwargs(entities, eids_from_to, kwargs)) - if not main_filter(hook, cnx, **first_kwargs): - pruned.add(hook) - return pruned - - - def filtered_possible_objects(self, pruned, *args, **kwargs): - for appobjects in self.values(): - if pruned: - filtered_objects = [obj for obj in appobjects if obj not in pruned] - if not filtered_objects: - continue - else: - filtered_objects = appobjects - obj = self._select_best(filtered_objects, - *args, **kwargs) - if obj is None: - continue - yield obj - -class HooksManager(object): - def __init__(self, vreg): - self.vreg = vreg - - def call_hooks(self, event, cnx=None, **kwargs): - try: - registry = self.vreg['%s_hooks' % event] - except RegistryNotFound: - return # no hooks for this event - registry.call_hooks(event, cnx, **kwargs) - - -for event in ALL_HOOKS: - CWRegistryStore.REGISTRY_FACTORY['%s_hooks' % event] = HooksRegistry - - -# some hook specific predicates ################################################# - -@objectify_predicate -def enabled_category(cls, req, **kwargs): - if req is None: - return True # XXX how to deactivate server startup / shutdown event - return req.is_hook_activated(cls) - -@objectify_predicate -def issued_from_user_query(cls, req, **kwargs): - return 0 if req.hooks_in_progress else 1 - -from_dbapi_query = class_renamed('from_dbapi_query', - issued_from_user_query, - message='[3.21] ') - - -class rechain(object): - def __init__(self, *iterators): - self.iterators = iterators - def __iter__(self): - return iter(chain(*self.iterators)) - - -class match_rtype(ExpectedValuePredicate): - """accept if the relation type is found in expected ones. Optional - named parameters `frometypes` and `toetypes` can be used to restrict - target subject and/or object entity types of the relation. - - :param \*expected: possible relation types - :param frometypes: candidate entity types as subject of relation - :param toetypes: candidate entity types as object of relation - """ - def __init__(self, *expected, **more): - self.expected = expected - self.frometypes = more.pop('frometypes', None) - self.toetypes = more.pop('toetypes', None) - assert not more, "unexpected kwargs in match_rtype: %s" % more - - def __call__(self, cls, req, *args, **kwargs): - if kwargs.get('rtype') not in self.expected: - return 0 - if self.frometypes is not None and \ - req.entity_metas(kwargs['eidfrom'])['type'] not in self.frometypes: - return 0 - if self.toetypes is not None and \ - req.entity_metas(kwargs['eidto'])['type'] not in self.toetypes: - return 0 - return 1 - - -class match_rtype_sets(ExpectedValuePredicate): - """accept if the relation type is in one of the sets given as initializer - argument. The goal of this predicate is that it keeps reference to original sets, - so modification to thoses sets are considered by the predicate. For instance - - .. sourcecode:: python - - MYSET = set() - - class Hook1(Hook): - __regid__ = 'hook1' - __select__ = Hook.__select__ & match_rtype_sets(MYSET) - ... - - class Hook2(Hook): - __regid__ = 'hook2' - __select__ = Hook.__select__ & match_rtype_sets(MYSET) - - Client code can now change `MYSET`, this will changes the selection criteria - of :class:`Hook1` and :class:`Hook1`. - """ - - def __init__(self, *expected): - self.expected = expected - - def __call__(self, cls, req, *args, **kwargs): - for rel_set in self.expected: - if kwargs.get('rtype') in rel_set: - return 1 - return 0 - - -# base class for hook ########################################################## - -class Hook(AppObject): - """Base class for hook. - - Hooks being appobjects like views, they have a `__regid__` and a `__select__` - class attribute. Like all appobjects, hooks have the `self._cw` attribute which - represents the current connection. In entity hooks, a `self.entity` attribute is - also present. - - The `events` tuple is used by the base class selector to dispatch the hook - on the right events. It is possible to dispatch on multiple events at once - if needed (though take care as hook attribute may vary as described above). - - .. Note:: - - Do not forget to extend the base class selectors as in: - - .. sourcecode:: python - - class MyHook(Hook): - __regid__ = 'whatever' - __select__ = Hook.__select__ & is_instance('Person') - - else your hooks will be called madly, whatever the event. - """ - __select__ = enabled_category() - # set this in derivated classes - events = None - category = None - order = 0 - # stop pylint from complaining about missing attributes in Hooks classes - eidfrom = eidto = entity = rtype = repo = None - - @classmethod - @cached - def filterable_selectors(cls): - search = cls.__select__.search_selector - if search((NotPredicate, OrPredicate)): - return None, None - enabled_cat = search(enabled_category) - main_filter = search((is_instance, match_rtype)) - return enabled_cat, main_filter - - @classmethod - def check_events(cls): - try: - for event in cls.events: - if event not in ALL_HOOKS: - raise Exception('bad event %s on %s.%s' % ( - event, cls.__module__, cls.__name__)) - except AttributeError: - raise - except TypeError: - raise Exception('bad .events attribute %s on %s.%s' % ( - cls.events, cls.__module__, cls.__name__)) - - @classmethod - def __registered__(cls, reg): - cls.check_events() - - @classproperty - def __registries__(cls): - if cls.events is None: - return [] - return ['%s_hooks' % ev for ev in cls.events] - - known_args = set(('entity', 'rtype', 'eidfrom', 'eidto', 'repo', 'timestamp')) - def __init__(self, req, event, **kwargs): - for arg in self.known_args: - if arg in kwargs: - setattr(self, arg, kwargs.pop(arg)) - super(Hook, self).__init__(req, **kwargs) - self.event = event - -set_log_methods(Hook, getLogger('cubicweb.hook')) - - -# abtract hooks for relation propagation ####################################### -# See example usage in hooks of the nosylist cube - -class PropagateRelationHook(Hook): - """propagate some `main_rtype` relation on entities linked as object of - `subject_relations` or as subject of `object_relations` (the watched - relations). - - This hook ensure that when one of the watched relation is added, the - `main_rtype` relation is added to the target entity of the relation. - Notice there are no default behaviour defined when a watched relation is - deleted, you'll have to handle this by yourself. - - You usually want to use the :class:`match_rtype_sets` predicate on concrete - classes. - """ - events = ('after_add_relation',) - - # to set in concrete class - main_rtype = None - subject_relations = None - object_relations = None - - def __call__(self): - assert self.main_rtype - for eid in (self.eidfrom, self.eidto): - etype = self._cw.entity_metas(eid)['type'] - if self.main_rtype not in self._cw.vreg.schema.eschema(etype).subjrels: - return - if self.rtype in self.subject_relations: - meid, seid = self.eidfrom, self.eidto - else: - assert self.rtype in self.object_relations - meid, seid = self.eidto, self.eidfrom - self._cw.execute( - 'SET E %s P WHERE X %s P, X eid %%(x)s, E eid %%(e)s, NOT E %s P' - % (self.main_rtype, self.main_rtype, self.main_rtype), - {'x': meid, 'e': seid}) - - -class PropagateRelationAddHook(Hook): - """Propagate to entities at the end of watched relations when a `main_rtype` - relation is added. - - `subject_relations` and `object_relations` attributes should be specified on - subclasses and are usually shared references with attributes of the same - name on :class:`PropagateRelationHook`. - - Because of those shared references, you can use `skip_subject_relations` and - `skip_object_relations` attributes when you don't want to propagate to - entities linked through some particular relations. - """ - events = ('after_add_relation',) - - # to set in concrete class (mandatory) - subject_relations = None - object_relations = None - # to set in concrete class (optionally) - skip_subject_relations = () - skip_object_relations = () - - def __call__(self): - eschema = self._cw.vreg.schema.eschema(self._cw.entity_metas(self.eidfrom)['type']) - execute = self._cw.execute - for rel in self.subject_relations: - if rel in eschema.subjrels and not rel in self.skip_subject_relations: - execute('SET R %s P WHERE X eid %%(x)s, P eid %%(p)s, ' - 'X %s R, NOT R %s P' % (self.rtype, rel, self.rtype), - {'x': self.eidfrom, 'p': self.eidto}) - for rel in self.object_relations: - if rel in eschema.objrels and not rel in self.skip_object_relations: - execute('SET R %s P WHERE X eid %%(x)s, P eid %%(p)s, ' - 'R %s X, NOT R %s P' % (self.rtype, rel, self.rtype), - {'x': self.eidfrom, 'p': self.eidto}) - - -class PropagateRelationDelHook(PropagateRelationAddHook): - """Propagate to entities at the end of watched relations when a `main_rtype` - relation is deleted. - - This is the opposite of the :class:`PropagateRelationAddHook`, see its - documentation for how to use this class. - """ - events = ('after_delete_relation',) - - def __call__(self): - eschema = self._cw.vreg.schema.eschema(self._cw.entity_metas(self.eidfrom)['type']) - execute = self._cw.execute - for rel in self.subject_relations: - if rel in eschema.subjrels and not rel in self.skip_subject_relations: - execute('DELETE R %s P WHERE X eid %%(x)s, P eid %%(p)s, ' - 'X %s R' % (self.rtype, rel), - {'x': self.eidfrom, 'p': self.eidto}) - for rel in self.object_relations: - if rel in eschema.objrels and not rel in self.skip_object_relations: - execute('DELETE R %s P WHERE X eid %%(x)s, P eid %%(p)s, ' - 'R %s X' % (self.rtype, rel), - {'x': self.eidfrom, 'p': self.eidto}) - - - -# abstract classes for operation ############################################### - -class Operation(object): - """Base class for operations. - - Operation may be instantiated in the hooks' `__call__` method. It always - takes a connection object as first argument (accessible as `.cnx` from the - operation instance), and optionally all keyword arguments needed by the - operation. These keyword arguments will be accessible as attributes from the - operation instance. - - An operation is triggered on connections set events related to commit / - rollback transations. Possible events are: - - * `precommit`: - - the transaction is being prepared for commit. You can freely do any heavy - computation, raise an exception if the commit can't go. or even add some - new operations during this phase. If you do anything which has to be - reverted if the commit fails afterwards (eg altering the file system for - instance), you'll have to support the 'revertprecommit' event to revert - things by yourself - - * `revertprecommit`: - - if an operation failed while being pre-commited, this event is triggered - for all operations which had their 'precommit' event already fired to let - them revert things (including the operation which made the commit fail) - - * `rollback`: - - the transaction has been either rolled back either: - - * intentionally - * a 'precommit' event failed, in which case all operations are rolled back - once 'revertprecommit'' has been called - - * `postcommit`: - - the transaction is over. All the ORM entities accessed by the earlier - transaction are invalid. If you need to work on the database, you need to - start a new transaction, for instance using a new internal connection, - which you will need to commit. - - For an operation to support an event, one has to implement the `_event` method with no arguments. - - The order of operations may be important, and is controlled according to - the insert_index's method output (whose implementation vary according to the - base hook class used). - """ - - def __init__(self, cnx, **kwargs): - self.cnx = cnx - self.__dict__.update(kwargs) - self.register(cnx) - # execution information - self.processed = None # 'precommit', 'commit' - self.failed = False - - @property - @deprecated('[3.19] Operation.session is deprecated, use Operation.cnx instead') - def session(self): - return self.cnx - - def register(self, cnx): - cnx.add_operation(self, self.insert_index()) - - def insert_index(self): - """return the index of the latest instance which is not a - LateOperation instance - """ - # faster by inspecting operation in reverse order for heavy transactions - i = None - for i, op in enumerate(reversed(self.cnx.pending_operations)): - if isinstance(op, (LateOperation, SingleLastOperation)): - continue - return -i or None - if i is None: - return None - return -(i + 1) - - def handle_event(self, event): - """delegate event handling to the opertaion""" - getattr(self, event)() - - def precommit_event(self): - """the observed connections set is preparing a commit""" - - def revertprecommit_event(self): - """an error went when pre-commiting this operation or a later one - - should revert pre-commit's changes but take care, they may have not - been all considered if it's this operation which failed - """ - - def rollback_event(self): - """the observed connections set has been rolled back - - do nothing by default - """ - - def postcommit_event(self): - """the observed connections set has committed""" - - # these are overridden by set_log_methods below - # only defining here to prevent pylint from complaining - info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None - -set_log_methods(Operation, getLogger('cubicweb.session')) - -def _container_add(container, value): - {set: set.add, list: list.append}[container.__class__](container, value) - - -class DataOperationMixIn(object): - """Mix-in class to ease applying a single operation on a set of data, - avoiding to create as many as operation as they are individual modification. - The body of the operation must then iterate over the values that have been - stored in a single operation instance. - - You should try to use this instead of creating on operation for each - `value`, since handling operations becomes costly on massive data import. - - Usage looks like: - - .. sourcecode:: python - - class MyEntityHook(Hook): - __regid__ = 'my.entity.hook' - __select__ = Hook.__select__ & is_instance('MyEntity') - events = ('after_add_entity',) - - def __call__(self): - MyOperation.get_instance(self._cw).add_data(self.entity) - - - class MyOperation(DataOperationMixIn, Operation): - def precommit_event(self): - for bucket in self.get_data(): - process(bucket) - - You can modify the `containercls` class attribute, which defines the - container class that should be instantiated to hold payloads. An instance is - created on instantiation, and then the :meth:`add_data` method will add the - given data to the existing container. Default to a `set`. Give `list` if you - want to keep arrival ordering. You can also use another kind of container - by redefining :meth:`_build_container` and :meth:`add_data` - - More optional parameters can be given to the `get_instance` operation, that - will be given to the operation constructor (for obvious reasons those - parameters should not vary accross different calls to this method for a - given operation). - - .. Note:: - For sanity reason `get_data` will reset the operation, so that once - the operation has started its treatment, if some hook want to push - additional data to this same operation, a new instance will be created - (else that data has a great chance to be never treated). This implies: - - * you should **always** call `get_data` when starting treatment - - * you should **never** call `get_data` for another reason. - """ - containercls = set - - @classproperty - def data_key(cls): - return ('cw.dataops', cls.__name__) - - @classmethod - def get_instance(cls, cnx, **kwargs): - # no need to lock: transaction_data already comes from thread's local storage - try: - return cnx.transaction_data[cls.data_key] - except KeyError: - op = cnx.transaction_data[cls.data_key] = cls(cnx, **kwargs) - return op - - def __init__(self, *args, **kwargs): - super(DataOperationMixIn, self).__init__(*args, **kwargs) - self._container = self._build_container() - self._processed = False - - def __contains__(self, value): - return value in self._container - - def _build_container(self): - return self.containercls() - - def union(self, data): - """only when container is a set""" - assert not self._processed, """Trying to add data to a closed operation. -Iterating over operation data closed it and should be reserved to precommit / -postcommit method of the operation.""" - self._container |= data - - def add_data(self, data): - assert not self._processed, """Trying to add data to a closed operation. -Iterating over operation data closed it and should be reserved to precommit / -postcommit method of the operation.""" - _container_add(self._container, data) - - def remove_data(self, data): - assert not self._processed, """Trying to add data to a closed operation. -Iterating over operation data closed it and should be reserved to precommit / -postcommit method of the operation.""" - self._container.remove(data) - - def get_data(self): - assert not self._processed, """Trying to get data from a closed operation. -Iterating over operation data closed it and should be reserved to precommit / -postcommit method of the operation.""" - self._processed = True - op = self.cnx.transaction_data.pop(self.data_key) - assert op is self, "Bad handling of operation data, found %s instead of %s for key %s" % ( - op, self, self.data_key) - return self._container - - - -class LateOperation(Operation): - """special operation which should be called after all possible (ie non late) - operations - """ - def insert_index(self): - """return the index of the lastest instance which is not a - SingleLastOperation instance - """ - # faster by inspecting operation in reverse order for heavy transactions - i = None - for i, op in enumerate(reversed(self.cnx.pending_operations)): - if isinstance(op, SingleLastOperation): - continue - return -i or None - if i is None: - return None - return -(i + 1) - - - -class SingleLastOperation(Operation): - """special operation which should be called once and after all other - operations - """ - - def register(self, cnx): - """override register to handle cases where this operation has already - been added - """ - operations = cnx.pending_operations - index = self.equivalent_index(operations) - if index is not None: - equivalent = operations.pop(index) - else: - equivalent = None - cnx.add_operation(self, self.insert_index()) - return equivalent - - def equivalent_index(self, operations): - """return the index of the equivalent operation if any""" - for i, op in enumerate(reversed(operations)): - if op.__class__ is self.__class__: - return -(i+1) - return None - - def insert_index(self): - return None - - -class SendMailOp(SingleLastOperation): - def __init__(self, cnx, msg=None, recipients=None, **kwargs): - # may not specify msg yet, as - # `cubicweb.sobjects.supervision.SupervisionMailOp` - if msg is not None: - assert recipients - self.to_send = [(msg, recipients)] - else: - assert recipients is None - self.to_send = [] - super(SendMailOp, self).__init__(cnx, **kwargs) - - def register(self, cnx): - previous = super(SendMailOp, self).register(cnx) - if previous: - self.to_send = previous.to_send + self.to_send - - def postcommit_event(self): - self.cnx.repo.threaded_task(self.sendmails) - - def sendmails(self): - self.cnx.vreg.config.sendmails(self.to_send) - - -class RQLPrecommitOperation(Operation): - # to be defined in concrete classes - rqls = None - - def precommit_event(self): - execute = self.cnx.execute - for rql in self.rqls: - execute(*rql) - - -class CleanupNewEidsCacheOp(DataOperationMixIn, SingleLastOperation): - """on rollback of a insert query we have to remove from repository's - type/source cache eids of entities added in that transaction. - - NOTE: querier's rqlst/solutions cache may have been polluted too with - queries such as Any X WHERE X eid 32 if 32 has been rolled back however - generated queries are unpredictable and analysing all the cache probably - too expensive. Notice that there is no pb when using args to specify eids - instead of giving them into the rql string. - """ - data_key = 'neweids' - - def rollback_event(self): - """the observed connections set has been rolled back, - remove inserted eid from repository type/source cache - """ - try: - self.cnx.repo.clear_caches(self.get_data()) - except KeyError: - pass - -class CleanupDeletedEidsCacheOp(DataOperationMixIn, SingleLastOperation): - """on commit of delete query, we have to remove from repository's - type/source cache eids of entities deleted in that transaction. - """ - data_key = 'pendingeids' - def postcommit_event(self): - """the observed connections set has been rolled back, - remove inserted eid from repository type/source cache - """ - try: - eids = self.get_data() - self.cnx.repo.clear_caches(eids) - self.cnx.repo.app_instances_bus.publish(['delete'] + list(str(eid) for eid in eids)) - except KeyError: - pass