Add a mapped_classes list, and a translation_classes list to each mapped class
[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 Column('local_language_id', Integer, ForeignKey(language_class.id),
82 primary_key=True, nullable=False),
83 )
84 Translations.__table__ = table
85
86 # Add ye columns
87 # Column objects have a _creation_order attribute in ascending order; use
88 # this to get the (unordered) kwargs sorted correctly
89 kwitems = kwargs.items()
90 kwitems.sort(key=lambda kv: kv[1]._creation_order)
91 for name, column in kwitems:
92 column.name = name
93 table.append_column(column)
94
95 # Construct ye mapper
96 mapper(Translations, table, properties={
97 'foreign_id': synonym(foreign_key_name),
98 'local_language': relationship(language_class,
99 primaryjoin=table.c.local_language_id == language_class.id,
100 innerjoin=True),
101 })
102
103 # Add full-table relations to the original class
104 # Foo.bars_table
105 setattr(foreign_class, relation_name + '_table', Translations)
106 # Foo.bars
107 setattr(foreign_class, relation_name, relationship(Translations,
108 primaryjoin=foreign_class.id == Translations.foreign_id,
109 collection_class=attribute_mapped_collection('local_language'),
110 ))
111 # Foo.bars_local
112 # This is a bit clever; it uses bindparam() to make the join clause
113 # modifiable on the fly. db sessions know the current language and
114 # populate the bindparam.
115 # The 'dummy' value is to trick SQLA; without it, SQLA thinks this
116 # bindparam is just its own auto-generated clause and everything gets
117 # fucked up.
118 local_relation_name = relation_name + '_local'
119 setattr(foreign_class, local_relation_name, relationship(Translations,
120 primaryjoin=and_(
121 Translations.foreign_id == foreign_class.id,
122 Translations.local_language_id == bindparam('_default_language_id',
123 value='dummy', type_=Integer, required=True),
124 ),
125 foreign_keys=[Translations.foreign_id, Translations.local_language_id],
126 uselist=False,
127 #innerjoin=True,
128 lazy=relation_lazy,
129 ))
130
131 # Add per-column proxies to the original class
132 for name, column in kwitems:
133 # Class.(column) -- accessor for the default language's value
134 setattr(foreign_class, name,
135 association_proxy(local_relation_name, name))
136
137 # Class.(column)_map -- accessor for the language dict
138 # Need a custom creator since Translations doesn't have an init, and
139 # these are passed as *args anyway
140 def creator(language, value):
141 row = Translations()
142 row.local_language = language
143 setattr(row, name, value)
144 return row
145 setattr(foreign_class, name + '_map',
146 association_proxy(relation_name, name, creator=creator))
147
148 # Add to the list of translation classes
149 foreign_class.translation_classes.append(Translations)
150
151 # Done
152 return Translations
153
154 class MultilangSession(Session):
155 """A tiny Session subclass that adds support for a default language."""
156 _default_language_id = 9 # English. XXX magic constant
157
158 @property
159 def default_language(self):
160 # XXX need to get the right mapped class for this to work
161 raise NotImplementedError
162
163 @default_language.setter
164 def default_language(self, new):
165 self._default_language_id = new#.id
166
167 @default_language.deleter
168 def default_language(self):
169 try:
170 del self._default_language_id
171 except AttributeError:
172 pass
173
174 def execute(self, clause, params=None, *args, **kwargs):
175 if not params:
176 params = {}
177 params.setdefault('_default_language_id', self._default_language_id)
178 return super(MultilangSession, self).execute(
179 clause, params, *args, **kwargs)
180
181 class MultilangScopedSession(ScopedSession):
182 """Dispatches language selection to the attached Session."""
183
184 @property
185 def default_language(self):
186 return self.registry().default_language
187
188 @default_language.setter
189 def default_language(self, new):
190 self.registry().default_language = new
191
192 def remove(self):
193 del self.registry().default_language
194 super(MultilangScopedSession, self).remove()