]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add an init_scalar event for attributes
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 11 Jun 2015 03:38:15 +0000 (23:38 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 2 Jun 2016 18:41:31 +0000 (14:41 -0400)
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
doc/build/changelog/changelog_11.rst
doc/build/changelog/migration_11.rst
examples/custom_attributes/__init__.py
examples/custom_attributes/active_column_defaults.py [new file with mode: 0644]
lib/sqlalchemy/orm/attributes.py
lib/sqlalchemy/orm/events.py

index 898b8a0ba32e70d87ad6e93a0ce2b6afc3ef13bb..6275054824d1570c7eda50c3e5c8ba86ea135acc 100644 (file)
 .. 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
index 0d94f35e99f8c8de064ebb8a3d05818ed78f63db..479681798392d42d036c14e7f8da99a15f16619a 100644 (file)
@@ -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
index 2072c051f3b1199e1c7834add78b30645c07b430..cbc65dfed966b66678b4d0134838d500fbf29542 100644 (file)
@@ -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 (file)
index 0000000..e24fcc0
--- /dev/null
@@ -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)
+    )
index 017ad030041c6faadcaa65694a5cc6fb34c2ac54..7239d41f29fc64dd011b80c7ec93aab80b60ef97 100644 (file)
@@ -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.
index d05fdc9cbab46358dc192746a392bb4ee761ac60..fa0d84f31bc0221294bcbd976d558a1ca487116e 100644 (file)
@@ -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.