]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- added an assertion within the "cascade" step of ORM relationships to check
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 3 Nov 2006 01:17:28 +0000 (01:17 +0000)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 3 Nov 2006 01:17:28 +0000 (01:17 +0000)
that the class of object attached to a parent object is appropriate
(i.e. if A.items stores B objects, raise an error if a C is appended to A.items)
- new extension sqlalchemy.ext.associationproxy, provides transparent "association object"
mappings.  new example examples/association/proxied_association.py illustrates.
- some example cleanup

CHANGES
doc/build/content/datamapping.txt
doc/build/content/plugins.txt
examples/association/basic_association.py [new file with mode: 0644]
examples/association/proxied_association.py [new file with mode: 0644]
examples/polymorph/polymorph.py
examples/vertical/vertical.py
lib/sqlalchemy/ext/associationproxy.py [new file with mode: 0644]
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/properties.py

diff --git a/CHANGES b/CHANGES
index e4abe15650978878de84a41c689627e905c06bc0..f8ba46f0f8e744f5fde27c6b1ca94909503ae223 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -19,6 +19,11 @@ the many-to-one synchronize of B's primary key attribute to A's foreign key
 attribute wouldnt occur.  [ticket:360]
 - implemented from_obj argument for query.count, improves count function
 on selectresults [ticket:325]
+- added an assertion within the "cascade" step of ORM relationships to check
+that the class of object attached to a parent object is appropriate
+(i.e. if A.items stores B objects, raise an error if a C is appended to A.items)
+- new extension sqlalchemy.ext.associationproxy, provides transparent "association object"
+mappings.  new example examples/association/proxied_association.py illustrates.
 
 0.3.0
 - General:
index f2c20814b9dfa06fe62b018bdb2a830ae13b1f4b..d9a94556ff8bb5d38e6d18df572e9341f119bcaa 100644 (file)
@@ -700,7 +700,7 @@ The `relation` function handles a basic many-to-many relationship when you speci
 
 ### Association Object {@name=association}
 
-Many to Many can also be done with an association object, that adds additional information about how two items are related.  In this pattern, the "secondary" option is no longer used; instead, the association object becomes a mapped entity itself, mapped to the association table.  If the association table has no explicit primary key columns defined, you also have to tell the mapper what columns will compose its "primary key", which are typically the two (or more) columns involved in the association.  Also, the relation between the parent and association mapping is typically set up with a cascade of `all, delete-orphan`.  This is to insure that when an association object is removed from its parent collection, it is deleted (otherwise, the unit of work tries to null out one of the foreign key columns, which raises an error condition since that column is also part of its "primary key").
+Many to Many can also be done with an association object, that adds additional information about how two items are related.  In this pattern, the "secondary" option to `relation()` is no longer used; instead, the association object becomes a mapped entity itself, mapped to the association table.  If the association table has no explicit primary key columns defined, you also have to tell the mapper what columns will compose its "primary key", which are typically the two (or more) columns involved in the association.  Also, the relation between the parent and association mapping is typically set up with a cascade of `all, delete-orphan`.  This is to insure that when an association object is removed from its parent collection, it is deleted (otherwise, the unit of work tries to null out one of the foreign key columns, which raises an error condition since that column is also part of its "primary key").
 
     {python}
     from sqlalchemy import *
@@ -812,5 +812,6 @@ Keep in mind that the association object works a little differently from a plain
     
     session.flush()
 
+SQLAlchemy includes an extension module which can be used in some cases to decrease the explicitness of the association object pattern; this extension is described in [plugins_associationproxy](rel:plugins_associationproxy).
 
     
\ No newline at end of file
index c5b094e542b06e4c6feeb067f1fa0e380f8df85e..8229693c1822c0691ab08193a8fc71b6533d2f2e 100644 (file)
@@ -287,6 +287,86 @@ It should be noted that the `flush()` method on the instance need not be called.
     # commit all changes
     ctx.current.flush()
 
