From: Mike Bayer Date: Thu, 11 Jun 2015 03:38:15 +0000 (-0400) Subject: Add an init_scalar event for attributes X-Git-Tag: rel_1_1_0b1~29^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e28b44813721f258bf76fb7c85bf5559ecd219ec;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add an init_scalar event for attributes This allows us to build default-setting recipes such as one that allows us to actively read column-level defaults. An example suite is also added. Change-Id: I7b022d52cc89526132d5bc4201ac27fea4cf088d Fixes: #1311 --- diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst index 898b8a0ba3..6275054824 100644 --- a/doc/build/changelog/changelog_11.rst +++ b/doc/build/changelog/changelog_11.rst @@ -21,6 +21,19 @@ .. changelog:: :version: 1.1.0b1 + .. change:: + :tags: feature, orm + :tickets: 1311 + + Added new event :meth:`.AttributeEvents.init_scalar`, as well + as a new example suite illustrating its use. This event can be used + to provide a Core-generated default value to a Python-side attribute + before the object is persisted. + + .. seealso:: + + :ref:`change_1311` + .. change:: :tags: feature, postgresql :tickets: 3720 diff --git a/doc/build/changelog/migration_11.rst b/doc/build/changelog/migration_11.rst index 0d94f35e99..4796817983 100644 --- a/doc/build/changelog/migration_11.rst +++ b/doc/build/changelog/migration_11.rst @@ -154,6 +154,31 @@ recipe illustrating how to replace the strong identity map. :ticket:`2677` +.. _change_1311: + +New init_scalar() event intercepts default values at ORM level +-------------------------------------------------------------- + +The ORM produces a value of ``None`` when an attribute that has not been +set is first accessed, for a non-persistent object:: + + >>> obj = MyObj() + >>> obj.some_value + None + +There's a use case for this in-Python value to correspond to that of a +Core-generated default value, even before the object is persisted. +To suit this use case a new event :meth:`.AttributeEvents.init_scalar` +is added. The new example ``active_column_defaults.py`` at +:ref:`examples_instrumentation` illustrates a sample use, so the effect +can instead be:: + + >>> obj = MyObj() + >>> obj.some_value + "my default" + +:ticket:`1311` + .. _change_3499: Changes regarding "unhashable" types diff --git a/examples/custom_attributes/__init__.py b/examples/custom_attributes/__init__.py index 2072c051f3..cbc65dfed9 100644 --- a/examples/custom_attributes/__init__.py +++ b/examples/custom_attributes/__init__.py @@ -1,5 +1,5 @@ """ -Two examples illustrating modifications to SQLAlchemy's attribute management +Examples illustrating modifications to SQLAlchemy's attribute management system. .. autosource:: diff --git a/examples/custom_attributes/active_column_defaults.py b/examples/custom_attributes/active_column_defaults.py new file mode 100644 index 0000000000..e24fcc0697 --- /dev/null +++ b/examples/custom_attributes/active_column_defaults.py @@ -0,0 +1,108 @@ +"""Illustrates use of the :meth:`.AttributeEvents.init_scalar` +event, in conjunction with Core column defaults to provide +ORM objects that automatically produce the default value +when an un-set attribute is accessed. + +""" + +from sqlalchemy import event + + +def configure_listener(mapper, class_): + """Establish attribute setters for every default-holding column on the + given mapper.""" + + # iterate through ColumnProperty objects + for col_attr in mapper.column_attrs: + + # look at the Column mapped by the ColumnProperty + # (we look at the first column in the less common case + # of a property mapped to multiple columns at once) + column = col_attr.columns[0] + + # if the Column has a "default", set up a listener + if column.default is not None: + default_listener(col_attr, column.default) + + +def default_listener(col_attr, default): + """Establish a default-setting listener. + + Given a class_, attrname, and a :class:`.DefaultGenerator` instance. + The default generator should be a :class:`.ColumnDefault` object with a + plain Python value or callable default; otherwise, the appropriate behavior + for SQL functions and defaults should be determined here by the + user integrating this feature. + + """ + @event.listens_for(col_attr, "init_scalar", retval=True, propagate=True) + def init_scalar(target, value, dict_): + + if default.is_callable: + # the callable of ColumnDefault always accepts a context + # argument; we can pass it as None here. + value = default.arg(None) + elif default.is_scalar: + value = default.arg + else: + # default is a Sequence, a SQL expression, server + # side default generator, or other non-Python-evaluable + # object. The feature here can't easily support this. This + # can be made to return None, rather than raising, + # or can procure a connection from an Engine + # or Session and actually run the SQL, if desired. + raise NotImplementedError( + "Can't invoke pre-default for a SQL-level column default") + + # set the value in the given dict_; this won't emit any further + # attribute set events or create attribute "history", but the value + # will be used in the INSERT statement + dict_[col_attr.key] = value + + # return the value as well + return value + + +if __name__ == '__main__': + + from sqlalchemy import Column, Integer, DateTime, create_engine + from sqlalchemy.orm import Session + from sqlalchemy.ext.declarative import declarative_base + import datetime + + Base = declarative_base() + + event.listen(Base, 'mapper_configured', configure_listener, propagate=True) + + class Widget(Base): + __tablename__ = 'widget' + + id = Column(Integer, primary_key=True) + + radius = Column(Integer, default=30) + timestamp = Column(DateTime, default=datetime.datetime.now) + + e = create_engine("sqlite://", echo=True) + Base.metadata.create_all(e) + + w1 = Widget() + + # not persisted at all, default values are present the moment + # we access them + assert w1.radius == 30 + current_time = w1.timestamp + assert ( + current_time > datetime.datetime.now() - datetime.timedelta(seconds=5) + ) + + # persist + sess = Session(e) + sess.add(w1) + sess.commit() + + # data is persisted. The timestamp is also the one we generated above; + # e.g. the default wasn't re-invoked later. + assert ( + sess.query(Widget.radius, Widget.timestamp).first() == + (30, current_time) + ) diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index 017ad03004..7239d41f29 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -551,12 +551,13 @@ class AttributeImpl(object): def initialize(self, state, dict_): """Initialize the given state's attribute with an empty value.""" - # As of 1.0, we don't actually set a value in - # dict_. This is so that the state of the object does not get - # modified without emitting the appropriate events. + value = None + for fn in self.dispatch.init_scalar: + ret = fn(state, value, dict_) + if ret is not ATTR_EMPTY: + value = ret - - return None + return value def get(self, state, dict_, passive=PASSIVE_OFF): """Retrieve a value from the given object. diff --git a/lib/sqlalchemy/orm/events.py b/lib/sqlalchemy/orm/events.py index d05fdc9cba..fa0d84f31b 100644 --- a/lib/sqlalchemy/orm/events.py +++ b/lib/sqlalchemy/orm/events.py @@ -1934,6 +1934,114 @@ class AttributeEvents(event.Events): """ + def init_scalar(self, target, value, dict_): + """Receive a scalar "init" event. + + This event is invoked when an uninitialized, unpersisted scalar + attribute is accessed. A value of ``None`` is typically returned + in this case; no changes are made to the object's state. + + The event handler can alter this behavior in two ways. + One is that a value other than ``None`` may be returned. The other + is that the value may be established as part of the object's state, + which will also have the effect that it is persisted. + + Typical use is to establish a specific default value of an attribute + upon access:: + + SOME_CONSTANT = 3.1415926 + + @event.listens_for( + MyClass.some_attribute, "init_scalar", + retval=True, propagate=True) + def _init_some_attribute(target, dict_, value): + dict_['some_attribute'] = SOME_CONSTANT + return SOME_CONSTANT + + Above, we initialize the attribute ``MyClass.some_attribute`` to the + value of ``SOME_CONSTANT``. The above code includes the following + features: + + * By setting the value ``SOME_CONSTANT`` in the given ``dict_``, + we indicate that the value is to be persisted to the database. + **The given value is only persisted to the database if we + explicitly associate it with the object**. The ``dict_`` given + is the ``__dict__`` element of the mapped object, assuming the + default attribute instrumentation system is in place. + + * By establishing the ``retval=True`` flag, the value we return + from the function will be returned by the attribute getter. + Without this flag, the event is assumed to be a passive observer + and the return value of our function is ignored. + + * The ``propagate=True`` flag is significant if the mapped class + includes inheriting subclasses, which would also make use of this + event listener. Without this flag, an inheriting subclass will + not use our event handler. + + When we establish the value in the given dictionary, the value will + be used in the INSERT statement established by the unit of work. + Normally, the default returned value of ``None`` is not established as + part of the object, to avoid the issue of mutations occurring to the + object in response to a normally passive "get" operation, and also + sidesteps the issue of whether or not the :meth:`.AttributeEvents.set` + event should be awkwardly fired off during an attribute access + operation. This does not impact the INSERT operation since the + ``None`` value matches the value of ``NULL`` that goes into the + database in any case; note that ``None`` is skipped during the INSERT + to ensure that column and SQL-level default functions can fire off. + + The attribute set event :meth:`.AttributeEvents.set` as well as the + related validation feature provided by :obj:`.orm.validates` is + **not** invoked when we apply our value to the given ``dict_``. To + have these events to invoke in response to our newly generated + value, apply the value to the given object as a normal attribute + set operation:: + + SOME_CONSTANT = 3.1415926 + + @event.listens_for( + MyClass.some_attribute, "init_scalar", + retval=True, propagate=True) + def _init_some_attribute(target, dict_, value): + # will also fire off attribute set events + target.some_attribute = SOME_CONSTANT + return SOME_CONSTANT + + When multiple listeners are set up, the generation of the value + is "chained" from one listener to the next by passing the value + returned by the previous listener that specifies ``retval=True`` + as the ``value`` argument of the next listener. + + The :meth:`.AttributeEvents.init_scalar` event may be used to + extract values from the default values and/or callables established on + mapped :class:`.Column` objects. See the "active column defaults" + example in :ref:`examples_instrumentation` for an example of this. + + .. versionadded:: 1.1 + + :param target: the object instance receiving the event. + If the listener is registered with ``raw=True``, this will + be the :class:`.InstanceState` object. + :param value: the value that is to be returned before this event + listener were invoked. This value begins as the value ``None``, + however will be the return value of the previous event handler + function if multiple listeners are present. + :param dict_: the attribute dictionary of this mapped object. + This is normally the ``__dict__`` of the object, but in all cases + represents the destination that the attribute system uses to get + at the actual value of this attribute. Placing the value in this + dictionary has the effect that the value will be used in the + INSERT statement generated by the unit of work. + + + .. seealso:: + + :ref:`examples_instrumentation` - see the + ``active_column_defaults.py`` example. + + """ + def init_collection(self, target, collection, collection_adapter): """Receive a 'collection init' event.