Joinedload current-language names.
[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, relation_lazy='select', **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
21 Also supports the notion of a default language, which is attached to the
22 session. This is English by default, for historical and practical reasons.
23
24 Usage looks like this:
25
26 class Foo(Base): ...
27
28 create_translation_table('foo_bars', Foo, 'bars',
29 name = Column(...),
30 )
31
32 # Now you can do the following:
33 foo.name
34 foo.name_map['en']
35 foo.foo_bars['en']
36
37 foo.name_map['en'] = "new name"
38 del foo.name_map['en']
39
40 q.options(joinedload(Foo.bars_local))
41 q.options(joinedload(Foo.bars))
42
43 The following properties are added to the passed class:
44
45 - `(relation_name)`, a relation to the new table. It uses a dict-based
46 collection class, where the keys are language identifiers and the values
47 are rows in the created tables.
48 - `(relation_name)_local`, a relation to the row in the new table that
49 matches the current default language.
50 - `(relation_name)_class`, the class created by this function.
51
52 Note that these are distinct relations. Even though the former necessarily
53 includes the latter, SQLAlchemy doesn't treat them as linked; loading one
54 will not load the other. Modifying both within the same transaction has
55 undefined behavior.
56
57 For each column provided, the following additional attributes are added to
58 Foo:
59
60 - `(column)_map`, an association proxy onto `foo_bars`.
61 - `(column)`, an association proxy onto `foo_bars_local`.
62
63 Pardon the naming disparity, but the grammar suffers otherwise.
64
65 Modifying these directly is not likely to be a good idea.
66 """
67 # n.b.: language_class only exists for the sake of tests, which sometimes
68 # want to create tables entirely separate from the pokedex metadata
69
70 foreign_key_name = foreign_class.__singlename__ + '_id'
71
72 Translations = type(_table_name, (object,), {
73 '_language_identifier': association_proxy('local_language', 'identifier'),
74 })
75
76 # Create the table object
77 table = Table(_table_name, foreign_class.__table__.metadata,
78 Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
79 primary_key=True, nullable=False),
80 Column('local_language_id', Integer, ForeignKey(language_class.id),
81 primary_key=True, nullable=False),
82 )
83 Translations.__table__ = table
84
85 # Add ye columns
86 # Column objects have a _creation_order attribute in ascending order; use
87 # this to get the (unordered) kwargs sorted correctly
88 kwitems = kwargs.items()
89 kwitems.sort(key=lambda kv: kv[1]._creation_order)
90 for name, column in kwitems:
91 column.name = name
92 table.append_column(column)
93
94 # Construct ye mapper
95 mapper(Translations, table, properties={
96 'foreign_id': synonym(foreign_key_name),
97 'local_language': relationship(language_class,
98 primaryjoin=table.c.local_language_id == language_class.id,
99 lazy='joined',
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 identifier
114 # populates the bindparam. The manual alias and join are (a) to make the
115 # condition nice (sqla prefers an EXISTS) and to make the columns play nice
116 # when foreign_class == language_class.
117 local_relation_name = relation_name + '_local'
118 language_class_a = aliased(language_class)
119 setattr(foreign_class, local_relation_name, relationship(Translations,
120 primaryjoin=and_(
121 foreign_class.id == Translations.foreign_id,
122 Translations.local_language_id == select(
123 [language_class_a.id],
124 language_class_a.identifier ==
125 bindparam('_default_language', required=True),
126 ),
127 ),
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 # Done
151 return Translations
152
153 class MultilangSession(Session):
154 """A tiny Session subclass that adds support for a default language."""
155 default_language = 'en'
156
157 def execute(self, clause, params=None, *args, **kwargs):
158 if not params:
159 params = {}
160 params.setdefault('_default_language', self.default_language)
161 return super(MultilangSession, self).execute(
162 clause, params, *args, **kwargs)