Remove submodule, just put Dependencies in ./libs

This commit is contained in:
Ruud
2011-02-28 15:25:42 +01:00
parent 2fb578d4de
commit 6888798ef6
541 changed files with 173394 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
'''
Ext package
Additional Elixir statements and functionality.
'''
+234
View File
@@ -0,0 +1,234 @@
'''
Associable Elixir Statement Generator
==========
Associable
==========
About Polymorphic Associations
------------------------------
A frequent pattern in database schemas is the has_and_belongs_to_many, or a
many-to-many table. Quite often multiple tables will refer to a single one
creating quite a few many-to-many intermediate tables.
Polymorphic associations lower the amount of many-to-many tables by setting up
a table that allows relations to any other table in the database, and relates
it to the associable table. In some implementations, this layout does not
enforce referential integrity with database foreign key constraints, this
implementation uses an additional many-to-many table with foreign key
constraints to avoid this problem.
.. note:
SQLite does not support foreign key constraints, so referential integrity
can only be enforced using database backends with such support.
Elixir Statement Generator for Polymorphic Associations
-------------------------------------------------------
The ``associable`` function generates the intermediary tables for an Elixir
entity that should be associable with other Elixir entities and returns an
Elixir Statement for use with them. This automates the process of creating the
polymorphic association tables and ensuring their referential integrity.
Matching select_XXX and select_by_XXX are also added to the associated entity
which allow queries to be run for the associated objects.
Example usage:
.. sourcecode:: python
class Tag(Entity):
name = Field(Unicode)
acts_as_taggable = associable(Tag)
class Entry(Entity):
title = Field(Unicode)
acts_as_taggable('tags')
class Article(Entity):
title = Field(Unicode)
acts_as_taggable('tags')
Or if one of the entities being associated should only have a single member of
the associated table:
.. sourcecode:: python
class Address(Entity):
street = Field(String(130))
city = Field(String(100))
is_addressable = associable(Address, 'addresses')
class Person(Entity):
name = Field(Unicode)
orders = OneToMany('Order')
is_addressable()
class Order(Entity):
order_num = Field(primary_key=True)
item_count = Field(Integer)
person = ManyToOne('Person')
is_addressable('address', uselist=False)
home = Address(street='123 Elm St.', city='Spooksville')
user = Person(name='Jane Doe')
user.addresses.append(home)
neworder = Order(item_count=4)
neworder.address = home
user.orders.append(neworder)
# Queries using the added helpers
Person.select_by_addresses(city='Cupertino')
Person.select_addresses(and_(Address.c.street=='132 Elm St',
Address.c.city=='Smallville'))
Statement Options
-----------------
The generated Elixir Statement has several options available:
+---------------+-------------------------------------------------------------+
| Option Name | Description |
+===============+=============================================================+
| ``name`` | Specify a custom name for the Entity attribute. This is |
| | used to declare the attribute used to access the associated |
| | table values. Otherwise, the name will use the plural_name |
| | provided to the associable call. |
+---------------+-------------------------------------------------------------+
| ``uselist`` | Whether or not the associated table should be represented |
| | as a list, or a single property. It should be set to False |
| | when the entity should only have a single associated |
| | entity. Defaults to True. |
+---------------+-------------------------------------------------------------+
| ``lazy`` | Determines eager loading of the associated entity objects. |
| | Defaults to False, to indicate that they should not be |
| | lazily loaded. |
+---------------+-------------------------------------------------------------+
'''
from elixir.statements import Statement
import sqlalchemy as sa
__doc_all__ = ['associable']
def associable(assoc_entity, plural_name=None, lazy=True):
'''
Generate an associable Elixir Statement
'''
interface_name = assoc_entity._descriptor.tablename
able_name = interface_name + 'able'
if plural_name:
attr_name = "%s_rel" % plural_name
else:
plural_name = interface_name
attr_name = "%s_rel" % interface_name
class GenericAssoc(object):
def __init__(self, tablename):
self.type = tablename
#TODO: inherit from entity builder
class Associable(object):
"""An associable Elixir Statement object"""
def __init__(self, entity, name=None, uselist=True, lazy=True):
self.entity = entity
self.lazy = lazy
self.uselist = uselist
if name is None:
self.name = plural_name
else:
self.name = name
def after_table(self):
col = sa.Column('%s_assoc_id' % interface_name, sa.Integer,
sa.ForeignKey('%s.id' % able_name))
self.entity._descriptor.add_column(col)
if not hasattr(assoc_entity, '_assoc_table'):
metadata = assoc_entity._descriptor.metadata
association_table = sa.Table("%s" % able_name, metadata,
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('type', sa.String(40), nullable=False),
)
tablename = "%s_to_%s" % (able_name, interface_name)
association_to_table = sa.Table(tablename, metadata,
sa.Column('assoc_id', sa.Integer,
sa.ForeignKey(association_table.c.id,
ondelete="CASCADE"),
primary_key=True),
#FIXME: this assumes a single id col
sa.Column('%s_id' % interface_name, sa.Integer,
sa.ForeignKey(assoc_entity.table.c.id,
ondelete="RESTRICT"),
primary_key=True),
)
assoc_entity._assoc_table = association_table
assoc_entity._assoc_to_table = association_to_table
def after_mapper(self):
if not hasattr(assoc_entity, '_assoc_mapper'):
assoc_entity._assoc_mapper = sa.orm.mapper(
GenericAssoc, assoc_entity._assoc_table, properties={
'targets': sa.orm.relation(
assoc_entity,
secondary=assoc_entity._assoc_to_table,
lazy=lazy, backref='associations',
order_by=assoc_entity.mapper.order_by)
})
entity = self.entity
entity.mapper.add_property(
attr_name,
sa.orm.relation(GenericAssoc, lazy=self.lazy,
backref='_backref_%s' % entity.table.name)
)
if self.uselist:
def get(self):
if getattr(self, attr_name) is None:
setattr(self, attr_name,
GenericAssoc(entity.table.name))
return getattr(self, attr_name).targets
setattr(entity, self.name, property(get))
else:
# scalar based property decorator
def get(self):
attr = getattr(self, attr_name)
if attr is not None:
return attr.targets[0]
else:
return None
def set(self, value):
if getattr(self, attr_name) is None:
setattr(self, attr_name,
GenericAssoc(entity.table.name))
getattr(self, attr_name).targets = [value]
setattr(entity, self.name, property(get, set))
# self.name is both set via mapper synonym and the python
# property, but that's how synonym properties work.
# adding synonym property after "real" property otherwise it
# breaks when using SQLAlchemy > 0.4.1
entity.mapper.add_property(self.name, sa.orm.synonym(attr_name))
# add helper methods
def select_by(cls, **kwargs):
return cls.query.join([attr_name, 'targets']) \
.filter_by(**kwargs).all()
setattr(entity, 'select_by_%s' % self.name, classmethod(select_by))
def select(cls, *args, **kwargs):
return cls.query.join([attr_name, 'targets']) \
.filter(*args, **kwargs).all()
setattr(entity, 'select_%s' % self.name, classmethod(select))
return Statement(Associable)
+124
View File
@@ -0,0 +1,124 @@
'''
An encryption plugin for Elixir utilizing the excellent PyCrypto library, which
can be downloaded here: http://www.amk.ca/python/code/crypto
Values for columns that are specified to be encrypted will be transparently
encrypted and safely encoded for storage in a unicode column using the powerful
and secure Blowfish Cipher using a specified "secret" which can be passed into
the plugin at class declaration time.
Example usage:
.. sourcecode:: python
from elixir import *
from elixir.ext.encrypted import acts_as_encrypted
class Person(Entity):
name = Field(Unicode)
password = Field(Unicode)
ssn = Field(Unicode)
acts_as_encrypted(for_fields=['password', 'ssn'],
with_secret='secret')
The above Person entity will automatically encrypt and decrypt the password and
ssn columns on save, update, and load. Different secrets can be specified on
an entity by entity basis, for added security.
**Important note**: instance attributes are encrypted in-place. This means that
if one of the encrypted attributes of an instance is accessed after the
instance has been flushed to the database (and thus encrypted), the value for
that attribute will be crypted in the in-memory object in addition to the
database row.
'''
from Crypto.Cipher import Blowfish
from elixir.statements import Statement
from sqlalchemy.orm import MapperExtension, EXT_CONTINUE, EXT_STOP
try:
from sqlalchemy.orm import EXT_PASS
SA05orlater = False
except ImportError:
SA05orlater = True
__all__ = ['acts_as_encrypted']
__doc_all__ = []
#
# encryption and decryption functions
#
def encrypt_value(value, secret):
return Blowfish.new(secret, Blowfish.MODE_CFB) \
.encrypt(value).encode('string_escape')
def decrypt_value(value, secret):
return Blowfish.new(secret, Blowfish.MODE_CFB) \
.decrypt(value.decode('string_escape'))
#
# acts_as_encrypted statement
#
class ActsAsEncrypted(object):
def __init__(self, entity, for_fields=[], with_secret='abcdef'):
def perform_encryption(instance, encrypt=True):
encrypted = getattr(instance, '_elixir_encrypted', None)
if encrypted is encrypt:
# skipping encryption or decryption, as it is already done
return
else:
# marking instance as already encrypted/decrypted
instance._elixir_encrypted = encrypt
if encrypt:
func = encrypt_value
else:
func = decrypt_value
for column_name in for_fields:
current_value = getattr(instance, column_name)
if current_value:
setattr(instance, column_name,
func(current_value, with_secret))
def perform_decryption(instance):
perform_encryption(instance, encrypt=False)
class EncryptedMapperExtension(MapperExtension):
def before_insert(self, mapper, connection, instance):
perform_encryption(instance)
return EXT_CONTINUE
def before_update(self, mapper, connection, instance):
perform_encryption(instance)
return EXT_CONTINUE
if SA05orlater:
def reconstruct_instance(self, mapper, instance):
perform_decryption(instance)
# no special return value is required for
# reconstruct_instance, but you never know...
return EXT_CONTINUE
else:
def populate_instance(self, mapper, selectcontext, row,
instance, *args, **kwargs):
mapper.populate_instance(selectcontext, instance, row,
*args, **kwargs)
perform_decryption(instance)
# EXT_STOP because we already did populate the instance and
# the normal processing should not happen
return EXT_STOP
# make sure that the entity's mapper has our mapper extension
entity._descriptor.add_mapper_extension(EncryptedMapperExtension())
acts_as_encrypted = Statement(ActsAsEncrypted)
+251
View File
@@ -0,0 +1,251 @@
'''
This extension is DEPRECATED. Please use the orderinglist SQLAlchemy
extension instead.
For details:
http://www.sqlalchemy.org/docs/05/reference/ext/orderinglist.html
For an Elixir example:
http://elixir.ematia.de/trac/wiki/Recipes/UsingEntityForOrderedList
or
http://elixir.ematia.de/trac/browser/elixir/0.7.0/tests/test_o2m.py#L155
An ordered-list plugin for Elixir to help you make an entity be able to be
managed in a list-like way. Much inspiration comes from the Ruby on Rails
acts_as_list plugin, which is currently more full-featured than this plugin.
Once you flag an entity with an `acts_as_list()` statement, a column will be
added to the entity called `position` which will be an integer column that is
managed for you by the plugin. You can pass an alternative column name to
the plugin using the `column_name` keyword argument.
In addition, your entity will get a series of new methods attached to it,
including:
+----------------------+------------------------------------------------------+
| Method Name | Description |
+======================+======================================================+
| ``move_lower`` | Move the item lower in the list |
+----------------------+------------------------------------------------------+
| ``move_higher`` | Move the item higher in the list |
+----------------------+------------------------------------------------------+
| ``move_to_bottom`` | Move the item to the bottom of the list |
+----------------------+------------------------------------------------------+
| ``move_to_top`` | Move the item to the top of the list |
+----------------------+------------------------------------------------------+
| ``move_to`` | Move the item to a specific position in the list |
+----------------------+------------------------------------------------------+
Sometimes, your entities that represent list items will be a part of different
lists. To implement this behavior, simply pass the `acts_as_list` statement a
callable that returns a "qualifier" SQLAlchemy expression. This expression will
be added to the generated WHERE clauses used by the plugin.
Example model usage:
.. sourcecode:: python
from elixir import *
from elixir.ext.list import acts_as_list
class ToDo(Entity):
subject = Field(String(128))
owner = ManyToOne('Person')
def qualify(self):
return ToDo.owner_id == self.owner_id
acts_as_list(qualifier=qualify)
class Person(Entity):
name = Field(String(64))
todos = OneToMany('ToDo', order_by='position')
The above example can then be used to manage ordered todo lists for people.
Note that you must set the `order_by` property on the `Person.todo` relation in
order for the relation to respect the ordering. Here is an example of using
this model in practice:
.. sourcecode:: python
p = Person.query.filter_by(name='Jonathan').one()
p.todos.append(ToDo(subject='Three'))
p.todos.append(ToDo(subject='Two'))
p.todos.append(ToDo(subject='One'))
session.commit(); session.clear()
p = Person.query.filter_by(name='Jonathan').one()
p.todos[0].move_to_bottom()
p.todos[2].move_to_top()
session.commit(); session.clear()
p = Person.query.filter_by(name='Jonathan').one()
assert p.todos[0].subject == 'One'
assert p.todos[1].subject == 'Two'
assert p.todos[2].subject == 'Three'
For more examples, refer to the unit tests for this plugin.
'''
from elixir.statements import Statement
from elixir.events import before_insert, before_delete
from sqlalchemy import Column, Integer, select, func, literal, and_
import warnings
__all__ = ['acts_as_list']
__doc_all__ = []
def get_entity_where(instance):
clauses = []
for column in instance.table.primary_key.columns:
instance_value = getattr(instance, column.name)
clauses.append(column == instance_value)
return and_(*clauses)
class ListEntityBuilder(object):
def __init__(self, entity, qualifier=None, column_name='position'):
warnings.warn("The act_as_list extension is deprecated. Please use "
"SQLAlchemy's orderinglist extension instead",
DeprecationWarning, stacklevel=6)
self.entity = entity
self.qualifier_method = qualifier
self.column_name = column_name
def create_non_pk_cols(self):
if self.entity._descriptor.autoload:
for c in self.entity.table.c:
if c.name == self.column_name:
self.position_column = c
if not hasattr(self, 'position_column'):
raise Exception(
"Could not find column '%s' in autoloaded table '%s', "
"needed by entity '%s'." % (self.column_name,
self.entity.table.name, self.entity.__name__))
else:
self.position_column = Column(self.column_name, Integer)
self.entity._descriptor.add_column(self.position_column)
def after_table(self):
position_column = self.position_column
position_column_name = self.column_name
qualifier_method = self.qualifier_method
if not qualifier_method:
qualifier_method = lambda self: None
def _init_position(self):
s = select(
[(func.max(position_column)+1).label('value')],
qualifier_method(self)
).union(
select([literal(1).label('value')])
)
a = s.alias()
# we use a second func.max to get the maximum between 1 and the
# real max position if any exist
setattr(self, position_column_name, select([func.max(a.c.value)]))
# Note that this method could be rewritten more simply like below,
# but because this extension is going to be deprecated anyway,
# I don't want to risk breaking something I don't want to maintain.
# setattr(self, position_column_name, select(
# [func.coalesce(func.max(position_column), 0) + 1],
# qualifier_method(self)
# ))
_init_position = before_insert(_init_position)
def _shift_items(self):
self.table.update(
and_(
position_column > getattr(self, position_column_name),
qualifier_method(self)
),
values={
position_column : position_column - 1
}
).execute()
_shift_items = before_delete(_shift_items)
def move_to_bottom(self):
# move the items that were above this item up one
self.table.update(
and_(
position_column >= getattr(self, position_column_name),
qualifier_method(self)
),
values = {
position_column : position_column - 1
}
).execute()
# move this item to the max position
# MySQL does not support the correlated subquery, so we need to
# execute the query (through scalar()). See ticket #34.
self.table.update(
get_entity_where(self),
values={
position_column : select(
[func.max(position_column) + 1],
qualifier_method(self)
).scalar()
}
).execute()
def move_to_top(self):
self.move_to(1)
def move_to(self, position):
current_position = getattr(self, position_column_name)
# determine which direction we're moving
if position < current_position:
where = and_(
position <= position_column,
position_column < current_position,
qualifier_method(self)
)
modifier = 1
elif position > current_position:
where = and_(
current_position < position_column,
position_column <= position,
qualifier_method(self)
)
modifier = -1
# shift the items in between the current and new positions
self.table.update(where, values = {
position_column : position_column + modifier
}).execute()
# update this item's position to the desired position
self.table.update(get_entity_where(self)) \
.execute(**{position_column_name: position})
def move_lower(self):
# replace for ex.: p.todos.insert(x + 1, p.todos.pop(x))
self.move_to(getattr(self, position_column_name) + 1)
def move_higher(self):
self.move_to(getattr(self, position_column_name) - 1)
# attach new methods to entity
self.entity._init_position = _init_position
self.entity._shift_items = _shift_items
self.entity.move_lower = move_lower
self.entity.move_higher = move_higher
self.entity.move_to_bottom = move_to_bottom
self.entity.move_to_top = move_to_top
self.entity.move_to = move_to
acts_as_list = Statement(ListEntityBuilder)
+106
View File
@@ -0,0 +1,106 @@
'''
DDL statements for Elixir.
Entities having the perform_ddl statement, will automatically execute the
given DDL statement, at the given moment: ether before or after the table
creation in SQL.
The 'when' argument can be either 'before-create' or 'after-create'.
The 'statement' argument can be one of:
- a single string statement
- a list of string statements, in which case, each of them will be executed
in turn.
- a callable which should take no argument and return either a single string
or a list of strings.
In each string statement, you may use the special '%(fullname)s' construct,
that will be replaced with the real table name including schema, if unknown
to you. Also, self explained '%(table)s' and '%(schema)s' may be used here.
You would use this extension to handle non elixir sql statemts, like triggers
etc.
.. sourcecode:: python
class Movie(Entity):
title = Field(Unicode(30), primary_key=True)
year = Field(Integer)
perform_ddl('after-create',
"insert into %(fullname)s values ('Alien', 1979)")
preload_data is a more specific statement meant to preload data in your
entity table from a list of tuples (of fields values for each row).
.. sourcecode:: python
class Movie(Entity):
title = Field(Unicode(30), primary_key=True)
year = Field(Integer)
preload_data(('title', 'year'),
[(u'Alien', 1979), (u'Star Wars', 1977)])
preload_data(('year', 'title'),
[(1982, u'Blade Runner')])
preload_data(data=[(u'Batman', 1966)])
'''
from elixir.statements import Statement
from elixir.properties import EntityBuilder
from sqlalchemy import DDL
__all__ = ['perform_ddl', 'preload_data']
__doc_all__ = []
#
# the perform_ddl statement
#
class PerformDDLEntityBuilder(EntityBuilder):
def __init__(self, entity, when, statement, on=None, context=None):
self.entity = entity
self.when = when
self.statement = statement
self.on = on
self.context = context
def after_table(self):
statement = self.statement
if hasattr(statement, '__call__'):
statement = statement()
if not isinstance(statement, list):
statement = [statement]
for s in statement:
ddl = DDL(s, self.on, self.context)
ddl.execute_at(self.when, self.entity.table)
perform_ddl = Statement(PerformDDLEntityBuilder)
#
# the preload_data statement
#
class PreloadDataEntityBuilder(EntityBuilder):
def __init__(self, entity, columns=None, data=None):
self.entity = entity
self.columns = columns
self.data = data
def after_table(self):
all_columns = [col.name for col in self.entity.table.columns]
def onload(event, schema_item, connection):
columns = self.columns
if columns is None:
columns = all_columns
data = self.data
if hasattr(data, '__call__'):
data = data()
insert = schema_item.insert()
connection.execute(insert,
[dict(zip(columns, values)) for values in data])
self.entity.table.append_ddl_listener('after-create', onload)
preload_data = Statement(PreloadDataEntityBuilder)
+288
View File
@@ -0,0 +1,288 @@
'''
A versioning plugin for Elixir.
Entities that are marked as versioned with the `acts_as_versioned` statement
will automatically have a history table created and a timestamp and version
column added to their tables. In addition, versioned entities are provided
with four new methods: revert, revert_to, compare_with and get_as_of, and one
new attribute: versions. Entities with compound primary keys are supported.
The `versions` attribute will contain a list of previous versions of the
instance, in increasing version number order.
The `get_as_of` method will retrieve a previous version of the instance "as of"
a specified datetime. If the current version is the most recent, it will be
returned.
The `revert` method will rollback the current instance to its previous version,
if possible. Once reverted, the current instance will be expired from the
session, and you will need to fetch it again to retrieve the now reverted
instance.
The `revert_to` method will rollback the current instance to the specified
version number, if possibe. Once reverted, the current instance will be expired
from the session, and you will need to fetch it again to retrieve the now
reverted instance.
The `compare_with` method will compare the instance with a previous version. A
dictionary will be returned with each field difference as an element in the
dictionary where the key is the field name and the value is a tuple of the
format (current_value, version_value). Version instances also have a
`compare_with` method so that two versions can be compared.
Also included in the module is a `after_revert` decorator that can be used to
decorate methods on the versioned entity that will be called following that
instance being reverted.
The acts_as_versioned statement also accepts an optional `ignore` argument
that consists of a list of strings, specifying names of fields. Changes in
those fields will not result in a version increment. In addition, you can
pass in an optional `check_concurrent` argument, which will use SQLAlchemy's
built-in optimistic concurrency mechanisms.
Note that relationships that are stored in mapping tables will not be included
as part of the versioning process, and will need to be handled manually. Only
values within the entity's main table will be versioned into the history table.
'''
from datetime import datetime
import inspect
from sqlalchemy import Table, Column, and_, desc
from sqlalchemy.orm import mapper, MapperExtension, EXT_CONTINUE, \
object_session
from elixir import Integer, DateTime
from elixir.statements import Statement
from elixir.properties import EntityBuilder
from elixir.entity import getmembers
__all__ = ['acts_as_versioned', 'after_revert']
__doc_all__ = []
#
# utility functions
#
def get_entity_where(instance):
clauses = []
for column in instance.table.primary_key.columns:
instance_value = getattr(instance, column.name)
clauses.append(column==instance_value)
return and_(*clauses)
def get_history_where(instance):
clauses = []
history_columns = instance.__history_table__.primary_key.columns
for column in instance.table.primary_key.columns:
instance_value = getattr(instance, column.name)
history_column = getattr(history_columns, column.name)
clauses.append(history_column==instance_value)
return and_(*clauses)
#
# a mapper extension to track versions on insert, update, and delete
#
class VersionedMapperExtension(MapperExtension):
def before_insert(self, mapper, connection, instance):
version_colname, timestamp_colname = \
instance.__class__.__versioned_column_names__
setattr(instance, version_colname, 1)
setattr(instance, timestamp_colname, datetime.now())
return EXT_CONTINUE
def before_update(self, mapper, connection, instance):
old_values = instance.table.select(get_entity_where(instance)) \
.execute().fetchone()
# SA might've flagged this for an update even though it didn't change.
# This occurs when a relation is updated, thus marking this instance
# for a save/update operation. We check here against the last version
# to ensure we really should save this version and update the version
# data.
ignored = instance.__class__.__ignored_fields__
version_colname, timestamp_colname = \
instance.__class__.__versioned_column_names__
for key in instance.table.c.keys():
if key in ignored:
continue
if getattr(instance, key) != old_values[key]:
# the instance was really updated, so we create a new version
dict_values = dict(old_values.items())
connection.execute(
instance.__class__.__history_table__.insert(), dict_values)
old_version = getattr(instance, version_colname)
setattr(instance, version_colname, old_version + 1)
setattr(instance, timestamp_colname, datetime.now())
break
return EXT_CONTINUE
def before_delete(self, mapper, connection, instance):
connection.execute(instance.__history_table__.delete(
get_history_where(instance)
))
return EXT_CONTINUE
versioned_mapper_extension = VersionedMapperExtension()
#
# the acts_as_versioned statement
#
class VersionedEntityBuilder(EntityBuilder):
def __init__(self, entity, ignore=None, check_concurrent=False,
column_names=None):
self.entity = entity
self.add_mapper_extension(versioned_mapper_extension)
#TODO: we should rather check that the version_id_col isn't set
# externally
self.check_concurrent = check_concurrent
# Changes in these fields will be ignored
if column_names is None:
column_names = ['version', 'timestamp']
entity.__versioned_column_names__ = column_names
if ignore is None:
ignore = []
ignore.extend(column_names)
entity.__ignored_fields__ = ignore
def create_non_pk_cols(self):
# add a version column to the entity, along with a timestamp
version_colname, timestamp_colname = \
self.entity.__versioned_column_names__
#XXX: fail in case the columns already exist?
#col_names = [col.name for col in self.entity._descriptor.columns]
#if version_colname not in col_names:
self.add_table_column(Column(version_colname, Integer))
#if timestamp_colname not in col_names:
self.add_table_column(Column(timestamp_colname, DateTime))
# add a concurrent_version column to the entity, if required
if self.check_concurrent:
self.entity._descriptor.version_id_col = 'concurrent_version'
# we copy columns from the main entity table, so we need it to exist first
def after_table(self):
entity = self.entity
version_colname, timestamp_colname = \
entity.__versioned_column_names__
# look for events
after_revert_events = []
for name, func in getmembers(entity, inspect.ismethod):
if getattr(func, '_elixir_after_revert', False):
after_revert_events.append(func)
# create a history table for the entity
skipped_columns = [version_colname]
if self.check_concurrent:
skipped_columns.append('concurrent_version')
columns = [
column.copy() for column in entity.table.c
if column.name not in skipped_columns
]
columns.append(Column(version_colname, Integer, primary_key=True))
table = Table(entity.table.name + '_history', entity.table.metadata,
*columns
)
entity.__history_table__ = table
# create an object that represents a version of this entity
class Version(object):
pass
# map the version class to the history table for this entity
Version.__name__ = entity.__name__ + 'Version'
Version.__versioned_entity__ = entity
mapper(Version, entity.__history_table__)
version_col = getattr(table.c, version_colname)
timestamp_col = getattr(table.c, timestamp_colname)
# attach utility methods and properties to the entity
def get_versions(self):
v = object_session(self).query(Version) \
.filter(get_history_where(self)) \
.order_by(version_col) \
.all()
# history contains all the previous records.
# Add the current one to the list to get all the versions
v.append(self)
return v
def get_as_of(self, dt):
# if the passed in timestamp is older than our current version's
# time stamp, then the most recent version is our current version
if getattr(self, timestamp_colname) < dt:
return self
# otherwise, we need to look to the history table to get our
# older version
sess = object_session(self)
query = sess.query(Version) \
.filter(and_(get_history_where(self),
timestamp_col <= dt)) \
.order_by(desc(timestamp_col)).limit(1)
return query.first()
def revert_to(self, to_version):
if isinstance(to_version, Version):
to_version = getattr(to_version, version_colname)
old_version = table.select(and_(
get_history_where(self),
version_col == to_version
)).execute().fetchone()
entity.table.update(get_entity_where(self)).execute(
dict(old_version.items())
)
table.delete(and_(get_history_where(self),
version_col >= to_version)).execute()
self.expire()
for event in after_revert_events:
event(self)
def revert(self):
assert getattr(self, version_colname) > 1
self.revert_to(getattr(self, version_colname) - 1)
def compare_with(self, version):
differences = {}
for column in self.table.c:
if column.name in (version_colname, 'concurrent_version'):
continue
this = getattr(self, column.name)
that = getattr(version, column.name)
if this != that:
differences[column.name] = (this, that)
return differences
entity.versions = property(get_versions)
entity.get_as_of = get_as_of
entity.revert_to = revert_to
entity.revert = revert
entity.compare_with = compare_with
Version.compare_with = compare_with
acts_as_versioned = Statement(VersionedEntityBuilder)
def after_revert(func):
"""
Decorator for watching for revert events.
"""
func._elixir_after_revert = True
return func