Make Pokemon.form an actual relation
[zzz-pokedex.git] / pokedex / db / multilang.py
index 96d70d6..f66ee20 100644 (file)
@@ -3,13 +3,14 @@ from functools import partial
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.orm import aliased, compile_mappers, mapper, relationship, synonym
 from sqlalchemy.orm.collections import attribute_mapped_collection
 from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy.orm import aliased, compile_mappers, mapper, relationship, synonym
 from sqlalchemy.orm.collections import attribute_mapped_collection
+from sqlalchemy.orm.scoping import ScopedSession
 from sqlalchemy.orm.session import Session, object_session
 from sqlalchemy.schema import Column, ForeignKey, Table
 from sqlalchemy.sql.expression import and_, bindparam, select
 from sqlalchemy.types import Integer
 
 def create_translation_table(_table_name, foreign_class, relation_name,
 from sqlalchemy.orm.session import Session, object_session
 from sqlalchemy.schema import Column, ForeignKey, Table
 from sqlalchemy.sql.expression import and_, bindparam, select
 from sqlalchemy.types import Integer
 
 def create_translation_table(_table_name, foreign_class, relation_name,
-    language_class, **kwargs):
+    language_class, relation_lazy='select', **kwargs):
     """Creates a table that represents some kind of data attached to the given
     foreign class, but translated across several languages.  Returns the new
     table's mapped class.  It won't be declarative, but it will have a
     """Creates a table that represents some kind of data attached to the given
     foreign class, but translated across several languages.  Returns the new
     table's mapped class.  It won't be declarative, but it will have a
@@ -17,7 +18,6 @@ def create_translation_table(_table_name, foreign_class, relation_name,
 
     `foreign_class` must have a `__singlename__`, currently only used to create
     the name of the foreign key column.
 
     `foreign_class` must have a `__singlename__`, currently only used to create
     the name of the foreign key column.
-TODO remove this requirement
 
     Also supports the notion of a default language, which is attached to the
     session.  This is English by default, for historical and practical reasons.
 
     Also supports the notion of a default language, which is attached to the
     session.  This is English by default, for historical and practical reasons.
@@ -48,7 +48,7 @@ TODO remove this requirement
       are rows in the created tables.
     - `(relation_name)_local`, a relation to the row in the new table that
       matches the current default language.
       are rows in the created tables.
     - `(relation_name)_local`, a relation to the row in the new table that
       matches the current default language.
-    - `(relation_name)_class`, the class created by this function.
+    - `(relation_name)_table`, the class created by this function.
 
     Note that these are distinct relations.  Even though the former necessarily
     includes the latter, SQLAlchemy doesn't treat them as linked; loading one
 
     Note that these are distinct relations.  Even though the former necessarily
     includes the latter, SQLAlchemy doesn't treat them as linked; loading one
@@ -69,22 +69,19 @@ TODO remove this requirement
     # want to create tables entirely separate from the pokedex metadata
 
     foreign_key_name = foreign_class.__singlename__ + '_id'
     # want to create tables entirely separate from the pokedex metadata
 
     foreign_key_name = foreign_class.__singlename__ + '_id'
-    # A foreign key "language_id" will clash with the language_id we naturally
-    # put in every table.  Rename it something else
-    if foreign_key_name == 'language_id':
-        # TODO change language_id below instead and rename this
-        foreign_key_name = 'lang_id'
 
     Translations = type(_table_name, (object,), {
 
     Translations = type(_table_name, (object,), {
-        '_language_identifier': association_proxy('language', 'identifier'),
+        '_language_identifier': association_proxy('local_language', 'identifier'),
     })
 
     # Create the table object
     table = Table(_table_name, foreign_class.__table__.metadata,
         Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
     })
 
     # Create the table object
     table = Table(_table_name, foreign_class.__table__.metadata,
         Column(foreign_key_name, Integer, ForeignKey(foreign_class.id),
-            primary_key=True, nullable=False),
-        Column('language_id', Integer, ForeignKey(language_class.id),
-            primary_key=True, nullable=False),
+            primary_key=True, nullable=False,
+            info=dict(description="ID of the %s these texts relate to" % foreign_class.__singlename__)),
+        Column('local_language_id', Integer, ForeignKey(language_class.id),
+            primary_key=True, nullable=False,
+            info=dict(description="Language these texts are in")),
     )
     Translations.__table__ = table
 
     )
     Translations.__table__ = table
 
@@ -99,14 +96,10 @@ TODO remove this requirement
 
     # Construct ye mapper
     mapper(Translations, table, properties={
 
     # Construct ye mapper
     mapper(Translations, table, properties={
-        # TODO change to foreign_id
-        'object_id': synonym(foreign_key_name),
-        # TODO change this as appropriate
-        'language': relationship(language_class,
-            primaryjoin=table.c.language_id == language_class.id,
-            lazy='joined',
+        'foreign_id': synonym(foreign_key_name),
+        'local_language': relationship(language_class,
+            primaryjoin=table.c.local_language_id == language_class.id,
             innerjoin=True),
             innerjoin=True),
-        # TODO does this need to join to the original table?
     })
 
     # Add full-table relations to the original class
     })
 
     # Add full-table relations to the original class
