Skip to content

Commit

Permalink
Merge pull request #17 from CloverHealth/new-style-temporal
Browse files Browse the repository at this point in the history
New style temporal
  • Loading branch information
multigl authored Mar 17, 2017
2 parents da3aafe + 0542b17 commit e8c2118
Show file tree
Hide file tree
Showing 21 changed files with 643 additions and 135 deletions.
12 changes: 12 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ language: python

matrix:
include:
- python: 3.6.0
addons:
postgresql: 9.5
env: PG=9.5
- python: 3.6.0
addons:
postgresql: 9.4
env: PG=9.4
- python: 3.6.0
addons:
postgresql: 9.3
env: PG=9.3
- python: 3.5.2
addons:
postgresql: 9.5
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
DEPENDENCIES = [l.strip() for l in open('requirements.txt', 'r')]
if sys.version_info < (3, 5):
DEPENDENCIES.append('typing>=3.5.2,<4.0.0')
if sys.version_info < (3, 4):
DEPENDENCIES.append('singledispatch>=3.4.0,<4.0.0')
TEST_DEPENDENCIES = [l.strip() for l in open('test-requirements.txt', 'r')]
SETUP_DEPENDENCIES = []
if {'pytest', 'test', 'ptr'}.intersection(sys.argv):
Expand Down
10 changes: 8 additions & 2 deletions temporal_sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# flake8: noqa
from .version import __version__
from .bases import Clocked, ClockedOption, EntityClock, TemporalProperty, TemporalActivityMixin
from .bases import (
Clocked,
ClockedOption,
EntityClock,
TemporalProperty,
TemporalActivityMixin)
from .session import temporal_session, persist_history
from .clock import add_clock, get_activity_clock_backref, get_history_model
from .clock import add_clock, get_activity_clock_backref, get_history_model, get_history_model
from .core import TemporalModel
36 changes: 26 additions & 10 deletions temporal_sqlalchemy/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import contextlib
import datetime as dt
import typing
import uuid # noqa: F401
import warnings

import sqlalchemy as sa
import sqlalchemy.orm as orm
Expand All @@ -19,8 +19,6 @@


class EntityClock(object):
entity_id = None # type: typing.Union[int, uuid.UUID]

tick = sa.Column(sa.Integer, primary_key=True, autoincrement=False)
timestamp = sa.Column(sa.DateTime(True),
server_default=sa.func.current_timestamp())
Expand All @@ -44,16 +42,30 @@ def id(self):
class ClockedOption(object):
def __init__(
self,
history_tables: typing.Dict[T_PROPS, nine.Type[TemporalProperty]],
history_models: typing.Dict[T_PROPS, nine.Type[TemporalProperty]],
temporal_props: typing.Iterable[T_PROPS],
clock_table: nine.Type[EntityClock],
clock_model: nine.Type[EntityClock],
activity_cls: nine.Type[TemporalActivityMixin] = None):
self.history_tables = history_tables
self.history_models = history_models
self.temporal_props = temporal_props

self.clock_table = clock_table
self.clock_model = clock_model
self.activity_cls = activity_cls

@property
def clock_table(self):
warnings.warn(
'use ClockedOption.clock_model instead',
PendingDeprecationWarning)
return self.clock_model

@property
def history_tables(self):
warnings.warn(
'use ClockedOption.history_models instead',
PendingDeprecationWarning)
return self.history_models