+### associationproxy
+
+**Author:** Mike Bayer<br/>
+**Version:** 0.3.1 or greater
+
+`associationproxy` is used to create a transparent proxy to the associated object in an association relationship, thereby decreasing the verbosity of the pattern in cases where explicit access to the association object is not required.  The association relationship pattern is a richer form of a many-to-many relationship, which is described in [datamapping_association](rel:datamapping_association).  It is strongly recommended to fully understand the association object pattern in its explicit form before using this extension; see the examples in the SQLAlchemy distribution under the directory `examples/association/`.
+
+When dealing with association relationships, the **association object** refers to the object that maps to a row in the association table (i.e. the many-to-many table), while the **associated object** refers to the "endpoint" of the association, i.e. the ultimate object referenced by the parent.  The proxy can return collections of objects attached to association objects, and can also create new association objects given only the associated object.  An example using the Keyword mapping described in the data mapping documentation is as follows:
+
+    {python}
+    from sqlalchemy.ext.associationproxy import AssociationProxy
+    
+    class User(object):
+        pass
+
+    class Keyword(object):
+        def __init__(self, name):
+            self.keyword_name = name
+
+    class Article(object):
+        # create "keywords" proxied association.
+        # the collection is called 'keyword_associations', the endpoint
+        # attribute of each association object is called 'keyword'.  the 
+        # class itself of the association object will be figured out automatically  .
+        keywords = AssociationProxy('keyword_associations', 'keyword')
+
+    class KeywordAssociation(object):
+        pass
+
+    # create mappers normally
+    # note that we set up 'keyword_associations' on Article,
+    # and 'keyword' on KeywordAssociation.
+    mapper(Article, articles_table, properties={
+        'keyword_associations':relation(KeywordAssociation, lazy=False, cascade="all, delete-orphan")
+        }
+    )
+    mapper(KeywordAssociation, itemkeywords_table,
+        primary_key=[itemkeywords_table.c.article_id, itemkeywords_table.c.keyword_id],
+        properties={
+            'keyword' : relation(Keyword, lazy=False), 
+            'user' : relation(User, lazy=False) 
+        }
+    )
+    mapper(User, users_table)
+    mapper(Keyword, keywords_table)
+
+    # now, Keywords can be attached to an Article directly;
+    # KeywordAssociation will be created by the AssociationProxy, and have the 
+    # 'keyword' attribute set to the new Keyword.
+    # note that these KeywordAssociation objects will not have a User attached to them.
+    article = Article()
+    article.keywords.append(Keyword('blue'))
+    article.keywords.append(Keyword('red'))
+    session.save(article)
+    session.flush()
+    
+    # the "keywords" collection also returns the underlying Keyword objects
+    article = session.query(Article).get_by(id=12)
+    for k in article.keywords:
+        print "Keyword:", k.keyword_name
+
+    # the original 'keyword_associations' relation exists normally with no awareness of the proxy
+    article.keyword_associations.append(KeywordAssociation())
+    print [ka for ka in article.keyword_associations]
+    
+Note that the above operations on the `keywords` collection are proxying operations to and from the `keyword_associations` collection, which exists normally and can be accessed directly.  `AssociationProxy` will also detect if the collection is list or scalar based and will configure the proxied property to act the same way.
+
+For the common case where the association object's creation needs to be specified by the application, `AssociationProxy` takes an optional callable `creator()` which takes a single associated object as an argument, and returns a new association object.
+
+    {python}
+    def create_keyword_association(keyword):
+        ka = KeywordAssociation()
+        ka.keyword = keyword
+        return ka
+        
+    class Article(object):
+        # create "keywords" proxied association
+        keywords = AssociationProxy('keyword_associations', 'keyword', creator=create_keyword_association)
+    
+
 ### threadlocal
 
 **Author:**  Mike Bayer and Daniel Miller
