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

Revision 504, 11.0 kB (checked in by ged, 4 years ago)

- Fixed custom bases classes and versioned extension when used with zope

interfaces (closes #98, patch from Valentin Lab)

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