Use a class + mapper instead of dynamic declarative magic
[zzz-pokedex.git] / pokedex / db / tables.py
index cf9b780..b77864c 100644 (file)
@@ -18,10 +18,16 @@ Columns have a info dictionary with these keys:
 """
 # XXX: Check if "gametext" is set correctly everywhere
 
+import operator
+
 from sqlalchemy import Column, ForeignKey, MetaData, PrimaryKeyConstraint, Table
-from sqlalchemy.ext.declarative import declarative_base, declared_attr
+from sqlalchemy.ext.declarative import (
+        declarative_base, declared_attr, DeclarativeMeta,
+    )
 from sqlalchemy.ext.associationproxy import association_proxy
-from sqlalchemy.orm import backref, eagerload_all, relation, class_mapper
+from sqlalchemy.orm import (
+        backref, eagerload_all, relation, class_mapper, synonym, mapper,
+    )
 from sqlalchemy.orm.session import Session
 from sqlalchemy.orm.collections import attribute_mapped_collection
 from sqlalchemy.sql import and_
@@ -30,8 +36,17 @@ from inspect import isclass
 
 from pokedex.db import markdown
 
+# A list of all table classes will live in table_classes
+table_classes = []
+
+class TableMetaclass(DeclarativeMeta):
+    def __init__(cls, name, bases, attrs):
+        super(TableMetaclass, cls).__init__(name, bases, attrs)
+        if hasattr(cls, '__tablename__'):
+            table_classes.append(cls)
+
 metadata = MetaData()
-TableBase = declarative_base(metadata=metadata)
+TableBase = declarative_base(metadata=metadata, metaclass=TableMetaclass)
 
 ### Helper classes
 class Named(object):
@@ -63,10 +78,13 @@ class LanguageSpecific(object):
 
 class LanguageSpecificColumn(object):
     """A column that will not appear in the table it's defined in, but in a related one"""
+    _ordering = [1]
     def __init__(self, *args, **kwargs):
         self.args = args
         self.plural = kwargs.pop('plural')
         self.kwargs = kwargs
+        self.order = self._ordering[0]
+        self._ordering[0] += 1
 
     def makeSAColumn(self):
         return Column(*self.args, **self.kwargs)
