root / elixir / trunk / elixir / ext / versioned.py @ 286

Revision 286, 9.4 kB (checked in by cleverdevil, 5 years ago)

Applied a patch, closing ticket 29, where the versioning extension was not appropriately handling versioned entities with onupdate events. Patch contributed by Remi Jolin <remi.jolin@…>.

Line 
1'''
2A versioning plugin for Elixir.
3
4Entities that are marked as versioned with the `acts_as_versioned` statement
5will automatically have a history table created and a timestamp and version
6column added to their tables. In addition, versioned entities are provided
7with four new methods: revert, revert_to, compare_with and get_as_of, and one
8new attribute: versions.  Entities with compound primary keys are supported.
9
10The `versions` attribute will contain a list of previous versions of the
11instance, in increasing version number order.
12
13The `get_as_of` method will retrieve a previous version of the instance "as of"
14a specified datetime. If the current version is the most recent, it will be
15returned.
16
17The `revert` method will rollback the current instance to its previous version,
18if possible. Once reverted, the current instance will be expired from the
19session, and you will need to fetch it again to retrieve the now reverted
20instance.
21
22The `revert_to` method will rollback the current instance to the specified
23version number, if possibe. Once reverted, the current instance will be expired
24from the session, and you will need to fetch it again to retrieve the now
25reverted instance.
26
27The `compare_with` method will compare the instance with a previous version. A
28dictionary will be returned with each field difference as an element in the
29dictionary where the key is the field name and the value is a tuple of the
30format (current_value, version_value). Version instances also have a
31`compare_with` method so that two versions can be compared.
32
33Also included in the module is a `after_revert` decorator that can be used to
34decorate methods on the versioned entity that will be called following that
35instance being reverted.
36
37The acts_as_versioned statement also accepts an optional `ignore` argument
38that consists of a list of strings, specifying names of fields.  Changes in
39those fields will not result in a version increment.
40
41Note that relationships that are stored in mapping tables will not be included
42as part of the versioning process, and will need to be handled manually. Only
43values within the entity's main table will be versioned into the history table.
44'''
45
46from datetime              import datetime
47import inspect
48
49from sqlalchemy            import Table, Column, and_, desc
50from sqlalchemy.orm        import mapper, MapperExtension, EXT_PASS, \
51                                  object_session
52
53from elixir                import Integer, DateTime
54from elixir.statements     import Statement
55
56__all__ = ['acts_as_versioned', 'after_revert']
57__doc_all__ = []
58
59#
60# utility functions
61#
62
63def get_entity_where(instance):
64    clauses = []
65    for column in instance.table.primary_key.columns:
66        instance_value = getattr(instance, column.name)
67        clauses.append(column==instance_value)
68    return and_(*clauses)
69
70
71def get_history_where(instance):
72    clauses = []
73    history_columns = instance.__history_table__.primary_key.columns
74    for column in instance.table.primary_key.columns:
75        instance_value = getattr(instance, column.name)
76        history_column = getattr(history_columns, column.name)
77        clauses.append(history_column==instance_value)
78    return and_(*clauses)
79
80
81#
82# a mapper extension to track versions on insert, update, and delete
83#
84
85class VersionedMapperExtension(MapperExtension):
86    def before_insert(self, mapper, connection, instance):
87        instance.version = 1
88        instance.timestamp = datetime.now()
89        return EXT_PASS
90           
91    def before_update(self, mapper, connection, instance):
92        values = instance.table.select(get_entity_where(instance)).execute().fetchone() 
93
94        # SA might've flagged this for an update even though it didn't change.
95        # This occurs when a relation is updated, thus marking this instance
96        # for a save/update operation. We check here against the last version
97        # to ensure we really should save this version and update the version
98        # data.
99        ignored = instance.__class__.__ignored_fields__
100        for key in instance.c.keys():
101            if key in ignored:
102                continue
103            if getattr(instance, key) != values[key]:
104                # the instance was really updated, so we create a new version
105                colvalues = dict(values.items()) 
106                instance.__class__.__history_table__.insert().execute(colvalues) 
107                instance.version = instance.version + 1
108                instance.timestamp = datetime.now()
109                break
110
111        return EXT_PASS
112       
113    def before_delete(self, mapper, connection, instance):
114        instance.__history_table__.delete(
115            get_history_where(instance)
116        ).execute()
117        return EXT_PASS
118
119
120versioned_mapper_extension = VersionedMapperExtension()
121
122
123#
124# the acts_as_versioned statement
125#
126
127class VersionedEntityBuilder(object):
128       
129    def __init__(self, entity, ignore=[]):
130        entity._descriptor.add_mapper_extension(versioned_mapper_extension)
131        self.entity = entity
132        # Changes in these fields will be ignored
133        entity.__ignored_fields__ = ignore
134        entity.__ignored_fields__.extend(['version', 'timestamp'])
135       
136    def create_non_pk_cols(self):
137        # add a version column to the entity, along with a timestamp
138        version_col = Column('version', Integer)
139        timestamp_col = Column('timestamp', DateTime)
140        self.entity._descriptor.add_column(version_col)
141        self.entity._descriptor.add_column(timestamp_col)
142   
143    # we copy columns from the main entity table, so we need it to exist first
144    def after_table(self):
145        entity = self.entity
146
147        # look for events
148        after_revert_events = []
149        for name, func in inspect.getmembers(entity, inspect.ismethod):
150            if getattr(func, '_elixir_after_revert', False):
151                after_revert_events.append(func)
152       
153        # create a history table for the entity
154        #TODO: fail more noticeably in case there is a version col
155        columns = [column.copy() for column in entity.table.c 
156                                 if column.name != 'version']
157        columns.append(Column('version', Integer, primary_key=True))
158        table = Table(entity.table.name + '_history', entity.table.metadata, 
159            *columns
160        )
161        entity.__history_table__ = table
162       
163        # create an object that represents a version of this entity
164        class Version(object):
165            pass
166           
167        # map the version class to the history table for this entity
168        Version.__name__ = entity.__name__ + 'Version'
169        Version.__versioned_entity__ = entity
170        mapper(Version, entity.__history_table__)
171                       
172        # attach utility methods and properties to the entity
173        def get_versions(self):
174            v = object_session(self).query(Version) \
175                                    .filter(get_history_where(self)) \
176                                    .order_by(Version.c.version) \
177                                    .all()
178            # history contains all the previous records.
179            # Add the current one to the list to get all the versions
180            v.append(self)
181            return v
182       
183        def get_as_of(self, dt):
184            # if the passed in timestamp is older than our current version's
185            # time stamp, then the most recent version is our current version
186            if self.timestamp < dt:
187                return self
188           
189            # otherwise, we need to look to the history table to get our
190            # older version
191            query = object_session(self).query(Version)
192            query = query.filter(and_(get_history_where(self), 
193                                      Version.c.timestamp <= dt))
194            query = query.order_by(desc(Version.c.timestamp)).limit(1)
195            return query.first()
196       
197        def revert_to(self, to_version):
198            if isinstance(to_version, Version):
199                to_version = to_version.version
200               
201            hist = entity.__history_table__
202            old_version = hist.select(and_(
203                get_history_where(self), 
204                hist.c.version == to_version
205            )).execute().fetchone()
206           
207            entity.table.update(get_entity_where(self)).execute(
208                dict(old_version.items())
209            )
210           
211            hist.delete(and_(get_history_where(self), 
212                             hist.c.version >= to_version)).execute()
213            self.expire()
214            for event in after_revert_events: 
215                event(self)
216       
217        def revert(self):
218            assert self.version > 1
219            self.revert_to(self.version - 1)
220           
221        def compare_with(self, version):
222            differences = {}
223            for column in self.c:
224                if column.name == 'version':
225                    continue
226                this = getattr(self, column.name)
227                that = getattr(version, column.name)
228                if this != that:
229                    differences[column.name] = (this, that)
230            return differences
231       
232        entity.versions = property(get_versions)
233        entity.get_as_of = get_as_of
234        entity.revert_to = revert_to
235        entity.revert = revert
236        entity.compare_with = compare_with
237        Version.compare_with = compare_with
238
239acts_as_versioned = Statement(VersionedEntityBuilder)
240
241
242def after_revert(func):
243    """
244    Decorator for watching for revert events.
245    """
246    func._elixir_after_revert = True
247    return func
248
Note: See TracBrowser for help on using the browser.