| 1 | ''' |
|---|
| 2 | A versioning plugin for Elixir. |
|---|
| 3 | |
|---|
| 4 | Entities that are marked as versioned with the `acts_as_versioned` statement |
|---|
| 5 | will automatically have a history table created and a timestamp and version |
|---|
| 6 | column added to their tables. In addition, versioned entities are provided |
|---|
| 7 | with four new methods: revert, revert_to, compare_with and get_as_of, and one |
|---|
| 8 | new attribute: versions. Entities with compound primary keys are supported. |
|---|
| 9 | |
|---|
| 10 | The `versions` attribute will contain a list of previous versions of the |
|---|
| 11 | instance, in increasing version number order. |
|---|
| 12 | |
|---|
| 13 | The `get_as_of` method will retrieve a previous version of the instance "as of" |
|---|
| 14 | a specified datetime. If the current version is the most recent, it will be |
|---|
| 15 | returned. |
|---|
| 16 | |
|---|
| 17 | The `revert` method will rollback the current instance to its previous version, |
|---|
| 18 | if possible. Once reverted, the current instance will be expired from the |
|---|
| 19 | session, and you will need to fetch it again to retrieve the now reverted |
|---|
| 20 | instance. |
|---|
| 21 | |
|---|
| 22 | The `revert_to` method will rollback the current instance to the specified |
|---|
| 23 | version number, if possibe. Once reverted, the current instance will be expired |
|---|
| 24 | from the session, and you will need to fetch it again to retrieve the now |
|---|
| 25 | reverted instance. |
|---|
| 26 | |
|---|
| 27 | The `compare_with` method will compare the instance with a previous version. A |
|---|
| 28 | dictionary will be returned with each field difference as an element in the |
|---|
| 29 | dictionary where the key is the field name and the value is a tuple of the |
|---|
| 30 | format (current_value, version_value). Version instances also have a |
|---|
| 31 | `compare_with` method so that two versions can be compared. |
|---|
| 32 | |
|---|
| 33 | Also included in the module is a `after_revert` decorator that can be used to |
|---|
| 34 | decorate methods on the versioned entity that will be called following that |
|---|
| 35 | instance being reverted. |
|---|
| 36 | |
|---|
| 37 | The acts_as_versioned statement also accepts an optional `ignore` argument |
|---|
| 38 | that consists of a list of strings, specifying names of fields. Changes in |
|---|
| 39 | those fields will not result in a version increment. In addition, you can |
|---|
| 40 | pass in an optional `check_concurrent` argument, which will use SQLAlchemy's |
|---|
| 41 | built-in optimistic concurrency mechanisms. |
|---|
| 42 | |
|---|
| 43 | Note that relationships that are stored in mapping tables will not be included |
|---|
| 44 | as part of the versioning process, and will need to be handled manually. Only |
|---|
| 45 | values within the entity's main table will be versioned into the history table. |
|---|
| 46 | ''' |
|---|
| 47 | |
|---|
| 48 | from datetime import datetime |
|---|
| 49 | import inspect |
|---|
| 50 | |
|---|
| 51 | from sqlalchemy import Table, Column, and_, desc |
|---|
| 52 | from sqlalchemy.orm import mapper, MapperExtension, EXT_CONTINUE, \ |
|---|
| 53 | object_session |
|---|
| 54 | |
|---|
| 55 | from elixir import Integer, DateTime |
|---|
| 56 | from elixir.statements import Statement |
|---|
| 57 | from elixir.properties import EntityBuilder |
|---|
| 58 | |
|---|
| 59 | __all__ = ['acts_as_versioned', 'after_revert'] |
|---|
| 60 | __doc_all__ = [] |
|---|
| 61 | |
|---|
| 62 | # |
|---|
| 63 | # utility functions |
|---|
| 64 | # |
|---|
| 65 | |
|---|
| 66 | def 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 | |
|---|
| 74 | def 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 | |
|---|
| 88 | class 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 | |
|---|
| 130 | versioned_mapper_extension = VersionedMapperExtension() |
|---|
| 131 | |
|---|
| 132 | |
|---|
| 133 | # |
|---|
| 134 | # the acts_as_versioned statement |
|---|
| 135 | # |
|---|
| 136 | |
|---|
| 137 | class 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 | |
|---|
| 277 | acts_as_versioned = Statement(VersionedEntityBuilder) |
|---|
| 278 | |
|---|
| 279 | |
|---|
| 280 | def after_revert(func): |
|---|
| 281 | """ |
|---|
| 282 | Decorator for watching for revert events. |
|---|
| 283 | """ |
|---|
| 284 | func._elixir_after_revert = True |
|---|
| 285 | return func |
|---|
| 286 | |
|---|