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

Revision 217, 9.2 kB (checked in by ged, 6 years ago)

minor cleanup as per a suggestion by Stou Sandalski

Line 
1'''
2A versioning plugin for Elixir.
3
4Entities that are marked as versioned will automatically have a history table
5created and a timestamp and version column added to their tables. In addition,
6versioned entities are provided with four new methods: revert, revert_to, and
7compare_with, get_as_of, and one new attribute: versions.  Entities with
8compound 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
37Note that relationships that are stored in mapping tables will not be included
38as part of the versioning process, and will need to be handled manually. Only
39values within the entity's main table will be versioned into the history table.
40'''
41
42from elixir                import Integer, DateTime
43from elixir.statements     import Statement
44from elixir.fields         import Field
45from sqlalchemy            import Table, Column, and_, desc
46from sqlalchemy.orm        import mapper, MapperExtension, EXT_PASS, \
47                                  object_session
48from datetime              import datetime
49
50import inspect
51
52
53#
54# utility functions
55#
56
57def get_entity_where(instance):
58    clauses = []
59    for column in instance.table.primary_key.columns:
60        instance_value = getattr(instance, column.name)
61        clauses.append(column==instance_value)
62    return and_(*clauses)
63
64
65def get_history_where(instance):
66    clauses = []
67    for column in instance.table.primary_key.columns:
68        instance_value = getattr(instance, column.name)
69        history_column = getattr(instance.__history_table__.primary_key.columns, column.name)
70        clauses.append(history_column==instance_value)
71    return and_(*clauses)
72
73
74#
75# a mapper extension to track versions on insert, update, and delete
76#
77
78class VersionedMapperExtension(MapperExtension):
79    def before_insert(self, mapper, connection, instance):
80        instance.version = 1
81        instance.timestamp = datetime.now()
82        return EXT_PASS
83       
84    def after_insert(self, mapper, connection, instance):
85        colvalues = dict([(key, getattr(instance, key)) for key in instance.c.keys()])
86        instance.__class__.__history_table__.insert().execute(colvalues)
87        return EXT_PASS
88   
89    def before_update(self, mapper, connection, instance):
90        colvalues = dict([(key, getattr(instance, key)) for key in instance.c.keys()])
91        history = instance.__class__.__history_table__
92       
93        values = history.select(get_history_where(instance), 
94                                order_by=[desc(history.c.timestamp)],
95                                limit=1).execute().fetchone()
96        # In case the data was dumped into the db, the initial version might
97        # be missing so we put this version in as the original.
98        if not values:
99            instance.version = colvalues['version'] = 1
100            instance.timestamp = colvalues['timestamp'] = datetime.now()
101            history.insert().execute(colvalues)
102            return EXT_PASS
103       
104        # SA might've flagged this for an update even though it didn't change.
105        # This occurs when a relation is updated, thus marking this instance
106        # for a save/update operation. We check here against the last version
107        # to ensure we really should save this version and update the version
108        # data.
109        for key in instance.c.keys():
110            if key in ['version', 'timestamp']:
111                continue
112            if getattr(instance, key) != values[key]:
113                # the instance was really updated, so we create a new version
114                instance.version = colvalues['version'] = instance.version + 1
115                instance.timestamp = colvalues['timestamp'] = datetime.now()
116                history.insert().execute(colvalues)
117                break
118
119        return EXT_PASS
120       
121    def before_delete(self, mapper, connection, instance):
122        instance.__history_table__.delete(
123            get_history_where(instance)
124        ).execute()
125        return EXT_PASS
126
127
128versioned_mapper_extension = VersionedMapperExtension()
129
130
131#
132# the acts_as_versioned statement
133#
134
135class ActsAsVersioned(object):
136       
137    def __init__(self, entity):
138        entity._descriptor.add_mapper_extension(versioned_mapper_extension)
139       
140        # add a version field to the entity, along with a timestamp
141        versionField = Field(Integer, colname='version')
142        timestampField = Field(DateTime, colname='timestamp')
143        entity._descriptor.add_field(versionField)
144        entity._descriptor.add_field(timestampField)
145        self.entity = entity
146   
147    def after_table(self):
148        entity = self.entity
149
150        # look for events
151        after_revert_events = []
152        for name, func in inspect.getmembers(entity, inspect.ismethod):
153            if getattr(func, '_elixir_after_revert', False):
154                after_revert_events.append(func)
155       
156        # create a history table for the entity
157        columns = [ column.copy() for column in entity.table.c if column.name != 'version' ]
158        columns.append(Column('version', Integer, primary_key=True))
159        table = Table(entity.table.name + '_history', entity.table.metadata, 
160            *columns
161        )
162        entity.__history_table__ = table
163       
164        # create an object that represents a version of this entity
165        class Version(object):
166            pass
167           
168        # map the version class to the history table for this entity
169        Version.__name__ = entity.__name__ + 'Version'
170        Version.__versioned_entity__ = entity
171        mapper(Version, entity.__history_table__)
172                       
173        # attach utility methods and properties to the entity
174        def get_versions(self):
175            return object_session(self).query(Version).filter(get_history_where(self)).all()
176       
177        def get_as_of(self, dt):
178            # if the passed in timestamp is older than our current version's
179            # time stamp, then the most recent version is our current version
180            if self.timestamp < dt:
181                return self
182           
183            # otherwise, we need to look to the history table to get our
184            # older version
185            query = object_session(self).query(Version)
186            query = query.filter(and_(get_history_where(self), 
187                                      Version.c.timestamp <= dt))
188            query = query.order_by(desc(Version.c.timestamp)).limit(1)
189            return query.first()
190       
191        def revert_to(self, to_version):
192            hist = entity.__history_table__
193            old_version = hist.select(and_(
194                get_history_where(self), 
195                hist.c.version == to_version
196            )).execute().fetchone()
197           
198            entity.table.update(get_entity_where(self)).execute(
199                dict(old_version.items())
200            )
201           
202            hist.delete(and_(get_history_where(self), 
203                             hist.c.version >= to_version)).execute()
204            for event in after_revert_events: 
205                event(self)
206       
207        def revert(self):
208            assert self.version > 1
209            self.revert_to(self.version - 1)
210           
211        def compare_with(self, version):
212            differences = {}
213            for column in self.c:
214                if column.name == 'version':
215                    continue
216                this = getattr(self, column.name)
217                that = getattr(version, column.name)
218                if this != that:
219                    differences[column.name] = (this, that)
220            return differences
221       
222        entity.versions      = property(get_versions)
223        entity.get_as_of     = get_as_of
224        entity.revert_to     = revert_to
225        entity.revert        = revert
226        entity.compare_with  = compare_with
227        Version.compare_with = compare_with
228
229
230acts_as_versioned = Statement(ActsAsVersioned)
231
232
233#
234# decorator for watching for revert events
235#
236
237def after_revert(func):
238    func._elixir_after_revert = True
239    return func
240
241
242__all__ = ['acts_as_versioned', 'after_revert']
Note: See TracBrowser for help on using the browser.