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