Commits

Mike Bayer  committed 8c9177a

- column_property(), composite_property(), and relation() now
accept a single or list of AttributeExtensions using the
"extension" keyword argument.
- Added a Validator AttributeExtension, as well as a
@validates decorator which is used in a similar fashion
as @reconstructor, and marks a method as validating
one or more mapped attributes.
- removed validate_attributes example, the new methodology replaces it

  • Participants
  • Parent commits 0848576

Comments (0)

Files changed (11)

       clause will appear in the WHERE clause of the query as well
       since this discrimination has multiple trigger points.
 
-    - AttributeListener has been refined such that the event
+    - AttributeExtension has been refined such that the event
       is fired before the mutation actually occurs.  Addtionally,
       the append() and set() methods must now return the given value,
       which is used as the value to be used in the mutation operation.
       This allows creation of validating AttributeListeners which
       raise before the action actually occurs, and which can change
       the given value into something else before its used.
-      A new example "validate_attributes.py" shows one such recipe
-      for doing this.   AttributeListener helper functions are
-      also on the way.
+
+    - column_property(), composite_property(), and relation() now 
+      accept a single or list of AttributeExtensions using the 
+      "extension" keyword argument.
+      
+    - Added a Validator AttributeExtension, as well as a 
+      @validates decorator which is used in a similar fashion
+      as @reconstructor, and marks a method as validating
+      one or more mapped attributes.
       
     - class.someprop.in_() raises NotImplementedError pending the
       implementation of "in_" for relation [ticket:1140]

File doc/build/content/mappers.txt

             )
     })
 
-#### Overriding Attribute Behavior with Synonyms {@name=overriding}
+#### Changing Attribute Behavior {@name=attributes}
 
-A common request is the ability to create custom class properties that override the behavior of setting/getting an attribute.  As of 0.4.2, the `synonym()` construct provides an easy way to do this in conjunction with a normal Python `property` constructs.  Below, we re-map the `email` column of our mapped table to a custom attribute setter/getter, mapping the actual column to the property named `_email`:
+##### Simple Validators {@name=validators}
+
+A quick way to add a "validation" routine to an attribute is to use the `@validates` decorator.  This is a shortcut for using the [docstrings_sqlalchemy.orm_Validator](rel:docstrings_sqlalchemy.orm_Validator) attribute extension with individual column or relation based attributes.   An attribute validator can raise an exception, halting the process of mutating the attribute's value, or can change the given value into something different.   Validators, like all attribute extensions, are only called by normal userland code; they are not issued when the ORM is populating the object.
 
     {python}
-    class MyAddress(object):
+    addresses_table = Table('addresses', metadata, 
+        Column('id', Integer, primary_key=True),
+        Column('email', String)
+    )
+    
+    class EmailAddress(object):
+        @validates('email')
+        def validate_email(self, key, address):
+            assert '@' in address
+            return address
+            
+    mapper(EmailAddress, addresses_table)
+        
+Validators also receive collection events, when items are added to a collection:
+
+    {python}
+    class User(object):
+        @validates('addresses')
+        def validate_address(self, key, address):
+            assert '@' in address.email
+            return address
+    
+##### Using Descriptors {@name=overriding}
+
+A more comprehensive way to produce modified behavior for an attribute is to use descriptors.   These are commonly used in Python using the `property()` function.   The standard SQLAlchemy technique for descriptors is to create a plain descriptor, and to have it read/write from a mapped attribute with a different name.  To have the descriptor named the same as a column, map the column under a different name, i.e.:
+
+    {python}
+    class EmailAddress(object):
        def _set_email(self, email):
           self._email = email
        def _get_email(self):
        email = property(_get_email, _set_email)
 
     mapper(MyAddress, addresses_table, properties={
+        '_email': addresses_table.c.email
+    })
+    
+However, the approach above is not complete.  While our `EmailAddress` object will shuttle the value through the `email` descriptor and into the `_email` mapped attribute, the class level `EmailAddress.email` attribute does not have the usual expression semantics usable with `Query`.  To provide these, we instead use the `synonym()` function as follows:
+
+    {python}
+    mapper(MyAddress, addresses_table, properties={
         'email': synonym('_email', map_column=True)
     })
 

