root / elixir / tags / 0.7.0 / elixir / ext / associable.py

Revision 408, 9.2 kB (checked in by ged, 3 years ago)

minor cleanups

Line 
1'''
2Associable Elixir Statement Generator
3
4==========
5Associable
6==========
7
8About Polymorphic Associations
9------------------------------
10
11A frequent pattern in database schemas is the has_and_belongs_to_many, or a
12many-to-many table. Quite often multiple tables will refer to a single one
13creating quite a few many-to-many intermediate tables.
14
15Polymorphic associations lower the amount of many-to-many tables by setting up
16a table that allows relations to any other table in the database, and relates
17it to the associable table. In some implementations, this layout does not
18enforce referential integrity with database foreign key constraints, this
19implementation uses an additional many-to-many table with foreign key
20constraints to avoid this problem.
21
22.. note:
23    SQLite does not support foreign key constraints, so referential integrity
24    can only be enforced using database backends with such support.
25
26Elixir Statement Generator for Polymorphic Associations
27-------------------------------------------------------
28
29The ``associable`` function generates the intermediary tables for an Elixir
30entity that should be associable with other Elixir entities and returns an
31Elixir Statement for use with them. This automates the process of creating the
32polymorphic association tables and ensuring their referential integrity.
33
34Matching select_XXX and select_by_XXX are also added to the associated entity
35which allow queries to be run for the associated objects.
36
37Example usage:
38
39.. sourcecode:: python
40
41    class Tag(Entity):
42        name = Field(Unicode)
43
44    acts_as_taggable = associable(Tag)
45
46    class Entry(Entity):
47        title = Field(Unicode)
48        acts_as_taggable('tags')
49
50    class Article(Entity):
51        title = Field(Unicode)
52        acts_as_taggable('tags')
53
54Or if one of the entities being associated should only have a single member of
55the associated table:
56
57.. sourcecode:: python
58
59    class Address(Entity):
60        street = Field(String(130))
61        city = Field(String(100))
62
63    is_addressable = associable(Address, 'addresses')
64
65    class Person(Entity):
66        name = Field(Unicode)
67        orders = OneToMany('Order')
68        is_addressable()
69
70    class Order(Entity):
71        order_num = Field(primary_key=True)
72        item_count = Field(Integer)
73        person = ManyToOne('Person')
74        is_addressable('address', uselist=False)
75
76    home = Address(street='123 Elm St.', city='Spooksville')
77    user = Person(name='Jane Doe')
78    user.addresses.append(home)
79
80    neworder = Order(item_count=4)
81    neworder.address = home
82    user.orders.append(neworder)
83
84    # Queries using the added helpers
85    Person.select_by_addresses(city='Cupertino')
86    Person.select_addresses(and_(Address.c.street=='132 Elm St',
87                                 Address.c.city=='Smallville'))
88
89Statement Options
90-----------------
91
92The generated Elixir Statement has several options available:
93
94+---------------+-------------------------------------------------------------+
95| Option Name   | Description                                                 |
96+===============+=============================================================+
97| ``name``      | Specify a custom name for the Entity attribute. This is     |
98|               | used to declare the attribute used to access the associated |
99|               | table values. Otherwise, the name will use the plural_name  |
100|               | provided to the associable call.                            |
101+---------------+-------------------------------------------------------------+
102| ``uselist``   | Whether or not the associated table should be represented   |
103|               | as a list, or a single property. It should be set to False  |
104|               | when the entity should only have a single associated        |
105|               | entity. Defaults to True.                                   |
106+---------------+-------------------------------------------------------------+
107| ``lazy``      | Determines eager loading of the associated entity objects.  |
108|               | Defaults to False, to indicate that they should not be      |
109|               | lazily loaded.                                              |
110+---------------+-------------------------------------------------------------+
111'''
112from elixir.statements import Statement
113import sqlalchemy as sa
114
115__doc_all__ = ['associable']
116
117
118def associable(assoc_entity, plural_name=None, lazy=True):
119    '''
120    Generate an associable Elixir Statement
121    '''
122    interface_name = assoc_entity._descriptor.tablename
123    able_name = interface_name + 'able'
124
125    if plural_name:
126        attr_name = "%s_rel" % plural_name
127    else:
128        plural_name = interface_name
129        attr_name = "%s_rel" % interface_name
130
131    class GenericAssoc(object):
132
133        def __init__(self, tablename):
134            self.type = tablename
135
136    #TODO: inherit from entity builder
137    class Associable(object):
138        """An associable Elixir Statement object"""
139
140        def __init__(self, entity, name=None, uselist=True, lazy=True):
141            self.entity = entity
142            self.lazy = lazy
143            self.uselist = uselist
144
145            if name is None:
146                self.name = plural_name
147            else:
148                self.name = name
149
150        def after_table(self):
151            col = sa.Column('%s_assoc_id' % interface_name, sa.Integer,
152                            sa.ForeignKey('%s.id' % able_name))
153            self.entity._descriptor.add_column(col)
154
155            if not hasattr(assoc_entity, '_assoc_table'):
156                metadata = assoc_entity._descriptor.metadata
157                association_table = sa.Table("%s" % able_name, metadata,
158                    sa.Column('id', sa.Integer, primary_key=True),
159                    sa.Column('type', sa.String(40), nullable=False),
160                )
161                tablename =  "%s_to_%s" % (able_name, interface_name)
162                association_to_table = sa.Table(tablename, metadata,
163                    sa.Column('assoc_id', sa.Integer,
164                              sa.ForeignKey(association_table.c.id,
165                                            ondelete="CASCADE"),
166                              primary_key=True),
167                    #FIXME: this assumes a single id col
168                    sa.Column('%s_id' % interface_name, sa.Integer,
169                              sa.ForeignKey(assoc_entity.table.c.id,
170                                            ondelete="RESTRICT"),
171                              primary_key=True),
172                )
173
174                assoc_entity._assoc_table = association_table
175                assoc_entity._assoc_to_table = association_to_table
176
177        def after_mapper(self):
178            if not hasattr(assoc_entity, '_assoc_mapper'):
179                assoc_entity._assoc_mapper = sa.orm.mapper(
180                    GenericAssoc, assoc_entity._assoc_table, properties={
181                        'targets': sa.orm.relation(
182                                       assoc_entity,
183                                       secondary=assoc_entity._assoc_to_table,
184                                       lazy=lazy, backref='associations',
185                                       order_by=assoc_entity.mapper.order_by)
186                })
187
188            entity = self.entity
189            entity.mapper.add_property(
190                attr_name,
191                sa.orm.relation(GenericAssoc, lazy=self.lazy,
192                                backref='_backref_%s' % entity.table.name)
193            )
194
195            if self.uselist:
196                def get(self):
197                    if getattr(self, attr_name) is None:
198                        setattr(self, attr_name,
199                                GenericAssoc(entity.table.name))
200                    return getattr(self, attr_name).targets
201                setattr(entity, self.name, property(get))
202            else:
203                # scalar based property decorator
204                def get(self):
205                    attr = getattr(self, attr_name)
206                    if attr is not None:
207                        return attr.targets[0]
208                    else:
209                        return None
210                def set(self, value):
211                    if getattr(self, attr_name) is None:
212                        setattr(self, attr_name,
213                                GenericAssoc(entity.table.name))
214                    getattr(self, attr_name).targets = [value]
215                setattr(entity, self.name, property(get, set))
216
217            # self.name is both set via mapper synonym and the python
218            # property, but that's how synonym properties work.
219            # adding synonym property after "real" property otherwise it
220            # breaks when using SQLAlchemy > 0.4.1
221            entity.mapper.add_property(self.name, sa.orm.synonym(attr_name))
222
223            # add helper methods
224            def select_by(cls, **kwargs):
225                return cls.query.join([attr_name, 'targets']) \
226                                .filter_by(**kwargs).all()
227            setattr(entity, 'select_by_%s' % self.name, classmethod(select_by))
228
229            def select(cls, *args, **kwargs):
230                return cls.query.join([attr_name, 'targets']) \
231                                .filter(*args, **kwargs).all()
232            setattr(entity, 'select_%s' % self.name, classmethod(select))
233
234    return Statement(Associable)
Note: See TracBrowser for help on using the browser.