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

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