File examples/custom_attributes/listen_for_events.py

 """
-Illustrates how to use AttributeExtension to listen for change events.
+Illustrates how to use AttributeExtension to listen for change events 
+across the board.
 
 """
 

File examples/custom_attributes/validate_attributes.py

-"""
-Illustrates how to use AttributeExtension to create attribute validators.
-
-"""
-
-from sqlalchemy.orm.interfaces import AttributeExtension, InstrumentationManager
-
-class InstallValidators(InstrumentationManager):
-    """Searches a class for methods with a '_validates' attribute and assembles Validators."""
-    
-    def __init__(self, cls):
-        self.validators = {}
-        for k in dir(cls):
-            item = getattr(cls, k)
-            if hasattr(item, '_validates'):
-                self.validators[item._validates] = item
-                
-    def instrument_attribute(self, class_, key, inst):
-        """Add an event listener to an InstrumentedAttribute."""
-        
-        if key in self.validators:
-            inst.impl.extensions.insert(0, Validator(key, self.validators[key]))
-        return super(InstallValidators, self).instrument_attribute(class_, key, inst)
-        
-class Validator(AttributeExtension):
-    """Validates an attribute, given the key and a validation function."""
-    
-    def __init__(self, key, validator):
-        self.key = key
-        self.validator = validator
-    
-    def append(self, state, value, initiator):
-        return self.validator(state.obj(), value)
-
-    def set(self, state, value, oldvalue, initiator):
-        return self.validator(state.obj(), value)
-
-def validates(key):
-    """Mark a method as validating a named attribute."""
-    
-    def wrap(fn):
-        fn._validates = key
-        return fn
-    return wrap
-
-if __name__ == '__main__':
-
-    from sqlalchemy import *
-    from sqlalchemy.orm import *
-    from sqlalchemy.ext.declarative import declarative_base
-    import datetime
-    
-    Base = declarative_base(engine=create_engine('sqlite://', echo=True))
-    Base.__sa_instrumentation_manager__ = InstallValidators
-
-    class MyMappedClass(Base):
-        __tablename__ = "mytable"
-    
-        id = Column(Integer, primary_key=True)
-        date = Column(Date)
-        related_id = Column(Integer, ForeignKey("related.id"))
-        related = relation("Related", backref="mapped")
-
-        @validates('date')
-        def check_date(self, value):
-            if isinstance(value, str):
-                m, d, y = [int(x) for x in value.split('/')]
-                return datetime.date(y, m, d)
-            else:
-                assert isinstance(value, datetime.date)
-                return value
-        
-        @validates('related')
-        def check_related(self, value):
-            assert value.data == 'r1'
-            return value
-            
-        def __str__(self):
-            return "MyMappedClass(date=%r)" % self.date
-            
-    class Related(Base):
-        __tablename__ = "related"
-
-        id = Column(Integer, primary_key=True)
-        data = Column(String(50))
-
-        def __str__(self):
-            return "Related(data=%r)" % self.data
-    
-    Base.metadata.create_all()
-    session = sessionmaker()()
-    
-    r1 = Related(data='r1')
-    r2 = Related(data='r2')
-    m1 = MyMappedClass(date='5/2/2005', related=r1)
-    m2 = MyMappedClass(date=datetime.date(2008, 10, 15))
-    r1.mapped.append(m2)
-
-    try:
-        m1.date = "this is not a date"
-    except:
-        pass
-    assert m1.date == datetime.date(2005, 5, 2)
-    
-    try:
-        m2.related = r2
-    except:
-        pass
-    assert m2.related is r1
-    
-    session.add(m1)
-    session.commit()
-    assert session.query(MyMappedClass.date).order_by(MyMappedClass.date).all() == [
-        (datetime.date(2005, 5, 2),),
-        (datetime.date(2008, 10, 15),)
-    ]
-    

