Started switching to create_translation_table.
[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 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
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
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 # A foreign key "language_id" will clash with the language_id we naturally
72 # put in every table. Rename it something else
73 if foreign_key_name == 'language_id':
74 # TODO change language_id below instead and rename this
75 foreign_key_name = 'lang_id'
76
77 Translations = type(_table_name, (object,), {
78 '_language_identifier': association_proxy('language', 'identifier'),
79 })
80
81 # Create the table object
82 table = Table(_table_name, foreign_class.__table__.metadata,
83 Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
84 primary_key=True, nullable=False),
85 Column('language_id', Integer, ForeignKey(language_class.id),
86 primary_key=True, nullable=False),
87 )
88 Translations.__table__ = table
89
90 # Add ye columns
91 # Column objects have a _creation_order attribute in ascending order; use
92 # this to get the (unordered) kwargs sorted correctly
93 kwitems = kwargs.items()
94 kwitems.sort(key=lambda kv: kv[1]._creation_order)
95 for name, column in kwitems:
96 column.name = name
97 table.append_column(column)
98
99 # Construct ye mapper
100 mapper(Translations, table, properties={
101 # TODO change to foreign_id
102 'object_id': synonym(foreign_key_name),
103 # TODO change this as appropriate
104 'language': relationship(language_class,
105 primaryjoin=table.c.language_id == language_class.id,
106 lazy='joined',
107 innerjoin=True),
108 # TODO does this need to join to the original table?
109 })
110
111 # Add full-table relations to the original class
112 # Foo.bars
113 setattr(foreign_class, relation_name, relationship(Translations,
114 primaryjoin=foreign_class.id == Translations.object_id,
115 collection_class=attribute_mapped_collection('language'),
116 # TODO
117 lazy='select',
118 ))
119 # Foo.bars_local
120 # This is a bit clever; it uses bindparam() to make the join clause
121 # modifiable on the fly. db sessions know the current language identifier
122 # populates the bindparam.
123 local_relation_name = relation_name + '_local'
124 setattr(foreign_class, local_relation_name, relationship(Translations,
125 primaryjoin=and_(
126 foreign_class.id == Translations.object_id,
127 Translations._language_identifier ==
128 bindparam('_default_language', required=True),
129 ),
130 uselist=False,
131 # TODO MORESO HERE
132 lazy='select',
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.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 # Done
153 return Translations
154
155 class MultilangSession(Session):
156 """A tiny Session subclass that adds support for a default language.
157
158 Change the default_language attribute to whatever language's IDENTIFIER you
159 would like to be the default.
160 """
161 default_language = 'en'
162
163 def execute(self, clause, params=None, *args, **kwargs):
164 if not params:
165 params = {}
166 params.setdefault('_default_language', self.default_language)
167 return super(MultilangSession, self).execute(
168 clause, params, *args, **kwargs)