Remove submodule, just put Dependencies in ./libs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
'''
|
||||
Ext package
|
||||
|
||||
Additional Elixir statements and functionality.
|
||||
'''
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user