Use a class + mapper instead of dynamic declarative magic
[zzz-pokedex.git] / pokedex / db / tables.py
index 1e3c3a7..b77864c 100644 (file)
@@ -21,9 +21,13 @@ Columns have a info dictionary with these keys:
 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_
@@ -32,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):
@@ -1737,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)
@@ -1766,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()
@@ -1819,20 +1826,20 @@ 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():
+for table in list(table_classes):
     # Find all the language-specific columns, keeping them in the order they
     # were defined
     all_columns = []
@@ -1857,12 +1864,10 @@ for table in all_tables():
 
     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
 
 ### 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)