File lib/sqlalchemy/orm/__init__.py

      )
 from sqlalchemy.orm.util import (
      AliasedClass as aliased,
+     Validator,
      join,
      object_mapper,
      outerjoin,
      SynonymProperty,
      )
 from sqlalchemy.orm import mapper as mapperlib
-from sqlalchemy.orm.mapper import reconstructor
+from sqlalchemy.orm.mapper import reconstructor, validates
 from sqlalchemy.orm import strategies
 from sqlalchemy.orm.query import AliasOption, Query
 from sqlalchemy.sql import util as sql_util
     'EXT_STOP',
     'InstrumentationManager',
     'MapperExtension',
+    'Validator',
     'PropComparator',
     'Query',
     'aliased',
     'synonym',
     'undefer',
     'undefer_group',
+    'validates'
     )
 
 
           a class or function that returns a new list-holding object. will be
           used in place of a plain list for storing elements.
 
+        extension
+          an [sqlalchemy.orm.interfaces#AttributeExtension] instance, 
+          or list of extensions, which will be prepended to the list of 
+          attribute listeners for the resulting descriptor placed on the class.
+          These listeners will receive append and set events before the 
+          operation proceeds, and may be used to halt (via exception throw)
+          or change the value used in the operation.
+          
         foreign_keys
           a list of columns which are to be used as "foreign key" columns.
           this parameter should be used in conjunction with explicit
           attribute is first accessed on an instance.  See also
           [sqlalchemy.orm#deferred()].
 
+      extension
+        an [sqlalchemy.orm.interfaces#AttributeExtension] instance, 
+        or list of extensions, which will be prepended to the list of 
+        attribute listeners for the resulting descriptor placed on the class.
+        These listeners will receive append and set events before the 
+        operation proceeds, and may be used to halt (via exception throw)
+        or change the value used in the operation.
+
     """
 
     return ColumnProperty(*args, **kwargs)
       An optional instance of [sqlalchemy.orm#PropComparator] which provides
       SQL expression generation functions for this composite type.
 
+    extension
+      an [sqlalchemy.orm.interfaces#AttributeExtension] instance, 
+      or list of extensions, which will be prepended to the list of 
+      attribute listeners for the resulting descriptor placed on the class.
+      These listeners will receive append and set events before the 
+      operation proceeds, and may be used to halt (via exception throw)
+      or change the value used in the operation.
+
     """
     return CompositeProperty(class_, *cols, **kwargs)
 

File lib/sqlalchemy/orm/mapper.py

         self.column_prefix = column_prefix
         self.polymorphic_on = polymorphic_on
         self._dependency_processors = []
+        self._validators = {}
         self._clause_adapter = None
         self._requires_row_aliasing = False
         self.__inherits_equated_pairs = None
         event_registry.add_listener('on_init', _event_on_init)
         event_registry.add_listener('on_init_failure', _event_on_init_failure)
         for key, method in util.iterate_attributes(self.class_):
-            if (isinstance(method, types.FunctionType) and
-                hasattr(method, '__sa_reconstructor__')):
-                event_registry.add_listener('on_load', method)
-                break
-
+            if isinstance(method, types.FunctionType):
+                if hasattr(method, '__sa_reconstructor__'):
+                    event_registry.add_listener('on_load', method)
+                elif hasattr(method, '__sa_validators__'):
+                    for name in method.__sa_validators__:
+                        self._validators[name] = method
+                        
         if 'reconstruct_instance' in self.extension.methods:
             def reconstruct(instance):
                 self.extension.reconstruct_instance(self, instance)
     fn.__sa_reconstructor__ = True
     return fn
 
-
+def validates(*names):
+    """Decorate a method as a 'validator' for one or more named properties.
+    
+    Designates a method as a validator, a method which receives the 
+    name of the attribute as well as a value to be assigned, or in the
+    case of a collection to be added to the collection.  The function 
+    can then raise validation exceptions to halt the process from continuing,
+    or can modify or replace the value before proceeding.   The function
+    should otherwise return the given value.
+    
+    """
+    def wrap(fn):
+        fn.__sa_validators__ = names
+        return fn
+    return wrap
+    
 def _event_on_init(state, instance, args, kwargs):
     """Trigger mapper compilation and run init_instance hooks."""
 

File lib/sqlalchemy/orm/properties.py

         self.group = kwargs.pop('group', None)
         self.deferred = kwargs.pop('deferred', False)
         self.comparator_factory = kwargs.pop('comparator_factory', ColumnProperty.ColumnComparator)
+        self.extension = kwargs.pop('extension', None)
         util.set_creation_order(self)
         if self.deferred:
             self.strategy_class = strategies.DeferredColumnLoader
 
 class CompositeProperty(ColumnProperty):
     """subclasses ColumnProperty to provide composite type support."""
-
+    
     def __init__(self, class_, *columns, **kwargs):
         super(CompositeProperty, self).__init__(*columns, **kwargs)
         self._col_position_map = dict((c, i) for i, c in enumerate(columns))
         return str(self.parent.class_.__name__) + "." + self.key
 
 class SynonymProperty(MapperProperty):
+
+    extension = None
+
     def __init__(self, name, map_column=None, descriptor=None, comparator_factory=None):
         self.name = name
         self.map_column = map_column
 class ComparableProperty(MapperProperty):
     """Instruments a Python property for use in query expressions."""
 
+    extension = None
+    
     def __init__(self, comparator_factory, descriptor=None):
         self.descriptor = descriptor
         self.comparator_factory = comparator_factory
         backref=None,
         _is_backref=False,
         post_update=False,
-        cascade=False,
+        cascade=False, extension=None,
         viewonly=False, lazy=True,
         collection_class=None, passive_deletes=False,
         passive_updates=True, remote_side=None,
         self.comparator = PropertyLoader.Comparator(self, None)
         self.join_depth = join_depth
         self.local_remote_pairs = _local_remote_pairs
+        self.extension = extension
         self.__join_cache = {}
         self.comparator_factory = PropertyLoader.Comparator
         util.set_creation_order(self)

File lib/sqlalchemy/orm/strategies.py

     def _register_attribute(self, compare_function, copy_function, mutable_scalars, comparator_factory, callable_=None, proxy_property=None, active_history=False):
         self.logger.info("%s register managed attribute" % self)
 
+        attribute_ext = util.to_list(self.parent_property.extension) or []
+        if self.key in self.parent._validators:
+            attribute_ext.append(mapperutil.Validator(self.key, self.parent._validators[self.key]))
+
         for mapper in self.parent.polymorphic_iterator():
             if (mapper is self.parent or not mapper.concrete) and mapper.has_property(self.key):
                 sessionlib.register_attribute(
                     comparator=comparator_factory(self.parent_property, mapper), 
                     parententity=mapper,
                     callable_=callable_,
+                    extension=attribute_ext,
                     proxy_property=proxy_property,
                     active_history=active_history
                     )
     def _register_attribute(self, class_, callable_=None, impl_class=None, **kwargs):
         self.logger.info("%s register managed %s attribute" % (self, (self.uselist and "collection" or "scalar")))
         
+        attribute_ext = util.to_list(self.parent_property.extension) or []
+        
         if self.parent_property.backref:
-            attribute_ext = self.parent_property.backref.extension
-        else:
-            attribute_ext = None
+            attribute_ext.append(self.parent_property.backref.extension)
         
+        if self.key in self.parent._validators:
+            attribute_ext.append(mapperutil.Validator(self.key, self.parent._validators[self.key]))
+            
         sessionlib.register_attribute(
             class_, 
             self.key, 

File lib/sqlalchemy/orm/unitofwork.py

         # for object-holding attributes, instrument UOWEventHandler
         # to process per-attribute cascades
         extension = util.to_list(kwargs.pop('extension', None) or [])
-        extension.insert(0, UOWEventHandler(key))
+        extension.append(UOWEventHandler(key))
+        
         kwargs['extension'] = extension
     return attributes.register_attribute(class_, key, *args, **kwargs)
 

File lib/sqlalchemy/orm/util.py

 import sqlalchemy.exceptions as sa_exc
 from sqlalchemy import sql, util
 from sqlalchemy.sql import expression, util as sql_util, operators
-from sqlalchemy.orm.interfaces import MapperExtension, EXT_CONTINUE, PropComparator, MapperProperty
+from sqlalchemy.orm.interfaces import MapperExtension, EXT_CONTINUE, PropComparator, MapperProperty, AttributeExtension
 from sqlalchemy.orm import attributes, exc
 
 
                          'delete_orphan', 'refresh-expire']
              if getattr(self, x, False) is True]))
 
