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

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