Make pokedex work with SQLAlchemy 0.7. Warning: ugly hack!
[zzz-pokedex.git] / pokedex / db / multilang.py
1 from functools import partial
2
3 from sqlalchemy.ext.associationproxy import association_proxy
4 from sqlalchemy.orm import aliased, compile_mappers, mapper, relationship, synonym
5 from sqlalchemy.orm.collections import attribute_mapped_collection
6 from sqlalchemy.orm.scoping import ScopedSession
7 from sqlalchemy.orm.session import Session, object_session
8 from sqlalchemy.engine.base import Connection
9 from sqlalchemy.schema import Column, ForeignKey, Table
10 from sqlalchemy.sql.expression import and_, bindparam, select, Select
11 from sqlalchemy.types import Integer
12
13 def create_translation_table(_table_name, foreign_class, relation_name,
14 language_class, relation_lazy='select', **kwargs):
15 """Creates a table that represents some kind of data attached to the given
16 foreign class, but translated across several languages. Returns the new
17 table's mapped class. It won't be declarative, but it will have a
18 `__table__` attribute so you can retrieve the Table object.
19
20 `foreign_class` must have a `__singlename__`, currently only used to create
21 the name of the foreign key column.
22
23 Also supports the notion of a default language, which is attached to the
24 session. This is English by default, for historical and practical reasons.
25
26 Usage looks like this:
27
28 class Foo(Base): ...
29
30 create_translation_table('foo_bars', Foo, 'bars',
31 name = Column(...),
32 )
33
34 # Now you can do the following:
35 foo.name
36 foo.name_map['en']
37 foo.foo_bars['en']
38
39 foo.name_map['en'] = "new name"
40 del foo.name_map['en']
41
42 q.options(joinedload(Foo.bars_local))
43 q.options(joinedload(Foo.bars))
44
45 The following properties are added to the passed class:
46
47 - `(relation_name)`, a relation to the new table. It uses a dict-based
48 collection class, where the keys are language identifiers and the values
49 are rows in the created tables.
50 - `(relation_name)_local`, a relation to the row in the new table that
51 matches the current default language.
52 - `(relation_name)_class`, the class created by this function.
53
54 Note that these are distinct relations. Even though the former necessarily
55 includes the latter, SQLAlchemy doesn't treat them as linked; loading one
56 will not load the other. Modifying both within the same transaction has
57 undefined behavior.
58
59 For each column provided, the following additional attributes are added to
60 Foo:
61
62 - `(column)_map`, an association proxy onto `foo_bars`.
63 - `(column)`, an association proxy onto `foo_bars_local`.
64
65 Pardon the naming disparity, but the grammar suffers otherwise.
66
67 Modifying these directly is not likely to be a good idea.
68 """
69 # n.b.: language_class only exists for the sake of tests, which sometimes
70 # want to create tables entirely separate from the pokedex metadata
71
72 foreign_key_name = foreign_class.__singlename__ + '_id'
73
74 Translations = type(_table_name, (object,), {
75 '_language_identifier': association_proxy('local_language', 'identifier'),
76 })
77
78 # Create the table object
79 table = Table(_table_name, foreign_class.__table__.metadata,
80 Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
81 primary_key=True, nullable=False,
82 info=dict(description="ID of the %s these texts relate to" % foreign_class.__singlename__)),
83 Column('local_language_id', Integer, ForeignKey(language_class.id),
84 primary_key=True, nullable=False,
85 info=dict(description="Language these texts are in")),
86 )
87 Translations.__table__ = table
88
89 # Add ye columns
90 # Column objects have a _creation_order attribute in ascending order; use
91 # this to get the (unordered) kwargs sorted correctly
92 kwitems = kwargs.items()
93 kwitems.sort(key=lambda kv: kv[1]._creation_order)
94 for name, column in kwitems:
95 column.name = name
96 table.append_column(column)
97
98 # Construct ye mapper
99 mapper(Translations, table, properties={
100 'foreign_id': synonym(foreign_key_name),
101 'local_language': relationship(language_class,
102 primaryjoin=table.c.local_language_id == language_class.id,
103 innerjoin=True,
104 lazy='joined'),
105 })
106
107 # Add full-table relations to the original class
108 # Foo.bars_table
109 setattr(foreign_class, relation_name + '_table', Translations)
110 # Foo.bars
111 setattr(foreign_class, relation_name, relationship(Translations,
112 primaryjoin=foreign_class.id == Translations.foreign_id,
113 collection_class=attribute_mapped_collection('local_language'),
114 ))
115 # Foo.bars_local
116 # This is a bit clever; it uses bindparam() to make the join clause
117 # modifiable on the fly. db sessions know the current language and
118 # populate the bindparam.
119 # The 'dummy' value is to trick SQLA; without it, SQLA thinks this
120 # bindparam is just its own auto-generated clause and everything gets
121 # fucked up.
122 local_relation_name = relation_name + '_local'
123 setattr(foreign_class, local_relation_name, relationship(Translations,
124 primaryjoin=and_(
125 Translations.foreign_id == foreign_class.id,
126 Translations.local_language_id == bindparam('_default_language_id',
127 value='dummy', type_=Integer, required=True),
128 ),
129 foreign_keys=[Translations.foreign_id, Translations.local_language_id],
130 uselist=False,
131 #innerjoin=True,
132 lazy=relation_lazy,
133 ))
134
135 # Add per-column proxies to the original class
136 for name, column in kwitems:
137 # Class.(column) -- accessor for the default language's value
138 setattr(foreign_class, name,
139 association_proxy(local_relation_name, name))
140
141 # Class.(column)_map -- accessor for the language dict
142 # Need a custom creator since Translations doesn't have an init, and
143 # these are passed as *args anyway
144 def creator(language, value):
145 row = Translations()
146 row.local_language = language
147 setattr(row, name, value)
148 return row
149 setattr(foreign_class, name + '_map',
150 association_proxy(relation_name, name, creator=creator))
151
152 # Add to the list of translation classes
153 foreign_class.translation_classes.append(Translations)
154
155 # Done
156 return Translations
157
158 class MultilangSession(Session):
159 """A tiny Session subclass that adds support for a default language.
160
161 Needs to be used with `MultilangScopedSession`, below.
162 """
163 default_language_id = None
164
165 def __init__(self, *args, **kwargs):
166 if 'default_language_id' in kwargs:
167 self.default_language_id = kwargs.pop('default_language_id')
168
169 super(MultilangSession, self).__init__(*args, **kwargs)
170
171 def connection(self, *args, **kwargs):
172 """Monkeypatch the connection. Not pretty at all.
173 """
174 conn = super(MultilangSession, self).connection(*args, **kwargs)
175 original_execute = conn.execute
176 if original_execute.__name__ != 'monkeypatched_execute':
177 def monkeypatched_execute(statement, *multiparams, **params):
178 if isinstance(statement, Select):
179 boundparams = dict(multiparams[0])
180 boundparams.setdefault('_default_language_id', self.default_language_id)
181 multiparams = [boundparams] + list(multiparams[1:])
182 return original_execute(statement, *multiparams, **params)
183 conn.execute = monkeypatched_execute
184 return conn
185
186 class MultilangScopedSession(ScopedSession):
187 """Dispatches language selection to the attached Session."""
188
189 @property
190 def default_language_id(self):
191 """Passes the new default language id through to the current session.
192 """
193 return self.registry().default_language_id
194
195 @default_language_id.setter
196 def default_language_id(self, new):
197 self.registry().default_language_id = new