@@ -114,31 +107,27 @@ TODO remove this requirement
     setattr(foreign_class, relation_name + '_table', Translations)
     # Foo.bars
     setattr(foreign_class, relation_name, relationship(Translations,
     setattr(foreign_class, relation_name + '_table', Translations)
     # Foo.bars
     setattr(foreign_class, relation_name, relationship(Translations,
-        primaryjoin=foreign_class.id == Translations.object_id,
-        collection_class=attribute_mapped_collection('language'),
-        # TODO
-        lazy='select',
+        primaryjoin=foreign_class.id == Translations.foreign_id,
+        collection_class=attribute_mapped_collection('local_language'),
     ))
     # Foo.bars_local
     # This is a bit clever; it uses bindparam() to make the join clause
     ))
     # Foo.bars_local
     # This is a bit clever; it uses bindparam() to make the join clause
-    # modifiable on the fly.  db sessions know the current language identifier
-    # populates the bindparam.  The manual alias and join are (a) to make the
-    # condition nice (sqla prefers an EXISTS) and to make the columns play nice
-    # when foreign_class == language_class.
+    # modifiable on the fly.  db sessions know the current language and
+    # populate the bindparam.
+    # The 'dummy' value is to trick SQLA; without it, SQLA thinks this
+    # bindparam is just its own auto-generated clause and everything gets
+    # fucked up.
     local_relation_name = relation_name + '_local'
     local_relation_name = relation_name + '_local'
-    language_class_a = aliased(language_class)
     setattr(foreign_class, local_relation_name, relationship(Translations,
         primaryjoin=and_(
     setattr(foreign_class, local_relation_name, relationship(Translations,
         primaryjoin=and_(
-            foreign_class.id == Translations.object_id,
-            Translations.language_id == select(
-                [language_class_a.id],
-                language_class_a.identifier ==
-                    bindparam('_default_language', required=True),
-            ),
+            Translations.foreign_id == foreign_class.id,
+            Translations.local_language_id == bindparam('_default_language_id',
+                value='dummy', type_=Integer, required=True),
         ),
         ),
+        foreign_keys=[Translations.foreign_id, Translations.local_language_id],
         uselist=False,
         uselist=False,
-        # TODO MORESO HERE
-        lazy='select',
+        #innerjoin=True,
+        lazy=relation_lazy,
     ))
 
     # Add per-column proxies to the original class
     ))
 
     # Add per-column proxies to the original class
@@ -152,22 +141,51 @@ TODO remove this requirement
         # these are passed as *args anyway
         def creator(language, value):
             row = Translations()
         # these are passed as *args anyway
         def creator(language, value):
             row = Translations()
-            row.language = language
+            row.local_language = language
             setattr(row, name, value)
             return row
         setattr(foreign_class, name + '_map',
             association_proxy(relation_name, name, creator=creator))
 
             setattr(row, name, value)
             return row
         setattr(foreign_class, name + '_map',
             association_proxy(relation_name, name, creator=creator))
 
+    # Add to the list of translation classes
+    foreign_class.translation_classes.append(Translations)
+
     # Done
     return Translations
 
 class MultilangSession(Session):
     # Done
     return Translations
 
 class MultilangSession(Session):
-    """A tiny Session subclass that adds support for a default language."""
-    default_language = 'en'
+    """A tiny Session subclass that adds support for a default language.
+
+    Needs to be used with `MultilangScopedSession`, below.
+    """
+    default_language_id = None
+
+    def __init__(self, *args, **kwargs):
+        if 'default_language_id' in kwargs:
+            self.default_language_id = kwargs.pop('default_language_id')
+
+        super(MultilangSession, self).__init__(*args, **kwargs)
 
     def execute(self, clause, params=None, *args, **kwargs):
         if not params:
             params = {}
 
     def execute(self, clause, params=None, *args, **kwargs):
         if not params:
             params = {}
-        params.setdefault('_default_language', self.default_language)
+        params.setdefault('_default_language_id', self.default_language_id)
+
         return super(MultilangSession, self).execute(
             clause, params, *args, **kwargs)
         return super(MultilangSession, self).execute(
             clause, params, *args, **kwargs)
+
+class MultilangScopedSession(ScopedSession):
+    """Dispatches language selection to the attached Session."""
+
+    def __init__(self, *args, **kwargs):
+        super(MultilangScopedSession, self).__init__(*args, **kwargs)
+
+    @property
+    def default_language_id(self):
+        """Passes the new default language id through to the current session.
+        """
+        return self.registry().default_language_id
+
+    @default_language_id.setter
+    def default_language_id(self, new):
+        self.registry().default_language_id = new