diff --git a/temporal_sqlalchemy/bases.py b/temporal_sqlalchemy/bases.py index 19a5171..a249107 100644 --- a/temporal_sqlalchemy/bases.py +++ b/temporal_sqlalchemy/bases.py @@ -9,18 +9,77 @@ import sqlalchemy as sa import sqlalchemy.dialects.postgresql as sap import sqlalchemy.orm as orm +import sqlalchemy.orm.base as base import sqlalchemy.orm.attributes as attributes import psycopg2.extras as psql_extras from temporal_sqlalchemy import nine -from temporal_sqlalchemy.metadata import get_session_metadata -_ClockSet = collections.namedtuple('_ClockSet', ('effective', 'vclock')) +_PersistentClockPair = collections.namedtuple('_PersistentClockPairs', + ('effective', 'vclock')) T_PROPS = typing.TypeVar( 'T_PROP', orm.RelationshipProperty, orm.ColumnProperty) +class ActivityState: + def __set__(self, instance, value): + if not instance.temporal_options.activity_cls: + raise ValueError( + "Can't set activity state on instance of %r " + "because the activity class is None." + % type(instance).__name__) + + # TODO should not be able to change activity once changes have + # TODO been made to temporal properties + setattr(instance, '__temporal_current_activity', value) + + if value: + current_clock = instance.current_clock + current_clock.activity = value + + def __get__(self, instance, owner): + if not instance: + return self + + return getattr(instance, '__temporal_current_activity', None) + + @staticmethod + def reset_activity(target, attr): + target.activity = None + + @staticmethod + def activity_required(target, value, oldvalue, initiator): + if not target.activity and oldvalue is not base.NEVER_SET: + raise ValueError("activity required") + + +class ClockState: + def __set__(self, instance, value: 'EntityClock'): + setattr(instance, '__temporal_current_tick', value) + if value: + instance.clock.append(value) + + def __get__(self, instance, owner): + if not instance: + return self + + vclock = getattr(instance, 'vclock', 0) or 0 # start at 0 if None + if not getattr(instance, '__temporal_current_tick', None): + new_version = vclock + 1 + instance.vclock = new_version + clock_tick = instance.temporal_options.clock_model(tick=new_version) + setattr(instance, '__temporal_current_tick', clock_tick) + instance.clock.append(clock_tick) + + return getattr(instance, '__temporal_current_tick') + + @staticmethod + def reset_tick(target, attr): + if target: + setattr(target, '__temporal_current_tick', None) + + class EntityClock(object): id = sa.Column(sap.UUID(as_uuid=True), default=uuid.uuid4, primary_key=True) tick = sa.Column(sa.Integer, nullable=False) @@ -50,11 +109,13 @@ def __init__( temporal_props: typing.Iterable[T_PROPS], clock_model: nine.Type[EntityClock], activity_cls: nine.Type[TemporalActivityMixin] = None): + self.history_models = history_models self.temporal_props = temporal_props self.clock_model = clock_model self.activity_cls = activity_cls + self.model = None @property def clock_table(self): @@ -73,7 +134,7 @@ def history_tables(self): @staticmethod def make_clock(effective_lower: dt.datetime, vclock_lower: int, - **kwargs) -> _ClockSet: + **kwargs) -> _PersistentClockPair: """construct a clock set tuple""" effective_upper = kwargs.get('effective_upper', None) vclock_upper = kwargs.get('vclock_upper', None) @@ -82,7 +143,7 @@ def make_clock(effective_lower: dt.datetime, effective_lower, effective_upper) vclock = psql_extras.NumericRange(vclock_lower, vclock_upper) - return _ClockSet(effective, vclock) + return _PersistentClockPair(effective, vclock) def record_history(self, clocked: 'Clocked', @@ -90,17 +151,6 @@ def record_history(self, timestamp: dt.datetime): """record all history for a given clocked object""" state = attributes.instance_state(clocked) - vclock_history = attributes.get_history(clocked, 'vclock') - try: - new_tick = state.dict['vclock'] - except KeyError: - # TODO understand why this is necessary - new_tick = getattr(clocked, 'vclock') - - is_strict_mode = get_session_metadata(session).get('strict_mode', False) - is_vclock_unchanged = vclock_history.unchanged and new_tick == vclock_history.unchanged[0] - - new_clock = self.make_clock(timestamp, new_tick) attr = {'entity': clocked} for prop, cls in self.history_models.items(): @@ -111,16 +161,14 @@ def record_history(self, if isinstance(prop, orm.RelationshipProperty): changes = attributes.get_history( - clocked, prop.key, + clocked, + prop.key, passive=attributes.PASSIVE_NO_INITIALIZE) else: changes = attributes.get_history(clocked, prop.key) if changes.added: - if is_strict_mode: - assert not is_vclock_unchanged, \ - 'flush() has triggered for a changed temporalized property outside of a clock tick' - + new_clock = self.make_clock(timestamp, clocked.current_clock.tick) # Cap previous history row if exists if sa.inspect(clocked).identity is not None: # but only if it already exists!! @@ -184,6 +232,10 @@ class Clocked(object): first_tick = None # type: EntityClock latest_tick = None # type: EntityClock + # temporal descriptors + current_clock = None # type: ClockState + activity = None # type: typing.Optional[ActivityState] + @property def date_created(self): return self.first_tick.timestamp @@ -194,22 +246,11 @@ def date_modified(self): @contextlib.contextmanager def clock_tick(self, activity: TemporalActivityMixin = None): - warnings.warn("clock_tick is going away in 0.5.0", - PendingDeprecationWarning) - """Increments vclock by 1 with changes scoped to the session""" - if self.temporal_options.activity_cls is not None and activity is None: - raise ValueError("activity is missing on edit") from None - - session = orm.object_session(self) - with session.no_autoflush: - yield self - - if session.is_modified(self): - self.vclock += 1 - - new_clock_tick = self.temporal_options.clock_model( - entity=self, tick=self.vclock) - if activity is not None: - new_clock_tick.activity = activity - - session.add(new_clock_tick) + warnings.warn("clock_tick is deprecated, assign an activity directly", + DeprecationWarning) + if self.temporal_options.activity_cls: + if not activity: + raise ValueError("activity is missing on edit") from None + self.activity = activity + + yield self diff --git a/temporal_sqlalchemy/clock.py b/temporal_sqlalchemy/clock.py index 800b60b..8b95fdc 100644 --- a/temporal_sqlalchemy/clock.py +++ b/temporal_sqlalchemy/clock.py @@ -12,6 +12,8 @@ from temporal_sqlalchemy import nine, util from temporal_sqlalchemy.bases import ( T_PROPS, + ClockState, + ActivityState, Clocked, TemporalOption, TemporalActivityMixin, @@ -78,6 +80,10 @@ def temporal_map(*track, mapper: orm.Mapper, activity_class=None, schema=None): backref_name = '%s_clock' % entity_table.name clock_properties['activity'] = \ orm.relationship(lambda: activity_class, backref=backref_name) + cls.activity = ActivityState() + event.listen(cls, 'expire', ActivityState.reset_activity) + for prop in tracked_props: + event.listen(prop, 'set', ActivityState.activity_required) clock_model = build_clock_class(cls.__name__, entity_table.metadata, @@ -94,24 +100,19 @@ def temporal_map(*track, mapper: orm.Mapper, activity_class=None, schema=None): clock_model=clock_model, activity_cls=activity_class ) - + cls.current_clock = ClockState() + event.listen(cls, 'expire', ClockState.reset_tick) event.listen(cls, 'init', init_clock) def init_clock(obj: Clocked, args, kwargs): kwargs.setdefault('vclock', 1) - initial_tick = obj.temporal_options.clock_model( - tick=kwargs['vclock'], - entity=obj, - ) + obj.current_clock = obj.temporal_options.clock_model(tick=kwargs['vclock']) if obj.temporal_options.activity_cls and 'activity' not in kwargs: raise ValueError( "%r missing keyword argument: activity" % obj.__class__) - if 'activity' in kwargs: - initial_tick.activity = kwargs.pop('activity') - materialize_defaults(obj, kwargs) diff --git a/temporal_sqlalchemy/metadata.py b/temporal_sqlalchemy/metadata.py deleted file mode 100644 index eb01faa..0000000 --- a/temporal_sqlalchemy/metadata.py +++ /dev/null @@ -1,24 +0,0 @@ -import sqlalchemy.orm as orm - -TEMPORAL_METADATA_KEY = '__temporal' - -__all__ = [ - 'get_session_metadata', - 'set_session_metadata', -] - - -def set_session_metadata(session: orm.Session, metadata: dict): - if isinstance(session, orm.Session): - session.info[TEMPORAL_METADATA_KEY] = metadata - elif isinstance(session, orm.sessionmaker): - session.configure(info={TEMPORAL_METADATA_KEY: metadata}) - else: - raise ValueError('Invalid session') - - -def get_session_metadata(session: orm.Session) -> dict: - """ - :return: metadata dictionary, or None if it was never installed - """ - return session.info.get(TEMPORAL_METADATA_KEY) diff --git a/temporal_sqlalchemy/session.py b/temporal_sqlalchemy/session.py index 9e78b41..44adb8c 100644 --- a/temporal_sqlalchemy/session.py +++ b/temporal_sqlalchemy/session.py @@ -4,16 +4,25 @@ import sqlalchemy.event as event import sqlalchemy.orm as orm +import sqlalchemy.util as util from temporal_sqlalchemy.bases import TemporalOption, Clocked -from temporal_sqlalchemy.metadata import ( - get_session_metadata, - set_session_metadata -) -def _temporal_models(session: orm.Session) -> typing.Iterable[Clocked]: - for obj in session: +TEMPORAL_METADATA_KEY = '__temporal' + + +def set_session_metadata(session: orm.Session, metadata: dict): + if isinstance(session, orm.Session): + session.info[TEMPORAL_METADATA_KEY] = metadata + elif isinstance(session, orm.sessionmaker): + session.configure(info={TEMPORAL_METADATA_KEY: metadata}) + else: + raise ValueError('Invalid session') + + +def _temporal_models(iset: util.IdentitySet) -> typing.Iterable[Clocked]: + for obj in iset: if isinstance(getattr(obj, 'temporal_options', None), TemporalOption): yield obj @@ -27,29 +36,25 @@ def persist_history(session: orm.Session, flush_context, instances): obj.temporal_options.record_history(obj, session, correlate_timestamp) -def temporal_session(session: typing.Union[orm.Session, orm.sessionmaker], strict_mode=False) -> orm.Session: +def temporal_session(session: typing.Union[orm.Session, orm.sessionmaker], + **opt) -> orm.Session: """ Setup the session to track changes via temporal :param session: SQLAlchemy ORM session to temporalize - :param strict_mode: if True, will raise exceptions when improper flush() calls are made (default is False) :return: temporalized SQLALchemy ORM session """ - temporal_metadata = { - 'strict_mode': strict_mode - } - - # defer listening to the flush hook until after we update the metadata - install_flush_hook = not is_temporal_session(session) + if is_temporal_session(session): + return session + opt.setdefault('ENABLED', True) # TODO make this significant # update to the latest metadata - set_session_metadata(session, temporal_metadata) - - if install_flush_hook: - event.listen(session, 'before_flush', persist_history) + set_session_metadata(session, opt) + event.listen(session, 'before_flush', persist_history) return session def is_temporal_session(session: orm.Session) -> bool: - return isinstance(session, orm.Session) and get_session_metadata(session) is not None + return isinstance(session, orm.Session) and \ + TEMPORAL_METADATA_KEY in session.info diff --git a/tests/test_concrete_base.py b/tests/test_concrete_base.py index b484833..cadbef1 100644 --- a/tests/test_concrete_base.py +++ b/tests/test_concrete_base.py @@ -260,7 +260,6 @@ def test_doesnt_duplicate_unnecessary_history(self, session): t.prop_a = 1 t.prop_c = datetime.datetime(2016, 5, 11, tzinfo=datetime.timezone.utc) - session.commit() assert t.vclock == 1 diff --git a/tests/test_session.py b/tests/test_session.py index a255cc4..d5eb62e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -42,3 +42,28 @@ def test_is_temporal_session_on_raw_session(self, session, connection): assert not is_temporal_session(raw_session) finally: raw_session.close() + + def test_different_sessions_update_vclock(self, session, connection, sessionmaker, newstylemodel): + session.add(newstylemodel) + assert newstylemodel.vclock == 1 + session.commit() + + # create different session + transaction = connection.begin() + second_session = sessionmaker(bind=connection) + refreshed_model = second_session.query(models.NewStyleModel).first() + + # update row within new session + refreshed_model.activity = models.Activity(description="Activity Description") + refreshed_model.description = "a new str" + second_session.add(refreshed_model) + assert refreshed_model.vclock == 2 + second_session.commit() + + # see vclock is still 2 after second session commits + refreshed_model = second_session.query(models.NewStyleModel).filter_by( + id=newstylemodel.id).first() + assert refreshed_model.vclock == 2 + # clear out db + transaction.rollback() + second_session.close() diff --git a/tests/test_temporal_model_mixin.py b/tests/test_temporal_model_mixin.py index 6389523..018b0be 100644 --- a/tests/test_temporal_model_mixin.py +++ b/tests/test_temporal_model_mixin.py @@ -154,11 +154,11 @@ def test_clock_tick_editing(self, session, newstylemodel): session.commit() activity = models.Activity(description="Activity Description #2") - with newstylemodel.clock_tick(activity=activity): - newstylemodel.description = "this is new" - newstylemodel.int_prop = 2 - newstylemodel.bool_prop = False - newstylemodel.datetime_prop = datetime.datetime(2017, 2, 10) + newstylemodel.activity = activity + newstylemodel.description = "this is new" + newstylemodel.int_prop = 2 + newstylemodel.bool_prop = False + newstylemodel.datetime_prop = datetime.datetime(2017, 2, 10) session.commit() diff --git a/tests/test_temporal_models.py b/tests/test_temporal_models.py index 97fecc0..4067ffa 100644 --- a/tests/test_temporal_models.py +++ b/tests/test_temporal_models.py @@ -224,43 +224,6 @@ def test_multiple_edits(self, session): assert 3 in recorded_history.vclock assert getattr(t, attr) == getattr(recorded_history, attr) - def test_edit_on_double_wrapped(self, session): - double_wrapped_session = temporal.temporal_session(session) - - t = models.SimpleTableTemporal( - prop_a=1, - prop_b='foo', - prop_c=datetime.datetime(2016, 5, 11, 1, 2, 3, - tzinfo=datetime.timezone.utc), - prop_d={'foo': 'old value'}, - prop_e=psql_extras.DateRange(datetime.date(2016, 1, 1), - datetime.date(2016, 1, 10)), - prop_f=['old', 'stuff'] - ) - double_wrapped_session.add(t) - double_wrapped_session.commit() - - t = double_wrapped_session.query(models.SimpleTableTemporal).first() - with t.clock_tick(): - t.prop_a = 2 - t.prop_b = 'bar' - double_wrapped_session.commit() - - history_tables = { - 'prop_a': temporal.get_history_model( - models.SimpleTableTemporal.prop_a), - 'prop_b': temporal.get_history_model( - models.SimpleTableTemporal.prop_b), - } - for attr, history in history_tables.items(): - clock_query = session.query(history) - assert clock_query.count() == 2, \ - "%r missing a history entry for initial value" % history - - recorded_history = clock_query[-1] - assert 2 in recorded_history.vclock - assert getattr(t, attr) == getattr(recorded_history, attr) - def test_doesnt_duplicate_unnecessary_history(self, session): history_tables = { 'prop_a': temporal.get_history_model( @@ -296,101 +259,3 @@ def test_doesnt_duplicate_unnecessary_history(self, session): recorded_history = clock_query.first() assert 1 in recorded_history.vclock assert getattr(t, attr) == getattr(recorded_history, attr) - - @pytest.mark.parametrize('session_func_name', ( - 'flush', - 'commit' - )) - def test_disallow_flushes_within_clock_ticks_when_strict(self, session, session_func_name): - session = temporal.temporal_session(session, strict_mode=True) - - t = models.SimpleTableTemporal( - prop_a=1, - prop_b='foo', - prop_c=datetime.datetime(2016, 5, 11, - tzinfo=datetime.timezone.utc)) - session.add(t) - session.commit() - - with t.clock_tick(): - t.prop_a = 2 - - with pytest.raises(AssertionError) as excinfo: - eval('session.{func_name}()'.format(func_name=session_func_name)) - - assert re.match( - r'.*flush\(\) has triggered for a changed temporalized property outside of a clock tick.*', - str(excinfo) - ) - - - @pytest.mark.parametrize('session_func_name', ( - 'flush', - 'commit' - )) - def test_allow_flushes_within_clock_ticks_when_strict_but_no_change(self, session, session_func_name): - session = temporal.temporal_session(session, strict_mode=True) - - t = models.SimpleTableTemporal( - prop_a=1, - prop_b='foo', - prop_c=datetime.datetime(2016, 5, 11, - tzinfo=datetime.timezone.utc)) - session.add(t) - session.commit() - - with t.clock_tick(): - t.prop_a = 1 - - eval('session.{func_name}()'.format(func_name=session_func_name)) - - - @pytest.mark.parametrize('session_func_name', ( - 'flush', - 'commit' - )) - def test_disallow_flushes_on_changes_without_clock_ticks_when_strict(self, session, session_func_name): - session = temporal.temporal_session(session, strict_mode=True) - - t = models.SimpleTableTemporal( - prop_a=1, - prop_b='foo', - prop_c=datetime.datetime(2016, 5, 11, - tzinfo=datetime.timezone.utc)) - session.add(t) - session.commit() - - # this change should have been done within a clock tick - t.prop_a = 2 - - with pytest.raises(AssertionError) as excinfo: - eval('session.{func_name}()'.format(func_name=session_func_name)) - - assert re.match( - r'.*flush\(\) has triggered for a changed temporalized property outside of a clock tick.*', - str(excinfo) - ) - - # TODO this test should be removed once strict flush() checking becomes the default behavior - @pytest.mark.parametrize('session_func_name', ( - 'flush', - 'commit' - )) - def test_allow_loose_flushes_when_not_strict(self, session, session_func_name): - t = models.SimpleTableTemporal( - prop_a=1, - prop_b='foo', - prop_c=datetime.datetime(2016, 5, 11, - tzinfo=datetime.timezone.utc)) - session.add(t) - session.commit() - - with t.clock_tick(): - t.prop_a = 2 - - # this should succeed in non-strict mode - eval('session.{func_name}()'.format(func_name=session_func_name)) - - # this should also succeed in non-strict mode - t.prop_a = 3 - eval('session.{func_name}()'.format(func_name=session_func_name)) diff --git a/tests/test_temporal_with_activity.py b/tests/test_temporal_with_activity.py index af33176..413bdf9 100644 --- a/tests/test_temporal_with_activity.py +++ b/tests/test_temporal_with_activity.py @@ -124,3 +124,18 @@ def test_activity_on_entity_edit_duplicate_activity(self, session): with t.clock_tick(create_activity): t.column = 4567 session.commit() + + def test_expire_clears_current_tick_and_activity(self, session): + create_activity = models.Activity(description='Create temp') + session.add(create_activity) + t = models.FirstTemporalWithActivity(column=1234, + activity=create_activity) + session.add(t) + + clock_model_instance = getattr(t, '__temporal_current_tick', None) + assert clock_model_instance + assert isinstance(clock_model_instance, models.FirstTemporalWithActivity.temporal_options.clock_model) + assert t.activity + session.commit() + assert not getattr(t, '__temporal_current_tick', None) + assert not t.activity