From 695f65db853a7b74a1ce2da75d8e3c55bbafae81 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Fri, 3 Nov 2006 01:17:28 +0000 Subject: [PATCH] - 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. - some example cleanup --- CHANGES | 5 + doc/build/content/datamapping.txt | 3 +- doc/build/content/plugins.txt | 80 +++++++++++++++ examples/association/basic_association.py | 104 +++++++++++++++++++ examples/association/proxied_association.py | 108 ++++++++++++++++++++ examples/polymorph/polymorph.py | 3 +- examples/vertical/vertical.py | 9 +- lib/sqlalchemy/ext/associationproxy.py | 96 +++++++++++++++++ lib/sqlalchemy/orm/__init__.py | 2 +- lib/sqlalchemy/orm/mapper.py | 2 +- lib/sqlalchemy/orm/properties.py | 4 + 11 files changed, 406 insertions(+), 10 deletions(-) create mode 100644 examples/association/basic_association.py create mode 100644 examples/association/proxied_association.py create mode 100644 lib/sqlalchemy/ext/associationproxy.py diff --git a/CHANGES b/CHANGES index e4abe15650..f8ba46f0f8 100644 --- 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: diff --git a/doc/build/content/datamapping.txt b/doc/build/content/datamapping.txt index f2c20814b9..d9a94556ff 100644 --- a/doc/build/content/datamapping.txt +++ b/doc/build/content/datamapping.txt @@ -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 diff --git a/doc/build/content/plugins.txt b/doc/build/content/plugins.txt index c5b094e542..8229693c18 100644 --- a/doc/build/content/plugins.txt +++ b/doc/build/content/plugins.txt @@ -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
+**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 index 0000000000..5ce6436717 --- /dev/null +++ b/examples/association/basic_association.py @@ -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 index 0000000000..3e80ffa164 --- /dev/null +++ b/examples/association/proxied_association.py @@ -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] + + + + + + + + + + diff --git a/examples/polymorph/polymorph.py b/examples/polymorph/polymorph.py index 92554e087e..6c4f0aae6a 100644 --- a/examples/polymorph/polymorph.py +++ b/examples/polymorph/polymorph.py @@ -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) diff --git a/examples/vertical/vertical.py b/examples/vertical/vertical.py index 4f0f5a15c6..a6ee174289 100644 --- a/examples/vertical/vertical.py +++ b/examples/vertical/vertical.py @@ -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 index 0000000000..644427902b --- /dev/null +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -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 + diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index 35ddacf15d..2ae9bd3aa5 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -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 diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 58865242d4..84a2540b76 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -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() diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py index 176989976b..6d11436118 100644 --- a/lib/sqlalchemy/orm/properties.py +++ b/lib/sqlalchemy/orm/properties.py @@ -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) -- 2.47.2