@staticmethod
def make_clock(effective_lower: dt.datetime,
vclock_lower: int,
Expand Down Expand Up @@ -83,7 +95,7 @@ def record_history(self,
new_clock = self.make_clock(timestamp, new_tick)
attr = {'entity': clocked}

for prop, cls in self.history_tables.items():
for prop, cls in self.history_models.items():
hist = attr.copy()
# fires a load on any deferred columns
if prop.key not in state.dict:
Expand Down Expand Up @@ -126,7 +138,11 @@ def record_history(self,
# Add new history row
hist[prop.key] = changes.added[0]
session.add(
cls(vclock=new_clock.vclock, effective=new_clock.effective, **hist)
cls(
vclock=new_clock.vclock,
effective=new_clock.effective,
**hist
)
)


Expand Down Expand Up @@ -177,7 +193,7 @@ def clock_tick(self, activity: TemporalActivityMixin = None):
if session.is_modified(self):
self.vclock += 1

new_clock_tick = self.temporal_options.clock_table(
new_clock_tick = self.temporal_options.clock_model(
entity=self, tick=self.vclock)
if activity is not None:
new_clock_tick.activity = activity
Expand Down
126 changes: 72 additions & 54 deletions temporal_sqlalchemy/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import sqlalchemy.ext.declarative as declarative
import sqlalchemy.orm as orm
import sqlalchemy.orm.attributes as attributes
import sqlalchemy.util as util
import sqlalchemy.util as sa_util
import psycopg2.extras as psql_extras

from temporal_sqlalchemy import nine
from temporal_sqlalchemy import nine, util
from temporal_sqlalchemy.bases import (
T_PROPS,
Clocked,
Expand Down Expand Up @@ -45,7 +45,7 @@ def get_activity_clock_backref(
def get_history_model(
target: attributes.InstrumentedAttribute) -> TemporalProperty:
"""Get the history model for given entity class."""
assert issubclass(target.class_, Clocked)
assert hasattr(target.class_, 'temporal_options')

return target.class_.temporal_options.history_tables[target.property]

Expand Down Expand Up @@ -122,7 +122,7 @@ def make_temporal(cls: nine.Type[Clocked]):
entity_table = mapper.local_table
entity_table_name = entity_table.name
schema = temporal_schema or entity_table.schema
clock_table_name = _truncate_identifier("%s_clock" % entity_table_name)
clock_table_name = truncate_identifier("%s_clock" % entity_table_name)

history_tables = {
p: build_history_class(cls, p, schema)
Expand Down Expand Up @@ -175,8 +175,8 @@ def make_temporal(cls: nine.Type[Clocked]):

temporal_options = ClockedOption(
temporal_props=local_props | relationship_props,
history_tables=history_tables,
clock_table=clock_table,
history_models=history_tables,
clock_model=clock_table,
activity_cls=activity_cls,
)
cls.temporal_options = temporal_options
Expand All @@ -192,21 +192,21 @@ def _copy_column(column: sa.Column) -> sa.Column:
original = column
new = column.copy()
original.info['history_copy'] = new
if column.foreign_keys:
new.foreign_keys = column.foreign_keys
for fk in column.foreign_keys:
new.append_foreign_key(sa.ForeignKey(fk.target_fullname))
new.unique = False
new.default = new.server_default = None

return new


def _truncate_identifier(identifier: str) -> str:
def truncate_identifier(identifier: str) -> str:
"""ensure identifier doesn't exceed max characters postgres allows"""
max_len = (sap.dialect.max_index_name_length
or sap.dialect.max_identifier_length)
if len(identifier) > max_len:
return "%s_%s" % (identifier[0:max_len - 8],
util.md5_hex(identifier)[-4:])
sa_util.md5_hex(identifier)[-4:])
return identifier


Expand All @@ -225,79 +225,97 @@ def build_history_class(
cls: declarative.DeclarativeMeta,
prop: T_PROPS,
schema: str = None) -> nine.Type[TemporalProperty]:
"""build a sql alchemy table for given prop"""
"""build a sqlalchemy model for given prop"""
class_name = "%s%s_%s" % (cls.__name__, 'History', prop.key)
table = build_history_table(cls, prop, schema)
base_classes = (
TemporalProperty,
declarative.declarative_base(metadata=cls.metadata),
declarative.declarative_base(metadata=table.metadata),
)
class_attrs = {
'__table__': table,
'entity': orm.relationship(
cls, backref=orm.backref('%s_history' % prop.key, lazy='dynamic')),
lambda: cls,
backref=orm.backref('%s_history' % prop.key, lazy='dynamic')
),
}
model = type(class_name, base_classes, class_attrs)

if isinstance(prop, orm.RelationshipProperty):
mapper = sa.inspect(model)
join_cond = (getattr(model, prop.info['temporal_on'])
== prop.argument.id)
rel = orm.relationship(
class_attrs[prop.key] = orm.relationship(
prop.argument,
primaryjoin=join_cond,
# TODO: different shaped FKs
lazy="noload") # write only rel
mapper.add_property(prop.key, rel)
lazy='noload')

model = type(class_name, base_classes, class_attrs)
return model


def _generate_history_table_name(local_table: sa.Table,
cols: typing.Iterable[sa.Column]) -> str:
base_name = '%s_history' % local_table.name
sort_col_names = sorted(col.key for col in cols)

return "%s_%s" % (base_name, "_".join(sort_col_names))


@nine.singledispatch
def _exclusion_in(type_, name) -> typing.Tuple:
return name, '='


@_exclusion_in.register(sap.UUID)
def _(type_, name):
return sa.cast(sa.text(name), sap.TEXT), '='


def build_history_table(
cls: declarative.DeclarativeMeta,
prop: T_PROPS,
schema: str = None) -> sa.Table:
"""build a sql alchemy table for given prop"""

if isinstance(prop, orm.RelationshipProperty):
assert 'temporal_on' in prop.info, \
'cannot temporal-ize a property without temporal_on=True'
# Convert rel prop to fk prop
prop_ = prop.parent.get_property(prop.info['temporal_on'])
assert prop_.parent.local_table is prop.parent.local_table
property_key = prop_.key
columns = (_copy_column(col) for col in prop_.columns)
columns = [_copy_column(column) for column in prop.local_columns]
else:
property_key = prop.key
columns = (_copy_column(col) for col in prop.columns)
columns = [_copy_column(column) for column in prop.columns]

local_table = cls.__table__
table_name = _truncate_identifier(
'%s_%s_%s' % (local_table.name, 'history', property_key))
index_name = _truncate_identifier('%s_effective_idx' % table_name)
effective_exclude_name = _truncate_identifier(
'%s_excl_effective' % table_name)
vclock_exclude_name = _truncate_identifier('%s_excl_vclock' % table_name)
table_name = truncate_identifier(
_generate_history_table_name(local_table, columns)
)
entity_foreign_keys = list(util.foreign_key_to(local_table))
entity_constraints = [
_exclusion_in(fk.type, fk.key)
for fk in entity_foreign_keys
]

constraints = [
sa.Index(index_name, 'effective', postgresql_using='gist'),
sa.Index(
truncate_identifier('%s_effective_idx' % table_name),
'effective',
postgresql_using='gist'
),
sap.ExcludeConstraint(
(sa.cast(sa.text('entity_id'), sap.TEXT), '='),
('effective', '&&'),
name=effective_exclude_name,
*itertools.chain(entity_constraints, [('vclock', '&&')]),
name=truncate_identifier('%s_excl_vclock' % table_name)
),
sap.ExcludeConstraint(
(sa.cast(sa.text('entity_id'), sap.TEXT), '='),
('vclock', '&&'),
name=vclock_exclude_name
*itertools.chain(entity_constraints, [('effective', '&&')]),
name=truncate_identifier('%s_excl_effective' % table_name)
),
]

foreign_key = getattr(prop.parent.class_, 'id') # TODO make this support different shape pks
return sa.Table(table_name, prop.parent.class_.metadata,
sa.Column('id', sap.UUID(as_uuid=True), default=uuid.uuid4, primary_key=True),
sa.Column('effective', sap.TSTZRANGE, default=effective_now, nullable=False),
sa.Column('vclock', sap.INT4RANGE, nullable=False),
sa.Column('entity_id', sa.ForeignKey(foreign_key)),
*itertools.chain(columns, constraints),
schema=schema or local_table.schema,
keep_existing=True) # memoization ftw
return sa.Table(
table_name,
local_table.metadata,
sa.Column('id',
sap.UUID(as_uuid=True),
default=uuid.uuid4,
primary_key=True),
sa.Column('effective',
sap.TSTZRANGE,
default=effective_now,
nullable=False),
sa.Column('vclock', sap.INT4RANGE, nullable=False),
*itertools.chain(entity_foreign_keys, columns, constraints),
schema=schema or local_table.schema,
keep_existing=True
) # memoization ftw
Loading

0 comments on commit e8c2118

Please sign in to comment.