diff --git a/examples/association/basic_association.py b/examples/association/basic_association.py
new file mode 100644 (file)
index 0000000..5ce6436
--- /dev/null
@@ -0,0 +1,104 @@
+"""basic example of using the association object pattern, which is
+a richer form of a many-to-many relationship."""
+
+
+# the model will be an ecommerce example.  We will have an
+# Order, which represents a set of Items purchased by a user.
+# each Item has a price.  however, the Order must store its own price for
+# each Item, representing the price paid by the user for that particular order, which 
+# is independent of the price on each Item (since those can change).
+
+from sqlalchemy import *
+from sqlalchemy.ext.selectresults import SelectResults
+from datetime import datetime
+
+import logging
+logging.basicConfig(format='%(message)s')
+logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
+
+engine = create_engine('sqlite://')
+metadata = BoundMetaData(engine)
+
+orders = Table('orders', metadata, 
+    Column('order_id', Integer, primary_key=True),
+    Column('customer_name', String(30), nullable=False),
+    Column('order_date', DateTime, nullable=False, default=datetime.now()),
+    )
+
+items = Table('items', metadata,
+    Column('item_id', Integer, primary_key=True),
+    Column('description', String(30), nullable=False),
+    Column('price', Float, nullable=False)
+    )
+
+orderitems = Table('orderitems', metadata,
+    Column('order_id', Integer, ForeignKey('orders.order_id'), primary_key=True),
+    Column('item_id', Integer, ForeignKey('items.item_id'), primary_key=True),
+    Column('price', Float, nullable=False)
+    )
+metadata.create_all()
+
+class Order(object):
+    def __init__(self, customer_name):
+        self.customer_name = customer_name
+
+class Item(object):
+    def __init__(self, description, price):
+        self.description = description
+        self.price = price
+
+class OrderItem(object):
+    def __init__(self, item, price=None):
+        self.item = item
+        self.price = price or item.price
+        
+mapper(Order, orders, properties={
+    'items':relation(OrderItem, cascade="all, delete-orphan", lazy=False)
+})
+mapper(Item, items)
+mapper(OrderItem, orderitems, properties={
+    'item':relation(Item, lazy=False)
+})
+
+session = create_session()
+
+# create our catalog
+session.save(Item('SA T-Shirt', 10.99))
+session.save(Item('SA Mug', 6.50))
+session.save(Item('SA Hat', 8.99))
+session.save(Item('MySQL Crowbar', 16.99))
+session.flush()
+
+# function to return items from the DB
+def item(name):
+    return session.query(Item).get_by(description=name)
+    
+# create an order
+order = Order('john smith')
+
+# add three OrderItem associations to the Order and save
+order.items.append(OrderItem(item('SA Mug')))
+order.items.append(OrderItem(item('MySQL Crowbar'), 10.99))
+order.items.append(OrderItem(item('SA Hat')))
+session.save(order)
+session.flush()
+
+session.clear()
+
+# query the order, print items
+order = session.query(Order).get_by(customer_name='john smith')
+print [(item.item.description, item.price) for item in order.items]
+
+# print customers who bought 'MySQL Crowbar' on sale
+result = SelectResults(session.query(Order)).join_to('item').select(and_(items.c.description=='MySQL Crowbar', items.c.price>orderitems.c.price))
+print [order.customer_name for order in result]
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/association/proxied_association.py b/examples/association/proxied_association.py
new file mode 100644 (file)
index 0000000..3e80ffa
--- /dev/null
@@ -0,0 +1,108 @@
+"""this is a modified version of the basic association example, which illustrates 
+the usage of the associationproxy extension."""
+
+from sqlalchemy import *
+from sqlalchemy.ext.selectresults import SelectResults
+from sqlalchemy.ext.associationproxy import AssociationProxy
+from datetime import datetime
+
+import logging
+logging.basicConfig(format='%(message)s')
+#logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
+
+engine = create_engine('sqlite://')
+metadata = BoundMetaData(engine)
+
+orders = Table('orders', metadata, 
+    Column('order_id', Integer, primary_key=True),
+    Column('customer_name', String(30), nullable=False),
+    Column('order_date', DateTime, nullable=False, default=datetime.now()),
+    )
+
+items = Table('items', metadata,
+    Column('item_id', Integer, primary_key=True),
+    Column('description', String(30), nullable=False),
+    Column('price', Float, nullable=False)
+    )
+
+orderitems = Table('orderitems', metadata,
+    Column('order_id', Integer, ForeignKey('orders.order_id'), primary_key=True),
+    Column('item_id', Integer, ForeignKey('items.item_id'), primary_key=True),
+    Column('price', Float, nullable=False)
+    )
+metadata.create_all()
+
+class Order(object):
+    def __init__(self, customer_name):
+        self.customer_name = customer_name
+    items = AssociationProxy('itemassociations', 'item', creator=lambda x:OrderItem(x))
+    
+class Item(object):
+    def __init__(self, description, price):
+        self.description = description
+        self.price = price
+
+class OrderItem(object):
+    def __init__(self, item, price=None):
+        self.item = item
+        self.price = price or item.price
+        
+mapper(Order, orders, properties={
+    'itemassociations':relation(OrderItem, cascade="all, delete-orphan", lazy=False)
+})
+mapper(Item, items)
+mapper(OrderItem, orderitems, properties={
+    'item':relation(Item, lazy=False)
+})
+
+session = create_session()
+
+# create our catalog
+session.save(Item('SA T-Shirt', 10.99))
+session.save(Item('SA Mug', 6.50))
+session.save(Item('SA Hat', 8.99))
+session.save(Item('MySQL Crowbar', 16.99))
+session.flush()
+
+# function to return items
+def item(name):
+    return session.query(Item).get_by(description=name)
+    
+# create an order
+order = Order('john smith')
+
+# append an OrderItem association via the "itemassociations" collection
+order.itemassociations.append(OrderItem(item('MySQL Crowbar'), 10.99))
+
+# append two more Items via the transparent "items" proxy, which
+# will create OrderItems automatically
+order.items.append(item('SA Mug'))
+order.items.append(item('SA Hat'))
+
+session.save(order)
+session.flush()
+
+session.clear()
+
+# query the order, print items
+order = session.query(Order).get_by(customer_name='john smith')
+
+# print items based on the OrderItem collection directly
+print [(item.item.description, item.price) for item in order.itemassociations]
+
+# print items based on the "proxied" items collection
+print [(item.description, item.price) for item in order.items]
+
+# print customers who bought 'MySQL Crowbar' on sale
+result = SelectResults(session.query(Order)).join_to('item').select(and_(items.c.description=='MySQL Crowbar', items.c.price>orderitems.c.price))
+print [order.customer_name for order in result]
+
+
+
+
+
+
+
+
+
+
index 92554e087efca2e424d08d7ceae59a8d1b625bd3..6c4f0aae6a732f46fcd7e04109b6d1acbe5e0411 100644 (file)
@@ -1,5 +1,5 @@
 from sqlalchemy import *
