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

Revision 263, 9.9 kB (checked in by ged, 6 years ago)
  • minor style improvements, correct typos, ...
  • bump version to 0.4.1
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 after_insert(self, mapper, connection, instance):
92        colvalues = dict([(key, getattr(instance, key)) for key in instance.c.keys()])
93        instance.__class__.__history_table__.insert().execute(colvalues)
94        return EXT_PASS
95   
96    def before_update(self, mapper, connection, instance):
97        colvalues = dict([(key, getattr(instance, key)) for key in instance.c.keys()])
98        history = instance.__class__.__history_table__
99       
100        values = history.select(get_history_where(instance), 
101                                order_by=[desc(history.c.timestamp)],
102                                limit=1).execute().fetchone()
103        # In case the data was dumped into the db, the initial version might
104        # be missing so we put this version in as the original.
105        if not values:
106            instance.version = colvalues['version'] = 1
107            instance.timestamp = colvalues['timestamp'] = datetime.now()
108            history.insert().execute(colvalues)
109            return EXT_PASS
110       
111        # SA might've flagged this for an update even though it didn't change.
112        # This occurs when a relation is updated, thus marking this instance
113        # for a save/update operation. We check here against the last version
114        # to ensure we really should save this version and update the version
115        # data.
116        ignored = instance.__class__.__ignored_fields__
117        for key in instance.c.keys():
118            if key in ignored:
119                continue
120            if getattr(instance, key) != values[key]:
121                # the instance was really updated, so we create a new version
122                instance.version = colvalues['version'] = instance.version + 1
123                instance.timestamp = colvalues['timestamp'] = datetime.now()
124                history.insert().execute(colvalues)
125                break
126
127        return EXT_PASS
128       
129    def before_delete(self, mapper, connection, instance):
130        instance.__history_table__.delete(
131            get_history_where(instance)
132        ).execute()
133        return EXT_PASS
134
135
136versioned_mapper_extension = VersionedMapperExtension()
137
138
139#
140# the acts_as_versioned statement
141#
142
143class VersionedEntityBuilder(object):
144       
145    def __init__(self, entity, ignore=[]):
146        entity._descriptor.add_mapper_extension(versioned_mapper_extension)
147        self.entity = entity
148        # Changes in these fields will be ignored
149        entity.__ignored_fields__ = ignore
150        entity.__ignored_fields__.extend(['version', 'timestamp'])
151       
152    def create_non_pk_cols(self):
153        # add a version column to the entity, along with a timestamp
154        version_col = Column('version', Integer)
155        timestamp_col = Column('timestamp', DateTime)
156        self.entity._descriptor.add_column(version_col)
157        self.entity._descriptor.add_column(timestamp_col)
158   
159    # we copy columns from the main entity table, so we need it to exist first
160    def after_table(self):
161        entity = self.entity
162
163        # look for events
164        after_revert_events = []
165        for name, func in inspect.getmembers(entity, inspect.ismethod):
166            if getattr(func, '_elixir_after_revert', False):
167                after_revert_events.append(func)
168       
169        # create a history table for the entity
170        #TODO: fail more noticeably in case there is a version col
171        columns = [column.copy() for column in entity.table.c 
172                                 if column.name != 'version']
173        columns.append(Column('version', Integer, primary_key=True))
174        table = Table(entity.table.name + '_history', entity.table.metadata, 
175            *columns
176        )
177        entity.__history_table__ = table
178       
179        # create an object that represents a version of this entity
180        class Version(object):
181            pass
182           
183        # map the version class to the history table for this entity
184        Version.__name__ = entity.__name__ + 'Version'
185        Version.__versioned_entity__ = entity
186        mapper(Version, entity.__history_table__)
187                       
188        # attach utility methods and properties to the entity
189        def get_versions(self):
190            return object_session(self).query(Version) \
191                                       .filter(get_history_where(self)) \
192                                       .all()
193       
194        def get_as_of(self, dt):
195            # if the passed in timestamp is older than our current version's
196            # time stamp, then the most recent version is our current version
197            if self.timestamp < dt:
198                return self
199           
200            # otherwise, we need to look to the history table to get our
201            # older version
202            query = object_session(self).query(Version)
203            query = query.filter(and_(get_history_where(self), 
204                                      Version.c.timestamp <= dt))
205            query = query.order_by(desc(Version.c.timestamp)).limit(1)
206            return query.first()
207       
208        def revert_to(self, to_version):
209            hist = entity.__history_table__
210            old_version = hist.select(and_(
211                get_history_where(self), 
212                hist.c.version == to_version
213            )).execute().fetchone()
214           
215            entity.table.update(get_entity_where(self)).execute(
216                dict(old_version.items())
217            )
218           
219            hist.delete(and_(get_history_where(self), 
220                             hist.c.version >= to_version)).execute()
221            for event in after_revert_events: 
222                event(self)
223       
224        def revert(self):
225            assert self.version > 1
226            self.revert_to(self.version - 1)
227           
228        def compare_with(self, version):
229            differences = {}
230            for column in self.c:
231                if column.name == 'version':
232                    continue
233                this = getattr(self, column.name)
234                that = getattr(version, column.name)
235                if this != that:
236                    differences[column.name] = (this, that)
237            return differences
238       
239        entity.versions      = property(get_versions)
240        entity.get_as_of     = get_as_of
241        entity.revert_to     = revert_to
242        entity.revert        = revert
243        entity.compare_with  = compare_with
244        Version.compare_with = compare_with
245
246acts_as_versioned = Statement(VersionedEntityBuilder)
247
248
249def after_revert(func):
250    """
251    Decorator for watching for revert events.
252    """
253    func._elixir_after_revert = True
254    return func
255
Note: See TracBrowser for help on using the browser.