root / elixir / tags / 0.7.0 / elixir / ext / versioned.py

Revision 409, 11.0 kB (checked in by ged, 3 years ago)

- Added new column_names argument to the acts_as_versioned extension, allowing

to specify custom column names (inspired by a patch by Alex Bodnaru).

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.  In addition, you can
40pass in an optional `check_concurrent` argument, which will use SQLAlchemy's
41built-in optimistic concurrency mechanisms.
42
43Note that relationships that are stored in mapping tables will not be included
44as part of the versioning process, and will need to be handled manually. Only
45values within the entity's main table will be versioned into the history table.
46'''
47
48from datetime              import datetime
49import inspect
50
51from sqlalchemy            import Table, Column, and_, desc
52from sqlalchemy.orm        import mapper, MapperExtension, EXT_CONTINUE, \
53                                  object_session
54
55from elixir                import Integer, DateTime
56from elixir.statements     import Statement
57from elixir.properties     import EntityBuilder
58
59__all__ = ['acts_as_versioned', 'after_revert']
60__doc_all__ = []
61
62#
63# utility functions
64#
65
66def get_entity_where(instance):
67    clauses = []
68    for column in instance.table.primary_key.columns:
69        instance_value = getattr(instance, column.name)
70        clauses.append(column==instance_value)
71    return and_(*clauses)
72
73
74def get_history_where(instance):
75    clauses = []
76    history_columns = instance.__history_table__.primary_key.columns
77    for column in instance.table.primary_key.columns:
78        instance_value = getattr(instance, column.name)
79        history_column = getattr(history_columns, column.name)
80        clauses.append(history_column==instance_value)
81    return and_(*clauses)
82
83
84#
85# a mapper extension to track versions on insert, update, and delete
86#
87
88class VersionedMapperExtension(MapperExtension):
89    def before_insert(self, mapper, connection, instance):
90        version_colname, timestamp_colname = \
91            instance.__class__.__versioned_column_names__
92        setattr(instance, version_colname, 1)
93        setattr(instance, timestamp_colname, datetime.now())
94        return EXT_CONTINUE
95
96    def before_update(self, mapper, connection, instance):
97        old_values = instance.table.select(get_entity_where(instance)) \
98                                   .execute().fetchone()
99
100        # SA might've flagged this for an update even though it didn't change.
101        # This occurs when a relation is updated, thus marking this instance
102        # for a save/update operation. We check here against the last version
103        # to ensure we really should save this version and update the version
104        # data.
105        ignored = instance.__class__.__ignored_fields__
106        version_colname, timestamp_colname = \
107            instance.__class__.__versioned_column_names__
108        for key in instance.table.c.keys():
109            if key in ignored:
110                continue
111            if getattr(instance, key) != old_values[key]:
112                # the instance was really updated, so we create a new version
113                dict_values = dict(old_values.items())
114                connection.execute(
115                    instance.__class__.__history_table__.insert(), dict_values)
116                old_version = getattr(instance, version_colname)
117                setattr(instance, version_colname, old_version + 1)
118                setattr(instance, timestamp_colname, datetime.now())
119                break
120
121        return EXT_CONTINUE
122
123    def before_delete(self, mapper, connection, instance):
124        connection.execute(instance.__history_table__.delete(
125            get_history_where(instance)
126        ))
127        return EXT_CONTINUE
128
129
130versioned_mapper_extension = VersionedMapperExtension()
131
132
133#
134# the acts_as_versioned statement
135#
136
137class VersionedEntityBuilder(EntityBuilder):
138
139    def __init__(self, entity, ignore=None, check_concurrent=False,
140                 column_names=None):
141        self.entity = entity
142        self.add_mapper_extension(versioned_mapper_extension)
143        #TODO: we should rather check that the version_id_col isn't set
144        # externally
145        self.check_concurrent = check_concurrent
146
147        # Changes in these fields will be ignored
148        if column_names is None:
149            column_names = ['version', 'timestamp']
150        entity.__versioned_column_names__ = column_names
151        if ignore is None:
152            ignore = []
153        ignore.extend(column_names)
154        entity.__ignored_fields__ = ignore
155
156    def create_non_pk_cols(self):
157        # add a version column to the entity, along with a timestamp
158        version_colname, timestamp_colname = \
159            self.entity.__versioned_column_names__
160        #XXX: fail in case the columns already exist?
161        #col_names = [col.name for col in self.entity._descriptor.columns]
162        #if version_colname not in col_names:
163        self.add_table_column(Column(version_colname, Integer))
164        #if timestamp_colname not in col_names:
165        self.add_table_column(Column(timestamp_colname, DateTime))
166
167        # add a concurrent_version column to the entity, if required
168        if self.check_concurrent:
169            self.entity._descriptor.version_id_col = 'concurrent_version'
170
171    # we copy columns from the main entity table, so we need it to exist first
172    def after_table(self):
173        entity = self.entity
174        version_colname, timestamp_colname = \
175            entity.__versioned_column_names__
176
177        # look for events
178        after_revert_events = []
179        for name, func in inspect.getmembers(entity, inspect.ismethod):
180            if getattr(func, '_elixir_after_revert', False):
181                after_revert_events.append(func)
182
183        # create a history table for the entity
184        skipped_columns = [version_colname]
185        if self.check_concurrent:
186            skipped_columns.append('concurrent_version')
187
188        columns = [
189            column.copy() for column in entity.table.c
190            if column.name not in skipped_columns
191        ]
192        columns.append(Column(version_colname, Integer, primary_key=True))
193        table = Table(entity.table.name + '_history', entity.table.metadata,
194            *columns
195        )
196        entity.__history_table__ = table
197
198        # create an object that represents a version of this entity
199        class Version(object):
200            pass
201
202        # map the version class to the history table for this entity
203        Version.__name__ = entity.__name__ + 'Version'
204        Version.__versioned_entity__ = entity
205        mapper(Version, entity.__history_table__)
206
207        version_col = getattr(table.c, version_colname)
208        timestamp_col = getattr(table.c, timestamp_colname)
209
210        # attach utility methods and properties to the entity
211        def get_versions(self):
212            v = object_session(self).query(Version) \
213                                    .filter(get_history_where(self)) \
214                                    .order_by(version_col) \
215                                    .all()
216            # history contains all the previous records.
217            # Add the current one to the list to get all the versions
218            v.append(self)
219            return v
220
221        def get_as_of(self, dt):
222            # if the passed in timestamp is older than our current version's
223            # time stamp, then the most recent version is our current version
224            if getattr(self, timestamp_colname) < dt:
225                return self
226
227            # otherwise, we need to look to the history table to get our
228            # older version
229            sess = object_session(self)
230            query = sess.query(Version) \
231                        .filter(and_(get_history_where(self),
232                                     timestamp_col <= dt)) \
233                        .order_by(desc(timestamp_col)).limit(1)
234            return query.first()
235
236        def revert_to(self, to_version):
237            if isinstance(to_version, Version):
238                to_version = getattr(to_version, version_colname)
239
240            old_version = table.select(and_(
241                get_history_where(self),
242                version_col == to_version
243            )).execute().fetchone()
244
245            entity.table.update(get_entity_where(self)).execute(
246                dict(old_version.items())
247            )
248
249            table.delete(and_(get_history_where(self),
250                              version_col >= to_version)).execute()
251            self.expire()
252            for event in after_revert_events:
253                event(self)
254
255        def revert(self):
256            assert getattr(self, version_colname) > 1
257            self.revert_to(getattr(self, version_colname) - 1)
258
259        def compare_with(self, version):
260            differences = {}
261            for column in self.table.c:
262                if column.name in (version_colname, 'concurrent_version'):
263                    continue
264                this = getattr(self, column.name)
265                that = getattr(version, column.name)
266                if this != that:
267                    differences[column.name] = (this, that)
268            return differences
269
270        entity.versions = property(get_versions)
271        entity.get_as_of = get_as_of
272        entity.revert_to = revert_to
273        entity.revert = revert
274        entity.compare_with = compare_with
275        Version.compare_with = compare_with
276
277acts_as_versioned = Statement(VersionedEntityBuilder)
278
279
280def after_revert(func):
281    """
282    Decorator for watching for revert events.
283    """
284    func._elixir_after_revert = True
285    return func
286
Note: See TracBrowser for help on using the browser.