+
+class Validator(AttributeExtension):
+    """Runs a validation method on an attribute value to be set or appended."""
+    
+    def __init__(self, key, validator):
+        """Construct a new Validator.
+        
+            key - name of the attribute to be validated;
+            will be passed as the second argument to 
+            the validation method (the first is the object instance itself).
+            
+            validator - an function or instance method which accepts
+            three arguments; an instance (usually just 'self' for a method),
+            the key name of the attribute, and the value.  The function should
+            return the same value given, unless it wishes to modify it.
+            
+        """
+        self.key = key
+        self.validator = validator
+    
+    def append(self, state, value, initiator):
+        return self.validator(state.obj(), self.key, value)
+
+    def set(self, state, value, oldvalue, initiator):
+        return self.validator(state.obj(), self.key, value)
+    
 def polymorphic_union(table_map, typecolname, aliasname='p_union'):
     """Create a ``UNION`` statement used by a polymorphic mapper.
 

File test/orm/mapper.py

 import testenv; testenv.configure_for_tests()
 from testlib import sa, testing
 from testlib.sa import MetaData, Table, Column, Integer, String, ForeignKey
-from testlib.sa.orm import mapper, relation, backref, create_session, class_mapper, reconstructor
+from testlib.sa.orm import mapper, relation, backref, create_session, class_mapper, reconstructor, validates
 from testlib.sa.orm import defer, deferred, synonym, attributes
 from testlib.testing import eq_
 import pickleable
             x = u[0].orders[1].items[0].keywords[1]
         self.sql_count_(2, go)
 
+class ValidatorTest(_fixtures.FixtureTest):
+    @testing.resolve_artifact_names
+    def test_scalar(self):
+        class User(_base.ComparableEntity):
+            @validates('name')
+            def validate_name(self, key, name):
+                assert name != 'fred'
+                return name + ' modified'
+                
+        mapper(User, users)
+        sess = create_session()
+        u1 = User(name='ed')
+        eq_(u1.name, 'ed modified')
+        self.assertRaises(AssertionError, setattr, u1, "name", "fred")
+        eq_(u1.name, 'ed modified')
+        sess.add(u1)
+        sess.flush()
+        sess.clear()
+        eq_(sess.query(User).filter_by(name='ed modified').one(), User(name='ed'))
+        
 
+    @testing.resolve_artifact_names
+    def test_collection(self):
+        class User(_base.ComparableEntity):
+            @validates('addresses')
+            def validate_address(self, key, ad):
+                assert '@' in ad.email_address
+                return ad
+                
+        mapper(User, users, properties={'addresses':relation(Address)})
+        mapper(Address, addresses)
+        sess = create_session()
+        u1 = User(name='edward')
+        self.assertRaises(AssertionError, u1.addresses.append, Address(email_address='noemail'))
+        u1.addresses.append(Address(id=15, email_address='foo@bar.com'))
+        sess.add(u1)
+        sess.flush()
+        sess.clear()
+        eq_(
+            sess.query(User).filter_by(name='edward').one(), 
+            User(name='edward', addresses=[Address(email_address='foo@bar.com')])
+        )
+        
 class DeferredTest(_fixtures.FixtureTest):
 
     @testing.resolve_artifact_names