-import sys, sets
+import sets
 
 # this example illustrates a polymorphic load of two classes, where each class has a very 
 # different set of properties
@@ -83,7 +83,6 @@ session.save(c)
 
 print session.new
 session.flush()
-#sys.exit()
 session.clear()
 
 c = session.query(Company).get(1)
index 4f0f5a15c65289eec719c56560a27dcf014d58a5..a6ee1742895879eb251b0364fff31b8a911d6716 100644 (file)
@@ -1,11 +1,10 @@
-from sqlalchemy import *
-import datetime
-import sys
-
 """this example illustrates a "vertical table".  an object is stored with individual attributes 
 represented in distinct database rows.  This allows objects to be created with dynamically changing
 fields that are all persisted in a normalized fashion."""
-            
+
+from sqlalchemy import *
+import datetime
+
 e = BoundMetaData('sqlite://', echo=True)
 
 # this table represents Entity objects.  each Entity gets a row in this table,
diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py
new file mode 100644 (file)
index 0000000..6444279
--- /dev/null
@@ -0,0 +1,96 @@
+"""contains the AssociationProxy class, a Python property object which
+provides transparent proxied access to the endpoint of an association object.
+
+See the example examples/association/proxied_association.py.
+"""
+
+from sqlalchemy.orm import class_mapper
+
+class AssociationProxy(object):
+    """a property object that automatically sets up AssociationLists on a parent object."""
+    def __init__(self, targetcollection, attr, creator=None):
+        """create a new association property.
+        
+        targetcollection - the attribute name which stores the collection of Associations
+        
+        attr - name of the attribute on the Association in which to get/set target values
+        
+        creator - optional callable which is used to create a new association object.  this 
+        callable is given a single argument which is an instance of the "proxied" object.
+        if creator is not given, the association object is created using the class associated
+        with the targetcollection attribute, using its __init__() constructor and setting
+        the proxied attribute.
+        """
+        self.targetcollection = targetcollection
+        self.attr = attr
+        self.creator = creator
+    def __init_deferred(self):
+        prop = class_mapper(self._owner_class).props[self.targetcollection]
+        self._cls = prop.mapper.class_
+        self._uselist = prop.uselist
+    def _get_class(self):
+        try:
+            return self._cls
+        except AttributeError:
+            self.__init_deferred()
+            return self._cls
+    def _get_uselist(self):
+        try:
+            return self._uselist
+        except AttributeError:
+            self.__init_deferred()
+            return self._uselist
+    cls = property(_get_class)
+    uselist = property(_get_uselist)
+    def create(self, target):
+        if self.creator is not None:
+            return self.creator(target)
+        else:
+            assoc = self.cls()
+            setattr(assoc, self.attr, target)
+            return assoc
+    def __get__(self, obj, owner):
+        self._owner_class = owner
+        if obj is None:
+            return self
+        storage_key = '_AssociationProxy_%s' % self.targetcollection
+        if self.uselist:
+            try:
+                return getattr(obj, storage_key)
+            except AttributeError:
+                a = _AssociationList(self, obj)
+                setattr(obj, storage_key, a)
+                return a
+        else:
+            return getattr(getattr(obj, self.targetcollection), self.attr)
+    def __set__(self, obj, value):
+        if self.uselist:
+            setattr(obj, self.targetcollection, [self.create(x) for x in value])
+        else:
+            setattr(obj, self.targetcollection, self.create(value))
+    def __del__(self, obj):
+        delattr(obj, self.targetcollection)
+
+class _AssociationList(object):
+    """generic proxying list which proxies list operations to a different 
+    list-holding attribute of the parent object, converting Association objects
+    to and from a target attribute on each Association object."""
+    def __init__(self, proxy, parent):
+        """create a new AssociationList."""
+        self.proxy = proxy
+        self.parent = parent
+    def append(self, item):
+        a = self.proxy.create(item)
+        getattr(self.parent, self.proxy.targetcollection).append(a)
+    def __iter__(self):
+        return iter([getattr(x, self.proxy.attr) for x in getattr(self.parent, self.proxy.targetcollection)])
+    def __repr__(self):
+        return repr([getattr(x, self.proxy.attr) for x in getattr(self.parent, self.proxy.targetcollection)])
+    def __len__(self):
+        return len(getattr(self.parent, self.proxy.targetcollection))
+    def __getitem__(self, index):
+        return getattr(getattr(self.parent, self.proxy.targetcollection)[index], self.proxy.attr)
+    def __setitem__(self, index, value):
+        a = self.proxy.create(item)
+        getattr(self.parent, self.proxy.targetcollection)[index] = a
+        
index 35ddacf15de99c61aaa577a4c3b5129055a4dbff..2ae9bd3aa5ff17117f2261bed7b5e30d0cde48f3 100644 (file)
@@ -10,7 +10,7 @@ packages and tying operations to class properties and constructors.
 """
 from sqlalchemy import exceptions
 from sqlalchemy.orm.mapper import *
-from sqlalchemy.orm.mapper import mapper_registry, ExtensionOption
+from sqlalchemy.orm import mapper as mapperlib
 from sqlalchemy.orm.query import Query
 from sqlalchemy.orm.util import polymorphic_union
 from sqlalchemy.orm import properties, strategies
index 58865242d4e76bcca2c4176e8b6e4135b22e1d60..84a2540b76a5059d5b702c6a4cdcf3eb733169b1 100644 (file)
@@ -11,7 +11,7 @@ from sqlalchemy.orm import sync
 from sqlalchemy.orm.interfaces import MapperProperty, MapperOption, OperationContext
 import weakref
 
-__all__ = ['Mapper', 'MapperExtension', 'class_mapper', 'object_mapper', 'EXT_PASS']
+__all__ = ['Mapper', 'MapperExtension', 'class_mapper', 'object_mapper', 'EXT_PASS', 'mapper_registry', 'ExtensionOption']
 
 # a dictionary mapping classes to their primary mappers
 mapper_registry = weakref.WeakKeyDictionary()
index 176989976b9ad4f1e82c88de6a1461e999e9f79e..6d11436118fe4ba2362a02c783f95d7473026682 100644 (file)
@@ -128,6 +128,8 @@ class PropertyLoader(StrategizedProperty):
         mapper = self.mapper.primary_mapper()
         for c in childlist.added_items() + childlist.deleted_items() + childlist.unchanged_items():
             if c is not None and c not in recursive:
+                if not isinstance(c, self.mapper.class_):
+                    raise exceptions.AssertionError("Attribute '%s' on class '%s' doesn't handle objects of type '%s'" % (self.key, str(self.parent.class_), str(c.__class__)))
                 recursive.add(c)
                 yield c
                 for c2 in mapper.cascade_iterator(type, c, recursive):
@@ -141,6 +143,8 @@ class PropertyLoader(StrategizedProperty):
         passive = type != 'delete' or self.passive_deletes
         for c in sessionlib.attribute_manager.get_as_list(object, self.key, passive=passive):
             if c is not None and c not in recursive:
+                if not isinstance(c, self.mapper.class_):
+                    raise exceptions.AssertionError("Attribute '%s' on class '%s' doesn't handle objects of type '%s'" % (self.key, str(self.parent.class_), str(c.__class__)))
                 recursive.add(c)
                 callable_(c, mapper.entity_name)
                 mapper.cascade_callable(type, c, callable_, recursive)