1 from functools
import partial
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
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.
19 `foreign_class` must have a `__singlename__`, currently only used to create
20 the name of the foreign key column.
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.
25 Usage looks like this:
29 create_translation_table('foo_bars', Foo, 'bars',
33 # Now you can do the following:
38 foo.name_map['en'] = "new name"
39 del foo.name_map['en']
41 q.options(joinedload(Foo.bars_local))
42 q.options(joinedload(Foo.bars))
44 The following properties are added to the passed class:
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.
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
58 For each column provided, the following additional attributes are added to
61 - `(column)_map`, an association proxy onto `foo_bars`.
62 - `(column)`, an association proxy onto `foo_bars_local`.
64 Pardon the naming disparity, but the grammar suffers otherwise.
66 Modifying these directly is not likely to be a good idea.
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
71 foreign_key_name
= foreign_class
.__singlename__
+ '_id'
73 Translations
= type(_table_name
, (object,), {
74 '_language_identifier': association_proxy('local_language', 'identifier'),
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")),
86 Translations
.__table__
= table
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
:
95 table
.append_column(column
)
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,
106 # Add full-table relations to the original class
108 setattr(foreign_class
, relation_name
+ '_table', Translations
)
110 setattr(foreign_class
, relation_name
, relationship(Translations
,
111 primaryjoin
=foreign_class
.id == Translations
.foreign_id
,
112 collection_class
=attribute_mapped_collection('local_language'),
115 # This is a bit clever; it uses bindparam() to make the join clause
116 # modifiable on the fly. db sessions know the current language and
117 # populate the bindparam.
118 # The 'dummy' value is to trick SQLA; without it, SQLA thinks this
119 # bindparam is just its own auto-generated clause and everything gets
121 local_relation_name
= relation_name
+ '_local'
122 setattr(foreign_class
, local_relation_name
, relationship(Translations
,
124 Translations
.foreign_id
== foreign_class
.id,
125 Translations
.local_language_id
== bindparam('_default_language_id',
126 value
='dummy', type_
=Integer
, required
=True),
128 foreign_keys
=[Translations
.foreign_id
, Translations
.local_language_id
],
134 # Add per-column proxies to the original class
135 for name
, column
in kwitems
:
136 # Class.(column) -- accessor for the default language's value
137 setattr(foreign_class
, name
,
138 association_proxy(local_relation_name
, name
))
140 # Class.(column)_map -- accessor for the language dict
141 # Need a custom creator since Translations doesn't have an init, and
142 # these are passed as *args anyway
143 def creator(language
, value
):
145 row
.local_language
= language
146 setattr(row
, name
, value
)
148 setattr(foreign_class
, name
+ '_map',
149 association_proxy(relation_name
, name
, creator
=creator
))
151 # Add to the list of translation classes
152 foreign_class
.translation_classes
.append(Translations
)
157 class MultilangSession(Session
):
158 """A tiny Session subclass that adds support for a default language.
160 Caller will need to assign something to `default_language` before this will
163 _default_language_id
= 0 # Better fill this in, caller
165 def __init__(self
, *args
, **kwargs
):
166 self
.language_class
= kwargs
.pop('language_class')
167 super(MultilangSession
, self
).__init__(*args
, **kwargs
)
170 def default_language(self
):
171 return self
.query(self
.language_class
) \
172 .filter_by(id=self
._default_language_id
) \
175 @default_language.setter
176 def default_language(self
, new
):
177 self
._default_language_id
= new
.id
179 @default_language.deleter
180 def default_language(self
):
182 del self
._default_language_id
183 except AttributeError:
186 def execute(self
, clause
, params
=None, *args
, **kwargs
):
189 params
.setdefault('_default_language_id', self
._default_language_id
)
190 return super(MultilangSession
, self
).execute(
191 clause
, params
, *args
, **kwargs
)
193 class MultilangScopedSession(ScopedSession
):
194 """Dispatches language selection to the attached Session."""
197 def default_language(self
):
198 return self
.registry().default_language
200 @default_language.setter
201 def default_language(self
, new
):
202 self
.registry().default_language
= new