@@ -1732,16 +1750,9 @@ VersionGroup.version_group_regions = relation(VersionGroupRegion, backref='versi
 VersionGroup.regions = association_proxy('version_group_regions', 'region')
 VersionGroup.pokedex = relation(Pokedex, back_populates='version_groups')
 
-### Convenience function
-def all_tables():
-    u"""Yields all tables in the pokédex"""
-    for table in set(t for t in globals().values() if isclass(t)):
-        if issubclass(table, TableBase) and table is not TableBase:
-            yield table
-
 
 ### Add name tables
-for table in all_tables():
+for table in list(table_classes):
     if issubclass(table, OfficiallyNamed):
         cls = TextColumn
         info=dict(description="The name", format='plaintext', official=True)
@@ -1761,52 +1772,53 @@ def makeTextTable(object_table, name_plural, name_singular, columns, lazy):
     if safe_name == 'language':
         safe_name = 'lang'
 
-    fields = {
-            safe_name + '_id': Column(Integer,
-                    ForeignKey(object_table.id), primary_key=True, nullable=False,
-                    info=dict(description="ID of the object this table represents")
-                ),
-            '__tablename__': table.__singlename__ + '_' + name_plural,
-            '__singlename__': table.__singlename__ + '_' + name_singular,
-            'is_%s_table' % name_singular: True,
-        }
+    tablename = object_table.__singlename__ + '_' + name_plural
+    singlename = object_table.__singlename__ + '_' + name_singular
 
-    fields.update((name, col) for name, plural, col in columns)
+    class Strings(object):
+        __tablename__ = tablename
+        __singlename__ = singlename
 
-    name = table.__name__ + name_singular.capitalize()
+    for name, plural, column in columns:
+        column.name = name
 
-    # There are some dynamic things that can only be set at class
-    # creation time because of declarative metaclass magic.
-    # So create class dynamically.
-    Strings = type(name, (TableBase, LanguageSpecific), fields)
+    table = Table(tablename, metadata,
+            Column(safe_name + '_id', Integer, ForeignKey(object_table.id),
+                    primary_key=True, nullable=False),
+            Column('language_id', Integer, ForeignKey(Language.id),
+                    primary_key=True, nullable=False),
+            *(column for name, plural, column in columns)
+        )
 
-    # Alias the described thing to 'object', to make meta stuff easier
-    Strings.object_id = getattr(Strings, safe_name + '_id')
+    mapper(Strings, table,
+            properties={
+                    "object_id": synonym(safe_name + '_id'),
+                    "language": relation(
+                            Language,
+                            primaryjoin=table.c.language_id == Language.id,
+                        ),
+                },
+        )
 
     # The relation to the object
-    setattr(table, name_plural, relation(
+    setattr(object_table, name_plural, relation(
             Strings,
-            primaryjoin=(table.id == Strings.object_id),
+            primaryjoin=(object_table.id == Strings.object_id),
             backref=safe_name,
             collection_class=attribute_mapped_collection('language'),
             lazy=lazy,
         ))
-
     Strings.object = getattr(Strings, safe_name)
 
-    Strings.object_table = table
-    setattr(table, name_singular + '_table', Strings)
-
-    Strings.language = relation(
-            Language,
-            primaryjoin=Strings.language_id == Language.id,
-        )
+    # Link the tables themselves, so we can get to them
+    Strings.object_table = object_table
+    setattr(object_table, name_singular + '_table', Strings)
 
     for colname, pluralname, column in columns:
         # Provide a relation with all the names, and an English accessor
         # for backwards compatibility
         def scope(colname, pluralname, column):
-            def get_string(self):
+            def get_strings(self):
                 return dict(
                         (l, getattr(t, colname))
                         for l, t in getattr(self, name_plural).items()
@@ -1814,38 +1826,48 @@ def makeTextTable(object_table, name_plural, name_singular, columns, lazy):
 
             def get_english_string(self):
                 try:
-                    return get_string(self)['en']
+                    return get_strings(self)['en']
                 except KeyError:
                     raise AttributeError(colname)
 
-            setattr(table, pluralname, property(get_string))
-            setattr(table, colname, property(get_english_string))
+            setattr(object_table, pluralname, property(get_strings))
+            setattr(object_table, colname, property(get_english_string))
         scope(colname, pluralname, column)
 
         if colname == 'name':
-            table.name_table = Strings
+            object_table.name_table = Strings
 
     return Strings
 
-for table in all_tables():
-    text_columns = []
-    prose_columns = []
+for table in list(table_classes):
+    # Find all the language-specific columns, keeping them in the order they
+    # were defined
+    all_columns = []
     for colname in dir(table):
         column = getattr(table, colname)
+        if isinstance(column, LanguageSpecificColumn):
+            all_columns.append((colname, column))
+    all_columns.sort(key=lambda pair: pair[1].order)
+
+    # Break them into text and prose columns
+    text_columns = []
+    prose_columns = []
+    for colname, column in all_columns:
+        spec = colname, column.plural, column.makeSAColumn()
         if isinstance(column, TextColumn):
-            text_columns.append((colname, column.plural, column.makeSAColumn()))
+            text_columns.append(spec)
         elif isinstance(column, ProseColumn):
-            prose_columns.append((colname, column.plural, column.makeSAColumn()))
+            prose_columns.append(spec)
+
+    if (text_columns or prose_columns) and issubclass(table, LanguageSpecific):
+        raise AssertionError("Language-specific table %s shouldn't have explicit language-specific columns" % table)
+
     if text_columns:
         string_table = makeTextTable(table, 'texts', 'text', text_columns, lazy=False)
-        globals()[string_table.__name__] = string_table
     if prose_columns:
         string_table = makeTextTable(table, 'prose', 'prose', prose_columns, lazy=True)
-        globals()[string_table.__name__] = string_table
-    if (text_columns or prose_columns) and issubclass(table, LanguageSpecific):
-        raise AssertionError("Language-specific table %s shouldn't have explicit language-specific columns" % table)
 
 ### Add language relations
-for table in all_tables():
+for table in list(table_classes):
     if issubclass(table, LanguageSpecific):
         table.language = relation(Language, primaryjoin=table.language_id == Language.id)