Started switching to create_translation_table.

- Moved the function to its own file.
- Implemented the session-based default language switching.
- Migrated a couple tables.
This commit is contained in:
Eevee 2011-03-21 22:32:52 -07:00
parent 6a9172151a
commit 68e14e663e
4 changed files with 232 additions and 192 deletions

View file

@ -2,6 +2,7 @@ from sqlalchemy import MetaData, Table, engine_from_config, orm
from ..defaults import get_default_db_uri
from .tables import metadata
from .multilang import MultilangSession
def connect(uri=None, session_args={}, engine_args={}, engine_prefix=''):
@ -40,7 +41,7 @@ def connect(uri=None, session_args={}, engine_args={}, engine_prefix=''):
all_session_args = dict(autoflush=True, autocommit=False, bind=engine)
all_session_args.update(session_args)
sm = orm.sessionmaker(**all_session_args)
sm = orm.sessionmaker(class_=MultilangSession, **all_session_args)
session = orm.scoped_session(sm)
return session

168
pokedex/db/multilang.py Normal file
View file

@ -0,0 +1,168 @@
from functools import partial
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import compile_mappers, mapper, relationship, synonym
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.session import Session, object_session
from sqlalchemy.schema import Column, ForeignKey, Table
from sqlalchemy.sql.expression import and_, bindparam
from sqlalchemy.types import Integer
def create_translation_table(_table_name, foreign_class, relation_name,
language_class, **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
`__table__` attribute so you can retrieve the Table object.
`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.
Usage looks like this:
class Foo(Base): ...
create_translation_table('foo_bars', Foo, 'bars',
name = Column(...),
)
# Now you can do the following:
foo.name
foo.name_map['en']
foo.foo_bars['en']
foo.name_map['en'] = "new name"
del foo.name_map['en']
q.options(joinedload(Foo.bars_local))
q.options(joinedload(Foo.bars))
The following properties are added to the passed class:
- `(relation_name)`, a relation to the new table. It uses a dict-based
collection class, where the keys are language identifiers and the values
are rows in the created tables.
- `(relation_name)_local`, a relation to the row in the new table that
matches the current default language.
Note that these are distinct relations. Even though the former necessarily
includes the latter, SQLAlchemy doesn't treat them as linked; loading one
will not load the other. Modifying both within the same transaction has
undefined behavior.
For each column provided, the following additional attributes are added to
Foo:
- `(column)_map`, an association proxy onto `foo_bars`.
- `(column)`, an association proxy onto `foo_bars_local`.
Pardon the naming disparity, but the grammar suffers otherwise.
Modifying these directly is not likely to be a good idea.
"""
# n.b.: language_class only exists for the sake of tests, which sometimes
# 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,), {
'_language_identifier': association_proxy('language', 'identifier'),
})
# 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),
)
Translations.__table__ = table
# Add ye columns
# Column objects have a _creation_order attribute in ascending order; use
# this to get the (unordered) kwargs sorted correctly
kwitems = kwargs.items()
kwitems.sort(key=lambda kv: kv[1]._creation_order)
for name, column in kwitems:
column.name = name
table.append_column(column)
# 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',
innerjoin=True),
# TODO does this need to join to the original table?
})
# Add full-table relations to the original class
# 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',
))
# 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.
local_relation_name = relation_name + '_local'
setattr(foreign_class, local_relation_name, relationship(Translations,
primaryjoin=and_(
foreign_class.id == Translations.object_id,
Translations._language_identifier ==
bindparam('_default_language', required=True),
),
uselist=False,
# TODO MORESO HERE
lazy='select',
))
# Add per-column proxies to the original class
for name, column in kwitems:
# Class.(column) -- accessor for the default language's value
setattr(foreign_class, name,
association_proxy(local_relation_name, name))
# Class.(column)_map -- accessor for the language dict
# Need a custom creator since Translations doesn't have an init, and
# these are passed as *args anyway
def creator(language, value):
row = Translations()
row.language = language
setattr(row, name, value)
return row
setattr(foreign_class, name + '_map',
association_proxy(relation_name, name, creator=creator))
# Done
return Translations
class MultilangSession(Session):
"""A tiny Session subclass that adds support for a default language.
Change the default_language attribute to whatever language's IDENTIFIER you
would like to be the default.
"""
default_language = 'en'
def execute(self, clause, params=None, *args, **kwargs):
if not params:
params = {}
params.setdefault('_default_language', self.default_language)
return super(MultilangSession, self).execute(
clause, params, *args, **kwargs)

View file

@ -47,7 +47,7 @@ from sqlalchemy.schema import ColumnDefault
from sqlalchemy.types import *
from inspect import isclass
from pokedex.db import markdown
from pokedex.db import markdown, multilang
# A list of all table classes will live in table_classes
table_classes = []
@ -113,6 +113,30 @@ class TextColumn(LanguageSpecificColumn):
"""A column that will appear in the corresponding _text table"""
### Need Language first, to create the partial() below
class Language(TableBase):
u"""A language the Pokémon games have been transleted into
"""
__tablename__ = 'languages'
__singlename__ = 'language'
id = Column(Integer, primary_key=True, nullable=False,
info=dict(description="A numeric ID"))
iso639 = Column(Unicode(2), nullable=False,
info=dict(description="The two-letter code of the country where this language is spoken. Note that it is not unique.", format='identifier'))
iso3166 = Column(Unicode(2), nullable=False,
info=dict(description="The two-letter code of the language. Note that it is not unique.", format='identifier'))
identifier = Column(Unicode(16), nullable=False,
info=dict(description="An identifier", format='identifier'))
official = Column(Boolean, nullable=False, index=True,
info=dict(description=u"True iff games are produced in the language."))
order = Column(Integer, nullable=True,
info=dict(description=u"Order for sorting in foreign name lists."))
name = TextColumn(Unicode(16), nullable=False, index=True, plural='names',
info=dict(description="The name", format='plaintext', official=True))
create_translation_table = partial(multilang.create_translation_table, language_class=Language)
### The actual tables
class Ability(TableBase):
@ -126,12 +150,17 @@ class Ability(TableBase):
info=dict(description="An identifier", format='identifier'))
generation_id = Column(Integer, ForeignKey('generations.id'), nullable=False,
info=dict(description="The ID of the generation this ability was introduced in", detail=True))
effect = ProseColumn(markdown.MarkdownColumn(5120), plural='effects', nullable=False,
info=dict(description="A detailed description of this ability's effect", format='markdown'))
short_effect = ProseColumn(markdown.MarkdownColumn(255), plural='short_effects', nullable=False,
info=dict(description="A short summary of this ability's effect", format='markdown'))
name = TextColumn(Unicode(24), nullable=False, index=True, plural='names',
info=dict(description="The name", format='plaintext', official=True))
create_translation_table('ability_texts', Ability, 'names',
name = Column(Unicode(24), nullable=False, index=True,
info=dict(description="The name", format='plaintext', official=True)),
)
create_translation_table('ability_prose', Ability, 'prose',
effect = Column(markdown.MarkdownColumn(5120), nullable=False,
info=dict(description="A detailed description of this ability's effect", format='markdown')),
short_effect = Column(markdown.MarkdownColumn(255), nullable=False,
info=dict(description="A short summary of this ability's effect", format='markdown')),
)
class AbilityChangelog(TableBase):
"""History of changes to abilities across main game versions."""
@ -550,26 +579,6 @@ class ItemPocket(TableBase):
name = TextColumn(Unicode(16), nullable=False, index=True, plural='names',
info=dict(description="The name", format='plaintext', official=True))
class Language(TableBase):
u"""A language the Pokémon games have been transleted into
"""
__tablename__ = 'languages'
__singlename__ = 'language'
id = Column(Integer, primary_key=True, nullable=False,
info=dict(description="A numeric ID"))
iso639 = Column(Unicode(2), nullable=False,
info=dict(description="The two-letter code of the country where this language is spoken. Note that it is not unique.", format='identifier'))
iso3166 = Column(Unicode(2), nullable=False,
info=dict(description="The two-letter code of the language. Note that it is not unique.", format='identifier'))
identifier = Column(Unicode(16), nullable=False,
info=dict(description="An identifier", format='identifier'))
official = Column(Boolean, nullable=False, index=True,
info=dict(description=u"True iff games are produced in the language."))
order = Column(Integer, nullable=True,
info=dict(description=u"Order for sorting in foreign name lists."))
name = TextColumn(Unicode(16), nullable=False, index=True, plural='names',
info=dict(description="The name", format='plaintext', official=True))
class Location(TableBase):
u"""A place in the Pokémon world
"""
@ -869,8 +878,12 @@ class Move(TableBase):
info=dict(description="ID of the move's Contest effect"))
super_contest_effect_id = Column(Integer, ForeignKey('super_contest_effects.id'), nullable=True,
info=dict(description="ID of the move's Super Contest effect"))
name = TextColumn(Unicode(24), nullable=False, index=True, plural='names',
create_translation_table('move_texts', Move, 'names',
name = Column(Unicode(24), nullable=False, index=True,
info=dict(description="The name", format='plaintext', official=True))
)
class MoveChangelog(TableBase):
"""History of changes to moves across main game versions."""
@ -992,9 +1005,6 @@ class Pokemon(TableBase):
info=dict(description=u"The height of the Pokémon, in decimeters (tenths of a meter)"))
weight = Column(Integer, nullable=False,
info=dict(description=u"The weight of the Pokémon, in tenths of a kilogram (decigrams)"))
species = TextColumn(Unicode(16), nullable=False, plural='species_names',
info=dict(description=u'The short flavor text, such as "Seed" or "Lizard"; usually affixed with the word "Pokémon"',
official=True, format='plaintext'))
color_id = Column(Integer, ForeignKey('pokemon_colors.id'), nullable=False,
info=dict(description=u"ID of this Pokémon's Pokédex color, as used for a gimmick search function in the games."))
pokemon_shape_id = Column(Integer, ForeignKey('pokemon_shapes.id'), nullable=True,
@ -1017,8 +1027,6 @@ class Pokemon(TableBase):
info=dict(description=u"Set iff the species exhibits enough sexual dimorphism to have separate sets of sprites in Gen IV and beyond."))
order = Column(Integer, nullable=False, index=True,
info=dict(description=u"Order for sorting. Almost national order, except families and forms are grouped together."))
name = TextColumn(Unicode(20), nullable=False, index=True, plural='names',
info=dict(description="The name", format='plaintext', official=True))
### Stuff to handle alternate Pokémon forms
@ -1102,6 +1110,14 @@ class Pokemon(TableBase):
else:
return None
create_translation_table('pokemon_texts', Pokemon, 'names',
name = Column(Unicode(20), nullable=False, index=True,
info=dict(description="The name", format='plaintext', official=True)),
species = Column(Unicode(16), nullable=False,
info=dict(description=u'The short flavor text, such as "Seed" or "Lizard"; usually affixed with the word "Pokémon"',
official=True, format='plaintext')),
)
class PokemonAbility(TableBase):
u"""Maps an ability to a Pokémon that can have it
"""
@ -1524,7 +1540,7 @@ Ability.generation = relation(Generation, backref='abilities')
Ability.all_pokemon = relation(Pokemon,
secondary=PokemonAbility.__table__,
order_by=Pokemon.order,
back_populates='all_abilities',
#back_populates='all_abilities',
)
Ability.pokemon = relation(Pokemon,
secondary=PokemonAbility.__table__,
@ -1533,7 +1549,7 @@ Ability.pokemon = relation(Pokemon,
PokemonAbility.is_dream == False
),
order_by=Pokemon.order,
back_populates='abilities',
#back_populates='abilities',
)
Ability.dream_pokemon = relation(Pokemon,
secondary=PokemonAbility.__table__,
@ -1542,7 +1558,7 @@ Ability.dream_pokemon = relation(Pokemon,
PokemonAbility.is_dream == True
),
order_by=Pokemon.order,
back_populates='dream_ability',
#back_populates='dream_ability',
)
AbilityChangelog.changed_in = relation(VersionGroup, backref='ability_changelog')
@ -1578,7 +1594,7 @@ EncounterSlot.version_group = relation(VersionGroup)
EvolutionChain.growth_rate = relation(GrowthRate, backref='evolution_chains')
EvolutionChain.baby_trigger_item = relation(Item, backref='evolution_chains')
EvolutionChain.pokemon = relation(Pokemon, order_by=Pokemon.order, back_populates='evolution_chain')
EvolutionChain.pokemon = relation(Pokemon, order_by=Pokemon.order)#, back_populates='evolution_chain')
Experience.growth_rate = relation(GrowthRate, backref='experience_table')
@ -1638,7 +1654,7 @@ Move.super_contest_effect = relation(SuperContestEffect, backref='moves')
Move.super_contest_combo_next = association_proxy('super_contest_combo_first', 'second')
Move.super_contest_combo_prev = association_proxy('super_contest_combo_second', 'first')
Move.target = relation(MoveTarget, backref='moves')
Move.type = relation(Type, back_populates='moves')
Move.type = relation(Type)#, back_populates='moves')
MoveChangelog.changed_in = relation(VersionGroup, backref='move_changelog')
MoveChangelog.move_effect = relation(MoveEffect, backref='move_changelog')
@ -1681,7 +1697,7 @@ NatureBattleStylePreference.battle_style = relation(MoveBattleStyle, backref='na
NaturePokeathlonStat.pokeathlon_stat = relation(PokeathlonStat, backref='nature_effects')
Pokedex.region = relation(Region, backref='pokedexes')
Pokedex.version_groups = relation(VersionGroup, order_by=VersionGroup.id, back_populates='pokedex')
Pokedex.version_groups = relation(VersionGroup, order_by=VersionGroup.id)#, back_populates='pokedex')
Pokemon.all_abilities = relation(Ability,
secondary=PokemonAbility.__table__,
@ -1709,7 +1725,7 @@ Pokemon.dex_numbers = relation(PokemonDexNumber, order_by=PokemonDexNumber.poked
Pokemon.egg_groups = relation(EggGroup, secondary=PokemonEggGroup.__table__,
order_by=PokemonEggGroup.egg_group_id,
backref=backref('pokemon', order_by=Pokemon.order))
Pokemon.evolution_chain = relation(EvolutionChain, back_populates='pokemon')
Pokemon.evolution_chain = relation(EvolutionChain)#, back_populates='pokemon')
Pokemon.child_pokemon = relation(Pokemon,
primaryjoin=Pokemon.id==PokemonEvolution.from_pokemon_id,
secondary=PokemonEvolution.__table__,
@ -1731,7 +1747,7 @@ Pokemon.shape = relation(PokemonShape, backref='pokemon')
Pokemon.stats = relation(PokemonStat, backref='pokemon', order_by=PokemonStat.stat_id.asc())
Pokemon.types = relation(Type, secondary=PokemonType.__table__,
order_by=PokemonType.slot.asc(),
back_populates='pokemon')
)#back_populates='pokemon')
PokemonDexNumber.pokedex = relation(Pokedex)
@ -1829,7 +1845,7 @@ Type.generation = relation(Generation, backref='types')
Type.damage_class = relation(MoveDamageClass, backref='types')
Type.pokemon = relation(Pokemon, secondary=PokemonType.__table__,
order_by=Pokemon.order,
back_populates='types')
)#back_populates='types')
Type.moves = relation(Move, back_populates='type', order_by=Move.id)
Version.version_group = relation(VersionGroup, back_populates='versions')
@ -1846,149 +1862,6 @@ VersionGroup.pokedex = relation(Pokedex, back_populates='version_groups')
default_lang = u'en'
def create_translation_table(_table_name, foreign_class,
_language_class=Language, **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.
TODO give it a __table__ or __init__?
`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.
Usage looks like this:
class Foo(Base): ...
create_translation_table('foo_bars', Foo,
name = Column(...),
)
# Now you can do the following:
foo.name
foo.name_map['en']
foo.foo_bars['en']
foo.name_map['en'] = "new name"
del foo.name_map['en']
q.options(joinedload(Foo.default_translation))
q.options(joinedload(Foo.foo_bars))
In the above example, the following attributes are added to Foo:
- `foo_bars`, a relation to the new table. It uses a dict-based collection
class, where the keys are language identifiers and the values are rows in
the created tables.
- `foo_bars_local`, a relation to the row in the new table that matches the
current default language.
Note that these are distinct relations. Even though the former necessarily
includes the latter, SQLAlchemy doesn't treat them as linked; loading one
will not load the other. Modifying both within the same transaction has
undefined behavior.
For each column provided, the following additional attributes are added to
Foo:
- `(column)_map`, an association proxy onto `foo_bars`.
- `(column)`, an association proxy onto `foo_bars_local`.
Pardon the naming disparity, but the grammar suffers otherwise.
Modifying these directly is not likely to be a good idea.
"""
# n.b.: _language_class only exists for the sake of tests, which sometimes
# 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,), {
'_language_identifier': association_proxy('language', 'identifier'),
})
# 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),
)
# Add ye columns
# Column objects have a _creation_order attribute in ascending order; use
# this to get the (unordered) kwargs sorted correctly
kwitems = kwargs.items()
kwitems.sort(key=lambda kv: kv[1]._creation_order)
for name, column in kwitems:
column.name = name
table.append_column(column)
# Construct ye mapper
mapper(Translations, table, properties={
# TODO change to foreign_id
'object_id': synonym(foreign_key_name),
# TODO change this as appropriate
'language': relation(_language_class,
primaryjoin=table.c.language_id == _language_class.id,
lazy='joined',
innerjoin=True),
# TODO does this need to join to the original table?
})
# Add full-table relations to the original class
# Class.foo_bars
setattr(foreign_class, _table_name, relation(Translations,
primaryjoin=foreign_class.id == Translations.object_id,
collection_class=attribute_mapped_collection('language'),
# TODO
lazy='select',
))
# Class.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.
local_relation_name = _table_name + '_local'
setattr(foreign_class, local_relation_name, relation(Translations,
primaryjoin=and_(
foreign_class.id == Translations.object_id,
Translations._language_identifier ==
bindparam('_default_language', required=True),
),
uselist=False,
# TODO MORESO HERE
lazy='select',
))
# Add per-column proxies to the original class
for name, column in kwitems:
# Class.(column) -- accessor for the default language's value
setattr(foreign_class, name,
association_proxy(local_relation_name, name))
# Class.(column)_map -- accessor for the language dict
# Need a custom creator since Translations doesn't have an init, and
# these are passed as *args anyway
def creator(language, value):
row = Translations()
row.language = language
setattr(row, name, value)
return row
setattr(foreign_class, name + '_map',
association_proxy(_table_name, name, creator=creator))
# Done
return Translations
def makeTextTable(foreign_table_class, table_suffix_plural, table_suffix_singular, columns, lazy, Language=Language):
# With "Language", we'd have two language_id. So, rename one to 'lang'
foreign_key_name = foreign_table_class.__singlename__

View file

@ -8,7 +8,6 @@ from sqlalchemy.ext.declarative import declarative_base
from pokedex.db import tables, markdown
from pokedex.db.multilang import create_translation_table
from pokedex.db.tables import create_translation_table
def test_variable_names():
"""We want pokedex.db.tables to export tables using the class name"""
@ -49,21 +48,20 @@ def test_i18n_table_creation():
id = Column(Integer, primary_key=True, nullable=False)
FooText = create_translation_table('foo_text', Foo,
_language_class=Language,
language_class=Language,
name = Column(String(100)),
)
# TODO move this to the real code
class DurpSession(Session):
class FauxSession(Session):
def execute(self, clause, params=None, *args, **kwargs):
if not params:
params = {}
params.setdefault('_default_language', 'en')
return super(DurpSession, self).execute(clause, params, *args, **kwargs)
return super(FauxSession, self).execute(clause, params, *args, **kwargs)
# OK, create all the tables and gimme a session
Base.metadata.create_all()
sess = sessionmaker(engine, class_=DurpSession)()
sess = sessionmaker(engine, class_=FauxSession)()
# Create some languages and foos to bind together
lang_en = Language(identifier='en')