Whoops; preserve column order.
[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.session import Session, object_session
7 from sqlalchemy.schema import Column, ForeignKey, Table
8 from sqlalchemy.sql.expression import and_, bindparam, select
9 from sqlalchemy.types import Integer
10
11 def create_translation_table(_table_name, foreign_class, relation_name,
12 language_class, **kwargs):
13 """Creates a table that represents some kind of data attached to the given
14 foreign class, but translated across several languages. Returns the new
15 table's mapped class. It won't be declarative, but it will have a
16 `__table__` attribute so you can retrieve the Table object.
17
18 `foreign_class` must have a `__singlename__`, currently only used to create
19 the name of the foreign key column.
20 TODO remove this requirement
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 # A foreign key "language_id" will clash with the language_id we naturally
73 # put in every table. Rename it something else
74 if foreign_key_name == 'language_id':
75 # TODO change language_id below instead and rename this
76 foreign_key_name = 'lang_id'
77
78 Translations = type(_table_name, (object,), {
79 '_language_identifier': association_proxy('language', 'identifier'),
80 })
81
82 # Create the table object
83 table = Table(_table_name, foreign_class.__table__.metadata,
84 Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
85 primary_key=True, nullable=False),
86 Column('language_id', Integer, ForeignKey(language_class.id),
87 primary_key=True, nullable=False),
88 )
89 Translations.__table__ = table
90
91 # Add ye columns
92 # Column objects have a _creation_order attribute in ascending order; use
93 # this to get the (unordered) kwargs sorted correctly
94 kwitems = kwargs.items()
95 kwitems.sort(key=lambda kv: kv[1]._creation_order)
96 for name, column in kwitems:
97 column.name = name
98 table.append_column(column)
99
100 # Construct ye mapper
101 mapper(Translations, table, properties={
102 # TODO change to foreign_id
103 'object_id': synonym(foreign_key_name),
104 # TODO change this as appropriate
105 'language': relationship(language_class,
106 primaryjoin=table.c.language_id == language_class.id,
107 lazy='joined',
108 innerjoin=True),
109 # TODO does this need to join to the original table?
110 })
111
112 # Add full-table relations to the original class
113 # Foo.bars_table
114 setattr(foreign_class, relation_name + '_table', Translations)
115 # Foo.bars
116 setattr(foreign_class, relation_name, relationship(Translations,
117 primaryjoin=foreign_class.id == Translations.object_id,
118 collection_class=attribute_mapped_collection('language'),
119 # TODO
120 lazy='select',
121 ))
122 # Foo.bars_local
123 # This is a bit clever; it uses bindparam() to make the join clause
124 # modifiable on the fly. db sessions know the current language identifier
125 # populates the bindparam. The manual alias and join are (a) to make the
126 # condition nice (sqla prefers an EXISTS) and to make the columns play nice
127 # when foreign_class == language_class.
128 local_relation_name = relation_name + '_local'
129 language_class_a = aliased(language_class)
130 setattr(foreign_class, local_relation_name, relationship(Translations,
131 primaryjoin=and_(
132 foreign_class.id == Translations.object_id,
133 Translations.language_id == select(
134 [language_class_a.id],
135 language_class_a.identifier ==
136 bindparam('_default_language', required=True),
137 ),
138 ),
139 uselist=False,
140 # TODO MORESO HERE
141 lazy='select',
142 ))
143
144 # Add per-column proxies to the original class
145 for name, column in kwitems:
146 # Class.(column) -- accessor for the default language's value
147 setattr(foreign_class, name,
148 association_proxy(local_relation_name, name))
149
150 # Class.(column)_map -- accessor for the language dict
151 # Need a custom creator since Translations doesn't have an init, and
152 # these are passed as *args anyway
153 def creator(language, value):
154 row = Translations()
155 row.language = language
156 setattr(row, name, value)
157 return row
158 setattr(foreign_class, name + '_map',
159 association_proxy(relation_name, name, creator=creator))
160
161 # Done
162 return Translations
163
164 class MultilangSession(Session):
165 """A tiny Session subclass that adds support for a default language."""
166 default_language = 'en'
167
168 def execute(self, clause, params=None, *args, **kwargs):
169 if not params:
170 params = {}
171 params.setdefault('_default_language', self.default_language)
172 return super(MultilangSession, self).execute(
173 clause, params, *args, **kwargs)